All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m6s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m13s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m6s
CI / Import/export fidelity gate (push) Successful in 45s
The SPA now probes GET /setup on boot: while setup is pending it renders only the wizard at /setup, the login page (to resume a started wizard as the Site Admin), and a "setup pending" notice on every other route — without the regular chrome, whose pond/search queries would all 503. If the probe itself fails (offline reload), the app falls through to the normal routes. Once completed, /setup just goes home. The wizard walks six steps against the #80 api: welcome with language choice (drives i18n immediately and pre-fills the admin/instance locales), Site Admin account (signs in via the step-1 session cookie), instance basics, SMTP with live-test-before-save plus an explicit skip, registration mode, and a summary whose finish unlocks the app logged-in-ready. Every step validates through the shared Zod schemas before advancing; entered values live in the parent component, so Back preserves them, and re-submitting a step on a second forward pass just overwrites the same settings. A wizard someone else started shows a sign-in hand-off instead of dead admin-gated steps. New `setup` i18n namespace in de and en. Two #80 touch-ups fell out of verifying this end to end: the SMTP port's NaN case now maps to the translated required-message, and GET /setup's smtpConfigured uses `||` instead of `??` so the empty strings compose passes for unset vars fall through to the secret store. The e2e pack (setup.spec.ts) needs an instance where setup is still pending, so the CI job provisions a second api + static web against a virgin database on their own ports and runs the full wizard journey there, including the failing-relay path and both languages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
644 lines
19 KiB
TypeScript
644 lines
19 KiB
TypeScript
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<SignupFormInput>;
|
|
instance?: Partial<SetupInstanceInput>;
|
|
smtp?: Partial<SetupSmtpInput>;
|
|
/** 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<WizardState>({
|
|
locale: i18n.language === 'de' ? 'de' : 'en',
|
|
adminDone: false,
|
|
});
|
|
|
|
const update = (patch: Partial<WizardState>): 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 (
|
|
<div className="auth-card">
|
|
<h1>{t('setup:admin.resumeTitle')}</h1>
|
|
<p>{t('setup:admin.resumeBody')}</p>
|
|
<p>
|
|
<Link className="button" to="/login?next=/setup">
|
|
{t('setup:admin.resumeLink')}
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const step = STEPS[stepIndex]!;
|
|
return (
|
|
<div className="auth-card setup-wizard">
|
|
<nav aria-label={t('setup:progress', { current: stepIndex + 1, total: STEPS.length })}>
|
|
<p className="setup-progress">
|
|
{t('setup:progress', { current: stepIndex + 1, total: STEPS.length })}
|
|
</p>
|
|
<ol className="setup-progress-steps">
|
|
{STEPS.map((id, index) => (
|
|
<li
|
|
key={id}
|
|
aria-current={index === stepIndex ? 'step' : undefined}
|
|
className={index < stepIndex ? 'setup-progress-steps__done' : undefined}
|
|
>
|
|
{t(`setup:steps.${id}`)}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</nav>
|
|
|
|
{step === 'language' && (
|
|
<LanguageStep
|
|
locale={state.locale}
|
|
onChoose={(locale) => {
|
|
void i18n.changeLanguage(locale);
|
|
update({ locale });
|
|
next();
|
|
}}
|
|
/>
|
|
)}
|
|
{step === 'admin' && (
|
|
<AdminStep
|
|
locale={state.locale}
|
|
done={adminCreated}
|
|
draft={state.admin}
|
|
onDone={(input) => {
|
|
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' && (
|
|
<InstanceStep
|
|
locale={state.locale}
|
|
draft={state.instance}
|
|
onDone={(input) => {
|
|
update({ instance: input });
|
|
next();
|
|
}}
|
|
onBack={(draft) => {
|
|
update({ instance: draft });
|
|
back();
|
|
}}
|
|
/>
|
|
)}
|
|
{step === 'smtp' && (
|
|
<SmtpStep
|
|
draft={state.smtp}
|
|
onDone={(input) => {
|
|
update({ smtp: input, smtpConfigured: true });
|
|
next();
|
|
}}
|
|
onSkip={(draft) => {
|
|
update({ smtp: draft, smtpConfigured: false });
|
|
next();
|
|
}}
|
|
onBack={(draft) => {
|
|
update({ smtp: draft });
|
|
back();
|
|
}}
|
|
/>
|
|
)}
|
|
{step === 'registration' && (
|
|
<RegistrationStep
|
|
draft={state.registration}
|
|
onDone={(input) => {
|
|
update({ registration: input.mode });
|
|
next();
|
|
}}
|
|
onBack={back}
|
|
/>
|
|
)}
|
|
{step === 'summary' && <SummaryStep state={state} user={user} onBack={back} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="setup-actions">
|
|
{onBack && (
|
|
<button type="button" className="linklike" onClick={onBack}>
|
|
{t('setup:back')}
|
|
</button>
|
|
)}
|
|
<span className="setup-actions__spacer" />
|
|
{extra}
|
|
{onNext ? (
|
|
<button type="button" className="button" disabled={busy} onClick={onNext}>
|
|
{nextLabel}
|
|
</button>
|
|
) : (
|
|
<button type="submit" className="button" disabled={busy}>
|
|
{nextLabel}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LanguageStep({
|
|
locale,
|
|
onChoose,
|
|
}: {
|
|
locale: Locale;
|
|
onChoose: (locale: Locale) => void;
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<section>
|
|
<h1>{t('setup:language.title')}</h1>
|
|
<p>{t('setup:language.body')}</p>
|
|
<p>{t('setup:language.choose')}</p>
|
|
<div className="setup-language-buttons">
|
|
{(['de', 'en'] as const).map((candidate) => (
|
|
<button
|
|
key={candidate}
|
|
type="button"
|
|
lang={candidate}
|
|
className={candidate === locale ? 'button' : 'button button--outline'}
|
|
onClick={() => onChoose(candidate)}
|
|
>
|
|
{t(`setup:language.${candidate}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function AdminStep({
|
|
locale,
|
|
done,
|
|
draft,
|
|
onDone,
|
|
onNext,
|
|
onBack,
|
|
onConflict,
|
|
}: {
|
|
locale: Locale;
|
|
done: boolean;
|
|
draft?: Partial<SignupFormInput>;
|
|
onDone: (input: Partial<SignupFormInput>) => void;
|
|
onNext: () => void;
|
|
onBack: (draft: Partial<SignupFormInput>) => void;
|
|
onConflict: () => void;
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
const { refresh } = useAuth();
|
|
const [error, setError] = useState<unknown>(null);
|
|
// One confined cast, like SignupPage: RHF cannot express Zod's
|
|
// input/output split (locale optional on input, defaulted on output).
|
|
const form = useForm<SignupFormInput>({
|
|
resolver: zodResolver(setupAdminInputSchema) as Resolver<SignupFormInput>,
|
|
defaultValues: { locale, ...draft },
|
|
});
|
|
|
|
if (done) {
|
|
return (
|
|
<section>
|
|
<h1>{t('setup:admin.title')}</h1>
|
|
<p className="form-banner form-banner--ok">{t('setup:admin.exists')}</p>
|
|
<StepActions
|
|
onBack={() => onBack(form.getValues())}
|
|
onNext={onNext}
|
|
nextLabel={t('setup:next')}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const onSubmit = form.handleSubmit(async (input) => {
|
|
setError(null);
|
|
try {
|
|
await apiPost<CurrentUser>('/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 (
|
|
<section>
|
|
<h1>{t('setup:admin.title')}</h1>
|
|
<p>{t('setup:admin.body')}</p>
|
|
<form onSubmit={onSubmit} noValidate>
|
|
<FormError error={error} />
|
|
<Field
|
|
label={t('auth:signup.username')}
|
|
hint={t('auth:signup.usernameHint')}
|
|
error={form.formState.errors.username?.message}
|
|
>
|
|
<input type="text" autoComplete="username" {...form.register('username')} />
|
|
</Field>
|
|
<Field label={t('auth:signup.email')} error={form.formState.errors.email?.message}>
|
|
<input type="email" autoComplete="email" {...form.register('email')} />
|
|
</Field>
|
|
<Field
|
|
label={t('auth:signup.displayName')}
|
|
error={form.formState.errors.displayName?.message}
|
|
>
|
|
<input type="text" autoComplete="name" {...form.register('displayName')} />
|
|
</Field>
|
|
<Field
|
|
label={t('auth:signup.password')}
|
|
hint={t('auth:signup.passwordHint')}
|
|
error={form.formState.errors.password?.message}
|
|
>
|
|
<input type="password" autoComplete="new-password" {...form.register('password')} />
|
|
</Field>
|
|
<StepActions
|
|
onBack={() => onBack(form.getValues())}
|
|
nextLabel={t('setup:admin.submit')}
|
|
busy={form.formState.isSubmitting}
|
|
/>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function InstanceStep({
|
|
locale,
|
|
draft,
|
|
onDone,
|
|
onBack,
|
|
}: {
|
|
locale: Locale;
|
|
draft?: Partial<SetupInstanceInput>;
|
|
onDone: (input: SetupInstanceInput) => void;
|
|
onBack: (draft: Partial<SetupInstanceInput>) => void;
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
const [error, setError] = useState<unknown>(null);
|
|
const form = useForm<SetupInstanceInput>({
|
|
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 (
|
|
<section>
|
|
<h1>{t('setup:instance.title')}</h1>
|
|
<form onSubmit={onSubmit} noValidate>
|
|
<FormError error={error} />
|
|
<Field
|
|
label={t('setup:instance.name')}
|
|
hint={t('setup:instance.nameHint')}
|
|
error={form.formState.errors.name?.message}
|
|
>
|
|
<input type="text" {...form.register('name')} />
|
|
</Field>
|
|
<Field
|
|
label={t('setup:instance.defaultLocale')}
|
|
hint={t('setup:instance.defaultLocaleHint')}
|
|
error={form.formState.errors.defaultLocale?.message}
|
|
>
|
|
<select {...form.register('defaultLocale')}>
|
|
<option value="de" lang="de">
|
|
{t('setup:language.de')}
|
|
</option>
|
|
<option value="en" lang="en">
|
|
{t('setup:language.en')}
|
|
</option>
|
|
</select>
|
|
</Field>
|
|
<StepActions
|
|
onBack={() => onBack(form.getValues())}
|
|
nextLabel={t('setup:next')}
|
|
busy={form.formState.isSubmitting}
|
|
/>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SmtpStep({
|
|
draft,
|
|
onDone,
|
|
onSkip,
|
|
onBack,
|
|
}: {
|
|
draft?: Partial<SetupSmtpInput>;
|
|
onDone: (input: SetupSmtpInput) => void;
|
|
onSkip: (draft: Partial<SetupSmtpInput>) => void;
|
|
onBack: (draft: Partial<SetupSmtpInput>) => void;
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
const [error, setError] = useState<unknown>(null);
|
|
const form = useForm<SetupSmtpInput>({
|
|
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 (
|
|
<section>
|
|
<h1>{t('setup:smtp.title')}</h1>
|
|
<p>{t('setup:smtp.body')}</p>
|
|
<form onSubmit={onSubmit} noValidate>
|
|
<FormError error={error} />
|
|
{transportDetail && <p className="setup-smtp-detail">{transportDetail}</p>}
|
|
<Field label={t('setup:smtp.host')} error={form.formState.errors.host?.message}>
|
|
<input type="text" {...form.register('host')} />
|
|
</Field>
|
|
<Field label={t('setup:smtp.port')} error={form.formState.errors.port?.message}>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={65535}
|
|
{...form.register('port', { valueAsNumber: true })}
|
|
/>
|
|
</Field>
|
|
<label className="setup-choice">
|
|
<input type="checkbox" {...form.register('secure')} />
|
|
<span>{t('setup:smtp.secure')}</span>
|
|
</label>
|
|
<Field label={t('setup:smtp.user')} error={form.formState.errors.user?.message}>
|
|
<input type="text" autoComplete="off" {...form.register('user')} />
|
|
</Field>
|
|
<Field label={t('setup:smtp.pass')} error={form.formState.errors.pass?.message}>
|
|
<input type="password" autoComplete="off" {...form.register('pass')} />
|
|
</Field>
|
|
<Field
|
|
label={t('setup:smtp.from')}
|
|
hint={t('setup:smtp.fromHint')}
|
|
error={form.formState.errors.from?.message}
|
|
>
|
|
<input type="text" {...form.register('from')} />
|
|
</Field>
|
|
<p className="field__hint">{t('setup:smtp.submitHint')}</p>
|
|
<StepActions
|
|
onBack={() => onBack(form.getValues())}
|
|
nextLabel={t('setup:smtp.submit')}
|
|
busy={form.formState.isSubmitting}
|
|
extra={
|
|
<button
|
|
type="button"
|
|
className="linklike"
|
|
onClick={() => onSkip(form.getValues())}
|
|
disabled={form.formState.isSubmitting}
|
|
>
|
|
{t('setup:smtp.skip')}
|
|
</button>
|
|
}
|
|
/>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function RegistrationStep({
|
|
draft,
|
|
onDone,
|
|
onBack,
|
|
}: {
|
|
draft?: SetupRegistrationInput['mode'];
|
|
onDone: (input: SetupRegistrationInput) => void;
|
|
onBack: () => void;
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation();
|
|
const [error, setError] = useState<unknown>(null);
|
|
const form = useForm<SetupRegistrationInput>({
|
|
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 (
|
|
<section>
|
|
<h1>{t('setup:registration.title')}</h1>
|
|
<form onSubmit={onSubmit} noValidate>
|
|
<FormError error={error} />
|
|
<label className="setup-choice">
|
|
<input type="radio" value="open" {...form.register('mode')} />
|
|
<span>
|
|
<strong>{t('setup:registration.open')}</strong>
|
|
<span className="field__hint setup-choice__hint">
|
|
{t('setup:registration.openHint')}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<label className="setup-choice">
|
|
<input type="radio" value="closed" {...form.register('mode')} />
|
|
<span>
|
|
<strong>{t('setup:registration.closed')}</strong>
|
|
<span className="field__hint setup-choice__hint">
|
|
{t('setup:registration.closedHint')}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<StepActions
|
|
onBack={onBack}
|
|
nextLabel={t('setup:next')}
|
|
busy={form.formState.isSubmitting}
|
|
/>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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<unknown>(null);
|
|
const [finishing, setFinishing] = useState(false);
|
|
|
|
async function finish(): Promise<void> {
|
|
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 (
|
|
<section>
|
|
<h1>{t('setup:summary.title')}</h1>
|
|
<p>{t('setup:summary.body')}</p>
|
|
<FormError error={error} />
|
|
<dl className="setup-summary">
|
|
<dt>{t('setup:summary.admin')}</dt>
|
|
<dd>
|
|
{adminName} ({adminEmail})
|
|
</dd>
|
|
<dt>{t('setup:summary.instance')}</dt>
|
|
<dd>{state.instance?.name}</dd>
|
|
<dt>{t('setup:summary.locale')}</dt>
|
|
<dd lang={locale}>{t(`setup:language.${locale}`)}</dd>
|
|
<dt>{t('setup:summary.smtp')}</dt>
|
|
<dd>
|
|
{state.smtpConfigured
|
|
? t('setup:summary.smtpConfigured', { host: state.smtp?.host })
|
|
: t('setup:summary.smtpSkipped')}
|
|
</dd>
|
|
<dt>{t('setup:summary.registration')}</dt>
|
|
<dd>
|
|
{state.registration === 'closed'
|
|
? t('setup:summary.registrationClosed')
|
|
: t('setup:summary.registrationOpen')}
|
|
</dd>
|
|
</dl>
|
|
<StepActions
|
|
onBack={onBack}
|
|
onNext={() => void finish()}
|
|
nextLabel={t('setup:summary.finish')}
|
|
busy={finishing}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|