chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
'use client'
import { type KeyboardEvent, useState } from 'react'
import { cn, Input, Label } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { chatSSOContract } from '@/lib/api/contracts/chats'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AuthSubmitButton } from '@/app/(auth)/components'
const logger = createLogger('SSOAuth')
interface SSOAuthProps {
identifier: string
}
const validateEmailField = (emailValue: string): string[] => {
const errors: string[] = []
if (!emailValue || !emailValue.trim()) {
errors.push('Email is required.')
return errors
}
const validation = quickValidateEmail(emailValue.trim().toLowerCase())
if (!validation.isValid) {
errors.push(validation.reason || 'Please enter a valid email address.')
}
return errors
}
export default function SSOAuth({ identifier }: SSOAuthProps) {
const router = useRouter()
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAuthenticate()
}
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value
setEmail(newEmail)
setShowEmailValidationError(false)
setEmailErrors([])
}
const handleAuthenticate = async () => {
const emailValidationErrors = validateEmailField(email)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
if (emailValidationErrors.length > 0) {
return
}
setIsLoading(true)
try {
const { eligible } = await requestJson(chatSSOContract, {
params: { identifier },
body: { email },
})
if (!eligible) {
setEmailErrors(['Email not authorized for this chat'])
setShowEmailValidationError(true)
setIsLoading(false)
return
}
const callbackUrl = `/chat/${identifier}`
const ssoUrl = `/sso?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`
router.push(ssoUrl)
} catch (error) {
if (error instanceof ApiClientError) {
setEmailErrors([error.message || 'Email not authorized for this chat'])
setShowEmailValidationError(true)
setIsLoading(false)
return
}
logger.error('SSO authentication error:', error)
setEmailErrors(['An error occurred during authentication'])
setShowEmailValidationError(true)
setIsLoading(false)
}
}
return (
<div className='flex flex-1 items-center justify-center px-4 py-16'>
<div className='w-full max-w-[410px]'>
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
SSO Authentication
</h1>
<p className='text-[color-mix(in_srgb,var(--text-muted)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
This chat requires SSO authentication
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault()
handleAuthenticate()
}}
className='mt-8 w-full max-w-[410px] space-y-6'
>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='email'>Work Email</Label>
</div>
<Input
id='email'
name='email'
required
type='email'
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
placeholder='Enter your work email'
value={email}
onChange={handleEmailChange}
onKeyDown={handleKeyDown}
className={cn(
showEmailValidationError &&
emailErrors.length > 0 &&
'border-[var(--text-error)] focus:border-[var(--text-error)]'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-[var(--text-error)] text-xs'>
{emailErrors.map((error) => (
<p key={error}>{error}</p>
))}
</div>
)}
</div>
<AuthSubmitButton loading={isLoading} loadingLabel='Redirecting to SSO…'>
Continue with SSO
</AuthSubmitButton>
</form>
</div>
</div>
</div>
)
}
+246
View File
@@ -0,0 +1,246 @@
'use client'
import { useEffect, useState } from 'react'
import { Button, cn, Input, Label } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { client } from '@/lib/auth/auth-client'
import { env, isFalsy } from '@/lib/core/config/env'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AuthSubmitButton } from '@/app/(auth)/components'
const logger = createLogger('SSOForm')
const validateEmailField = (emailValue: string): string[] => {
const errors: string[] = []
if (!emailValue || !emailValue.trim()) {
errors.push('Email is required.')
return errors
}
const validation = quickValidateEmail(emailValue.trim().toLowerCase())
if (!validation.isValid) {
errors.push(validation.reason || 'Please enter a valid email address.')
}
return errors
}
export default function SSOForm() {
const router = useRouter()
const searchParams = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
const [email, setEmail] = useState('')
const [emailErrors, setEmailErrors] = useState<string[]>([])
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
const [callbackUrl, setCallbackUrl] = useState('/workspace')
useEffect(() => {
if (searchParams) {
const callback = searchParams.get('callbackUrl')
if (callback) {
if (validateCallbackUrl(callback)) {
setCallbackUrl(callback)
} else {
logger.warn('Invalid callback URL detected and blocked:', { url: callback })
}
}
const emailParam = searchParams.get('email')
if (emailParam) {
setEmail(emailParam)
}
const error = searchParams.get('error')
if (error) {
const errorMessages: Record<string, string> = {
account_not_found:
'No account found. Please contact your administrator to set up SSO access.',
sso_failed: 'SSO authentication failed. Please try again.',
invalid_provider: 'SSO provider not configured correctly.',
}
setEmailErrors([errorMessages[error] || 'SSO authentication failed. Please try again.'])
setShowEmailValidationError(true)
}
}
}, [searchParams])
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEmail = e.target.value
setEmail(newEmail)
const errors = validateEmailField(newEmail)
setEmailErrors(errors)
setShowEmailValidationError(false)
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.currentTarget)
const emailRaw = formData.get('email') as string
const emailValue = emailRaw.trim().toLowerCase()
const emailValidationErrors = validateEmailField(emailValue)
setEmailErrors(emailValidationErrors)
setShowEmailValidationError(emailValidationErrors.length > 0)
if (emailValidationErrors.length > 0) {
setIsLoading(false)
return
}
try {
const safeCallbackUrl = callbackUrl
await client.signIn.sso({
email: emailValue,
callbackURL: safeCallbackUrl,
errorCallbackURL: `/sso?error=sso_failed&callbackUrl=${encodeURIComponent(safeCallbackUrl)}`,
})
} catch (err) {
logger.error('SSO sign-in failed', { error: err, email: emailValue })
let errorMessage = 'SSO sign-in failed. Please try again.'
if (err instanceof Error) {
if (err.message.includes('NO_PROVIDER_FOUND')) {
errorMessage = 'SSO provider not found. Please check your configuration.'
} else if (err.message.includes('INVALID_EMAIL_DOMAIN')) {
errorMessage = 'Email domain not configured for SSO. Please contact your administrator.'
} else if (err.message.includes('network')) {
errorMessage = 'Network error. Please check your connection and try again.'
} else if (err.message.includes('rate limit')) {
errorMessage = 'Too many requests. Please wait a moment before trying again.'
} else if (err.message.includes('SSO_DISABLED')) {
errorMessage = 'SSO authentication is disabled. Please use another sign-in method.'
} else {
errorMessage = err.message
}
}
setEmailErrors([errorMessage])
setShowEmailValidationError(true)
setIsLoading(false)
}
}
return (
<>
<div className='space-y-1 text-center'>
<h1
className={
'text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'
}
>
Sign in with SSO
</h1>
<p
className={
'text-[color-mix(in_srgb,var(--text-muted)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'
}
>
Enter your work email to continue
</p>
</div>
<form onSubmit={onSubmit} className={'mt-8 space-y-8'}>
<div className='space-y-6'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label htmlFor='email'>Work email</Label>
</div>
<Input
id='email'
name='email'
placeholder='Enter your work email'
required
autoCapitalize='none'
autoComplete='email'
autoCorrect='off'
value={email}
onChange={handleEmailChange}
className={cn(
showEmailValidationError &&
emailErrors.length > 0 &&
'border-[var(--text-error)] focus:border-[var(--text-error)]'
)}
/>
{showEmailValidationError && emailErrors.length > 0 && (
<div className='mt-1 space-y-1 text-[var(--text-error)] text-xs'>
{emailErrors.map((error) => (
<p key={error}>{error}</p>
))}
</div>
)}
</div>
</div>
<AuthSubmitButton loading={isLoading} loadingLabel='Redirecting to SSO provider…'>
Continue with SSO
</AuthSubmitButton>
</form>
{/* Only show divider and email signin button if email/password is enabled */}
{!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && (
<>
<div className='relative my-6 font-light'>
<div className='absolute inset-0 flex items-center'>
<div className='w-full border-[var(--border)] border-t' />
</div>
<div className='relative flex justify-center text-sm'>
<span className='bg-[var(--bg)] px-4 font-normal text-[var(--text-muted)]'>Or</span>
</div>
</div>
<div className='space-y-3'>
<Link
href={`/login${callbackUrl ? `?callbackUrl=${encodeURIComponent(callbackUrl)}` : ''}`}
>
<Button variant='outline' className='w-full rounded-[10px]' type='button'>
Sign in with email
</Button>
</Link>
</div>
</>
)}
{/* Only show signup link if email/password signup is enabled */}
{!isFalsy(env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED) && (
<div className='pt-6 text-center font-light text-base'>
<span className='font-normal'>Don't have an account? </span>
<Link
href={`/signup${callbackUrl ? `?callbackUrl=${encodeURIComponent(callbackUrl)}` : ''}`}
className='font-medium text-[var(--text-primary)] underline-offset-4 transition hover:underline'
>
Sign up
</Link>
</div>
)}
<div className='absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-normal text-[var(--text-muted)] text-sm leading-relaxed sm:px-8 md:px-[44px]'>
By signing in, you agree to our{' '}
<Link
href='/terms'
target='_blank'
rel='noopener noreferrer'
className='text-[var(--text-muted)] underline-offset-4 transition hover:text-[var(--text-primary)] hover:underline'
>
Terms of Service
</Link>{' '}
and{' '}
<Link
href='/privacy'
target='_blank'
rel='noopener noreferrer'
className='text-[var(--text-muted)] underline-offset-4 transition hover:text-[var(--text-primary)] hover:underline'
>
Privacy Policy
</Link>
</div>
</>
)
}
+862
View File
@@ -0,0 +1,862 @@
'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>
)
}
+46
View File
@@ -0,0 +1,46 @@
/**
* List of trusted SSO provider identifiers.
* Used for validation and autocomplete in SSO configuration.
*/
export const SSO_TRUSTED_PROVIDERS = [
'okta',
'okta-saml',
'okta-prod',
'okta-dev',
'okta-staging',
'okta-test',
'azure-ad',
'azure-active-directory',
'azure-corp',
'azure-enterprise',
'adfs',
'adfs-company',
'adfs-corp',
'adfs-enterprise',
'auth0',
'auth0-prod',
'auth0-dev',
'auth0-staging',
'onelogin',
'onelogin-prod',
'onelogin-corp',
'jumpcloud',
'jumpcloud-prod',
'jumpcloud-corp',
'ping-identity',
'ping-federate',
'pingone',
'shibboleth',
'shibboleth-idp',
'google-workspace',
'google-sso',
'saml',
'saml2',
'saml-sso',
'oidc',
'oidc-sso',
'openid-connect',
'custom-sso',
'enterprise-sso',
'company-sso',
]
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
listSsoProvidersContract,
type SsoRegistrationBody,
ssoRegistrationContract,
} from '@/lib/api/contracts/auth'
import { organizationKeys } from '@/hooks/queries/organization'
/**
* Query key factories for SSO-related queries
*/
export const ssoKeys = {
all: ['sso'] as const,
providers: () => [...ssoKeys.all, 'providers'] as const,
providerList: (organizationId?: string) =>
[...ssoKeys.providers(), organizationId ?? ''] as const,
}
/**
* Fetch SSO providers
*/
async function fetchSSOProviders(signal: AbortSignal, organizationId?: string) {
return requestJson(listSsoProvidersContract, {
query: organizationId ? { organizationId } : {},
signal,
})
}
/**
* Hook to fetch SSO providers
*/
interface UseSSOProvidersOptions {
enabled?: boolean
organizationId?: string
}
export function useSSOProviders({ enabled = true, organizationId }: UseSSOProvidersOptions = {}) {
return useQuery({
queryKey: ssoKeys.providerList(organizationId),
queryFn: ({ signal }) => fetchSSOProviders(signal, organizationId),
staleTime: 5 * 60 * 1000,
placeholderData: keepPreviousData,
enabled,
})
}
/**
* Configure SSO provider mutation
*/
type ConfigureSSOParams = Record<string, unknown>
export function useConfigureSSO() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (config: ConfigureSSOParams) =>
requestJson(ssoRegistrationContract, {
body: config as SsoRegistrationBody,
}),
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: ssoKeys.providers() })
const orgId = typeof variables.orgId === 'string' ? variables.orgId : undefined
if (orgId) {
queryClient.invalidateQueries({ queryKey: organizationKeys.detail(orgId) })
queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
}
},
})
}