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:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+59
View File
@@ -0,0 +1,59 @@
/*
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 { api } from '@/lib/api'
import type { SetupFormValues, SetupResponse } from './types'
export async function getSetupStatus(): Promise<SetupResponse> {
const res = await api.get('/api/setup', {
// We want fresh status on every visit.
params: {
t: Date.now(),
},
})
return res.data
}
export async function submitSetup(
payload: Record<string, unknown>
): Promise<SetupResponse> {
const res = await api.post('/api/setup', payload)
return res.data
}
export function buildSetupPayload(
values: SetupFormValues,
rootInitialized: boolean
) {
const { usageMode, ...rest } = values
const basePayload = {
SelfUseModeEnabled: usageMode === 'self',
DemoSiteEnabled: usageMode === 'demo',
}
if (rootInitialized) {
return basePayload
}
return {
...rest,
...basePayload,
}
}
+125
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+80
View File
@@ -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>
)
}
+129
View File
@@ -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>
)}
/>
)
}
+19
View File
@@ -0,0 +1,19 @@
/*
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
*/
export * from './setup-wizard'
+402
View File
@@ -0,0 +1,402 @@
/*
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ErrorState } from '@/components/error-state'
import { LanguageSwitcher } from '@/components/language-switcher'
import { LoadingState } from '@/components/loading-state'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Form } from '@/components/ui/form'
import { Skeleton } from '@/components/ui/skeleton'
import { useSystemConfig } from '@/hooks/use-system-config'
import { cn } from '@/lib/utils'
import { buildSetupPayload, getSetupStatus, submitSetup } from './api'
import { AdminStep } from './components/admin-step'
import { CompleteStep } from './components/complete-step'
import { DatabaseStep } from './components/database-step'
import { StepNavigation } from './components/step-navigation'
import { UsageModeStep } from './components/usage-mode-step'
import type { SetupFormValues, SetupStatus } from './types'
const STEPS = [
{
titleKey: 'Database check',
descriptionKey: 'Verify your database connection',
},
{
titleKey: 'Administrator account',
descriptionKey: 'Create credentials for the root user',
},
{
titleKey: 'Usage mode',
descriptionKey: 'Choose how the platform will operate',
},
{
titleKey: 'Review & initialize',
descriptionKey: 'Confirm settings and finish setup',
},
]
const DEFAULT_FORM_VALUES: SetupFormValues = {
username: '',
password: '',
confirmPassword: '',
usageMode: 'external',
}
export function SetupWizard() {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { systemName, logo, loading: systemConfigLoading } = useSystemConfig()
const [currentStep, setCurrentStep] = useState(0)
const [setupStatus, setSetupStatus] = useState<SetupStatus | undefined>()
const form = useForm<SetupFormValues>({
defaultValues: DEFAULT_FORM_VALUES,
mode: 'onBlur',
})
const watchedValues = form.watch()
const {
data: statusResponse,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ['setup-status'],
queryFn: getSetupStatus,
retry: false,
})
const mutation = useMutation({
mutationKey: ['setup-submit'],
mutationFn: submitSetup,
onSuccess: async (response) => {
if (response.success) {
toast.success(t('System initialized successfully! Redirecting…'))
await queryClient.invalidateQueries({ queryKey: ['setup-status'] })
setTimeout(() => {
navigate({ to: '/' })
}, 1200)
} else {
toast.error(
response.message || t('Initialization failed, please try again.')
)
}
},
onError: () => {
toast.error(t('Failed to initialize system'))
},
})
useEffect(() => {
if (!statusResponse) return
if (!statusResponse.success) {
toast.error(statusResponse.message || t('Failed to load setup status'))
return
}
const status = statusResponse.data
if (!status) return
if (status.status) {
navigate({ to: '/' })
return
}
setSetupStatus(status)
setCurrentStep(0)
// Pre-fill usage mode if backend echoes it
if (status.SelfUseModeEnabled) {
form.setValue('usageMode', 'self', {
shouldDirty: false,
shouldTouch: false,
shouldValidate: false,
})
} else if (status.DemoSiteEnabled) {
form.setValue('usageMode', 'demo', {
shouldDirty: false,
shouldTouch: false,
shouldValidate: false,
})
} else {
form.setValue('usageMode', 'external', {
shouldDirty: false,
shouldTouch: false,
shouldValidate: false,
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusResponse, navigate, form])
useEffect(() => {
if (!setupStatus) return
// Reset admin fields when backend reports they are already initialized
if (setupStatus.root_init) {
form.setValue('username', '', {
shouldDirty: false,
shouldTouch: false,
shouldValidate: false,
})
form.setValue('password', '', {
shouldDirty: false,
shouldTouch: false,
shouldValidate: false,
})
form.setValue('confirmPassword', '', {
shouldDirty: false,
shouldTouch: false,
shouldValidate: false,
})
}
}, [setupStatus, form])
const currentStepComponent = useMemo(() => {
if (currentStep === 0) {
return <DatabaseStep status={setupStatus} />
}
if (currentStep === 1) {
return (
<AdminStep
form={form}
rootInitialized={Boolean(setupStatus?.root_init)}
/>
)
}
if (currentStep === 2) {
return <UsageModeStep form={form} />
}
return <CompleteStep status={setupStatus} values={watchedValues} />
}, [currentStep, setupStatus, form, watchedValues])
const validateAdminStep = () => {
if (setupStatus?.root_init) return true
const username = form.getValues('username')?.trim()
const password = form.getValues('password')?.trim()
const confirmPassword = form.getValues('confirmPassword')?.trim()
if (!username) {
form.setError('username', {
type: 'manual',
message: t('Please enter an administrator username'),
})
toast.error(t('Please enter an administrator username'))
return false
}
if (!password || password.length < 8) {
form.setError('password', {
type: 'manual',
message: t('Password must be at least 8 characters'),
})
toast.error(t('Password must be at least 8 characters'))
return false
}
if (password !== confirmPassword) {
form.setError('confirmPassword', {
type: 'manual',
message: t('Passwords do not match'),
})
toast.error(t('Passwords do not match'))
return false
}
return true
}
const validateUsageModeStep = () => {
const usageMode = form.getValues('usageMode')
if (!usageMode) {
form.setError('usageMode', {
type: 'manual',
message: t('Select a usage mode to continue'),
})
toast.error(t('Select a usage mode to continue'))
return false
}
return true
}
const handleNextStep = () => {
if (currentStep === 1 && !validateAdminStep()) return
if (currentStep === 2 && !validateUsageModeStep()) return
setCurrentStep((step) => Math.min(step + 1, STEPS.length - 1))
}
const handlePreviousStep = () => {
setCurrentStep((step) => Math.max(step - 1, 0))
}
const handleSubmit = async () => {
const adminValid = validateAdminStep()
const usageValid = validateUsageModeStep()
if (!adminValid || !usageValid) return
const payload = buildSetupPayload(
form.getValues(),
Boolean(setupStatus?.root_init)
)
mutation.mutate(payload)
}
return (
<div className='bg-muted/40 relative min-h-svh py-10'>
<div className='absolute top-4 right-4 sm:top-6 sm:right-6'>
<LanguageSwitcher />
</div>
<div className='container mx-auto flex max-w-5xl flex-col gap-8 px-4 sm:px-6'>
<div className='flex flex-col items-center gap-3'>
<div className='relative h-12 w-12'>
{systemConfigLoading ? (
<Skeleton className='absolute inset-0 rounded-full' />
) : (
<img
src={logo}
alt={t('System logo')}
className='h-12 w-12 rounded-full object-cover shadow-sm'
/>
)}
</div>
{systemConfigLoading ? (
<Skeleton className='h-7 w-40' />
) : (
<h1 className='text-2xl font-semibold tracking-tight'>
{t('Initialize')} {systemName}
</h1>
)}
<p className='text-muted-foreground text-center text-sm sm:text-base'>
{t(
'Follow the guided steps to prepare your workspace before the first login.'
)}
</p>
</div>
<Card className='shadow-lg'>
<CardHeader className='space-y-2'>
<CardTitle className='text-xl font-semibold'>
{t('System setup wizard')}
</CardTitle>
<CardDescription>
{t('Complete these steps to finish the initial installation.')}
</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<ol className='grid gap-3 sm:grid-cols-4'>
{STEPS.map((step, index) => {
const isActive = currentStep === index
const isCompleted = currentStep > index
return (
<li
key={step.titleKey}
className={cn(
'rounded-xl border p-3',
isActive
? 'border-primary ring-primary/20 ring-2'
: isCompleted
? 'border-primary/40 bg-primary/5'
: 'border-muted bg-card'
)}
>
<div className='flex items-start gap-3'>
<span
className={cn(
'flex size-6 items-center justify-center rounded-md border text-xs font-semibold',
isActive
? 'border-primary bg-primary text-primary-foreground'
: isCompleted
? 'border-primary bg-primary text-primary-foreground'
: 'border-muted-foreground/40 text-muted-foreground'
)}
>
{index + 1}
</span>
<div className='space-y-1'>
<p className='text-sm font-semibold'>
{t(step.titleKey)}
</p>
<p className='text-muted-foreground text-xs'>
{t(step.descriptionKey)}
</p>
</div>
</div>
</li>
)
})}
</ol>
{isLoading ? (
<LoadingState message={t('Loading setup status…')} />
) : isError ? (
<ErrorState
title={t('We could not load the setup status.')}
onRetry={() => refetch()}
/>
) : (
<Form {...form}>
<form
className='space-y-6'
onSubmit={(event) => event.preventDefault()}
>
{currentStepComponent}
</form>
</Form>
)}
</CardContent>
{!isLoading && !isError && (
<CardFooter className='w-full justify-end border-t'>
<StepNavigation
currentStep={currentStep}
totalSteps={STEPS.length}
onBack={handlePreviousStep}
onNext={handleNextStep}
onSubmit={handleSubmit}
isSubmitting={mutation.isPending}
/>
</CardFooter>
)}
</Card>
</div>
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
/*
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
*/
export type SetupUsageMode = 'external' | 'self' | 'demo'
export interface SetupStatus {
status: boolean
root_init: boolean
database_type: string
// Some backends also echo mode flags; they are optional here.
SelfUseModeEnabled?: boolean
DemoSiteEnabled?: boolean
}
export interface SetupFormValues {
username: string
password: string
confirmPassword: string
usageMode: SetupUsageMode
}
export interface SetupResponse {
success: boolean
message?: string
data?: SetupStatus
}