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:
Vendored
+59
@@ -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
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Vendored
+19
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+41
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user