import { zodResolver } from '@hookform/resolvers/zod'; import { CurrentUser, SetupInstanceInput, SetupRegistrationInput, SetupSmtpInput, SignupFormInput, setupAdminInputSchema, setupInstanceInputSchema, setupRegistrationInputSchema, setupSmtpInputSchema, } from '@dorfteich/shared'; import { useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { Resolver, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useAuth } from '../auth/auth-context'; import { Field, FormError, applyFieldErrors } from '../components/forms'; import { ApiError, apiPost } from '../lib/api'; import { SETUP_QUERY_KEY, useSetupStatus } from './use-setup-status'; type Locale = 'de' | 'en'; const STEPS = ['language', 'admin', 'instance', 'smtp', 'registration', 'summary'] as const; /** * Everything entered so far, kept here in the parent so Back preserves the * entries (issue #81). Steps that were already submitted are applied on the * server immediately — re-submitting on a second forward pass just * overwrites the same settings, which is safe. */ interface WizardState { locale: Locale; adminDone: boolean; admin?: Partial; instance?: Partial; smtp?: Partial; /** true = saved after a delivered test mail, false = explicitly skipped. */ smtpConfigured?: boolean; registration?: SetupRegistrationInput['mode']; } /** * The guided first-run experience at /setup (issue #81, ADR 0012): language * choice, Site Admin account, instance basics, SMTP with a live test, * registration mode, summary. Talks to the #80 wizard api; step 1 signs the * created admin in, so finishing leaves the visitor logged-in-ready. */ export function SetupWizardPage(): React.JSX.Element { const { t, i18n } = useTranslation(); const { user, isLoading } = useAuth(); const status = useSetupStatus(); const [stepIndex, setStepIndex] = useState(0); const [state, setState] = useState({ locale: i18n.language === 'de' ? 'de' : 'en', adminDone: false, }); const update = (patch: Partial): void => setState((previous) => ({ ...previous, ...patch })); const next = (): void => setStepIndex((index) => Math.min(index + 1, STEPS.length - 1)); const back = (): void => setStepIndex((index) => Math.max(index - 1, 0)); const adminCreated = state.adminDone || status.data?.adminCreated === true; // A wizard someone else started: the remaining steps require that admin's // session (the api enforces it), so all this visitor can do is sign in as // the Site Admin and resume. Skipped right after our own step 1 — the // session cookie is set, /auth/me is merely still refreshing. if (adminCreated && !user && !isLoading && !state.adminDone) { return (

{t('setup:admin.resumeTitle')}

{t('setup:admin.resumeBody')}

{t('setup:admin.resumeLink')}

); } const step = STEPS[stepIndex]!; return (
{step === 'language' && ( { void i18n.changeLanguage(locale); update({ locale }); next(); }} /> )} {step === 'admin' && ( { update({ adminDone: true, admin: input }); next(); }} onNext={next} onBack={(draft) => { update({ admin: draft }); back(); }} onConflict={() => { // Someone else created the admin meanwhile — refresh the status // so the resume screen above can take over. void status.refetch(); }} /> )} {step === 'instance' && ( { update({ instance: input }); next(); }} onBack={(draft) => { update({ instance: draft }); back(); }} /> )} {step === 'smtp' && ( { update({ smtp: input, smtpConfigured: true }); next(); }} onSkip={(draft) => { update({ smtp: draft, smtpConfigured: false }); next(); }} onBack={(draft) => { update({ smtp: draft }); back(); }} /> )} {step === 'registration' && ( { update({ registration: input.mode }); next(); }} onBack={back} /> )} {step === 'summary' && }
); } function StepActions({ onBack, onNext, nextLabel, busy, extra, }: { onBack?: () => void; /** When set, the primary button is a plain button; otherwise it submits. */ onNext?: () => void; nextLabel: string; busy?: boolean; extra?: React.ReactNode; }): React.JSX.Element { const { t } = useTranslation(); return (
{onBack && ( )} {extra} {onNext ? ( ) : ( )}
); } function LanguageStep({ locale, onChoose, }: { locale: Locale; onChoose: (locale: Locale) => void; }): React.JSX.Element { const { t } = useTranslation(); return (

{t('setup:language.title')}

{t('setup:language.body')}

{t('setup:language.choose')}

{(['de', 'en'] as const).map((candidate) => ( ))}
); } function AdminStep({ locale, done, draft, onDone, onNext, onBack, onConflict, }: { locale: Locale; done: boolean; draft?: Partial; onDone: (input: Partial) => void; onNext: () => void; onBack: (draft: Partial) => void; onConflict: () => void; }): React.JSX.Element { const { t } = useTranslation(); const { refresh } = useAuth(); const [error, setError] = useState(null); // One confined cast, like SignupPage: RHF cannot express Zod's // input/output split (locale optional on input, defaulted on output). const form = useForm({ resolver: zodResolver(setupAdminInputSchema) as Resolver, defaultValues: { locale, ...draft }, }); if (done) { return (

{t('setup:admin.title')}

{t('setup:admin.exists')}

onBack(form.getValues())} onNext={onNext} nextLabel={t('setup:next')} />
); } const onSubmit = form.handleSubmit(async (input) => { setError(null); try { await apiPost('/setup/admin', { ...input, locale }); await refresh(); onDone(input); } catch (err) { if (err instanceof ApiError && err.body.code === 'setup_admin_exists') { onConflict(); return; } setError(err); applyFieldErrors(err, (name, fieldError) => form.setError(name as keyof SignupFormInput, fieldError), ); } }); return (

{t('setup:admin.title')}

{t('setup:admin.body')}

onBack(form.getValues())} nextLabel={t('setup:admin.submit')} busy={form.formState.isSubmitting} />
); } function InstanceStep({ locale, draft, onDone, onBack, }: { locale: Locale; draft?: Partial; onDone: (input: SetupInstanceInput) => void; onBack: (draft: Partial) => void; }): React.JSX.Element { const { t } = useTranslation(); const [error, setError] = useState(null); const form = useForm({ resolver: zodResolver(setupInstanceInputSchema), defaultValues: { name: draft?.name ?? '', defaultLocale: draft?.defaultLocale ?? locale }, }); const onSubmit = form.handleSubmit(async (input) => { setError(null); try { await apiPost('/setup/instance', input); onDone(input); } catch (err) { setError(err); applyFieldErrors(err, (name, fieldError) => form.setError(name as keyof SetupInstanceInput, fieldError), ); } }); return (

{t('setup:instance.title')}

onBack(form.getValues())} nextLabel={t('setup:next')} busy={form.formState.isSubmitting} />
); } function SmtpStep({ draft, onDone, onSkip, onBack, }: { draft?: Partial; onDone: (input: SetupSmtpInput) => void; onSkip: (draft: Partial) => void; onBack: (draft: Partial) => void; }): React.JSX.Element { const { t } = useTranslation(); const [error, setError] = useState(null); const form = useForm({ resolver: zodResolver(setupSmtpInputSchema), defaultValues: { host: draft?.host ?? '', port: draft?.port ?? 587, secure: draft?.secure ?? false, user: draft?.user ?? '', pass: draft?.pass ?? '', from: draft?.from ?? '', }, }); const onSubmit = form.handleSubmit(async (input) => { setError(null); try { await apiPost('/setup/smtp', input); onDone(input); } catch (err) { setError(err); applyFieldErrors(err, (name, fieldError) => form.setError(name as keyof SetupSmtpInput, fieldError), ); } }); // The live test's transport error, straight from the api (`details.smtp`): // untranslated but the single most actionable line for fixing the relay. const transportDetail = error instanceof ApiError && error.body.code === 'smtp_test_failed' ? error.body.details?.smtp?.[0] : undefined; return (

{t('setup:smtp.title')}

{t('setup:smtp.body')}

{transportDetail &&

{transportDetail}

}

{t('setup:smtp.submitHint')}

onBack(form.getValues())} nextLabel={t('setup:smtp.submit')} busy={form.formState.isSubmitting} extra={ } />
); } function RegistrationStep({ draft, onDone, onBack, }: { draft?: SetupRegistrationInput['mode']; onDone: (input: SetupRegistrationInput) => void; onBack: () => void; }): React.JSX.Element { const { t } = useTranslation(); const [error, setError] = useState(null); const form = useForm({ resolver: zodResolver(setupRegistrationInputSchema), defaultValues: { mode: draft ?? 'open' }, }); const onSubmit = form.handleSubmit(async (input) => { setError(null); try { await apiPost('/setup/registration', input); onDone(input); } catch (err) { setError(err); } }); return (

{t('setup:registration.title')}

); } function SummaryStep({ state, user, onBack, }: { state: WizardState; user: CurrentUser | null; onBack: () => void; }): React.JSX.Element { const { t } = useTranslation(); const queryClient = useQueryClient(); const [error, setError] = useState(null); const [finishing, setFinishing] = useState(false); async function finish(): Promise { setError(null); setFinishing(true); try { await apiPost('/setup/complete'); // Flips the app's setup gate to 'completed'; the regular route tree // then redirects /setup to the home page — logged-in-ready, because // step 1 already set the session cookie. await queryClient.invalidateQueries({ queryKey: SETUP_QUERY_KEY }); } catch (err) { setError(err); setFinishing(false); } } const adminName = state.admin?.username ?? user?.username ?? ''; const adminEmail = state.admin?.email ?? user?.email ?? ''; const locale = state.instance?.defaultLocale ?? state.locale; return (

{t('setup:summary.title')}

{t('setup:summary.body')}

{t('setup:summary.admin')}
{adminName} ({adminEmail})
{t('setup:summary.instance')}
{state.instance?.name}
{t('setup:summary.locale')}
{t(`setup:language.${locale}`)}
{t('setup:summary.smtp')}
{state.smtpConfigured ? t('setup:summary.smtpConfigured', { host: state.smtp?.host }) : t('setup:summary.smtpSkipped')}
{t('setup:summary.registration')}
{state.registration === 'closed' ? t('setup:summary.registrationClosed') : t('setup:summary.registrationOpen')}
void finish()} nextLabel={t('setup:summary.finish')} busy={finishing} />
); }