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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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() })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user