* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { useEffect, useMemo, useRef } from 'react'
|
|
import { useForm } from 'react-hook-form'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import * as z from 'zod'
|
|
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
|
|
import {
|
|
SettingsForm,
|
|
SettingsSwitchContent,
|
|
SettingsSwitchItem,
|
|
} from '../components/settings-form-layout'
|
|
import { SettingsPageFormActions } from '../components/settings-page-context'
|
|
import { SettingsSection } from '../components/settings-section'
|
|
import { useUpdateOption } from '../hooks/use-update-option'
|
|
|
|
type AttachmentPreference = '' | 'platform' | 'cross-platform'
|
|
type AttachmentSelectValue = 'none' | 'platform' | 'cross-platform'
|
|
|
|
/**
|
|
* Use a nested object so the dotted FormField `name` props line up with
|
|
* react-hook-form's path semantics. Flat keys with dots cause the form state
|
|
* to silently diverge from what zod validates on submit.
|
|
*/
|
|
const passkeySchema = z.object({
|
|
passkey: z.object({
|
|
enabled: z.boolean(),
|
|
rp_display_name: z.string(),
|
|
rp_id: z.string(),
|
|
origins: z.string(),
|
|
allow_insecure_origin: z.boolean(),
|
|
user_verification: z.enum(['required', 'preferred', 'discouraged']),
|
|
attachment_preference: z.enum(['none', 'platform', 'cross-platform']),
|
|
}),
|
|
})
|
|
|
|
type PasskeyFormInput = z.input<typeof passkeySchema>
|
|
type PasskeyFormValues = z.output<typeof passkeySchema>
|
|
|
|
type FlatPasskeyDefaults = {
|
|
'passkey.enabled': boolean
|
|
'passkey.rp_display_name': string
|
|
'passkey.rp_id': string
|
|
'passkey.origins': string
|
|
'passkey.allow_insecure_origin': boolean
|
|
'passkey.user_verification': 'required' | 'preferred' | 'discouraged'
|
|
'passkey.attachment_preference': AttachmentPreference
|
|
}
|
|
|
|
const toAttachmentSelectValue = (
|
|
value: AttachmentPreference
|
|
): AttachmentSelectValue => (value === '' ? 'none' : value)
|
|
|
|
const fromAttachmentSelectValue = (
|
|
value: AttachmentSelectValue
|
|
): AttachmentPreference => (value === 'none' ? '' : value)
|
|
|
|
const buildFormDefaults = (
|
|
defaults: FlatPasskeyDefaults
|
|
): PasskeyFormInput => ({
|
|
passkey: {
|
|
enabled: defaults['passkey.enabled'],
|
|
rp_display_name: defaults['passkey.rp_display_name'] ?? '',
|
|
rp_id: defaults['passkey.rp_id'] ?? '',
|
|
origins: (defaults['passkey.origins'] ?? '')
|
|
.split(',')
|
|
.map((origin) => origin.trim())
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
allow_insecure_origin: defaults['passkey.allow_insecure_origin'],
|
|
user_verification: defaults['passkey.user_verification'],
|
|
attachment_preference: toAttachmentSelectValue(
|
|
defaults['passkey.attachment_preference']
|
|
),
|
|
},
|
|
})
|
|
|
|
const normalizeFormValues = (
|
|
values: PasskeyFormValues
|
|
): FlatPasskeyDefaults => ({
|
|
'passkey.enabled': values.passkey.enabled,
|
|
'passkey.rp_display_name': values.passkey.rp_display_name,
|
|
'passkey.rp_id': values.passkey.rp_id,
|
|
'passkey.origins': values.passkey.origins
|
|
.split('\n')
|
|
.map((origin) => origin.trim())
|
|
.filter(Boolean)
|
|
.join(','),
|
|
'passkey.allow_insecure_origin': values.passkey.allow_insecure_origin,
|
|
'passkey.user_verification': values.passkey.user_verification,
|
|
'passkey.attachment_preference': fromAttachmentSelectValue(
|
|
values.passkey.attachment_preference
|
|
),
|
|
})
|
|
|
|
interface PasskeySectionProps {
|
|
defaultValues: FlatPasskeyDefaults
|
|
}
|
|
|
|
export function PasskeySection(props: PasskeySectionProps) {
|
|
const { t } = useTranslation()
|
|
const updateOption = useUpdateOption()
|
|
|
|
const formDefaults = useMemo(
|
|
() => buildFormDefaults(props.defaultValues),
|
|
[props.defaultValues]
|
|
)
|
|
|
|
const form = useForm<PasskeyFormInput, unknown, PasskeyFormValues>({
|
|
resolver: zodResolver(passkeySchema),
|
|
defaultValues: formDefaults,
|
|
})
|
|
|
|
const baselineRef = useRef<FlatPasskeyDefaults>(props.defaultValues)
|
|
const baselineSerializedRef = useRef<string>(
|
|
JSON.stringify(props.defaultValues)
|
|
)
|
|
|
|
useEffect(() => {
|
|
const serialized = JSON.stringify(props.defaultValues)
|
|
if (serialized === baselineSerializedRef.current) return
|
|
baselineRef.current = props.defaultValues
|
|
baselineSerializedRef.current = serialized
|
|
form.reset(buildFormDefaults(props.defaultValues))
|
|
}, [props.defaultValues, form])
|
|
|
|
const onSubmit = async (values: PasskeyFormValues) => {
|
|
const normalized = normalizeFormValues(values)
|
|
const changedKeys = (
|
|
Object.keys(normalized) as Array<keyof FlatPasskeyDefaults>
|
|
).filter((key) => normalized[key] !== baselineRef.current[key])
|
|
|
|
if (changedKeys.length === 0) {
|
|
toast.info(t('No changes to save'))
|
|
return
|
|
}
|
|
|
|
for (const key of changedKeys) {
|
|
await updateOption.mutateAsync({
|
|
key,
|
|
value: normalized[key],
|
|
})
|
|
}
|
|
|
|
baselineRef.current = normalized
|
|
baselineSerializedRef.current = JSON.stringify(normalized)
|
|
form.reset(buildFormDefaults(normalized))
|
|
}
|
|
|
|
return (
|
|
<SettingsSection title={t('Passkey Authentication')}>
|
|
<Form {...form}>
|
|
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
|
|
<SettingsPageFormActions
|
|
onSave={form.handleSubmit(onSubmit)}
|
|
isSaving={updateOption.isPending}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name='passkey.enabled'
|
|
render={({ field }) => (
|
|
<SettingsSwitchItem>
|
|
<SettingsSwitchContent>
|
|
<FormLabel>{t('Enable Passkey')}</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Allow users to register and sign in with Passkey (WebAuthn)'
|
|
)}
|
|
</FormDescription>
|
|
</SettingsSwitchContent>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</SettingsSwitchItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='passkey.rp_display_name'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Relying Party Display Name')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t('e.g. New API Console')}
|
|
value={field.value ?? ''}
|
|
onChange={(event) => field.onChange(event.target.value)}
|
|
name={field.name}
|
|
onBlur={field.onBlur}
|
|
ref={field.ref}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Human-readable name shown to users during Passkey prompts.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='passkey.rp_id'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Relying Party ID')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t('e.g. example.com')}
|
|
value={field.value ?? ''}
|
|
onChange={(event) => field.onChange(event.target.value)}
|
|
name={field.name}
|
|
onBlur={field.onBlur}
|
|
ref={field.ref}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'The effective domain for Passkey registration. Must match the current domain or be its parent domain.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='passkey.user_verification'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('User Verification')}</FormLabel>
|
|
<FormControl>
|
|
<Select
|
|
items={[
|
|
{ value: 'required', label: t('Required') },
|
|
{ value: 'preferred', label: t('Recommended') },
|
|
{ value: 'discouraged', label: t('Discouraged') },
|
|
]}
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t('Select requirement')} />
|
|
</SelectTrigger>
|
|
<SelectContent alignItemWithTrigger={false}>
|
|
<SelectGroup>
|
|
<SelectItem value='required'>
|
|
{t('Required')}
|
|
</SelectItem>
|
|
<SelectItem value='preferred'>
|
|
{t('Recommended')}
|
|
</SelectItem>
|
|
<SelectItem value='discouraged'>
|
|
{t('Discouraged')}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Controls whether user verification (biometrics/PIN) is required during Passkey flows.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='passkey.attachment_preference'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Device Type Preference')}</FormLabel>
|
|
<FormControl>
|
|
<Select
|
|
items={[
|
|
{ value: 'none', label: t('Unlimited') },
|
|
{ value: 'platform', label: t('Built-in Device') },
|
|
{ value: 'cross-platform', label: t('External Device') },
|
|
]}
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t('No preference')} />
|
|
</SelectTrigger>
|
|
<SelectContent alignItemWithTrigger={false}>
|
|
<SelectGroup>
|
|
<SelectItem value='none'>{t('Unlimited')}</SelectItem>
|
|
<SelectItem value='platform'>
|
|
{t('Built-in Device')}
|
|
</SelectItem>
|
|
<SelectItem value='cross-platform'>
|
|
{t('External Device')}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Built-in: phone fingerprint/face, or Windows Hello; External: USB security key'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='passkey.allow_insecure_origin'
|
|
render={({ field }) => (
|
|
<SettingsSwitchItem>
|
|
<SettingsSwitchContent>
|
|
<FormLabel>{t('Allow Insecure Origins')}</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Permit Passkey registration on non-HTTPS origins (only recommended for development)'
|
|
)}
|
|
</FormDescription>
|
|
</SettingsSwitchContent>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</SettingsSwitchItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='passkey.origins'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Allowed Origins')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
rows={4}
|
|
placeholder={t('https://example.com')}
|
|
value={field.value ?? ''}
|
|
onChange={(event) => field.onChange(event.target.value)}
|
|
name={field.name}
|
|
onBlur={field.onBlur}
|
|
ref={field.ref}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'List of origins (one per line) allowed for Passkey registration and authentication.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</SettingsForm>
|
|
</Form>
|
|
</SettingsSection>
|
|
)
|
|
}
|