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

@@ -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;