import { useState, useEffect } from 'react'; import { Input } from '@/components/ui/input'; import { TogglePill } from '@/components/ui/toggle-pill'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Combobox } from '@/components/ui/combobox'; import { Badge } from '@/components/ui/badge'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { CapabilityGate } from './CapabilityGate'; import { PaidGate } from './PaidGate'; import { Loader2, CheckCircle, XCircle } from 'lucide-react'; import { SettingsPrimaryButton } from './settings/SettingsActions'; import { useMastheadStats } from './settings/MastheadStatsContext'; const ROLE_OPTIONS = [ { value: 'viewer', label: 'Viewer' }, { value: 'admin', label: 'Admin' }, ]; interface SSOProviderConfig { provider: string; enabled: boolean; displayName: string; // LDAP ldapUrl?: string; ldapBindDn?: string; ldapBindPassword?: string; ldapSearchBase?: string; ldapSearchFilter?: string; ldapAdminGroupDn?: string; ldapDefaultRole?: string; ldapTlsRejectUnauthorized?: boolean; // OIDC oidcIssuerUrl?: string; oidcClientId?: string; oidcClientSecret?: string; oidcScopes?: string; oidcAdminClaim?: string; oidcAdminClaimValue?: string; oidcDefaultRole?: string; // Custom OIDC claim mapping oidcIdClaim?: string; oidcUsernameClaim?: string; oidcEmailClaim?: string; } // Ordered by tier: free OIDC (Custom + presets) first, then LDAP/AD (paid). // The ordering reinforces the free → paid progression in the UI. const PROVIDERS = [ { id: 'oidc_custom', label: 'Custom OIDC', type: 'oidc' as const }, { id: 'oidc_google', label: 'Google', type: 'oidc' as const }, { id: 'oidc_github', label: 'GitHub', type: 'oidc' as const }, { id: 'oidc_okta', label: 'Okta', type: 'oidc' as const }, { id: 'ldap', label: 'LDAP / Active Directory', type: 'ldap' as const }, ]; function ProviderCard({ providerId, type, label, initialConfig, onSave }: { providerId: string; type: 'ldap' | 'oidc'; label: string; initialConfig: SSOProviderConfig | null; onSave: () => void; }) { const [config, setConfig] = useState>(initialConfig || { enabled: false }); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null); const [expanded, setExpanded] = useState(!!initialConfig?.enabled); const update = (field: string, value: string | boolean) => { setConfig(prev => ({ ...prev, [field]: value })); }; const handleSave = async () => { setSaving(true); try { const body = { ...config, provider: providerId, displayName: config.displayName || label, }; const res = await apiFetch(`/sso/config/${providerId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (res.ok) { toast.success('SSO configuration saved'); onSave(); } else { const data = await res.json(); toast.error(data?.error || data?.message || 'Failed to save'); } } catch (error: unknown) { toast.error((error as Error)?.message || 'Failed to save SSO configuration'); } finally { setSaving(false); } }; const handleTest = async () => { setTesting(true); setTestResult(null); try { const res = await apiFetch(`/sso/config/${providerId}/test`, { method: 'POST' }); const data = await res.json().catch(() => null); if (!res.ok) { const message = data?.error || data?.message || 'Connection test failed'; setTestResult({ success: false, error: message }); toast.error(message); return; } setTestResult(data); if (data?.success) { toast.success('Connection successful'); } else { toast.error(data?.error || 'Connection failed'); } } catch (error: unknown) { const message = (error as Error)?.message || 'Connection test failed'; setTestResult({ success: false, error: message }); toast.error(message); } finally { setTesting(false); } }; const handleDelete = async () => { try { const res = await apiFetch(`/sso/config/${providerId}`, { method: 'DELETE' }); if (res.ok) { toast.success('SSO provider removed'); setConfig({ enabled: false }); setExpanded(false); onSave(); } else { const data = await res.json().catch(() => null); toast.error(data?.error || data?.message || 'Failed to remove provider'); } } catch (error: unknown) { toast.error((error as Error)?.message || 'Failed to remove provider'); } }; return (
setExpanded(!expanded)} >
{label} {initialConfig?.enabled && ( Active )}
update('enabled', checked)} onClick={(e) => e.stopPropagation()} />
{expanded && (
{type === 'ldap' ? ( <>
update('ldapUrl', e.target.value)} />
update('ldapBindDn', e.target.value)} />
update('ldapBindPassword', e.target.value)} />
update('ldapSearchBase', e.target.value)} />
update('ldapSearchFilter', e.target.value)} />

Use {'{{username}}'} as placeholder. For Active Directory: {'(sAMAccountName={{username}})'}

update('ldapAdminGroupDn', e.target.value)} />
update('ldapDefaultRole', v)} placeholder="Select role" />
update('ldapTlsRejectUnauthorized', checked)} />
) : ( <> {providerId === 'oidc_custom' && (
update('displayName', e.target.value)} />

Name shown on the login button (e.g., "Corporate SSO").

)} {(providerId === 'oidc_okta' || providerId === 'oidc_custom') && (
update('oidcIssuerUrl', e.target.value)} /> {providerId === 'oidc_custom' && (

Base URL of the OIDC discovery endpoint (without /.well-known/openid-configuration).

)}
)}
update('oidcClientId', e.target.value)} />
update('oidcClientSecret', e.target.value)} />
update('oidcAdminClaim', e.target.value)} />
update('oidcAdminClaimValue', e.target.value)} />
update('oidcScopes', e.target.value)} />

Space-separated list of OAuth scopes. Leave blank for default.

update('oidcDefaultRole', v)} placeholder="Select role" />
{providerId === 'oidc_custom' && ( <>
update('oidcIdClaim', e.target.value)} />
update('oidcUsernameClaim', e.target.value)} />
update('oidcEmailClaim', e.target.value)} />

Map claims from your provider's token to Sencho user fields. Leave blank for standard OIDC defaults.

)} )}
{saving ? <> Saving : 'Save'} {testResult && ( testResult.success ? : )}
{initialConfig && ( )}
)}
); } // Mirrors the backend tier split in ssoConfig.ts requireTierForProvider: Custom OIDC // and preset OIDC (Google/GitHub/Okta) are free, LDAP/AD requires the paid plan. function ProviderCardWithGate(props: { providerId: string; type: 'ldap' | 'oidc'; label: string; initialConfig: SSOProviderConfig | null; onSave: () => void; }) { const card = ; if (props.providerId === 'ldap') { return {card}; } return card; } export function SSOSection() { const [configs, setConfigs] = useState([]); const fetchConfigs = async () => { try { const res = await apiFetch('/sso/config'); if (res.ok) { setConfigs(await res.json()); } else { const data = await res.json().catch(() => null); toast.error(data?.error || data?.message || 'Failed to load SSO configuration'); } } catch (error: unknown) { toast.error((error as Error)?.message || 'Failed to load SSO configuration'); } }; // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { fetchConfigs(); }, []); const enabledProviders = configs.filter(c => c.enabled).length; useMastheadStats([ { label: 'PROVIDERS', value: `${configs.length}` }, { label: 'ENABLED', value: `${enabledProviders}`, tone: enabledProviders > 0 ? 'value' : 'subtitle', }, ]); const getConfig = (provider: string) => configs.find(c => c.provider === provider) || null; return (
{PROVIDERS.map(p => ( ))}

SSO users are automatically provisioned on first login and assigned a role based on your identity provider's group membership.

For OIDC providers, set the OAuth callback URL to: {'https:///api/auth/sso/oidc//callback'}

); }