refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* 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
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
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 { ShieldCheck } from 'lucide-react'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import type { SetupFormValues } from '../types'
|
||||
|
||||
interface AdminStepProps {
|
||||
form: UseFormReturn<SetupFormValues>
|
||||
rootInitialized?: boolean
|
||||
}
|
||||
|
||||
export function AdminStep({ form, rootInitialized }: AdminStepProps) {
|
||||
const { t } = useTranslation()
|
||||
if (rootInitialized) {
|
||||
return (
|
||||
<Alert className='border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/40'>
|
||||
<AlertDescription className='flex items-start gap-2'>
|
||||
<ShieldCheck className='mt-0.5 size-4 text-sky-500' />
|
||||
{t(
|
||||
'The administrator account is already initialized. You can keep your existing credentials and continue to the next step.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Administrator username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t('Choose a username')}
|
||||
autoComplete='username'
|
||||
onChange={(event) => {
|
||||
form.clearErrors('username')
|
||||
field.onChange(event)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder={t('Set a secure password (min. 8 characters)')}
|
||||
autoComplete='new-password'
|
||||
onChange={(event) => {
|
||||
form.clearErrors('password')
|
||||
field.onChange(event)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem className='sm:col-span-2'>
|
||||
<FormLabel>{t('Confirm password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
placeholder={t('Repeat the administrator password')}
|
||||
autoComplete='new-password'
|
||||
onChange={(event) => {
|
||||
form.clearErrors('confirmPassword')
|
||||
field.onChange(event)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
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 { CheckCircle2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
import type { SetupFormValues, SetupStatus } from '../types'
|
||||
|
||||
interface CompleteStepProps {
|
||||
status?: SetupStatus
|
||||
values: SetupFormValues
|
||||
}
|
||||
|
||||
const USAGE_MODE_LABEL_KEYS: Record<SetupFormValues['usageMode'], string> = {
|
||||
external: 'External operations mode',
|
||||
self: 'Personal use mode',
|
||||
demo: 'Demo site mode',
|
||||
}
|
||||
|
||||
const DATABASE_VARIANT: Record<
|
||||
string,
|
||||
'info' | 'success' | 'warning' | 'neutral'
|
||||
> = {
|
||||
sqlite: 'warning',
|
||||
mysql: 'success',
|
||||
postgres: 'success',
|
||||
}
|
||||
|
||||
export function CompleteStep({ status, values }: CompleteStepProps) {
|
||||
const { t } = useTranslation()
|
||||
const usageLabelKey = USAGE_MODE_LABEL_KEYS[values.usageMode]
|
||||
const dbType = status?.database_type ?? 'Unknown'
|
||||
const databaseVariant = DATABASE_VARIANT[dbType.toLowerCase()] ?? 'neutral'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center gap-6 text-center'>
|
||||
<div className='rounded-2xl bg-emerald-500/10 p-4 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300'>
|
||||
<CheckCircle2 className='size-8' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('Ready to initialize')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground max-w-lg text-sm sm:text-base'>
|
||||
{t(
|
||||
'Double check the configuration below. Your system will be locked until initialization is complete.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='bg-card w-full rounded-xl border p-6 text-left shadow-sm sm:p-8'>
|
||||
<dl className='grid gap-6'>
|
||||
<div className='space-y-1.5'>
|
||||
<dt className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
|
||||
{t('Database')}
|
||||
</dt>
|
||||
<dd className='flex flex-wrap items-center gap-2'>
|
||||
<span className='text-sm font-semibold'>{dbType}</span>
|
||||
<StatusBadge
|
||||
label={dbType}
|
||||
variant={databaseVariant}
|
||||
copyable={false}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<dt className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
|
||||
{t('Administrator account')}
|
||||
</dt>
|
||||
<dd className='text-sm font-semibold'>
|
||||
{status?.root_init
|
||||
? t('Existing account will be reused')
|
||||
: values.username || t('Not set yet')}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<dt className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
|
||||
{t('Usage mode')}
|
||||
</dt>
|
||||
<dd className='text-sm font-semibold'>{t(usageLabelKey)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
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 { Database, HardDrive, Server } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
|
||||
import type { SetupStatus } from '../types'
|
||||
|
||||
interface DatabaseStepProps {
|
||||
status?: SetupStatus
|
||||
}
|
||||
|
||||
const DATABASE_META: Record<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
descriptionKey: string
|
||||
variant: 'info' | 'success' | 'warning'
|
||||
}
|
||||
> = {
|
||||
sqlite: {
|
||||
label: 'SQLite',
|
||||
descriptionKey:
|
||||
'SQLite stores all data in a single file. Make sure that file is persisted when running in containers.',
|
||||
variant: 'warning',
|
||||
},
|
||||
mysql: {
|
||||
label: 'MySQL',
|
||||
descriptionKey:
|
||||
'MySQL is a production-ready relational database. Keep your credentials secure.',
|
||||
variant: 'success',
|
||||
},
|
||||
postgres: {
|
||||
label: 'PostgreSQL',
|
||||
descriptionKey:
|
||||
'PostgreSQL offers advanced reliability and data integrity for production workloads.',
|
||||
variant: 'success',
|
||||
},
|
||||
}
|
||||
|
||||
function resolveDatabaseMeta(type?: string) {
|
||||
if (!type) return null
|
||||
const normalized = type.toLowerCase()
|
||||
return (
|
||||
DATABASE_META[normalized] ?? {
|
||||
label: type,
|
||||
descriptionKey: 'Custom database driver detected.',
|
||||
variant: 'info' as const,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function DatabaseStep({ status }: DatabaseStepProps) {
|
||||
const { t } = useTranslation()
|
||||
const meta = resolveDatabaseMeta(status?.database_type)
|
||||
const electronApi =
|
||||
typeof window !== 'undefined'
|
||||
? ((window as unknown as Record<string, unknown>)?.electron as
|
||||
| Record<string, unknown>
|
||||
| undefined)
|
||||
: undefined
|
||||
const isElectron = Boolean(electronApi?.isElectron)
|
||||
const electronDataDir = electronApi?.dataDir as string | undefined
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='bg-card flex items-center justify-between rounded-lg border p-4'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-muted-foreground text-sm font-medium'>
|
||||
{t('Detected database')}
|
||||
</p>
|
||||
<p className='text-foreground text-base font-semibold'>
|
||||
{meta?.label ?? t('Unknown')}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
meta?.descriptionKey ??
|
||||
'The setup wizard will use this database during initialization.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
label={meta?.label ?? t('Unknown')}
|
||||
variant={meta?.variant ?? 'info'}
|
||||
className='cursor-default'
|
||||
copyable={false}
|
||||
icon={Database}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status?.database_type === 'sqlite' && (
|
||||
<Alert className='border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/40'>
|
||||
<AlertTitle className='flex items-center gap-2'>
|
||||
<HardDrive className='size-4 text-amber-500' />
|
||||
{t('Persist your data file')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{t(
|
||||
'When running in containers or ephemeral environments, ensure the SQLite file is mapped to persistent storage to avoid data loss on restart.'
|
||||
)}
|
||||
</p>
|
||||
{isElectron && electronDataDir && (
|
||||
<p className='mt-3 rounded-md bg-amber-100/70 px-3 py-2 font-mono text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200'>
|
||||
{t('Data directory:')} {electronDataDir}
|
||||
</p>
|
||||
)}
|
||||
{isElectron && !electronDataDir && (
|
||||
<p className='text-muted-foreground mt-3 text-xs'>
|
||||
{t(
|
||||
'Data is stored locally on this device. Use system backups to keep a safe copy.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status?.database_type === 'mysql' && (
|
||||
<Alert className='border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/40'>
|
||||
<AlertTitle className='flex items-center gap-2'>
|
||||
<Server className='size-4 text-emerald-500' />
|
||||
{t('MySQL detected')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'MySQL is production ready. Ensure automated backups and a dedicated user with the minimal required privileges are configured.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status?.database_type === 'postgres' && (
|
||||
<Alert className='border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/40'>
|
||||
<AlertTitle className='flex items-center gap-2'>
|
||||
<Server className='size-4 text-sky-500' />
|
||||
{t('PostgreSQL detected')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'PostgreSQL offers strong reliability guarantees. Double check your maintenance window and retention policies before going live.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
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 { CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface StepNavigationProps {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
onSubmit: () => void
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
export function StepNavigation({
|
||||
currentStep,
|
||||
totalSteps,
|
||||
onBack,
|
||||
onNext,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
}: StepNavigationProps) {
|
||||
const { t } = useTranslation()
|
||||
const isFirstStep = currentStep === 0
|
||||
const isLastStep = currentStep === totalSteps - 1
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center'>
|
||||
<div className='flex justify-end gap-2 sm:justify-start'>
|
||||
{!isFirstStep && (
|
||||
<Button type='button' variant='outline' onClick={onBack}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-1 justify-end gap-2'>
|
||||
{!isLastStep && (
|
||||
<Button type='button' onClick={onNext}>
|
||||
{t('Next')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isLastStep && (
|
||||
<Button type='button' onClick={onSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 size-4 animate-spin' />
|
||||
{t('Initializing…')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className='mr-2 size-4' />
|
||||
{t('Initialize system')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
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 { Building2, Home, Presentation } from 'lucide-react'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { SetupFormValues, SetupUsageMode } from '../types'
|
||||
|
||||
interface UsageModeStepProps {
|
||||
form: UseFormReturn<SetupFormValues>
|
||||
}
|
||||
|
||||
const USAGE_MODE_OPTIONS: Array<{
|
||||
value: SetupUsageMode
|
||||
titleKey: string
|
||||
descriptionKey: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}> = [
|
||||
{
|
||||
value: 'external',
|
||||
titleKey: 'External operations',
|
||||
descriptionKey:
|
||||
'Serve multiple users or teams with billing and quota control.',
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
value: 'self',
|
||||
titleKey: 'Personal use',
|
||||
descriptionKey:
|
||||
'Best for single-tenant deployments. Pricing and billing options stay hidden.',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
value: 'demo',
|
||||
titleKey: 'Demo site',
|
||||
descriptionKey:
|
||||
'Showcase core capabilities with demo credentials and limited access.',
|
||||
icon: Presentation,
|
||||
},
|
||||
]
|
||||
|
||||
export function UsageModeStep({ form }: UsageModeStepProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='usageMode'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('How will you use the platform?')}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
form.clearErrors('usageMode')
|
||||
field.onChange(value as SetupUsageMode)
|
||||
}}
|
||||
className='grid gap-3 sm:grid-cols-3'
|
||||
>
|
||||
{USAGE_MODE_OPTIONS.map(
|
||||
({ value, titleKey, descriptionKey, icon: Icon }) => {
|
||||
return (
|
||||
<Label
|
||||
key={value}
|
||||
htmlFor={`usage-mode-${value}`}
|
||||
className={cn(
|
||||
'hover:border-primary/40 focus-within:border-primary/50 has-data-[checked]:border-primary has-data-[checked]:ring-primary/20 group bg-card border-muted flex cursor-pointer flex-col gap-3 rounded-xl border p-4 font-normal transition-all has-data-[checked]:ring-2'
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-3'>
|
||||
<RadioGroupItem
|
||||
id={`usage-mode-${value}`}
|
||||
value={value}
|
||||
className='mt-1'
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={`usage-mode-${value}`}
|
||||
className='text-base leading-none font-semibold'
|
||||
>
|
||||
{t(titleKey)}
|
||||
</Label>
|
||||
<p className='text-muted-foreground mt-2 text-sm'>
|
||||
{t(descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
<Icon className='text-muted-foreground/70 group-hover:text-primary group-focus-within:text-primary group-has-data-[checked]:text-primary ml-auto size-5 shrink-0 transition' />
|
||||
</div>
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user