d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
863 lines
30 KiB
TypeScript
863 lines
30 KiB
TypeScript
'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 (
|
|
<div className='flex flex-col gap-[9px]'>
|
|
<Label className='font-normal text-[var(--text-muted)]'>
|
|
{label}
|
|
{optional ? <span className='ml-1'>(optional)</span> : null}
|
|
</Label>
|
|
{children}
|
|
{error ? <p className='text-[var(--text-error)] text-caption'>{error}</p> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<Record<string, string[]>>(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 (
|
|
<SettingsEmptyState>
|
|
You must be part of an organization to configure Single Sign-On.
|
|
</SettingsEmptyState>
|
|
)
|
|
}
|
|
|
|
if (!hasEnterprisePlan) {
|
|
return (
|
|
<SettingsEmptyState>
|
|
Single Sign-On is available on Enterprise plans only.
|
|
</SettingsEmptyState>
|
|
)
|
|
}
|
|
|
|
if (!canManageSSO) {
|
|
return (
|
|
<SettingsEmptyState>
|
|
Only organization owners and admins can configure Single Sign-On settings.
|
|
</SettingsEmptyState>
|
|
)
|
|
}
|
|
} else {
|
|
if (activeOrganization && !canManageSSO) {
|
|
return (
|
|
<SettingsEmptyState>
|
|
Only organization owners and admins can configure Single Sign-On settings.
|
|
</SettingsEmptyState>
|
|
)
|
|
}
|
|
if (
|
|
!activeOrganization &&
|
|
!isLoadingProviders &&
|
|
isSSOProviderOwner === false &&
|
|
providers.length > 0
|
|
) {
|
|
return (
|
|
<SettingsEmptyState>
|
|
Only the user who configured SSO can manage these settings.
|
|
</SettingsEmptyState>
|
|
)
|
|
}
|
|
}
|
|
|
|
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<string, string[]> = {
|
|
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<string, string[]>) =>
|
|
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 (
|
|
<SettingsPanel actions={[{ text: 'Edit', variant: 'primary', onSelect: handleEdit }]}>
|
|
<div className='flex flex-col gap-4.5'>
|
|
<FormField label='Provider ID'>
|
|
<p className='text-[var(--text-primary)] text-small'>{existingProvider.providerId}</p>
|
|
</FormField>
|
|
|
|
<FormField label='Provider Type'>
|
|
<p className='text-[var(--text-primary)] text-small'>
|
|
{existingProvider.providerType.toUpperCase()}
|
|
</p>
|
|
</FormField>
|
|
|
|
<FormField label='Domain'>
|
|
<p className='text-[var(--text-primary)] text-small'>{existingProvider.domain}</p>
|
|
</FormField>
|
|
|
|
<FormField label='Issuer URL'>
|
|
<p className='break-all font-mono text-[var(--text-primary)] text-small leading-relaxed'>
|
|
{existingProvider.issuer}
|
|
</p>
|
|
</FormField>
|
|
|
|
<FormField label='Callback URL'>
|
|
<ChipInput
|
|
value={providerCallbackUrl}
|
|
readOnly
|
|
endAdornment={
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
onClick={() => copyToClipboard(providerCallbackUrl)}
|
|
className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]'
|
|
aria-label='Copy callback URL'
|
|
>
|
|
{copied ? (
|
|
<Check className='size-[14px]' />
|
|
) : (
|
|
<Clipboard className='size-[14px]' />
|
|
)}
|
|
</Button>
|
|
}
|
|
/>
|
|
<p className='text-[var(--text-muted)] text-small'>
|
|
Configure this in your identity provider
|
|
</p>
|
|
</FormField>
|
|
</div>
|
|
</SettingsPanel>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} autoComplete='off'>
|
|
<input
|
|
type='text'
|
|
name='fakeusernameremembered'
|
|
autoComplete='username'
|
|
className='-left-[9999px] pointer-events-none absolute opacity-0'
|
|
tabIndex={-1}
|
|
readOnly
|
|
/>
|
|
<input
|
|
type='password'
|
|
name='fakepasswordremembered'
|
|
autoComplete='current-password'
|
|
className='-left-[9999px] pointer-events-none absolute opacity-0'
|
|
tabIndex={-1}
|
|
readOnly
|
|
/>
|
|
<input
|
|
type='email'
|
|
name='fakeemailremembered'
|
|
autoComplete='email'
|
|
className='-left-[9999px] pointer-events-none absolute opacity-0'
|
|
tabIndex={-1}
|
|
readOnly
|
|
/>
|
|
<input type='text' name='hidden' className='hidden' autoComplete='off' />
|
|
|
|
<SettingsPanel
|
|
actions={[
|
|
...(isEditing && !hasChanges
|
|
? [
|
|
{
|
|
text: 'Cancel',
|
|
onSelect: handleDiscard,
|
|
disabled: configureSSOMutation.isPending,
|
|
} satisfies SettingsAction,
|
|
]
|
|
: []),
|
|
...saveDiscardActions({
|
|
dirty: hasChanges,
|
|
saving: configureSSOMutation.isPending,
|
|
saveDisabled: hasAnyErrors(errors) || !isFormValid(),
|
|
saveLabel: isEditing ? 'Update' : 'Save',
|
|
savingLabel: isEditing ? 'Updating...' : 'Saving...',
|
|
onSave: () => void handleSubmit(),
|
|
onDiscard: handleDiscard,
|
|
}),
|
|
]}
|
|
>
|
|
<div className='flex flex-col gap-4.5'>
|
|
<FormField label='Provider Type'>
|
|
<ChipSelect
|
|
align='start'
|
|
value={formData.providerType}
|
|
onChange={(value: string) =>
|
|
handleInputChange('providerType', value as 'oidc' | 'saml')
|
|
}
|
|
options={[
|
|
{ label: 'OIDC', value: 'oidc' },
|
|
{ label: 'SAML', value: 'saml' },
|
|
]}
|
|
placeholder='Select provider type'
|
|
/>
|
|
<p className='text-[var(--text-muted)] text-small'>
|
|
{formData.providerType === 'oidc'
|
|
? 'OpenID Connect (Okta, Azure AD, Auth0, etc.)'
|
|
: 'Security Assertion Markup Language (ADFS, Shibboleth, etc.)'}
|
|
</p>
|
|
</FormField>
|
|
|
|
<FormField
|
|
label='Provider ID'
|
|
error={
|
|
showErrors && errors.providerId.length > 0 ? errors.providerId.join(' ') : undefined
|
|
}
|
|
>
|
|
<ChipCombobox
|
|
value={formData.providerId}
|
|
onChange={(value: string) => handleInputChange('providerId', value)}
|
|
options={SSO_TRUSTED_PROVIDERS.map((id) => ({
|
|
label: id,
|
|
value: id,
|
|
}))}
|
|
placeholder='Select or enter a provider ID'
|
|
editable
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField
|
|
label='Issuer URL'
|
|
error={
|
|
showErrors && errors.issuerUrl.length > 0 ? errors.issuerUrl.join(' ') : undefined
|
|
}
|
|
>
|
|
<ChipInput
|
|
id='sso-issuer'
|
|
type='url'
|
|
placeholder='https://your-identity-provider.com/oauth2/default'
|
|
value={formData.issuerUrl}
|
|
name='sso_issuer_endpoint'
|
|
autoComplete='off'
|
|
autoCapitalize='none'
|
|
spellCheck={false}
|
|
readOnly
|
|
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
|
onChange={(e) => handleInputChange('issuerUrl', e.target.value)}
|
|
error={showErrors && errors.issuerUrl.length > 0}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField
|
|
label='Domain'
|
|
error={showErrors && errors.domain.length > 0 ? errors.domain.join(' ') : undefined}
|
|
>
|
|
<ChipInput
|
|
id='sso-domain'
|
|
type='text'
|
|
placeholder='company.com'
|
|
value={formData.domain}
|
|
name='sso_identity_domain'
|
|
autoComplete='off'
|
|
autoCapitalize='none'
|
|
spellCheck={false}
|
|
readOnly
|
|
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
|
onChange={(e) => handleInputChange('domain', e.target.value)}
|
|
error={showErrors && errors.domain.length > 0}
|
|
/>
|
|
<p className='text-[var(--text-muted)] text-small'>
|
|
The email domain users sign in with (e.g. company.com)
|
|
</p>
|
|
</FormField>
|
|
|
|
{formData.providerType === 'oidc' ? (
|
|
<>
|
|
<FormField
|
|
label='Client ID'
|
|
error={
|
|
showErrors && errors.clientId.length > 0 ? errors.clientId.join(' ') : undefined
|
|
}
|
|
>
|
|
<ChipInput
|
|
id='sso-client-id'
|
|
type='text'
|
|
placeholder='Enter Client ID'
|
|
value={formData.clientId}
|
|
name='sso_client_identifier'
|
|
autoComplete='off'
|
|
autoCapitalize='none'
|
|
spellCheck={false}
|
|
readOnly
|
|
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
|
onChange={(e) => handleInputChange('clientId', e.target.value)}
|
|
error={showErrors && errors.clientId.length > 0}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField
|
|
label='Client Secret'
|
|
error={
|
|
showErrors && errors.clientSecret.length > 0
|
|
? errors.clientSecret.join(' ')
|
|
: undefined
|
|
}
|
|
>
|
|
<ChipInput
|
|
id='sso-client-secret'
|
|
type='text'
|
|
placeholder='Enter Client Secret'
|
|
value={formData.clientSecret}
|
|
name='sso_client_key'
|
|
autoComplete='off'
|
|
autoCapitalize='none'
|
|
spellCheck={false}
|
|
readOnly
|
|
onFocus={(e) => {
|
|
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={
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
onClick={() => setShowClientSecret((s) => !s)}
|
|
className='size-6 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
|
aria-label={showClientSecret ? 'Hide client secret' : 'Show client secret'}
|
|
>
|
|
{showClientSecret ? (
|
|
<EyeOff className='size-[14px]' />
|
|
) : (
|
|
<Eye className='size-[14px]' />
|
|
)}
|
|
</Button>
|
|
}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField
|
|
label='Scopes'
|
|
error={showErrors && errors.scopes.length > 0 ? errors.scopes.join(' ') : undefined}
|
|
>
|
|
<ChipInput
|
|
id='sso-scopes'
|
|
type='text'
|
|
placeholder='openid,profile,email'
|
|
value={formData.scopes}
|
|
autoComplete='off'
|
|
autoCapitalize='none'
|
|
spellCheck={false}
|
|
onChange={(e) => handleInputChange('scopes', e.target.value)}
|
|
error={showErrors && errors.scopes.length > 0}
|
|
/>
|
|
<p className='text-[var(--text-muted)] text-small'>
|
|
Comma-separated list of OIDC scopes to request
|
|
</p>
|
|
</FormField>
|
|
</>
|
|
) : (
|
|
<>
|
|
<FormField
|
|
label='Entry Point URL'
|
|
error={
|
|
showErrors && errors.entryPoint.length > 0
|
|
? errors.entryPoint.join(' ')
|
|
: undefined
|
|
}
|
|
>
|
|
<ChipInput
|
|
id='sso-entry-point'
|
|
type='url'
|
|
placeholder='https://idp.example.com/sso/saml'
|
|
value={formData.entryPoint}
|
|
autoComplete='off'
|
|
autoCapitalize='none'
|
|
spellCheck={false}
|
|
onChange={(e) => handleInputChange('entryPoint', e.target.value)}
|
|
error={showErrors && errors.entryPoint.length > 0}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField
|
|
label='Identity Provider Certificate'
|
|
error={showErrors && errors.cert.length > 0 ? errors.cert.join(' ') : undefined}
|
|
>
|
|
<ChipTextarea
|
|
id='sso-cert'
|
|
placeholder={'-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'}
|
|
value={formData.cert}
|
|
autoComplete='off'
|
|
autoCapitalize='none'
|
|
spellCheck={false}
|
|
onChange={(e) => handleInputChange('cert', e.target.value)}
|
|
className='min-h-[80px] font-mono'
|
|
error={showErrors && errors.cert.length > 0}
|
|
rows={3}
|
|
/>
|
|
</FormField>
|
|
|
|
<div className='flex flex-col gap-2'>
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
onClick={() => setShowAdvanced((v) => !v)}
|
|
className='w-fit gap-1.5 px-0 text-[var(--text-muted)] hover:bg-transparent hover:text-[var(--text-primary)]'
|
|
>
|
|
<ChevronDown
|
|
className={cn('size-[14px] transition-transform', showAdvanced && 'rotate-180')}
|
|
/>
|
|
Advanced Options
|
|
</Button>
|
|
|
|
<Expandable expanded={showAdvanced}>
|
|
<ExpandableContent>
|
|
<div className='flex flex-col gap-4.5 pt-2'>
|
|
<FormField label='Audience (Entity ID)' optional>
|
|
<ChipInput
|
|
type='text'
|
|
placeholder='Enter Audience'
|
|
value={formData.audience}
|
|
autoComplete='off'
|
|
autoCapitalize='none'
|
|
spellCheck={false}
|
|
onChange={(e) => handleInputChange('audience', e.target.value)}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField label='Callback URL Override' optional>
|
|
<ChipInput
|
|
type='url'
|
|
placeholder={`${getBaseUrl()}/api/auth/sso/saml2/callback/provider-id`}
|
|
value={formData.callbackUrl}
|
|
autoComplete='off'
|
|
autoCapitalize='none'
|
|
spellCheck={false}
|
|
onChange={(e) => handleInputChange('callbackUrl', e.target.value)}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField label='Require signed SAML assertions'>
|
|
<Switch
|
|
checked={formData.wantAssertionsSigned}
|
|
onCheckedChange={(checked) =>
|
|
handleInputChange('wantAssertionsSigned', checked)
|
|
}
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField label='IDP Metadata XML' optional>
|
|
<ChipTextarea
|
|
placeholder='Paste IDP metadata XML here'
|
|
value={formData.idpMetadata}
|
|
autoComplete='off'
|
|
autoCapitalize='none'
|
|
spellCheck={false}
|
|
onChange={(e) => handleInputChange('idpMetadata', e.target.value)}
|
|
className='min-h-[60px] font-mono'
|
|
rows={2}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</ExpandableContent>
|
|
</Expandable>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<FormField label='Callback URL'>
|
|
<ChipInput
|
|
value={callbackUrl}
|
|
readOnly
|
|
endAdornment={
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
onClick={() => copyToClipboard(callbackUrl)}
|
|
className='size-6 p-0 text-[var(--text-icon)] hover:text-[var(--text-primary)]'
|
|
aria-label='Copy callback URL'
|
|
>
|
|
{copied ? (
|
|
<Check className='size-[14px]' />
|
|
) : (
|
|
<Clipboard className='size-[14px]' />
|
|
)}
|
|
</Button>
|
|
}
|
|
/>
|
|
<p className='text-[var(--text-muted)] text-small'>
|
|
Configure this in your identity provider
|
|
</p>
|
|
</FormField>
|
|
</div>
|
|
</SettingsPanel>
|
|
</form>
|
|
)
|
|
}
|