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')}

+
+ + + + + + + + + + + + + + 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} + /> +
+ ); +} 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(),