feat: replace tenant OIDC page with Enterprise SSO connector management
All checks were successful
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 46s

- Add LogtoManagementClient methods for SSO connector CRUD + org JIT
- Add TenantSsoService with tenant isolation (validates connector-org link)
- Add TenantSsoController at /api/tenant/sso with test endpoint
- Create SsoPage with provider selection, dynamic config form, test button
- Remove old OIDC config endpoints from tenant portal (server OIDC is
  now platform-managed, set during provisioning)
- Sidebar: OIDC -> SSO with Shield icon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 15:48:51 +02:00
parent 4341656a5e
commit e559267f1e
11 changed files with 682 additions and 232 deletions

View File

@@ -286,6 +286,118 @@ public class LogtoManagementClient {
}
}
// --- SSO Connector Management ---
/** List all SSO connectors. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listSsoConnectors() {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/sso-connectors?page=1&page_size=100")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list SSO connectors: {}", e.getMessage());
return List.of();
}
}
/** Create an SSO connector. */
@SuppressWarnings("unchecked")
public Map<String, Object> createSsoConnector(String providerName, String connectorName,
Map<String, Object> connectorConfig, List<String> domains) {
if (!isAvailable()) return null;
var body = new java.util.HashMap<String, Object>();
body.put("providerName", providerName);
body.put("connectorName", connectorName);
if (connectorConfig != null && !connectorConfig.isEmpty()) body.put("config", connectorConfig);
if (domains != null && !domains.isEmpty()) body.put("domains", domains);
return (Map<String, Object>) restClient.post()
.uri(config.getLogtoEndpoint() + "/api/sso-connectors")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(Map.class);
}
/** Get an SSO connector by ID. */
@SuppressWarnings("unchecked")
public Map<String, Object> getSsoConnector(String connectorId) {
if (!isAvailable()) return null;
return (Map<String, Object>) restClient.get()
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(Map.class);
}
/** Update an SSO connector (partial update). */
@SuppressWarnings("unchecked")
public Map<String, Object> updateSsoConnector(String connectorId, Map<String, Object> updates) {
if (!isAvailable()) return null;
return (Map<String, Object>) restClient.patch()
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(updates)
.retrieve()
.body(Map.class);
}
/** Delete an SSO connector. */
public void deleteSsoConnector(String connectorId) {
if (!isAvailable()) return;
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
}
/** List SSO connectors linked to an organization via JIT provisioning. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> getOrgJitSsoConnectors(String orgId) {
if (!isAvailable()) return List.of();
try {
var resp = restClient.get()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors?page=1&page_size=100")
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.body(List.class);
return resp != null ? resp : List.of();
} catch (Exception e) {
log.warn("Failed to list org JIT SSO connectors for {}: {}", orgId, e.getMessage());
return List.of();
}
}
/** Link an SSO connector to an organization for JIT provisioning. */
public void linkSsoConnectorToOrg(String orgId, String connectorId) {
if (!isAvailable()) return;
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("ssoConnectorIds", List.of(connectorId)))
.retrieve()
.toBodilessEntity();
}
/** Unlink an SSO connector from an organization's JIT provisioning. */
public void unlinkSsoConnectorFromOrg(String orgId, String connectorId) {
if (!isAvailable()) return;
restClient.delete()
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors/" + connectorId)
.header("Authorization", "Bearer " + getAccessToken())
.retrieve()
.toBodilessEntity();
}
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
private synchronized String getAccessToken() {

View File

@@ -46,17 +46,6 @@ public class TenantPortalController {
return ResponseEntity.ok(license);
}
@GetMapping("/oidc")
public ResponseEntity<Map<String, Object>> getOidcConfig() {
return ResponseEntity.ok(portalService.getOidcConfig());
}
@PutMapping("/oidc")
public ResponseEntity<Void> updateOidcConfig(@RequestBody Map<String, Object> body) {
portalService.updateOidcConfig(body);
return ResponseEntity.ok().build();
}
@GetMapping("/team")
public ResponseEntity<List<Map<String, Object>>> listTeamMembers() {
return ResponseEntity.ok(portalService.listTeamMembers());

View File

@@ -116,24 +116,6 @@ public class TenantPortalService {
.orElse(null);
}
public Map<String, Object> getOidcConfig() {
TenantEntity tenant = resolveTenant();
String endpoint = tenant.getServerEndpoint();
if (endpoint == null || endpoint.isBlank()) {
return Map.of();
}
return serverApiClient.getOidcConfig(endpoint);
}
public void updateOidcConfig(Map<String, Object> config) {
TenantEntity tenant = resolveTenant();
String endpoint = tenant.getServerEndpoint();
if (endpoint == null || endpoint.isBlank()) {
throw new IllegalStateException("Tenant has no server endpoint configured");
}
serverApiClient.pushOidcConfig(endpoint, config);
}
public List<Map<String, Object>> listTeamMembers() {
TenantEntity tenant = resolveTenant();
String orgId = tenant.getLogtoOrgId();

View File

@@ -0,0 +1,68 @@
package net.siegeln.cameleer.saas.portal;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/tenant/sso")
public class TenantSsoController {
private final TenantSsoService ssoService;
public TenantSsoController(TenantSsoService ssoService) {
this.ssoService = ssoService;
}
public record CreateSsoConnectorRequest(
String providerName,
String connectorName,
Map<String, Object> config,
List<String> domains
) {}
@GetMapping
public ResponseEntity<List<Map<String, Object>>> list() {
return ResponseEntity.ok(ssoService.listConnectors());
}
@PostMapping
public ResponseEntity<Map<String, Object>> create(@RequestBody CreateSsoConnectorRequest request) {
var connector = ssoService.createConnector(
request.providerName(), request.connectorName(),
request.config(), request.domains());
return ResponseEntity.status(HttpStatus.CREATED).body(connector);
}
@GetMapping("/{connectorId}")
public ResponseEntity<Map<String, Object>> get(@PathVariable String connectorId) {
return ResponseEntity.ok(ssoService.getConnector(connectorId));
}
@PatchMapping("/{connectorId}")
public ResponseEntity<Map<String, Object>> update(@PathVariable String connectorId,
@RequestBody Map<String, Object> updates) {
return ResponseEntity.ok(ssoService.updateConnector(connectorId, updates));
}
@DeleteMapping("/{connectorId}")
public ResponseEntity<Void> delete(@PathVariable String connectorId) {
ssoService.deleteConnector(connectorId);
return ResponseEntity.noContent().build();
}
@PostMapping("/{connectorId}/test")
public ResponseEntity<Map<String, Object>> test(@PathVariable String connectorId) {
return ResponseEntity.ok(ssoService.testConnector(connectorId));
}
}

View File

@@ -0,0 +1,124 @@
package net.siegeln.cameleer.saas.portal;
import net.siegeln.cameleer.saas.config.TenantContext;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class TenantSsoService {
private static final Logger log = LoggerFactory.getLogger(TenantSsoService.class);
private final LogtoManagementClient logtoClient;
private final TenantService tenantService;
public TenantSsoService(LogtoManagementClient logtoClient, TenantService tenantService) {
this.logtoClient = logtoClient;
this.tenantService = tenantService;
}
/** List SSO connectors linked to the current tenant's organization. */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> listConnectors() {
String orgId = resolveOrgId();
List<Map<String, Object>> jitConnectors = logtoClient.getOrgJitSsoConnectors(orgId);
Set<String> linkedIds = jitConnectors.stream()
.map(c -> String.valueOf(c.get("id")))
.collect(Collectors.toSet());
if (linkedIds.isEmpty()) return List.of();
// Enrich with full connector details
List<Map<String, Object>> allConnectors = logtoClient.listSsoConnectors();
return allConnectors.stream()
.filter(c -> linkedIds.contains(String.valueOf(c.get("id"))))
.toList();
}
/** Create an SSO connector and link it to the tenant's organization. */
public Map<String, Object> createConnector(String providerName, String connectorName,
Map<String, Object> config, List<String> domains) {
String orgId = resolveOrgId();
var connector = logtoClient.createSsoConnector(providerName, connectorName, config, domains);
if (connector == null) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create SSO connector");
}
String connectorId = String.valueOf(connector.get("id"));
logtoClient.linkSsoConnectorToOrg(orgId, connectorId);
log.info("Created SSO connector '{}' ({}) and linked to org {}", connectorName, connectorId, orgId);
return connector;
}
/** Get a single SSO connector (validates it belongs to this tenant). */
public Map<String, Object> getConnector(String connectorId) {
validateConnectorBelongsToTenant(connectorId);
return logtoClient.getSsoConnector(connectorId);
}
/** Update an SSO connector (validates it belongs to this tenant). */
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> updates) {
validateConnectorBelongsToTenant(connectorId);
return logtoClient.updateSsoConnector(connectorId, updates);
}
/** Delete an SSO connector (unlinks from org and deletes). */
public void deleteConnector(String connectorId) {
String orgId = resolveOrgId();
validateConnectorBelongsToTenant(connectorId);
logtoClient.unlinkSsoConnectorFromOrg(orgId, connectorId);
logtoClient.deleteSsoConnector(connectorId);
log.info("Deleted SSO connector {} from org {}", connectorId, orgId);
}
/** Test an SSO connector by fetching its details (validates provider metadata). */
public Map<String, Object> testConnector(String connectorId) {
validateConnectorBelongsToTenant(connectorId);
var connector = logtoClient.getSsoConnector(connectorId);
if (connector == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Connector not found");
}
// Logto resolves providerConfig (OIDC discovery / SAML metadata) when fetching.
// If providerConfig is present and non-empty, the IdP is reachable.
@SuppressWarnings("unchecked")
var providerConfig = (Map<String, Object>) connector.get("providerConfig");
boolean reachable = providerConfig != null && !providerConfig.isEmpty();
return Map.of(
"status", reachable ? "ok" : "unreachable",
"providerName", String.valueOf(connector.get("providerName")),
"connectorName", String.valueOf(connector.get("connectorName"))
);
}
private String resolveOrgId() {
UUID tenantId = TenantContext.getTenantId();
TenantEntity tenant = tenantService.getById(tenantId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Tenant not found"));
String orgId = tenant.getLogtoOrgId();
if (orgId == null || orgId.isBlank()) {
throw new ResponseStatusException(HttpStatus.PRECONDITION_FAILED, "Tenant has no Logto organization");
}
return orgId;
}
private void validateConnectorBelongsToTenant(String connectorId) {
String orgId = resolveOrgId();
List<Map<String, Object>> jitConnectors = logtoClient.getOrgJitSsoConnectors(orgId);
boolean linked = jitConnectors.stream()
.anyMatch(c -> connectorId.equals(String.valueOf(c.get("id"))));
if (!linked) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SSO connector does not belong to this tenant");
}
}
}

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './client';
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters } from '../types/api';
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult } from '../types/api';
export function useTenantDashboard() {
return useQuery<DashboardData>({
@@ -17,18 +17,49 @@ export function useTenantLicense() {
});
}
export function useTenantOidc() {
return useQuery<Record<string, unknown>>({
queryKey: ['tenant', 'oidc'],
queryFn: () => api.get('/tenant/oidc'),
// SSO connector hooks
export function useSsoConnectors() {
return useQuery<SsoConnector[]>({
queryKey: ['tenant', 'sso'],
queryFn: () => api.get('/tenant/sso'),
});
}
export function useUpdateOidc() {
export function useSsoConnector(id: string | null) {
return useQuery<SsoConnector>({
queryKey: ['tenant', 'sso', id],
queryFn: () => api.get(`/tenant/sso/${id}`),
enabled: !!id,
});
}
export function useCreateSsoConnector() {
const qc = useQueryClient();
return useMutation<void, Error, Record<string, unknown>>({
mutationFn: (config) => api.post('/tenant/oidc', config),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'oidc'] }),
return useMutation<SsoConnector, Error, CreateSsoConnectorRequest>({
mutationFn: (req) => api.post('/tenant/sso', req),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'sso'] }),
});
}
export function useUpdateSsoConnector() {
const qc = useQueryClient();
return useMutation<SsoConnector, Error, { id: string; updates: Record<string, unknown> }>({
mutationFn: ({ id, updates }) => api.patch(`/tenant/sso/${id}`, updates),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'sso'] }),
});
}
export function useDeleteSsoConnector() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => api.delete(`/tenant/sso/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'sso'] }),
});
}
export function useTestSsoConnector() {
return useMutation<SsoTestResult, Error, string>({
mutationFn: (id) => api.post(`/tenant/sso/${id}/test`),
});
}

View File

@@ -4,7 +4,7 @@ import {
Sidebar,
TopBar,
} from '@cameleer/design-system';
import { LayoutDashboard, ShieldCheck, Server, Users, Settings, KeyRound, Building, Fingerprint, ScrollText } from 'lucide-react';
import { LayoutDashboard, ShieldCheck, Server, Users, Settings, Shield, Building, Fingerprint, ScrollText } from 'lucide-react';
import { useAuth } from '../auth/useAuth';
import { useScopes } from '../auth/useScopes';
import { useOrgStore } from '../auth/useOrganization';
@@ -117,11 +117,11 @@ export function Layout() {
</Sidebar.Section>
<Sidebar.Section
icon={<KeyRound size={16} />}
label="OIDC"
icon={<Shield size={16} />}
label="SSO"
open={false}
active={isActive(location, '/tenant/oidc')}
onToggle={() => navigate('/tenant/oidc')}
active={isActive(location, '/tenant/sso')}
onToggle={() => navigate('/tenant/sso')}
>
{null}
</Sidebar.Section>

View File

@@ -1,187 +0,0 @@
import { useState, useEffect } from 'react';
import {
Alert,
Badge,
Button,
Card,
FormField,
Input,
Spinner,
useToast,
} from '@cameleer/design-system';
import { useTenantOidc, useUpdateOidc } from '../../api/tenant-hooks';
import styles from '../../styles/platform.module.css';
interface OidcFormState {
issuerUri: string;
clientId: string;
clientSecret: string;
audience: string;
rolesClaim: string;
}
const EMPTY_FORM: OidcFormState = {
issuerUri: '',
clientId: '',
clientSecret: '',
audience: '',
rolesClaim: '',
};
function isExternalOidc(config: Record<string, unknown>): boolean {
return typeof config['issuerUri'] === 'string' && config['issuerUri'] !== '';
}
export function OidcConfigPage() {
const { data: oidcConfig, isLoading, isError } = useTenantOidc();
const updateOidc = useUpdateOidc();
const { toast } = useToast();
const [form, setForm] = useState<OidcFormState>(EMPTY_FORM);
// Pre-fill form when config loads
useEffect(() => {
if (!oidcConfig) return;
setForm({
issuerUri: String(oidcConfig['issuerUri'] ?? ''),
clientId: String(oidcConfig['clientId'] ?? ''),
clientSecret: String(oidcConfig['clientSecret'] ?? ''),
audience: String(oidcConfig['audience'] ?? ''),
rolesClaim: String(oidcConfig['rolesClaim'] ?? ''),
});
}, [oidcConfig]);
function handleChange(field: keyof OidcFormState, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
try {
await updateOidc.mutateAsync({
issuerUri: form.issuerUri,
clientId: form.clientId,
clientSecret: form.clientSecret,
audience: form.audience,
rolesClaim: form.rolesClaim,
});
toast({ title: 'OIDC configuration saved', variant: 'success' });
} catch (err) {
toast({ title: 'Save failed', description: String(err), variant: 'error' });
}
}
async function handleReset() {
try {
await updateOidc.mutateAsync({});
setForm(EMPTY_FORM);
toast({ title: 'Reset to Logto (default)', variant: 'success' });
} catch (err) {
toast({ title: 'Reset failed', description: String(err), variant: 'error' });
}
}
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spinner />
</div>
);
}
if (isError) {
return (
<div style={{ padding: 24 }}>
<Alert variant="error" title="Failed to load OIDC configuration">
Could not fetch OIDC config. Please refresh.
</Alert>
</div>
);
}
const hasExternalOidc = oidcConfig ? isExternalOidc(oidcConfig) : false;
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>OIDC Configuration</h1>
<Badge
label={hasExternalOidc ? 'External OIDC configured' : 'Using Logto (default)'}
color={hasExternalOidc ? 'primary' : 'auto'}
/>
</div>
<Card title="External OIDC Provider">
<p className={styles.description} style={{ marginBottom: 16 }}>
Configure an external OIDC provider for your team to sign in. Leave blank to use the
default Logto-based authentication.
</p>
<form onSubmit={handleSave} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Issuer URI" htmlFor="oidc-issuer">
<Input
id="oidc-issuer"
type="url"
placeholder="https://your-idp.example.com/oidc"
value={form.issuerUri}
onChange={(e) => handleChange('issuerUri', e.target.value)}
/>
</FormField>
<FormField label="Client ID" htmlFor="oidc-client-id">
<Input
id="oidc-client-id"
placeholder="your-client-id"
value={form.clientId}
onChange={(e) => handleChange('clientId', e.target.value)}
/>
</FormField>
<FormField label="Client Secret" htmlFor="oidc-client-secret">
<Input
id="oidc-client-secret"
type="password"
placeholder="••••••••"
value={form.clientSecret}
onChange={(e) => handleChange('clientSecret', e.target.value)}
/>
</FormField>
<FormField label="Audience" htmlFor="oidc-audience">
<Input
id="oidc-audience"
placeholder="https://api.your-service.example.com"
value={form.audience}
onChange={(e) => handleChange('audience', e.target.value)}
/>
</FormField>
<FormField label="Roles Claim" htmlFor="oidc-roles-claim">
<Input
id="oidc-roles-claim"
placeholder="roles"
value={form.rolesClaim}
onChange={(e) => handleChange('rolesClaim', e.target.value)}
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={updateOidc.isPending}>
Save Configuration
</Button>
{hasExternalOidc && (
<Button
type="button"
variant="secondary"
onClick={handleReset}
loading={updateOidc.isPending}
>
Reset to Logto
</Button>
)}
</div>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { useState } from 'react';
import {
Alert, AlertDialog, Badge, Button, Card, DataTable,
EmptyState, FormField, Input, Spinner, useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { Shield, Plus, Trash2, FlaskConical } from 'lucide-react';
import {
useSsoConnectors, useCreateSsoConnector, useDeleteSsoConnector, useTestSsoConnector,
} from '../../api/tenant-hooks';
import type { SsoConnector } from '../../types/api';
const PROVIDERS = [
{ value: 'OIDC', label: 'OIDC', type: 'oidc' },
{ value: 'SAML', label: 'SAML', type: 'saml' },
{ value: 'AzureAD', label: 'Azure AD (SAML)', type: 'saml' },
{ value: 'AzureAdOidc', label: 'Azure AD (OIDC)', type: 'oidc' },
{ value: 'GoogleWorkspace', label: 'Google Workspace', type: 'oidc' },
{ value: 'Okta', label: 'Okta', type: 'oidc' },
];
function providerType(name: string): 'oidc' | 'saml' {
return PROVIDERS.find((p) => p.value === name)?.type as 'oidc' | 'saml' ?? 'oidc';
}
function providerLabel(name: string): string {
return PROVIDERS.find((p) => p.value === name)?.label ?? name;
}
const selectStyle: React.CSSProperties = {
width: '100%', padding: '6px 10px', borderRadius: 6,
border: '1px solid var(--border)', background: 'var(--bg-surface)',
color: 'var(--text-primary)', fontSize: '0.875rem',
};
export function SsoPage() {
const { data: connectors, isLoading, isError } = useSsoConnectors();
const createConnector = useCreateSsoConnector();
const deleteConnector = useDeleteSsoConnector();
const testConnector = useTestSsoConnector();
const { toast } = useToast();
const [showCreate, setShowCreate] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SsoConnector | null>(null);
// Create form state
const [provider, setProvider] = useState('OIDC');
const [connectorName, setConnectorName] = useState('');
const [domains, setDomains] = useState('');
// OIDC fields
const [issuer, setIssuer] = useState('');
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
// SAML fields
const [metadataUrl, setMetadataUrl] = useState('');
function resetForm() {
setProvider('OIDC');
setConnectorName('');
setDomains('');
setIssuer('');
setClientId('');
setClientSecret('');
setMetadataUrl('');
setShowCreate(false);
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const type = providerType(provider);
const config: Record<string, unknown> = type === 'oidc'
? { clientId, clientSecret, issuer: issuer || undefined }
: { metadataUrl };
try {
await createConnector.mutateAsync({
providerName: provider,
connectorName,
config,
domains: domains.split(',').map((d) => d.trim()).filter(Boolean),
});
toast({ title: `SSO connector "${connectorName}" created`, variant: 'success' });
resetForm();
} catch (err) {
toast({ title: 'Failed to create connector', description: String(err), variant: 'error' });
}
}
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteConnector.mutateAsync(deleteTarget.id);
toast({ title: `Deleted "${deleteTarget.connectorName}"`, variant: 'success' });
setDeleteTarget(null);
} catch (err) {
toast({ title: 'Delete failed', description: String(err), variant: 'error' });
setDeleteTarget(null);
}
}
async function handleTest(connector: SsoConnector) {
try {
const result = await testConnector.mutateAsync(connector.id);
if (result.status === 'ok') {
toast({ title: 'Connection test passed', description: `${connector.connectorName} is reachable`, variant: 'success' });
} else {
toast({ title: 'Connection test failed', description: 'IdP metadata could not be resolved', variant: 'warning' });
}
} catch (err) {
toast({ title: 'Connection test failed', description: String(err), variant: 'error' });
}
}
const columns: Column<SsoConnector>[] = [
{
key: 'connectorName',
header: 'Name',
render: (_v, row) => row.connectorName,
},
{
key: 'providerName',
header: 'Provider',
render: (_v, row) => <Badge label={providerLabel(row.providerName)} color="primary" />,
},
{
key: 'domains',
header: 'Domains',
render: (_v, row) => (
<span style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }}>
{row.domains?.join(', ') || '--'}
</span>
),
},
{
key: 'id',
header: 'Actions',
render: (_v, row) => (
<div style={{ display: 'flex', gap: 4 }}>
<Button
variant="secondary"
onClick={(e) => { e.stopPropagation(); handleTest(row); }}
disabled={testConnector.isPending}
>
<FlaskConical size={14} style={{ marginRight: 4 }} />
Test
</Button>
<Button
variant="danger"
onClick={(e) => { e.stopPropagation(); setDeleteTarget(row); }}
>
<Trash2 size={14} />
</Button>
</div>
),
},
];
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spinner />
</div>
);
}
if (isError) {
return (
<div style={{ padding: 24 }}>
<Alert variant="error" title="Failed to load SSO connectors">
Could not fetch SSO configuration. Please refresh.
</Alert>
</div>
);
}
const isOidc = providerType(provider) === 'oidc';
return (
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>Enterprise SSO</h1>
<Button variant="primary" onClick={() => setShowCreate((v) => !v)}>
<Plus size={16} style={{ marginRight: 6 }} />
Add SSO Connection
</Button>
</div>
{showCreate && (
<Card title="New SSO Connection">
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Provider" htmlFor="sso-provider">
<select id="sso-provider" value={provider} onChange={(e) => setProvider(e.target.value)} style={selectStyle}>
{PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</FormField>
<FormField label="Display Name" htmlFor="sso-name">
<Input
id="sso-name"
placeholder="e.g. Acme Corp SSO"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
required
/>
</FormField>
<FormField label="Email Domains" htmlFor="sso-domains" hint="Comma-separated domains for SSO routing (e.g. acme.com)">
<Input
id="sso-domains"
placeholder="acme.com, acme.io"
value={domains}
onChange={(e) => setDomains(e.target.value)}
/>
</FormField>
{isOidc ? (
<>
{provider !== 'GoogleWorkspace' && (
<FormField label="Issuer URI" htmlFor="sso-issuer">
<Input
id="sso-issuer"
type="url"
placeholder="https://login.microsoftonline.com/{tenant}/v2.0"
value={issuer}
onChange={(e) => setIssuer(e.target.value)}
required
/>
</FormField>
)}
<FormField label="Client ID" htmlFor="sso-client-id">
<Input
id="sso-client-id"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
required
/>
</FormField>
<FormField label="Client Secret" htmlFor="sso-client-secret">
<Input
id="sso-client-secret"
type="password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
required
/>
</FormField>
</>
) : (
<FormField label="SAML Metadata URL" htmlFor="sso-metadata">
<Input
id="sso-metadata"
type="url"
placeholder="https://idp.acme.com/metadata.xml"
value={metadataUrl}
onChange={(e) => setMetadataUrl(e.target.value)}
required
/>
</FormField>
)}
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={createConnector.isPending}>
Create
</Button>
<Button type="button" variant="secondary" onClick={resetForm}>
Cancel
</Button>
</div>
</form>
</Card>
)}
{(!connectors || connectors.length === 0) && !showCreate && (
<EmptyState
icon={<Shield size={32} />}
title="No SSO connections"
description="Add an enterprise SSO connection to let your team sign in with their corporate identity provider."
action={
<Button variant="primary" onClick={() => setShowCreate(true)}>
<Plus size={16} style={{ marginRight: 6 }} />
Add SSO Connection
</Button>
}
/>
)}
{connectors && connectors.length > 0 && (
<DataTable columns={columns} data={connectors} />
)}
<AlertDialog
open={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title="Delete SSO Connection"
description={`Are you sure you want to delete "${deleteTarget?.connectorName ?? ''}"? Users authenticating via this provider will lose access.`}
confirmLabel="Delete"
cancelLabel="Cancel"
variant="danger"
loading={deleteConnector.isPending}
/>
</div>
);
}

View File

@@ -14,7 +14,7 @@ import { TenantDetailPage } from './pages/vendor/TenantDetailPage';
import { VendorAuditPage } from './pages/vendor/VendorAuditPage';
import { TenantDashboardPage } from './pages/tenant/TenantDashboardPage';
import { TenantLicensePage } from './pages/tenant/TenantLicensePage';
import { OidcConfigPage } from './pages/tenant/OidcConfigPage';
import { SsoPage } from './pages/tenant/SsoPage';
import { TeamPage } from './pages/tenant/TeamPage';
import { SettingsPage } from './pages/tenant/SettingsPage';
import { TenantAuditPage } from './pages/tenant/TenantAuditPage';
@@ -77,7 +77,7 @@ export function AppRouter() {
{/* Tenant portal */}
<Route path="/tenant" element={<TenantDashboardPage />} />
<Route path="/tenant/license" element={<TenantLicensePage />} />
<Route path="/tenant/oidc" element={<OidcConfigPage />} />
<Route path="/tenant/sso" element={<SsoPage />} />
<Route path="/tenant/team" element={<TeamPage />} />
<Route path="/tenant/audit" element={<TenantAuditPage />} />
<Route path="/tenant/settings" element={<SettingsPage />} />

View File

@@ -94,6 +94,31 @@ export interface TenantSettings {
createdAt: string;
}
// SSO connector types
export interface SsoConnector {
id: string;
providerName: string;
connectorName: string;
config: Record<string, unknown>;
domains: string[];
branding?: { displayName?: string; logo?: string };
syncProfile: boolean;
createdAt: string;
}
export interface CreateSsoConnectorRequest {
providerName: string;
connectorName: string;
config: Record<string, unknown>;
domains: string[];
}
export interface SsoTestResult {
status: string;
providerName: string;
connectorName: string;
}
// Audit log types
export interface AuditLogEntry {
id: string;