Compare commits

..

5 Commits

Author SHA1 Message Date
64f2deb40f Quota override cell stays a table cell, flex on an inner wrapper (#329)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m54s
CI / Build container images (pull_request) Successful in 1m25s
CI / Auth e2e pack (pull_request) Successful in 9m27s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 25s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m53s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 9m0s
CI / Import/export fidelity gate (push) Successful in 59s
Same defect and same fix as the user list's actions cell (#177):
display:flex directly on the override td removed its table-cell
behaviour, so the cell stopped growing to row height and its bottom
border no longer met the row's — visibly uneven separator lines
(Stefan's screenshot from the self-hosting walkthrough). The flex
layout now lives on .quota-row__override-inner.

Measured locally like #177: bottom-delta across all cells of every
quota row was 24–49 px before, 0 px after (override set, so the cell
carries input + two link buttons); admin-quotas e2e pack green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 12:44:17 +02:00
20677ea247 Self-hosting findings: URL-safe password advice, operator-readable pre-seed errors (#324, #325)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m58s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 9m12s
CI / Import/export fidelity gate (pull_request) Successful in 59s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
CD / Promote to Int (push) Blocked by required conditions
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 12:44:16 +02:00
6999b3dd73 Document title follows the configured instance name (#323)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m58s
CI / Build container images (pull_request) Successful in 1m26s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m2s
CD / Build and push images (push) Successful in 13s
CD / Deploy to Test (push) Successful in 14s
CI / Lint, typecheck, test (push) Successful in 7m31s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m24s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 9m25s
CI / Import/export fidelity gate (push) Successful in 55s
useDocumentTitle pinned APP_NAME = 'Dorfteich', so every route title —
tab, bookmarks, the window title a screen reader announces (WCAG 2.4.2)
— named the product instead of the operator's instance. The trailing
name now comes from the public branding query, exactly like the TopBar
brand (#306); until the 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. The static index.html title
stays the pre-JS placeholder — server-rendering it is #179's territory,
deliberately out of scope (recorded in the issue).

The admin-settings e2e now also asserts the title carries the new name
right after saving, without a reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 11:43:05 +02:00
4d6a27194f Follow the field rename in vs-nfd-marking locators
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 7m0s
CI / Build container images (pull_request) Successful in 1m23s
CI / Auth e2e pack (pull_request) Successful in 9m0s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
The pack addresses the registration-mode select by its DOM name
attribute, which react-hook-form derives from the field name — now
`registrationMode` (dot-free, see admin-settings-form.ts). Caught by CI
run 713; the pack needs VS_NFD_MODE stages and was not part of the local
verification set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 11:42:55 +02:00
9f754649d4 Fix admin general settings form: dot-free field names, flat PATCH keys (#322)
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m42s
CI / Build container images (pull_request) Successful in 1m20s
CI / Auth e2e pack (pull_request) Failing after 10m3s
CI / Import/export fidelity gate (pull_request) Has been skipped
The general and quota cards registered their react-hook-form fields under
the dotted settings keys. RHF treats dots as nested-path separators, so
the form DISPLAYED fine (its getter falls back to the literal flat key)
but typing nested the value ({ instance: { name } }) and the api's strict
PATCH schema rejected the body — none of these fields ever saved through
the UI, on any instance. Found by Stefan on a fresh self-hosted install.

- admin-settings-form.ts: dot-free form model with one explicit mapping
  to the dotted settings keys and converters in both directions; the
  submit now also carries ONLY the settings these cards edit, so the
  internal branding metadata keys never ride along.
- Saving invalidates the branding query too — the TopBar reads the
  instance name from it and kept the old name until its staleTime ran out.
- admin-settings.spec.ts (new e2e pack, registered in ci.yml): drives the
  rename THROUGH THE FORM — success message, TopBar update without
  reload, value survives reload, api returns it. Verified locally to fail
  against the unfixed page and pass against the fix. Every existing
  admin-settings test patched the api directly, which is why this bug was
  invisible to CI.
- admin-settings-form.test.ts pins that no form field name contains a dot
  and the mapping round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-04 11:18:26 +02:00
11 changed files with 331 additions and 38 deletions

View File

@ -345,6 +345,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
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
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -317,6 +317,42 @@ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)'
expect(locked.body.code).toBe('setup_locked');
});
});
describe('env pre-seeding with invalid values (issue #325)', () => {
const dbName = `dorfteich_preseed_bad_${suffix}`;
let app: INestApplication;
const badEnv = {
SETUP_ADMIN_USERNAME: `preseed-bad-${suffix}`,
SETUP_ADMIN_EMAIL: `preseed-bad-${suffix}@example.org`,
SETUP_ADMIN_PASSWORD: 'short',
} as const;
beforeAll(async () => {
const url = await createFreshDatabase(dbName);
process.env.TEST_DATABASE_URL = url;
process.env.SECRETS_FILE = join(
mkdtempSync(join(tmpdir(), 'dorfteich-preseed-bad-')),
'secrets.env',
);
Object.assign(process.env, badEnv);
app = await createTestApp();
}, 60_000);
afterAll(async () => {
for (const key of Object.keys(badEnv)) delete process.env[key];
await app.close();
await dropDatabase(dbName);
});
it('fails the boot naming the SETUP_* variable, not a raw ZodError', async () => {
await expect(app.get(SetupService).preseedFromEnv()).rejects.toThrow(
/SETUP_ADMIN_PASSWORD must be at least 10 characters/,
);
// Fail-fast left nothing half-seeded: the wizard is still pending.
const status = await request(app.getHttpServer()).get('/api/v1/setup').expect(200);
expect(status.body.status).toBe('required');
});
});
});
interface FakeSmtpServer {

View File

@ -15,6 +15,7 @@ import {
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { ZodError } from 'zod';
import { SessionsService } from '../auth/sessions.service';
import { AppConfig } from '../config/app-config.service';
@ -70,14 +71,23 @@ export class SetupService implements OnModuleInit {
if (!(await this.state.isPending())) return;
// Fails the boot loudly on invalid values — a half-seeded instance
// would be much harder to diagnose than a startup error.
const input = setupAdminInputSchema.parse({
// would be much harder to diagnose than a startup error. Translated
// into operator terms first: the raw ZodError names schema fields and
// i18n keys, not the SETUP_* variable to fix (issue #325).
const parsed = setupAdminInputSchema.safeParse({
username: env.SETUP_ADMIN_USERNAME,
email: env.SETUP_ADMIN_EMAIL,
password: env.SETUP_ADMIN_PASSWORD,
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
locale: env.SETUP_DEFAULT_LOCALE,
});
if (!parsed.success) {
throw new Error(
`Pre-seeding failed: ${describePreseedIssues(parsed.error)}. ` +
'Fix .env and recreate the api container.',
);
}
const input = parsed.data;
const admin = await this.createAdmin(input);
if (env.SETUP_INSTANCE_NAME) {
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id);
@ -223,3 +233,27 @@ export class SetupService implements OnModuleInit {
return (await this.prisma.user.count({ where: { isSiteAdmin: true } })) > 0;
}
}
/** The env variable behind each schema field of the pre-seeded admin. */
const PRESEED_FIELD_TO_ENV: Record<string, string> = {
username: 'SETUP_ADMIN_USERNAME',
email: 'SETUP_ADMIN_EMAIL',
password: 'SETUP_ADMIN_PASSWORD',
displayName: 'SETUP_ADMIN_DISPLAY_NAME',
locale: 'SETUP_DEFAULT_LOCALE',
};
function describePreseedIssues(error: ZodError): string {
return error.issues
.map((issue) => {
const variable = PRESEED_FIELD_TO_ENV[String(issue.path[0])] ?? String(issue.path[0]);
if (issue.code === 'too_small' && issue.type === 'string') {
return `${variable} must be at least ${issue.minimum} characters`;
}
if (issue.code === 'invalid_string' && issue.validation === 'email') {
return `${variable} is not a valid e-mail address`;
}
return `${variable} is invalid (${issue.message})`;
})
.join('; ');
}

View File

@ -0,0 +1,55 @@
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
// back, no save in between.
const regField = page.locator('label.field', {
has: page.locator('select[name="auth.registrationMode"]'),
has: page.locator('select[name="registrationMode"]'),
});
const regSelect = regField.locator('select');
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
// compliant choice (seed leaves it open = violating? then all options).
const regSelect = page.locator('select[name="auth.registrationMode"]');
const regSelect = page.locator('select[name="registrationMode"]');
const regField = page.locator('label.field', { has: regSelect });
const optionCount = await regSelect.locator('option').count();
const marked = await regField.locator('.vs-nfd-mark').count();

View File

@ -1,22 +1,33 @@
import { useEffect } from 'react';
import { useBranding } from '../branding/use-branding';
const APP_NAME = 'Dorfteich';
/**
* Route-specific document title (issue #163, WCAG 2.4.2): joins the given
* parts with the app name ("Page — Pond — Dorfteich"). Empty/undefined
* parts with the instance name ("Page — Pond — My Wiki"). Empty/undefined
* parts are skipped, so callers can pass still-loading data directly.
* Falls back to the bare app name on unmount.
* Falls back to the bare instance 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 {
const joined = [...parts.filter(Boolean), APP_NAME].join(' — ');
const appName = useBranding()?.instanceName.trim() || APP_NAME;
const joined = [...parts.filter(Boolean), appName].join(' — ');
useEffect(() => {
document.title = joined;
}, [joined]);
useEffect(
// On unmount only in effect: `joined` always changes with `appName`,
// so the title effect above re-runs right after this cleanup.
() => () => {
document.title = APP_NAME;
document.title = appName;
},
[],
[appName],
);
}

View File

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

View File

@ -0,0 +1,53 @@
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

@ -0,0 +1,60 @@
/**
* 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;
}

View File

@ -2,14 +2,17 @@
# next to docker-compose.yml and adjust the values.
# --- required ---------------------------------------------------------------
# PostgreSQL password for the `dorfteich` database user.
# PostgreSQL password for the `dorfteich` database user. URL-SAFE
# characters only (generate with `openssl rand -hex 24`): the compose file
# interpolates it into DATABASE_URL unescaped, so base64's `/`, `+`, `=`
# break the URL — db stays healthy while api/collab/backup restart-loop.
POSTGRES_PASSWORD=change-me
# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
# purpose (collaboration tokens, digest unsubscribe links) derives its own
# HKDF subkey from this value — nothing signs with it directly. The api and
# collab services share this one value; use a long random string
# (e.g. `openssl rand -base64 32`). Min length 16. Rotating it rotates all
# (e.g. `openssl rand -hex 24`). Min length 16. Rotating it rotates all
# derived keys at once and invalidates outstanding tokens.
COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
@ -155,6 +158,9 @@ SMTP_FROM=Dorfteich <wiki@example.com>
# wizard. Automated deploys can skip it entirely by pre-seeding the Site
# Admin here; the wizard then completes and locks itself at first boot.
# All three SETUP_ADMIN_* values are required for pre-seeding to trigger.
# The wizard's validation applies: the password needs at least 10
# characters — a violation fails the boot with a message naming the
# variable (deliberate: no half-seeded instance).
#SETUP_ADMIN_USERNAME=admin
#SETUP_ADMIN_EMAIL=admin@example.com
#SETUP_ADMIN_PASSWORD=change-me-please

View File

@ -33,8 +33,12 @@ work, that is a bug (issue #88).
```
2. Edit `.env` — the minimum:
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings
(`openssl rand -base64 32`).
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings —
generate both with `openssl rand -hex 24`. Stick to URL-safe
characters for the database password (hex is): it is interpolated
into a connection URL, and a `/`, `+` or `=` from base64 output
breaks it in a confusing way (db healthy, everything else
restart-looping — see Troubleshooting).
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`: pin
the latest release tag (semver, e.g. `v0.14.0`) — the
[release list](https://gitea.101010.cloud/stwaidele/dorfteich/releases)
@ -81,7 +85,10 @@ and health endpoints with `503 setup_required` — that is not an error.
Unattended installs skip the wizard by pre-seeding: set the
`SETUP_ADMIN_*` variables in `.env` before the first start (see
`.env.example`).
`.env.example`). The same validation as in the wizard applies —
`SETUP_ADMIN_PASSWORD` needs **at least 10 characters** — and an invalid
value deliberately fails the boot with a message naming the variable
(a half-seeded instance would be harder to diagnose).
## Updating
@ -185,6 +192,15 @@ and the OpenAPI document: [public-api.md](public-api.md).
mutations fail with 403 `csrf_origin_mismatch``APP_BASE_URL` does not
match the URL in the browser (scheme and host must be identical).
E-mail links point at the wrong host → same variable.
- api, collab **and** backup restart-looping while `db` is healthy →
`POSTGRES_PASSWORD` contains characters that break the connection URL
(base64's `/`, `+`, `=`); regenerate with `openssl rand -hex 24` and
recreate the stack. The db container looks fine because only its
clients build a URL from the password.
- api restart-looping right after the first start with a
`Pre-seeding failed` (or `validation.password.tooShort`) message →
`SETUP_ADMIN_PASSWORD` is shorter than 10 characters; fix `.env` and
recreate the api container.
- Wizard reappears after a restart → the database volume was not
persisted; never run without the `db-data` volume.
- `docker compose ps` shows `unhealthy` → that container's liveness check