Files
new-api/web/default/src/features/setup/setup-wizard.tsx
t0ng7u d146e45e2f ⚖️ chore(web/default): add reusable copyright header tooling
Add a Bun script to apply and normalize AGPL copyright headers across the default frontend source files.

The script keeps headers idempotent, upgrades existing headers to the 2023-2026 QuantumNous range, and is exposed through `bun run copyright` for future maintenance.
2026-05-09 11:35:07 +08:00

401 lines
12 KiB
TypeScript
Vendored

/*
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 { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useSystemConfig } from '@/hooks/use-system-config'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Form } from '@/components/ui/form'
import { Skeleton } from '@/components/ui/skeleton'
import { ErrorState } from '@/components/error-state'
import { LanguageSwitcher } from '@/components/language-switcher'
import { LoadingState } from '@/components/loading-state'
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 long'),
})
toast.error(t('Password must be at least 8 characters long'))
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>
)
}