Compare commits

..

1 Commits

Author SHA1 Message Date
06b54747f1 Self-hosting findings: URL-safe password advice, operator-readable pre-seed errors (#324, #325)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m16s
CI / Build container images (pull_request) Successful in 2m59s
CI / Auth e2e pack (pull_request) Successful in 9m0s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
Two findings from Stefan's manual clean install per the guide, both
ending in an api restart loop that was hard to diagnose:

- #324: the guide recommended `openssl rand -base64 32` for
  POSTGRES_PASSWORD, but the compose interpolates the password unescaped
  into DATABASE_URL — base64's `/`, `+`, `=` break the URL. Misleadingly,
  db stays healthy (it gets the password as a plain env var) while
  api/collab/backup crash. Guide and .env.example now recommend
  `openssl rand -hex 24` for both secrets and say why; Troubleshooting
  gained the symptom line.
- #325: SETUP_ADMIN_PASSWORD's minimum (10 chars,
  packages/shared/src/auth.ts) was undocumented, and a violation crashed
  the boot with a raw ZodError naming schema fields and i18n keys.
  Failing the boot stays — deliberately, no half-seeded instance — but
  preseedFromEnv now translates validation errors into operator terms
  ("Pre-seeding failed: SETUP_ADMIN_PASSWORD must be at least 10
  characters. Fix .env and recreate the api container."). Documented in
  the guide's first-run section, .env.example, and Troubleshooting; new
  test pins the message and that nothing is half-seeded afterwards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 11:25:45 +02:00
7 changed files with 31 additions and 232 deletions

View File

@ -345,16 +345,6 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/social.spec.ts
- name: Reset login rate limit before admin-settings pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run admin-settings pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/admin-settings.spec.ts
- name: Reset login rate limit before admin-quotas pack - name: Reset login rate limit before admin-quotas pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -1,55 +0,0 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* The general admin settings card saves THROUGH THE FORM (issue #322).
*
* This must drive the UI, not the api: the bug it fences was invisible to
* every api-level test react-hook-form nested the dotted field names on
* input, the strict PATCH schema rejected the body, and the form looked
* fine while never saving. Verified end to end: success message, the value
* survives a full reload, the api returns it, and the TopBar picks it up
* without a reload (branding query invalidation).
*/
test('instance name changed in the general settings form persists', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
const before = (
(await (await admin.request.get('/api/v1/admin/settings')).json()) as Record<string, unknown>
)['instance.name'] as string;
const newName = `Renamed ${Date.now()}`;
const nameLabel = /^(Instance name|Name der Instanz)$/;
const page = await admin.newPage();
try {
await page.goto('/admin');
const generalCard = page
.locator('section.settings-section')
.filter({ has: page.getByLabel(nameLabel) });
await page.getByLabel(nameLabel).fill(newName);
await generalCard.getByRole('button', { name: /^(Save|Speichern)$/ }).click();
// Scoped to the card: the page has several forms with status regions.
await expect(generalCard.getByRole('status')).toHaveText(/^(Saved\.|Gespeichert\.)$/);
// The TopBar and the document title show the new name without a reload —
// the save invalidates the branding query both read from (issue #323).
await expect(page.locator('.topbar__brand')).toHaveText(newName);
await expect(page).toHaveTitle(new RegExp(`${newName}$`));
// The proof the form really persisted: the value survives a reload and
// the api returns it.
await page.reload();
await expect(page.getByLabel(nameLabel)).toHaveValue(newName);
const stored = (
(await (await admin.request.get('/api/v1/admin/settings')).json()) as Record<string, unknown>
)['instance.name'];
expect(stored).toBe(newName);
} finally {
await admin.request.patch('/api/v1/admin/settings', {
data: { 'instance.name': before },
});
await admin.close();
}
});

View File

@ -34,7 +34,7 @@ test('mode marked: card, checkbox marking, and point-of-choice marking', async (
// select value — compliant choice clears it, violating choice brings it // select value — compliant choice clears it, violating choice brings it
// back, no save in between. // back, no save in between.
const regField = page.locator('label.field', { const regField = page.locator('label.field', {
has: page.locator('select[name="registrationMode"]'), has: page.locator('select[name="auth.registrationMode"]'),
}); });
const regSelect = regField.locator('select'); const regSelect = regField.locator('select');
await regSelect.selectOption('open'); await regSelect.selectOption('open');
@ -72,7 +72,7 @@ test('mode hidden: rows disappear, notes mark the hiding, a11y clean', async ({
// Value-listed control: the compliant registration mode keeps only its // Value-listed control: the compliant registration mode keeps only its
// compliant choice (seed leaves it open = violating? then all options). // compliant choice (seed leaves it open = violating? then all options).
const regSelect = page.locator('select[name="registrationMode"]'); const regSelect = page.locator('select[name="auth.registrationMode"]');
const regField = page.locator('label.field', { has: regSelect }); const regField = page.locator('label.field', { has: regSelect });
const optionCount = await regSelect.locator('option').count(); const optionCount = await regSelect.locator('option').count();
const marked = await regField.locator('.vs-nfd-mark').count(); const marked = await regField.locator('.vs-nfd-mark').count();

View File

@ -1,33 +1,22 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useBranding } from '../branding/use-branding';
const APP_NAME = 'Dorfteich'; const APP_NAME = 'Dorfteich';
/** /**
* Route-specific document title (issue #163, WCAG 2.4.2): joins the given * Route-specific document title (issue #163, WCAG 2.4.2): joins the given
* parts with the instance name ("Page — Pond — My Wiki"). Empty/undefined * parts with the app name ("Page — Pond — Dorfteich"). Empty/undefined
* parts are skipped, so callers can pass still-loading data directly. * parts are skipped, so callers can pass still-loading data directly.
* Falls back to the bare instance name on unmount. * Falls back to the bare app name on unmount.
*
* The trailing name is the OPERATOR'S instance name, not the product name
* (issue #323) same reasoning as the TopBar brand (issue #306). Until
* the branding query resolves (or when it cannot, e.g. maintenance mode)
* the shipped default keeps the title stable, so an untouched instance
* reads exactly as before.
*/ */
export function useDocumentTitle(...parts: (string | null | undefined)[]): void { export function useDocumentTitle(...parts: (string | null | undefined)[]): void {
const appName = useBranding()?.instanceName.trim() || APP_NAME; const joined = [...parts.filter(Boolean), APP_NAME].join(' — ');
const joined = [...parts.filter(Boolean), appName].join(' — ');
useEffect(() => { useEffect(() => {
document.title = joined; document.title = joined;
}, [joined]); }, [joined]);
useEffect( useEffect(
// On unmount only in effect: `joined` always changes with `appName`,
// so the title effect above re-runs right after this cleanup.
() => () => { () => () => {
document.title = appName; document.title = APP_NAME;
}, },
[appName], [],
); );
} }

View File

@ -5,17 +5,10 @@ import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { BRANDING_KEY } from '../branding/use-branding';
import { Field, FormError, FormSuccess } from '../components/forms'; import { Field, FormError, FormSuccess } from '../components/forms';
import { SettingsLayout } from '../components/SettingsLayout'; import { SettingsLayout } from '../components/SettingsLayout';
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd'; import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
import { apiGet, apiPatch } from '../lib/api'; import { apiGet, apiPatch } from '../lib/api';
import {
GENERAL_FORM_FIELDS,
GeneralSettingsForm,
toFormValues,
toSettingsPatch,
} from './admin-settings-form';
import { BrandingManager } from './BrandingManager'; import { BrandingManager } from './BrandingManager';
import { CustomFontManager } from './CustomFontManager'; import { CustomFontManager } from './CustomFontManager';
import { PluginManager } from './PluginManager'; import { PluginManager } from './PluginManager';
@ -58,23 +51,15 @@ export function AdminSettingsPage(): React.JSX.Element {
queryFn: () => apiGet<InstanceSettings>('/admin/settings'), queryFn: () => apiGet<InstanceSettings>('/admin/settings'),
}); });
// Dot-free field names with an explicit mapping to the dotted settings const form = useForm<InstanceSettings>({ values: settings.data });
// keys — see admin-settings-form.ts for why the names must not contain
// dots (issue #322).
const form = useForm<GeneralSettingsForm>({
values: settings.data ? toFormValues(settings.data) : undefined,
});
const vsNfd = useVsNfdMarking(); const vsNfd = useVsNfdMarking();
const onSubmit = form.handleSubmit(async (input) => { const onSubmit = form.handleSubmit(async (input) => {
setError(null); setError(null);
setSaved(false); setSaved(false);
try { try {
await apiPatch('/admin/settings', toSettingsPatch(input)); await apiPatch('/admin/settings', input);
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }); await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
// The TopBar takes the instance name from the public branding query;
// without this it keeps the old name until its staleTime runs out.
await queryClient.invalidateQueries({ queryKey: BRANDING_KEY });
setSaved(true); setSaved(true);
} catch (err) { } catch (err) {
setError(err); setError(err);
@ -106,19 +91,22 @@ export function AdminSettingsPage(): React.JSX.Element {
<FormError error={error} /> <FormError error={error} />
<FormSuccess message={saved ? t('settings:admin.saved') : null} /> <FormSuccess message={saved ? t('settings:admin.saved') : null} />
<Field label={t('settings:admin.instanceName')}> <Field label={t('settings:admin.instanceName')}>
<input type="text" {...form.register('instanceName')} /> <input type="text" {...form.register('instance.name')} />
</Field> </Field>
<Field label={t('settings:admin.defaultLocale')}> <Field label={t('settings:admin.defaultLocale')}>
<select {...form.register('defaultLocale')}> <select {...form.register('instance.defaultLocale')}>
<option value="de">{t('settings:profile.locales.de')}</option> <option value="de">{t('settings:profile.locales.de')}</option>
<option value="en">{t('settings:profile.locales.en')}</option> <option value="en">{t('settings:profile.locales.en')}</option>
</select> </select>
</Field> </Field>
<Field <Field
label={t('settings:admin.registrationMode')} label={t('settings:admin.registrationMode')}
marking={vsNfd.markingFor('auth.registrationMode', form.watch('registrationMode'))} marking={vsNfd.markingFor(
'auth.registrationMode',
form.watch('auth.registrationMode'),
)}
> >
<select {...form.register('registrationMode')}> <select {...form.register('auth.registrationMode')}>
{!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && ( {!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && (
<option value="open">{t('settings:admin.registrationOpen')}</option> <option value="open">{t('settings:admin.registrationOpen')}</option>
)} )}
@ -130,10 +118,10 @@ export function AdminSettingsPage(): React.JSX.Element {
hint={t('settings:admin.newPageClassificationHelp')} hint={t('settings:admin.newPageClassificationHelp')}
marking={vsNfd.markingFor( marking={vsNfd.markingFor(
'classification.newPageDefault', 'classification.newPageDefault',
form.watch('newPageClassification'), form.watch('classification.newPageDefault'),
)} )}
> >
<select {...form.register('newPageClassification')}> <select {...form.register('classification.newPageDefault')}>
{!vsNfd.hides( {!vsNfd.hides(
'classification.newPageDefault', 'classification.newPageDefault',
settings.data['classification.newPageDefault'], settings.data['classification.newPageDefault'],
@ -148,9 +136,12 @@ export function AdminSettingsPage(): React.JSX.Element {
<Field <Field
label={t('settings:admin.uploadPolicy')} label={t('settings:admin.uploadPolicy')}
hint={t('settings:admin.uploadPolicyHelp')} hint={t('settings:admin.uploadPolicyHelp')}
marking={vsNfd.markingFor('classification.uploadPolicy', form.watch('uploadPolicy'))} marking={vsNfd.markingFor(
'classification.uploadPolicy',
form.watch('classification.uploadPolicy'),
)}
> >
<select {...form.register('uploadPolicy')}> <select {...form.register('classification.uploadPolicy')}>
{!vsNfd.hides( {!vsNfd.hides(
'classification.uploadPolicy', 'classification.uploadPolicy',
settings.data['classification.uploadPolicy'], settings.data['classification.uploadPolicy'],
@ -169,18 +160,15 @@ export function AdminSettingsPage(): React.JSX.Element {
<form onSubmit={onSubmit} noValidate> <form onSubmit={onSubmit} noValidate>
{( {(
[ [
'quotaEditorsPerPond', 'quota.editorsPerPond',
'quotaReadersPerPond', 'quota.readersPerPond',
'quotaAdditionalPonds', 'quota.additionalPonds',
'quotaStorageBytes', 'quota.storageBytes',
'quotaMaxFileBytes', 'quota.maxFileBytes',
] as const ] as const
).map((field) => ( ).map((key) => (
<Field <Field key={key} label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[key]}`)}>
key={field} <input type="number" min={0} {...form.register(key, { valueAsNumber: true })} />
label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[GENERAL_FORM_FIELDS[field]]}`)}
>
<input type="number" min={0} {...form.register(field, { valueAsNumber: true })} />
</Field> </Field>
))} ))}
<button type="submit" className="button" disabled={form.formState.isSubmitting}> <button type="submit" className="button" disabled={form.formState.isSubmitting}>

View File

@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
GENERAL_FORM_FIELDS,
GeneralSettingsForm,
toFormValues,
toSettingsPatch,
} from './admin-settings-form';
describe('admin general settings form model (issue #322)', () => {
// The regression this file exists for: a dotted field name makes
// react-hook-form nest the typed value and the strict PATCH schema
// reject the body — the form then looks fine but never saves.
it('uses no dots in any form field name', () => {
for (const field of Object.keys(GENERAL_FORM_FIELDS)) {
expect(field).not.toContain('.');
}
});
it('round-trips settings through form values back to a flat patch', () => {
const settings = {
'instance.name': 'My Wiki',
'instance.defaultLocale': 'de',
'auth.registrationMode': 'closed',
'classification.newPageDefault': 'unclassified',
'classification.uploadPolicy': 'warn',
'quota.editorsPerPond': 5,
'quota.readersPerPond': 50,
'quota.additionalPonds': 0,
'quota.storageBytes': 1024,
'quota.maxFileBytes': 25,
};
expect(toSettingsPatch(toFormValues(settings))).toEqual(settings);
});
it('patches only the settings this form edits, under their dotted keys', () => {
const input: GeneralSettingsForm = {
instanceName: 'Renamed',
defaultLocale: 'en',
registrationMode: 'open',
newPageClassification: 'vs_nfd',
uploadPolicy: 'block',
quotaEditorsPerPond: 1,
quotaReadersPerPond: 2,
quotaAdditionalPonds: 3,
quotaStorageBytes: 4,
quotaMaxFileBytes: 5,
};
const patch = toSettingsPatch(input);
expect(patch['instance.name']).toBe('Renamed');
expect(Object.keys(patch).sort()).toEqual(Object.values(GENERAL_FORM_FIELDS).slice().sort());
});
});

View File

@ -1,60 +0,0 @@
/**
* Form model of the general + quota cards on the admin settings page.
*
* Field names MUST NOT contain dots: react-hook-form treats a dot in a
* field name as a nested-path separator. A field registered under its
* settings key ('instance.name') DISPLAYS fine RHF's getter falls back
* to the literal flat key but typing writes the value into a nested
* object ({ instance: { name } }), which the api's strict PATCH schema
* rejects, so nothing ever saved (issue #322). This mapping is the single
* place tying a dot-free field name to its dotted settings key; the
* converters below translate in both directions.
*/
export const GENERAL_FORM_FIELDS = {
instanceName: 'instance.name',
defaultLocale: 'instance.defaultLocale',
registrationMode: 'auth.registrationMode',
newPageClassification: 'classification.newPageDefault',
uploadPolicy: 'classification.uploadPolicy',
quotaEditorsPerPond: 'quota.editorsPerPond',
quotaReadersPerPond: 'quota.readersPerPond',
quotaAdditionalPonds: 'quota.additionalPonds',
quotaStorageBytes: 'quota.storageBytes',
quotaMaxFileBytes: 'quota.maxFileBytes',
} as const;
export type GeneralFormField = keyof typeof GENERAL_FORM_FIELDS;
export type GeneralFormSettingKey = (typeof GENERAL_FORM_FIELDS)[GeneralFormField];
export interface GeneralSettingsForm {
instanceName: string;
defaultLocale: 'de' | 'en';
registrationMode: 'open' | 'closed';
newPageClassification: 'unclassified' | 'vs_nfd';
uploadPolicy: 'warn' | 'block';
quotaEditorsPerPond: number;
quotaReadersPerPond: number;
quotaAdditionalPonds: number;
quotaStorageBytes: number;
quotaMaxFileBytes: number;
}
/** The settings this form reads and writes, keyed by their dotted names. */
export type GeneralFormSettings = Record<GeneralFormSettingKey, unknown>;
export function toFormValues(settings: GeneralFormSettings): GeneralSettingsForm {
return Object.fromEntries(
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [field, settings[key]]),
) as unknown as GeneralSettingsForm;
}
/** Flat dotted keys, exactly what PATCH /admin/settings expects. */
export function toSettingsPatch(input: GeneralSettingsForm): GeneralFormSettings {
return Object.fromEntries(
Object.entries(GENERAL_FORM_FIELDS).map(([field, key]) => [
key,
input[field as GeneralFormField],
]),
) as GeneralFormSettings;
}