Add the first-run setup wizard UI (#81)
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
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
This commit is contained in:
parent
224ae3e6db
commit
28aa04d5e4
@ -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
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
88
apps/web/e2e/setup.spec.ts
Normal file
88
apps/web/e2e/setup.spec.ts
Normal file
@ -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();
|
||||
});
|
||||
@ -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 (
|
||||
<div className="setup-shell">
|
||||
<Routes>
|
||||
<Route path="setup" element={<SetupWizardPage />} />
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="*" element={<SetupPendingPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
@ -35,6 +58,8 @@ export function App(): React.JSX.Element {
|
||||
<Route path="reset-password" element={<ResetPasswordPage />} />
|
||||
{/* Public read-only page view — reachable without a session (issue #56). */}
|
||||
<Route path="public/:pondSlug/:pageSlug" element={<PublicPageView />} />
|
||||
{/* Setup is done exactly once — afterwards the wizard url just goes home. */}
|
||||
<Route path="setup" element={<Navigate to="/" replace />} />
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
22
apps/web/src/setup/SetupPendingPage.tsx
Normal file
22
apps/web/src/setup/SetupPendingPage.tsx
Normal file
@ -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 (
|
||||
<div className="auth-card">
|
||||
<h1>{t('setup:pending.title')}</h1>
|
||||
<p>{t('setup:pending.body')}</p>
|
||||
<p>
|
||||
<Link className="button" to="/setup">
|
||||
{t('setup:pending.start')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
643
apps/web/src/setup/SetupWizardPage.tsx
Normal file
643
apps/web/src/setup/SetupWizardPage.tsx
Normal file
@ -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<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>
|
||||
);
|
||||
}
|
||||
23
apps/web/src/setup/use-setup-status.ts
Normal file
23
apps/web/src/setup/use-setup-status.ts
Normal file
@ -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<SetupStatusView> {
|
||||
return useQuery({
|
||||
queryKey: SETUP_QUERY_KEY,
|
||||
queryFn: () => apiGet<SetupStatusView>('/setup'),
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
|
||||
76
packages/shared/i18n/de/setup.json
Normal file
76
packages/shared/i18n/de/setup.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
76
packages/shared/i18n/en/setup.json
Normal file
76
packages/shared/i18n/en/setup.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
@ -26,7 +26,9 @@ export type SetupInstanceInput = z.infer<typeof setupInstanceInputSchema>;
|
||||
*/
|
||||
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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user