From e559267f1e5a779ed3697f9ff76e1a9e783abdbe Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:48:51 +0200 Subject: [PATCH] feat: replace tenant OIDC page with Enterprise SSO connector management - 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) --- .../saas/identity/LogtoManagementClient.java | 112 +++++++ .../saas/portal/TenantPortalController.java | 11 - .../saas/portal/TenantPortalService.java | 18 -- .../saas/portal/TenantSsoController.java | 68 ++++ .../saas/portal/TenantSsoService.java | 124 +++++++ ui/src/api/tenant-hooks.ts | 49 ++- ui/src/components/Layout.tsx | 10 +- ui/src/pages/tenant/OidcConfigPage.tsx | 187 ----------- ui/src/pages/tenant/SsoPage.tsx | 306 ++++++++++++++++++ ui/src/router.tsx | 4 +- ui/src/types/api.ts | 25 ++ 11 files changed, 682 insertions(+), 232 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/portal/TenantSsoController.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/portal/TenantSsoService.java delete mode 100644 ui/src/pages/tenant/OidcConfigPage.tsx create mode 100644 ui/src/pages/tenant/SsoPage.tsx diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java index e617f92..7b2129b 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -286,6 +286,118 @@ public class LogtoManagementClient { } } + // --- SSO Connector Management --- + + /** List all SSO connectors. */ + @SuppressWarnings("unchecked") + public List> 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 createSsoConnector(String providerName, String connectorName, + Map connectorConfig, List domains) { + if (!isAvailable()) return null; + var body = new java.util.HashMap(); + 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) 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 getSsoConnector(String connectorId) { + if (!isAvailable()) return null; + return (Map) 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 updateSsoConnector(String connectorId, Map updates) { + if (!isAvailable()) return null; + return (Map) 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> 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() { diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java index ab1e472..fb85e3f 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java @@ -46,17 +46,6 @@ public class TenantPortalController { return ResponseEntity.ok(license); } - @GetMapping("/oidc") - public ResponseEntity> getOidcConfig() { - return ResponseEntity.ok(portalService.getOidcConfig()); - } - - @PutMapping("/oidc") - public ResponseEntity updateOidcConfig(@RequestBody Map body) { - portalService.updateOidcConfig(body); - return ResponseEntity.ok().build(); - } - @GetMapping("/team") public ResponseEntity>> listTeamMembers() { return ResponseEntity.ok(portalService.listTeamMembers()); diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java index 8ce144f..0518d6e 100644 --- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java @@ -116,24 +116,6 @@ public class TenantPortalService { .orElse(null); } - public Map getOidcConfig() { - TenantEntity tenant = resolveTenant(); - String endpoint = tenant.getServerEndpoint(); - if (endpoint == null || endpoint.isBlank()) { - return Map.of(); - } - return serverApiClient.getOidcConfig(endpoint); - } - - public void updateOidcConfig(Map 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> listTeamMembers() { TenantEntity tenant = resolveTenant(); String orgId = tenant.getLogtoOrgId(); diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantSsoController.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantSsoController.java new file mode 100644 index 0000000..95c8fc2 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantSsoController.java @@ -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 config, + List domains + ) {} + + @GetMapping + public ResponseEntity>> list() { + return ResponseEntity.ok(ssoService.listConnectors()); + } + + @PostMapping + public ResponseEntity> 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> get(@PathVariable String connectorId) { + return ResponseEntity.ok(ssoService.getConnector(connectorId)); + } + + @PatchMapping("/{connectorId}") + public ResponseEntity> update(@PathVariable String connectorId, + @RequestBody Map updates) { + return ResponseEntity.ok(ssoService.updateConnector(connectorId, updates)); + } + + @DeleteMapping("/{connectorId}") + public ResponseEntity delete(@PathVariable String connectorId) { + ssoService.deleteConnector(connectorId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{connectorId}/test") + public ResponseEntity> test(@PathVariable String connectorId) { + return ResponseEntity.ok(ssoService.testConnector(connectorId)); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantSsoService.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantSsoService.java new file mode 100644 index 0000000..4671d08 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantSsoService.java @@ -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> listConnectors() { + String orgId = resolveOrgId(); + List> jitConnectors = logtoClient.getOrgJitSsoConnectors(orgId); + Set 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> 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 createConnector(String providerName, String connectorName, + Map config, List 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 getConnector(String connectorId) { + validateConnectorBelongsToTenant(connectorId); + return logtoClient.getSsoConnector(connectorId); + } + + /** Update an SSO connector (validates it belongs to this tenant). */ + public Map updateConnector(String connectorId, Map 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 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) 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> 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"); + } + } +} diff --git a/ui/src/api/tenant-hooks.ts b/ui/src/api/tenant-hooks.ts index d6d2e93..85b2371 100644 --- a/ui/src/api/tenant-hooks.ts +++ b/ui/src/api/tenant-hooks.ts @@ -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({ @@ -17,18 +17,49 @@ export function useTenantLicense() { }); } -export function useTenantOidc() { - return useQuery>({ - queryKey: ['tenant', 'oidc'], - queryFn: () => api.get('/tenant/oidc'), +// SSO connector hooks +export function useSsoConnectors() { + return useQuery({ + queryKey: ['tenant', 'sso'], + queryFn: () => api.get('/tenant/sso'), }); } -export function useUpdateOidc() { +export function useSsoConnector(id: string | null) { + return useQuery({ + queryKey: ['tenant', 'sso', id], + queryFn: () => api.get(`/tenant/sso/${id}`), + enabled: !!id, + }); +} + +export function useCreateSsoConnector() { const qc = useQueryClient(); - return useMutation>({ - mutationFn: (config) => api.post('/tenant/oidc', config), - onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'oidc'] }), + return useMutation({ + mutationFn: (req) => api.post('/tenant/sso', req), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'sso'] }), + }); +} + +export function useUpdateSsoConnector() { + const qc = useQueryClient(); + return useMutation }>({ + mutationFn: ({ id, updates }) => api.patch(`/tenant/sso/${id}`, updates), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'sso'] }), + }); +} + +export function useDeleteSsoConnector() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.delete(`/tenant/sso/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'sso'] }), + }); +} + +export function useTestSsoConnector() { + return useMutation({ + mutationFn: (id) => api.post(`/tenant/sso/${id}/test`), }); } diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index f30f593..0df6c6d 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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() { } - label="OIDC" + icon={} + label="SSO" open={false} - active={isActive(location, '/tenant/oidc')} - onToggle={() => navigate('/tenant/oidc')} + active={isActive(location, '/tenant/sso')} + onToggle={() => navigate('/tenant/sso')} > {null} diff --git a/ui/src/pages/tenant/OidcConfigPage.tsx b/ui/src/pages/tenant/OidcConfigPage.tsx deleted file mode 100644 index 34840f4..0000000 --- a/ui/src/pages/tenant/OidcConfigPage.tsx +++ /dev/null @@ -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): 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(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 ( -
- -
- ); - } - - if (isError) { - return ( -
- - Could not fetch OIDC config. Please refresh. - -
- ); - } - - const hasExternalOidc = oidcConfig ? isExternalOidc(oidcConfig) : false; - - return ( -
-
-

OIDC Configuration

- -
- - -

- Configure an external OIDC provider for your team to sign in. Leave blank to use the - default Logto-based authentication. -

- -
- - handleChange('issuerUri', e.target.value)} - /> - - - - handleChange('clientId', e.target.value)} - /> - - - - handleChange('clientSecret', e.target.value)} - /> - - - - handleChange('audience', e.target.value)} - /> - - - - handleChange('rolesClaim', e.target.value)} - /> - - -
- - {hasExternalOidc && ( - - )} -
-
-
-
- ); -} diff --git a/ui/src/pages/tenant/SsoPage.tsx b/ui/src/pages/tenant/SsoPage.tsx new file mode 100644 index 0000000..560944e --- /dev/null +++ b/ui/src/pages/tenant/SsoPage.tsx @@ -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(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 = 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[] = [ + { + key: 'connectorName', + header: 'Name', + render: (_v, row) => row.connectorName, + }, + { + key: 'providerName', + header: 'Provider', + render: (_v, row) => , + }, + { + key: 'domains', + header: 'Domains', + render: (_v, row) => ( + + {row.domains?.join(', ') || '--'} + + ), + }, + { + key: 'id', + header: 'Actions', + render: (_v, row) => ( +
+ + +
+ ), + }, + ]; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ + Could not fetch SSO configuration. Please refresh. + +
+ ); + } + + const isOidc = providerType(provider) === 'oidc'; + + return ( +
+
+

Enterprise SSO

+ +
+ + {showCreate && ( + +
+ + + + + + setConnectorName(e.target.value)} + required + /> + + + + setDomains(e.target.value)} + /> + + + {isOidc ? ( + <> + {provider !== 'GoogleWorkspace' && ( + + setIssuer(e.target.value)} + required + /> + + )} + + setClientId(e.target.value)} + required + /> + + + setClientSecret(e.target.value)} + required + /> + + + ) : ( + + setMetadataUrl(e.target.value)} + required + /> + + )} + +
+ + +
+
+
+ )} + + {(!connectors || connectors.length === 0) && !showCreate && ( + } + title="No SSO connections" + description="Add an enterprise SSO connection to let your team sign in with their corporate identity provider." + action={ + + } + /> + )} + + {connectors && connectors.length > 0 && ( + + )} + + 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} + /> +
+ ); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 828aa7a..2ef7a47 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -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 */} } /> } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index 4341005..8eb8658 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -94,6 +94,31 @@ export interface TenantSettings { createdAt: string; } +// SSO connector types +export interface SsoConnector { + id: string; + providerName: string; + connectorName: string; + config: Record; + domains: string[]; + branding?: { displayName?: string; logo?: string }; + syncProfile: boolean; + createdAt: string; +} + +export interface CreateSsoConnectorRequest { + providerName: string; + connectorName: string; + config: Record; + domains: string[]; +} + +export interface SsoTestResult { + status: string; + providerName: string; + connectorName: string; +} + // Audit log types export interface AuditLogEntry { id: string;