diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 1de7324..3ba1f50 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -389,9 +389,41 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/mermaid.spec.ts
+ # The setup wizard (issue #81) needs an instance where setup is still
+ # pending — the main stack is seeded and long past it. Provision a
+ # second api + static web against a virgin database on their own ports.
+ # SMTP_HOST/PORT are forced empty so the fresh api boots unconfigured
+ # like a real first run (loadApiEnv drops empty strings, issue #80).
+ - name: Provision fresh setup stack
+ run: |
+ (cd apps/api && node -e '
+ const { PrismaClient } = require("@prisma/client");
+ const admin = new PrismaClient();
+ admin.$executeRawUnsafe("CREATE DATABASE setup_e2e")
+ .finally(() => admin.$disconnect());
+ ')
+ DATABASE_URL=postgresql://e2e:e2e@postgres:5432/setup_e2e \
+ pnpm --filter @dorfteich/api exec prisma migrate deploy
+ (cd apps/api && PORT=3005 \
+ DATABASE_URL=postgresql://e2e:e2e@postgres:5432/setup_e2e \
+ APP_BASE_URL=http://localhost:5175 SMTP_HOST= SMTP_PORT= \
+ SECRETS_FILE=/tmp/setup-secrets.env \
+ node dist/main.js > /tmp/api-setup.log 2>&1 &)
+ (PORT=5175 API_TARGET=http://127.0.0.1:3005 \
+ node scripts/e2e-static-server.mjs > /tmp/web-setup.log 2>&1 &)
+ for i in $(seq 1 30); do
+ curl -sf http://localhost:3005/api/v1/readyz >/dev/null && break
+ sleep 2
+ done
+
+ - name: Run setup wizard pack
+ run: |
+ E2E_BASE_URL=http://localhost:5175 E2E_SETUP=1 \
+ pnpm --filter @dorfteich/web exec playwright test e2e/setup.spec.ts
+
- name: Dump server logs on failure
if: failure()
- run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true
+ run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log /tmp/api-setup.log /tmp/web-setup.log || true
# The import/export fidelity gate (issue #69, ADR 0009): runs the corpus
# snapshot suites and the PDF smoke check against the *pinned* sidecar images
diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts
index 70f9a68..2f2e696 100644
--- a/apps/api/src/setup/setup.service.ts
+++ b/apps/api/src/setup/setup.service.ts
@@ -97,7 +97,9 @@ export class SetupService implements OnModuleInit {
adminCreated: await this.siteAdminExists(),
// Configured means: some source (stage env or the wizard via the
// secret store) sets a relay host — the Zod default alone does not.
- smtpConfigured: Boolean(process.env.SMTP_HOST ?? this.secretStore.read().SMTP_HOST),
+ // `||`, not `??`: compose passes unset vars as empty strings, which
+ // must fall through to the store (the loadApiEnv convention, #80).
+ smtpConfigured: Boolean(process.env.SMTP_HOST || this.secretStore.read().SMTP_HOST),
};
}
diff --git a/apps/web/e2e/setup.spec.ts b/apps/web/e2e/setup.spec.ts
new file mode 100644
index 0000000..3bc1e75
--- /dev/null
+++ b/apps/web/e2e/setup.spec.ts
@@ -0,0 +1,88 @@
+import { expect, test } from '@playwright/test';
+
+/**
+ * First-run setup wizard (issue #81). Runs only against the dedicated fresh
+ * stack (empty database, setup still pending) that CI provisions on its own
+ * ports — the regular e2e stack is seeded and long past setup. The tests
+ * advance one shared instance's state, so they run serially.
+ */
+test.skip(!process.env.E2E_SETUP, 'requires the fresh setup stack (E2E_SETUP=1)');
+test.describe.configure({ mode: 'serial' });
+
+const SMTP_HOST = process.env.E2E_SMTP_HOST ?? 'mailpit';
+const SMTP_PORT = process.env.E2E_SMTP_PORT ?? '1025';
+
+test('while setup is pending, every route shows the pending screen', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('link', { name: /start setup|einrichtung starten/i }).click();
+ await expect(page).toHaveURL(/\/setup$/);
+ await expect(page.getByRole('heading', { name: /welcome|willkommen/i })).toBeVisible();
+});
+
+test('a full wizard run configures the instance and ends signed in', async ({ page }) => {
+ await page.goto('/setup');
+
+ // Language step — both languages must carry the wizard (AC): look at
+ // German first, come back, then continue in English.
+ await page.getByRole('button', { name: 'Deutsch' }).click();
+ await expect(page.getByRole('heading', { name: 'Site-Admin-Konto anlegen' })).toBeVisible();
+ await page.getByRole('button', { name: 'Zurück' }).click();
+ await page.getByRole('button', { name: 'English' }).click();
+ await expect(page.getByRole('heading', { name: 'Create the Site Admin account' })).toBeVisible();
+
+ // The admin step validates before advancing.
+ await page.getByRole('button', { name: 'Create account & continue' }).click();
+ await expect(page.getByRole('alert').first()).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Create the Site Admin account' })).toBeVisible();
+
+ await page.getByLabel('Username').fill('wizard-admin');
+ await page.getByLabel('E-mail address').fill('wizard-admin@dorfteich.test');
+ await page.getByLabel('Display name').fill('Wizard Admin');
+ await page.getByLabel(/^Password/).fill('ein sehr langes wizard passwort');
+ await page.getByRole('button', { name: 'Create account & continue' }).click();
+
+ // Instance step; Back must preserve what was typed (AC).
+ await expect(page.getByRole('heading', { name: 'Name your instance' })).toBeVisible();
+ await page.getByLabel('Instance name').fill('Wizard Pond');
+ await page.getByRole('button', { name: 'Continue' }).click();
+ await expect(page.getByRole('heading', { name: 'E-mail delivery' })).toBeVisible();
+ await page.getByRole('button', { name: 'Back' }).click();
+ await expect(page.getByLabel('Instance name')).toHaveValue('Wizard Pond');
+ await page.getByRole('button', { name: 'Continue' }).click();
+
+ // SMTP step: a dead relay blocks the step with the transport error…
+ await page.getByLabel('SMTP server').fill('127.0.0.1');
+ await page.getByLabel('Port').fill('2525');
+ await page.getByLabel('Sender address').fill('dorfteich@dorfteich.test');
+ await page.getByRole('button', { name: 'Send test mail & continue' }).click();
+ await expect(page.getByText(/SMTP test failed/i)).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'E-mail delivery' })).toBeVisible();
+
+ // …the real one (Mailpit in CI) lets it advance.
+ await page.getByLabel('SMTP server').fill(SMTP_HOST);
+ await page.getByLabel('Port').fill(SMTP_PORT);
+ await page.getByRole('button', { name: 'Send test mail & continue' }).click();
+
+ // Registration step.
+ await expect(page.getByRole('heading', { name: 'Who may join?' })).toBeVisible();
+ await page.getByRole('radio', { name: /open registration/i }).check();
+ await page.getByRole('button', { name: 'Continue' }).click();
+
+ // The summary shows the collected values; finishing unlocks the app.
+ await expect(page.getByRole('heading', { name: 'Summary' })).toBeVisible();
+ await expect(page.getByText('Wizard Pond')).toBeVisible();
+ await expect(page.getByText('wizard-admin@dorfteich.test')).toBeVisible();
+ await page.getByRole('button', { name: 'Finish setup' }).click();
+
+ // Logged-in-ready (AC): the regular app loads with the admin's session.
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole('button', { name: 'Wizard Admin' })).toBeVisible();
+});
+
+test('the wizard url just goes home once setup completed', async ({ page }) => {
+ // A fresh anonymous context: the app is unlocked, /setup redirects home,
+ // and the regular login navigation is available again.
+ await page.goto('/setup');
+ await expect(page).toHaveURL(/\/$/);
+ await expect(page.getByRole('link', { name: /sign in|anmelden/i })).toBeVisible();
+});
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 45704c0..b8645ca 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,4 +1,4 @@
-import { Route, Routes } from 'react-router-dom';
+import { Navigate, Route, Routes } from 'react-router-dom';
import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards';
import { AppLayout } from './layout/AppLayout';
@@ -18,8 +18,31 @@ import { LoginPage } from './pages/auth/LoginPage';
import { ResetPasswordPage } from './pages/auth/ResetPasswordPage';
import { SignupPage } from './pages/auth/SignupPage';
import { VerifyEmailPage } from './pages/auth/VerifyEmailPage';
+import { SetupPendingPage } from './setup/SetupPendingPage';
+import { SetupWizardPage } from './setup/SetupWizardPage';
+import { useSetupStatus } from './setup/use-setup-status';
export function App(): React.JSX.Element {
+ const setup = useSetupStatus();
+
+ // First-run gate (issue #81): while setup is pending the api 503s almost
+ // everything, so the SPA offers only the wizard, the login (to resume a
+ // started wizard as the Site Admin), and a pending notice — without the
+ // regular chrome, whose pond/search queries would all fail. If the status
+ // probe itself fails (offline reload, #38), fall through to the app.
+ if (setup.isPending) return <>>;
+ if (setup.data?.status === 'required') {
+ return (
+
+
+ } />
+ } />
+ } />
+
+
+ );
+ }
+
return (
}>
@@ -35,6 +58,8 @@ export function App(): React.JSX.Element {
} />
{/* Public read-only page view — reachable without a session (issue #56). */}
} />
+ {/* Setup is done exactly once — afterwards the wizard url just goes home. */}
+ } />
}>
} />
diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts
index a14d6d4..ccb9c68 100644
--- a/apps/web/src/i18n/index.ts
+++ b/apps/web/src/i18n/index.ts
@@ -14,6 +14,7 @@ import dePlugins from '@dorfteich/shared/i18n/de/plugins.json';
import dePublic from '@dorfteich/shared/i18n/de/public.json';
import deQuotas from '@dorfteich/shared/i18n/de/quotas.json';
import deSearch from '@dorfteich/shared/i18n/de/search.json';
+import deSetup from '@dorfteich/shared/i18n/de/setup.json';
import deUsers from '@dorfteich/shared/i18n/de/users.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAccess from '@dorfteich/shared/i18n/en/access.json';
@@ -32,6 +33,7 @@ import enPlugins from '@dorfteich/shared/i18n/en/plugins.json';
import enPublic from '@dorfteich/shared/i18n/en/public.json';
import enQuotas from '@dorfteich/shared/i18n/en/quotas.json';
import enSearch from '@dorfteich/shared/i18n/en/search.json';
+import enSetup from '@dorfteich/shared/i18n/en/setup.json';
import enUsers from '@dorfteich/shared/i18n/en/users.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json';
import i18n from 'i18next';
@@ -67,6 +69,7 @@ void i18n
public: enPublic,
quotas: enQuotas,
search: enSearch,
+ setup: enSetup,
users: enUsers,
},
de: {
@@ -87,6 +90,7 @@ void i18n
public: dePublic,
quotas: deQuotas,
search: deSearch,
+ setup: deSetup,
users: deUsers,
},
},
diff --git a/apps/web/src/setup/SetupPendingPage.tsx b/apps/web/src/setup/SetupPendingPage.tsx
new file mode 100644
index 0000000..dac10b1
--- /dev/null
+++ b/apps/web/src/setup/SetupPendingPage.tsx
@@ -0,0 +1,22 @@
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+
+/**
+ * What every route except the wizard shows while first-run setup is pending
+ * (issue #81): the api answers almost everything with 503 `setup_required`
+ * anyway, so instead of a broken app the visitor gets pointed at the wizard.
+ */
+export function SetupPendingPage(): React.JSX.Element {
+ const { t } = useTranslation();
+ return (
+
+
{t('setup:pending.title')}
+
{t('setup:pending.body')}
+
+
+ {t('setup:pending.start')}
+
+
+
+ );
+}
diff --git a/apps/web/src/setup/SetupWizardPage.tsx b/apps/web/src/setup/SetupWizardPage.tsx
new file mode 100644
index 0000000..78566ca
--- /dev/null
+++ b/apps/web/src/setup/SetupWizardPage.tsx
@@ -0,0 +1,643 @@
+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')}
+
+
+ );
+}
+
+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')}
+
+
+ );
+}
+
+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')}
+
+
+ );
+}
+
+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}
+ />
+
+ );
+}
diff --git a/apps/web/src/setup/use-setup-status.ts b/apps/web/src/setup/use-setup-status.ts
new file mode 100644
index 0000000..39687ab
--- /dev/null
+++ b/apps/web/src/setup/use-setup-status.ts
@@ -0,0 +1,23 @@
+import { UseQueryResult, useQuery } from '@tanstack/react-query';
+import type { SetupStatusView } from '@dorfteich/shared';
+
+import { apiGet } from '../lib/api';
+
+export const SETUP_QUERY_KEY = ['setup', 'status'] as const;
+
+/**
+ * Whether the first-run wizard still has to run (issue #81). `GET /setup`
+ * is readable in every instance state, so the app can ask on boot; the
+ * answer only ever flips from 'required' to 'completed', hence the infinite
+ * staleness — finishing the wizard invalidates the key explicitly. No
+ * retries: offline (issue #38) the app must fall through to the regular
+ * routes immediately instead of blocking boot on retry backoff.
+ */
+export function useSetupStatus(): UseQueryResult {
+ return useQuery({
+ queryKey: SETUP_QUERY_KEY,
+ queryFn: () => apiGet('/setup'),
+ staleTime: Infinity,
+ retry: false,
+ });
+}
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css
index 9dc54ad..0d373e8 100644
--- a/apps/web/src/styles/base.css
+++ b/apps/web/src/styles/base.css
@@ -371,6 +371,92 @@ button {
font-size: 0.95rem;
}
+/* First-run setup wizard (issue #81) */
+.setup-shell {
+ min-height: 100vh;
+ padding: var(--space-4);
+}
+
+.setup-wizard {
+ max-width: 28rem;
+}
+
+.setup-progress {
+ margin-bottom: var(--space-1);
+ color: var(--color-text-muted);
+ font-size: 0.9rem;
+}
+
+.setup-progress-steps {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1) var(--space-3);
+ margin: 0 0 var(--space-6);
+ padding: 0;
+ list-style: none;
+ color: var(--color-text-muted);
+ font-size: 0.85rem;
+}
+
+.setup-progress-steps li[aria-current='step'] {
+ color: inherit;
+ font-weight: 600;
+}
+
+.setup-progress-steps__done {
+ color: var(--color-accent);
+}
+
+.setup-language-buttons {
+ display: flex;
+ gap: var(--space-3);
+}
+
+.button--outline {
+ background: transparent;
+ color: var(--color-accent);
+ box-shadow: inset 0 0 0 1px var(--color-accent);
+}
+
+.setup-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ margin-top: var(--space-4);
+}
+
+.setup-actions__spacer {
+ flex: 1;
+}
+
+.setup-choice {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+}
+
+.setup-choice__hint {
+ display: block;
+}
+
+.setup-smtp-detail {
+ color: var(--color-danger);
+ font-family: var(--font-mono);
+ font-size: 0.85rem;
+ overflow-wrap: anywhere;
+}
+
+.setup-summary dt {
+ margin-top: var(--space-3);
+ color: var(--color-text-muted);
+ font-size: 0.9rem;
+}
+
+.setup-summary dd {
+ margin: 0;
+}
+
/* Settings */
.settings-section {
max-width: 36rem;
diff --git a/packages/shared/i18n/de/setup.json b/packages/shared/i18n/de/setup.json
new file mode 100644
index 0000000..679ea24
--- /dev/null
+++ b/packages/shared/i18n/de/setup.json
@@ -0,0 +1,76 @@
+{
+ "pending": {
+ "title": "Fast geschafft",
+ "body": "Diese Dorfteich-Instanz ist noch nicht eingerichtet. Eine Administratorin oder ein Administrator muss zuerst die Ersteinrichtung durchführen.",
+ "start": "Einrichtung starten"
+ },
+ "progress": "Schritt {{current}} von {{total}}",
+ "back": "Zurück",
+ "next": "Weiter",
+ "steps": {
+ "language": "Sprache",
+ "admin": "Admin-Konto",
+ "instance": "Instanz",
+ "smtp": "E-Mail",
+ "registration": "Registrierung",
+ "summary": "Abschluss"
+ },
+ "language": {
+ "title": "Willkommen bei Dorfteich",
+ "body": "Dieser Assistent richtet deine Instanz in wenigen Schritten ein.",
+ "choose": "In welcher Sprache sollen Einrichtung und Instanz laufen?",
+ "de": "Deutsch",
+ "en": "English"
+ },
+ "admin": {
+ "title": "Site-Admin-Konto anlegen",
+ "body": "Das erste Konto verwaltet diese Instanz: Es erhält volle Administrationsrechte und wird direkt angemeldet.",
+ "submit": "Konto anlegen & weiter",
+ "exists": "Das Site-Admin-Konto existiert bereits — weiter mit den Instanz-Einstellungen.",
+ "resumeTitle": "Einrichtung bereits begonnen",
+ "resumeBody": "Das Site-Admin-Konto dieser Instanz existiert bereits. Melde dich mit diesem Konto an, um die Einrichtung fortzusetzen.",
+ "resumeLink": "Anmelden und fortsetzen"
+ },
+ "instance": {
+ "title": "Gib deiner Instanz einen Namen",
+ "name": "Name der Instanz",
+ "nameHint": "Sichtbar für alle, die diesen Dorfteich besuchen.",
+ "defaultLocale": "Standardsprache",
+ "defaultLocaleHint": "Gilt für öffentliche Seiten und als Voreinstellung für neue Konten."
+ },
+ "smtp": {
+ "title": "E-Mail-Versand",
+ "body": "Dorfteich verschickt Registrierungs-Bestätigungen und Passwort-Zurücksetzungen über einen SMTP-Server. Du kannst diesen Schritt überspringen und später in den Admin-Einstellungen nachholen — bis dahin verschickt die Instanz keine E-Mails.",
+ "host": "SMTP-Server",
+ "port": "Port",
+ "secure": "Von Anfang an verschlüsseln (SMTPS)",
+ "user": "Benutzername (optional)",
+ "pass": "Passwort (optional)",
+ "from": "Absenderadresse",
+ "fromHint": "Zum Beispiel dorfteich@example.org.",
+ "submit": "Testmail senden & weiter",
+ "submitHint": "Vor dem Speichern geht eine Testmail an deine Adresse — erst wenn sie zugestellt wurde, geht es weiter.",
+ "skip": "Vorerst überspringen"
+ },
+ "registration": {
+ "title": "Wer darf mitmachen?",
+ "open": "Offene Registrierung",
+ "openHint": "Alle können ein Konto anlegen (mit E-Mail-Bestätigung).",
+ "closed": "Geschlossen",
+ "closedHint": "Keine Selbst-Registrierung — Konten legen die Administrierenden an."
+ },
+ "summary": {
+ "title": "Zusammenfassung",
+ "body": "Alles ist vorbereitet. Schließe die Einrichtung ab, um die Instanz freizuschalten.",
+ "admin": "Site-Admin",
+ "instance": "Name der Instanz",
+ "locale": "Standardsprache",
+ "smtp": "E-Mail-Versand",
+ "smtpConfigured": "Eingerichtet ({{host}})",
+ "smtpSkipped": "Übersprungen — später einrichtbar",
+ "registration": "Registrierung",
+ "registrationOpen": "Offen",
+ "registrationClosed": "Geschlossen",
+ "finish": "Einrichtung abschließen"
+ }
+}
diff --git a/packages/shared/i18n/en/setup.json b/packages/shared/i18n/en/setup.json
new file mode 100644
index 0000000..05b4cbe
--- /dev/null
+++ b/packages/shared/i18n/en/setup.json
@@ -0,0 +1,76 @@
+{
+ "pending": {
+ "title": "Almost there",
+ "body": "This Dorfteich instance has not been set up yet. An administrator needs to run the first-run setup before anyone can use it.",
+ "start": "Start setup"
+ },
+ "progress": "Step {{current}} of {{total}}",
+ "back": "Back",
+ "next": "Continue",
+ "steps": {
+ "language": "Language",
+ "admin": "Admin account",
+ "instance": "Instance",
+ "smtp": "E-mail",
+ "registration": "Registration",
+ "summary": "Finish"
+ },
+ "language": {
+ "title": "Welcome to Dorfteich",
+ "body": "This wizard sets up your instance in a few short steps.",
+ "choose": "Which language should the setup and the instance use?",
+ "de": "Deutsch",
+ "en": "English"
+ },
+ "admin": {
+ "title": "Create the Site Admin account",
+ "body": "The first account manages this instance: it gets full administration rights and is signed in right away.",
+ "submit": "Create account & continue",
+ "exists": "The Site Admin account already exists — continue with the instance settings.",
+ "resumeTitle": "Setup already started",
+ "resumeBody": "The Site Admin account for this instance already exists. Sign in with that account to continue the setup.",
+ "resumeLink": "Sign in to continue"
+ },
+ "instance": {
+ "title": "Name your instance",
+ "name": "Instance name",
+ "nameHint": "Shown to everyone who visits this Dorfteich.",
+ "defaultLocale": "Default language",
+ "defaultLocaleHint": "Used on public pages and as the preset for new accounts."
+ },
+ "smtp": {
+ "title": "E-mail delivery",
+ "body": "Dorfteich sends sign-up confirmations and password resets through an SMTP relay. You can skip this step and configure it later in the admin settings — until then the instance sends no e-mail.",
+ "host": "SMTP server",
+ "port": "Port",
+ "secure": "Encrypt from the start (SMTPS)",
+ "user": "Username (optional)",
+ "pass": "Password (optional)",
+ "from": "Sender address",
+ "fromHint": "For example dorfteich@example.org.",
+ "submit": "Send test mail & continue",
+ "submitHint": "Before anything is saved, a test mail goes to your address — the step only advances once it is delivered.",
+ "skip": "Skip for now"
+ },
+ "registration": {
+ "title": "Who may join?",
+ "open": "Open registration",
+ "openHint": "Anyone can create an account (with e-mail verification).",
+ "closed": "Closed",
+ "closedHint": "No self-registration — administrators create the accounts."
+ },
+ "summary": {
+ "title": "Summary",
+ "body": "Everything is in place. Finish the setup to unlock the instance.",
+ "admin": "Site Admin",
+ "instance": "Instance name",
+ "locale": "Default language",
+ "smtp": "E-mail delivery",
+ "smtpConfigured": "Configured ({{host}})",
+ "smtpSkipped": "Skipped — can be configured later",
+ "registration": "Registration",
+ "registrationOpen": "Open",
+ "registrationClosed": "Closed",
+ "finish": "Finish setup"
+ }
+}
diff --git a/packages/shared/src/setup.ts b/packages/shared/src/setup.ts
index e6f8059..e6099d7 100644
--- a/packages/shared/src/setup.ts
+++ b/packages/shared/src/setup.ts
@@ -26,7 +26,9 @@ export type SetupInstanceInput = z.infer;
*/
export const setupSmtpInputSchema = z.object({
host: z.string().trim().min(1, 'validation.required').max(255),
- port: z.number().int().min(1).max(65535),
+ // The wizard form feeds this through valueAsNumber — an empty input
+ // arrives as NaN, so the type error doubles as the "required" message.
+ port: z.number({ invalid_type_error: 'validation.required' }).int().min(1).max(65535),
secure: z.boolean(),
user: z.string().max(255).optional(),
pass: z.string().max(1024).optional(),