'use client' import { type ReactNode, useState } from 'react' import { Button, ChipCombobox, ChipInput, ChipSelect, ChipTextarea, cn, Expandable, ExpandableContent, Label, Switch, toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react' import type { SsoRegistrationBody } from '@/lib/api/contracts/auth' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { isBillingEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { getUserRole } from '@/lib/workspaces/organization/utils' import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso' import { useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' const logger = createLogger('SSO') interface FormFieldProps { label: ReactNode children: ReactNode optional?: boolean error?: ReactNode } /** * Page-level labeled-field row for the SSO settings form, matching the * standalone-field rhythm: muted label, control, then a caption-sized error. */ function FormField({ label, children, optional = false, error }: FormFieldProps) { return (
{children} {error ?

{error}

: null}
) } interface SSOProvider { id: string providerId: string domain: string issuer: string organizationId: string userId?: string oidcConfig?: string samlConfig?: string providerType: 'oidc' | 'saml' } const DEFAULT_FORM_DATA = { providerType: 'oidc' as 'oidc' | 'saml', providerId: '', issuerUrl: '', domain: '', clientId: '', clientSecret: '', scopes: 'openid,profile,email', entryPoint: '', cert: '', callbackUrl: '', audience: '', wantAssertionsSigned: true, idpMetadata: '', } const DEFAULT_ERRORS = { providerType: [], providerId: [], issuerUrl: [], domain: [], clientId: [], clientSecret: [], entryPoint: [], cert: [], scopes: [], callbackUrl: [], audience: [], } export function SSO() { const { data: session } = useSession() const { data: orgsData } = useOrganizations() const { data: subscriptionData } = useSubscriptionData() const activeOrganization = orgsData?.activeOrganization const { data: providersData, isLoading: isLoadingProviders } = useSSOProviders({ organizationId: activeOrganization?.id, }) const providers = providersData?.providers || [] const existingProvider = providers[0] as SSOProvider | undefined const userEmail = session?.user?.email const userId = session?.user?.id const userRole = getUserRole(orgsData?.activeOrganization, userEmail) const isOwner = userRole === 'owner' const isAdmin = userRole === 'admin' const canManageSSO = isOwner || isAdmin const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data) const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess const isSSOProviderOwner = !isBillingEnabled && userId ? providers.some((p) => p.userId === userId) : null const configureSSOMutation = useConfigureSSO() const [showClientSecret, setShowClientSecret] = useState(false) const [copied, setCopied] = useState(false) const [isEditing, setIsEditing] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) const [formData, setFormData] = useState(DEFAULT_FORM_DATA) const [originalFormData, setOriginalFormData] = useState(DEFAULT_FORM_DATA) const [errors, setErrors] = useState>(DEFAULT_ERRORS) const [showErrors, setShowErrors] = useState(false) const hasChanges = (Object.keys(formData) as (keyof typeof formData)[]).some( (k) => formData[k] !== originalFormData[k] ) useSettingsUnsavedGuard({ isDirty: hasChanges }) if (isBillingEnabled) { if (!activeOrganization) { return ( You must be part of an organization to configure Single Sign-On. ) } if (!hasEnterprisePlan) { return ( Single Sign-On is available on Enterprise plans only. ) } if (!canManageSSO) { return ( Only organization owners and admins can configure Single Sign-On settings. ) } } else { if (activeOrganization && !canManageSSO) { return ( Only organization owners and admins can configure Single Sign-On settings. ) } if ( !activeOrganization && !isLoadingProviders && isSSOProviderOwner === false && providers.length > 0 ) { return ( Only the user who configured SSO can manage these settings. ) } } const validateProviderId = (value: string): string[] => { if (!value || !value.trim()) return ['Provider ID is required.'] if (!/^[-a-z0-9]+$/i.test(value.trim())) return ['Use letters, numbers, and dashes only.'] return [] } const validateIssuerUrl = (value: string): string[] => { const out: string[] = [] if (!value || !value.trim()) return ['Issuer URL is required.'] try { const url = new URL(value.trim()) const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1' if (url.protocol !== 'https:' && !isLocalhost) { out.push('Issuer URL must use HTTPS.') } } catch { out.push('Enter a valid issuer URL like https://your-identity-provider.com/oauth2/default') } return out } const validateDomain = (value: string): string[] => { const out: string[] = [] if (!value || !value.trim()) return ['Domain is required.'] if (/^https?:\/\//i.test(value.trim())) out.push('Do not include protocol (https://).') if (!/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(value.trim())) out.push('Enter a valid domain like company.com') return out } const validateRequired = (label: string, value: string): string[] => { const out: string[] = [] if (!value || !value.trim()) out.push(`${label} is required.`) return out } const validateAll = (data: typeof formData) => { const newErrors: Record = { providerType: [], providerId: validateProviderId(data.providerId), issuerUrl: validateIssuerUrl(data.issuerUrl), domain: validateDomain(data.domain), clientId: [], clientSecret: [], entryPoint: [], cert: [], scopes: [], callbackUrl: [], audience: [], } const providerType = data.providerType || 'oidc' if (providerType === 'oidc') { newErrors.clientId = validateRequired('Client ID', data.clientId) newErrors.clientSecret = validateRequired('Client Secret', data.clientSecret) if (!data.scopes || !data.scopes.trim()) { newErrors.scopes = ['Scopes are required for OIDC providers'] } } else if (providerType === 'saml') { newErrors.entryPoint = validateIssuerUrl(data.entryPoint || '') if (!newErrors.entryPoint.length && !data.entryPoint) { newErrors.entryPoint = ['Entry Point URL is required for SAML providers'] } newErrors.cert = validateRequired('Certificate', data.cert) } setErrors(newErrors) return newErrors } const hasAnyErrors = (errs: Record) => Object.values(errs).some((l) => l.length > 0) const handleDiscard = () => { setIsEditing(false) setFormData(DEFAULT_FORM_DATA) setOriginalFormData(DEFAULT_FORM_DATA) setErrors(DEFAULT_ERRORS) setShowErrors(false) setShowAdvanced(false) } const isFormValid = () => { const requiredFields = ['providerId', 'issuerUrl', 'domain'] const hasRequiredFields = requiredFields.every((field) => { const value = formData[field as keyof typeof formData] return typeof value === 'string' && value.trim() !== '' }) const providerType = formData.providerType || 'oidc' if (providerType === 'oidc') { return ( hasRequiredFields && formData.clientId.trim() !== '' && formData.clientSecret.trim() !== '' && formData.scopes.trim() !== '' ) } if (providerType === 'saml') { return hasRequiredFields && formData.entryPoint.trim() !== '' && formData.cert.trim() !== '' } return false } const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault() setShowErrors(true) const validation = validateAll(formData) if (hasAnyErrors(validation)) { return } try { const providerType = formData.providerType || 'oidc' const requestBody: SsoRegistrationBody = providerType === 'oidc' ? { providerType: 'oidc', providerId: formData.providerId, issuer: formData.issuerUrl, domain: formData.domain, orgId: activeOrganization?.id, mapping: { id: 'sub', email: 'email', name: 'name', image: 'picture', }, clientId: formData.clientId, clientSecret: formData.clientSecret, scopes: formData.scopes.split(',').map((s) => s.trim()), } : { providerType: 'saml', providerId: formData.providerId, issuer: formData.issuerUrl, domain: formData.domain, orgId: activeOrganization?.id, mapping: { id: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier', email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', }, entryPoint: formData.entryPoint, cert: formData.cert, wantAssertionsSigned: formData.wantAssertionsSigned, ...(formData.callbackUrl ? { callbackUrl: formData.callbackUrl } : {}), ...(formData.audience ? { audience: formData.audience } : {}), ...(formData.idpMetadata ? { idpMetadata: formData.idpMetadata } : {}), } await configureSSOMutation.mutateAsync(requestBody) logger.info('SSO provider configured', { providerId: formData.providerId }) toast.success(isEditing ? 'SSO provider updated' : 'SSO provider configured') setFormData(DEFAULT_FORM_DATA) setOriginalFormData(DEFAULT_FORM_DATA) setErrors(DEFAULT_ERRORS) setShowErrors(false) setIsEditing(false) setShowAdvanced(false) } catch (err) { const message = getErrorMessage(err, 'Unknown error occurred') toast.error(message) logger.error('Failed to configure SSO provider', { error: err }) } } const handleInputChange = (field: keyof typeof formData, value: string | boolean) => { const next = { ...formData, [field]: value } if (field === 'providerType') { setShowErrors(false) } setFormData(next) validateAll(next) } const isSaml = formData.providerType === 'saml' const callbackUrl = `${getBaseUrl()}/api/auth/${isSaml ? 'sso/saml2/callback' : 'sso/callback'}/${formData.providerId || existingProvider?.providerId || 'provider-id'}` const copyToClipboard = async (url: string) => { try { await navigator.clipboard.writeText(url) setCopied(true) setTimeout(() => setCopied(false), 1500) } catch {} } const handleEdit = () => { if (!existingProvider) return try { let clientId = '' let clientSecret = '' let scopes = 'openid,profile,email' let entryPoint = '' let cert = '' let callbackUrl = '' let audience = '' let wantAssertionsSigned = true let idpMetadata = '' if (existingProvider.providerType === 'oidc' && existingProvider.oidcConfig) { const config = JSON.parse(existingProvider.oidcConfig) clientId = config.clientId || '' clientSecret = config.clientSecret || '' scopes = config.scopes?.join(',') || 'openid,profile,email' } else if (existingProvider.providerType === 'saml' && existingProvider.samlConfig) { const config = JSON.parse(existingProvider.samlConfig) entryPoint = config.entryPoint || '' cert = config.cert || '' callbackUrl = config.callbackUrl || '' audience = config.audience || '' wantAssertionsSigned = config.wantAssertionsSigned ?? true idpMetadata = config.idpMetadata?.metadata || config.idpMetadata || '' } const snapshot = { providerType: existingProvider.providerType, providerId: existingProvider.providerId, issuerUrl: existingProvider.issuer, domain: existingProvider.domain, clientId, clientSecret, scopes, entryPoint, cert, callbackUrl, audience, wantAssertionsSigned, idpMetadata, } setFormData(snapshot) setOriginalFormData(snapshot) setIsEditing(true) setShowErrors(false) setShowAdvanced(false) } catch (err) { logger.error('Failed to parse provider config', { error: err }) toast.error('Failed to load provider configuration') } } if (isLoadingProviders) { return null } if (existingProvider && !isEditing) { const providerCallbackUrl = `${getBaseUrl()}/api/auth/${existingProvider.providerType === 'saml' ? 'sso/saml2/callback' : 'sso/callback'}/${existingProvider.providerId}` return (

{existingProvider.providerId}

{existingProvider.providerType.toUpperCase()}

{existingProvider.domain}

{existingProvider.issuer}

copyToClipboard(providerCallbackUrl)} className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]' aria-label='Copy callback URL' > {copied ? ( ) : ( )} } />

Configure this in your identity provider

) } return (
void handleSubmit(), onDiscard: handleDiscard, }), ]} >
handleInputChange('providerType', value as 'oidc' | 'saml') } options={[ { label: 'OIDC', value: 'oidc' }, { label: 'SAML', value: 'saml' }, ]} placeholder='Select provider type' />

{formData.providerType === 'oidc' ? 'OpenID Connect (Okta, Azure AD, Auth0, etc.)' : 'Security Assertion Markup Language (ADFS, Shibboleth, etc.)'}

0 ? errors.providerId.join(' ') : undefined } > handleInputChange('providerId', value)} options={SSO_TRUSTED_PROVIDERS.map((id) => ({ label: id, value: id, }))} placeholder='Select or enter a provider ID' editable /> 0 ? errors.issuerUrl.join(' ') : undefined } > e.target.removeAttribute('readOnly')} onChange={(e) => handleInputChange('issuerUrl', e.target.value)} error={showErrors && errors.issuerUrl.length > 0} /> 0 ? errors.domain.join(' ') : undefined} > e.target.removeAttribute('readOnly')} onChange={(e) => handleInputChange('domain', e.target.value)} error={showErrors && errors.domain.length > 0} />

The email domain users sign in with (e.g. company.com)

{formData.providerType === 'oidc' ? ( <> 0 ? errors.clientId.join(' ') : undefined } > e.target.removeAttribute('readOnly')} onChange={(e) => handleInputChange('clientId', e.target.value)} error={showErrors && errors.clientId.length > 0} /> 0 ? errors.clientSecret.join(' ') : undefined } > { e.target.removeAttribute('readOnly') setShowClientSecret(true) }} onBlurCapture={() => setShowClientSecret(false)} onChange={(e) => handleInputChange('clientSecret', e.target.value)} inputClassName={!showClientSecret ? '[-webkit-text-security:disc]' : undefined} error={showErrors && errors.clientSecret.length > 0} endAdornment={ } /> 0 ? errors.scopes.join(' ') : undefined} > handleInputChange('scopes', e.target.value)} error={showErrors && errors.scopes.length > 0} />

Comma-separated list of OIDC scopes to request

) : ( <> 0 ? errors.entryPoint.join(' ') : undefined } > handleInputChange('entryPoint', e.target.value)} error={showErrors && errors.entryPoint.length > 0} /> 0 ? errors.cert.join(' ') : undefined} > handleInputChange('cert', e.target.value)} className='min-h-[80px] font-mono' error={showErrors && errors.cert.length > 0} rows={3} />
handleInputChange('audience', e.target.value)} /> handleInputChange('callbackUrl', e.target.value)} /> handleInputChange('wantAssertionsSigned', checked) } /> handleInputChange('idpMetadata', e.target.value)} className='min-h-[60px] font-mono' rows={2} />
)} copyToClipboard(callbackUrl)} className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]' aria-label='Copy callback URL' > {copied ? ( ) : ( )} } />

Configure this in your identity provider

) }