Compare commits
5 Commits
ec4e086bc7
...
64f2deb40f
| Author | SHA1 | Date | |
|---|---|---|---|
| 64f2deb40f | |||
| 20677ea247 | |||
| 6999b3dd73 | |||
| 4d6a27194f | |||
| 9f754649d4 |
@ -345,6 +345,16 @@ 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%';" | \
|
||||||
|
|||||||
@ -317,6 +317,42 @@ describe.skipIf(!hasTestDb)('first-run setup wizard (fresh database, issue #80)'
|
|||||||
expect(locked.body.code).toBe('setup_locked');
|
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 {
|
interface FakeSmtpServer {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import { User } from '@prisma/client';
|
import { User } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
import { SessionsService } from '../auth/sessions.service';
|
import { SessionsService } from '../auth/sessions.service';
|
||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
@ -70,14 +71,23 @@ export class SetupService implements OnModuleInit {
|
|||||||
if (!(await this.state.isPending())) return;
|
if (!(await this.state.isPending())) return;
|
||||||
|
|
||||||
// Fails the boot loudly on invalid values — a half-seeded instance
|
// Fails the boot loudly on invalid values — a half-seeded instance
|
||||||
// would be much harder to diagnose than a startup error.
|
// would be much harder to diagnose than a startup error. Translated
|
||||||
const input = setupAdminInputSchema.parse({
|
// 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,
|
username: env.SETUP_ADMIN_USERNAME,
|
||||||
email: env.SETUP_ADMIN_EMAIL,
|
email: env.SETUP_ADMIN_EMAIL,
|
||||||
password: env.SETUP_ADMIN_PASSWORD,
|
password: env.SETUP_ADMIN_PASSWORD,
|
||||||
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
|
displayName: env.SETUP_ADMIN_DISPLAY_NAME ?? env.SETUP_ADMIN_USERNAME,
|
||||||
locale: env.SETUP_DEFAULT_LOCALE,
|
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);
|
const admin = await this.createAdmin(input);
|
||||||
if (env.SETUP_INSTANCE_NAME) {
|
if (env.SETUP_INSTANCE_NAME) {
|
||||||
await this.settings.set('instance.name', env.SETUP_INSTANCE_NAME, admin.id);
|
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;
|
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('; ');
|
||||||
|
}
|
||||||
|
|||||||
55
apps/web/e2e/admin-settings.spec.ts
Normal file
55
apps/web/e2e/admin-settings.spec.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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="auth.registrationMode"]'),
|
has: page.locator('select[name="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="auth.registrationMode"]');
|
const regSelect = page.locator('select[name="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();
|
||||||
|
|||||||
@ -1,22 +1,33 @@
|
|||||||
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 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.
|
* 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 {
|
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(() => {
|
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 = APP_NAME;
|
document.title = appName;
|
||||||
},
|
},
|
||||||
[],
|
[appName],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,10 +5,17 @@ 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';
|
||||||
@ -51,15 +58,23 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
queryFn: () => apiGet<InstanceSettings>('/admin/settings'),
|
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 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', input);
|
await apiPatch('/admin/settings', toSettingsPatch(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);
|
||||||
@ -91,22 +106,19 @@ 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('instance.name')} />
|
<input type="text" {...form.register('instanceName')} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('settings:admin.defaultLocale')}>
|
<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="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(
|
marking={vsNfd.markingFor('auth.registrationMode', form.watch('registrationMode'))}
|
||||||
'auth.registrationMode',
|
|
||||||
form.watch('auth.registrationMode'),
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<select {...form.register('auth.registrationMode')}>
|
<select {...form.register('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>
|
||||||
)}
|
)}
|
||||||
@ -118,10 +130,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('classification.newPageDefault'),
|
form.watch('newPageClassification'),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<select {...form.register('classification.newPageDefault')}>
|
<select {...form.register('newPageClassification')}>
|
||||||
{!vsNfd.hides(
|
{!vsNfd.hides(
|
||||||
'classification.newPageDefault',
|
'classification.newPageDefault',
|
||||||
settings.data['classification.newPageDefault'],
|
settings.data['classification.newPageDefault'],
|
||||||
@ -136,12 +148,9 @@ 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(
|
marking={vsNfd.markingFor('classification.uploadPolicy', form.watch('uploadPolicy'))}
|
||||||
'classification.uploadPolicy',
|
|
||||||
form.watch('classification.uploadPolicy'),
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<select {...form.register('classification.uploadPolicy')}>
|
<select {...form.register('uploadPolicy')}>
|
||||||
{!vsNfd.hides(
|
{!vsNfd.hides(
|
||||||
'classification.uploadPolicy',
|
'classification.uploadPolicy',
|
||||||
settings.data['classification.uploadPolicy'],
|
settings.data['classification.uploadPolicy'],
|
||||||
@ -160,15 +169,18 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
<form onSubmit={onSubmit} noValidate>
|
<form onSubmit={onSubmit} noValidate>
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
'quota.editorsPerPond',
|
'quotaEditorsPerPond',
|
||||||
'quota.readersPerPond',
|
'quotaReadersPerPond',
|
||||||
'quota.additionalPonds',
|
'quotaAdditionalPonds',
|
||||||
'quota.storageBytes',
|
'quotaStorageBytes',
|
||||||
'quota.maxFileBytes',
|
'quotaMaxFileBytes',
|
||||||
] as const
|
] as const
|
||||||
).map((key) => (
|
).map((field) => (
|
||||||
<Field key={key} label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[key]}`)}>
|
<Field
|
||||||
<input type="number" min={0} {...form.register(key, { valueAsNumber: true })} />
|
key={field}
|
||||||
|
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}>
|
||||||
|
|||||||
@ -138,6 +138,10 @@ function QuotaRow({
|
|||||||
<td>{t(`keys.${line.key}`)}</td>
|
<td>{t(`keys.${line.key}`)}</td>
|
||||||
<td>{line.instanceDefault}</td>
|
<td>{line.instanceDefault}</td>
|
||||||
<td className="quota-row__override">
|
<td className="quota-row__override">
|
||||||
|
{/* Flex lives on the inner div: a td with display:flex stops behaving
|
||||||
|
like a table cell and its bottom border no longer meets the row's
|
||||||
|
(same fix as the user list's actions cell, #177). */}
|
||||||
|
<div className="quota-row__override-inner">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@ -153,6 +157,7 @@ function QuotaRow({
|
|||||||
{t('overrides.clear')}
|
{t('overrides.clear')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="quota-row__effective">{line.effective}</td>
|
<td className="quota-row__effective">{line.effective}</td>
|
||||||
<td className="quota-row__usage">
|
<td className="quota-row__usage">
|
||||||
|
|||||||
53
apps/web/src/pages/admin-settings-form.test.ts
Normal file
53
apps/web/src/pages/admin-settings-form.test.ts
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
60
apps/web/src/pages/admin-settings-form.ts
Normal file
60
apps/web/src/pages/admin-settings-form.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -3048,7 +3048,7 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quota-row__override {
|
.quota-row__override-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
|||||||
@ -2,14 +2,17 @@
|
|||||||
# next to docker-compose.yml and adjust the values.
|
# next to docker-compose.yml and adjust the values.
|
||||||
|
|
||||||
# --- required ---------------------------------------------------------------
|
# --- 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
|
POSTGRES_PASSWORD=change-me
|
||||||
|
|
||||||
# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
|
# ROOT key of the token key hierarchy (ADR 0020, issue #188): every token
|
||||||
# purpose (collaboration tokens, digest unsubscribe links) derives its own
|
# purpose (collaboration tokens, digest unsubscribe links) derives its own
|
||||||
# HKDF subkey from this value — nothing signs with it directly. The api and
|
# HKDF subkey from this value — nothing signs with it directly. The api and
|
||||||
# collab services share this one value; use a long random string
|
# 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.
|
# derived keys at once and invalidates outstanding tokens.
|
||||||
COLLAB_TOKEN_SECRET=change-me-to-a-long-random-string
|
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
|
# wizard. Automated deploys can skip it entirely by pre-seeding the Site
|
||||||
# Admin here; the wizard then completes and locks itself at first boot.
|
# Admin here; the wizard then completes and locks itself at first boot.
|
||||||
# All three SETUP_ADMIN_* values are required for pre-seeding to trigger.
|
# 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_USERNAME=admin
|
||||||
#SETUP_ADMIN_EMAIL=admin@example.com
|
#SETUP_ADMIN_EMAIL=admin@example.com
|
||||||
#SETUP_ADMIN_PASSWORD=change-me-please
|
#SETUP_ADMIN_PASSWORD=change-me-please
|
||||||
|
|||||||
@ -33,8 +33,12 @@ work, that is a bug (issue #88).
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. Edit `.env` — the minimum:
|
2. Edit `.env` — the minimum:
|
||||||
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings
|
- `POSTGRES_PASSWORD`, `COLLAB_TOKEN_SECRET`: long random strings —
|
||||||
(`openssl rand -base64 32`).
|
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
|
- `IMAGE_PREFIX=gitea.101010.cloud/stwaidele/dorfteich` and `TAG`: pin
|
||||||
the latest release tag (semver, e.g. `v0.14.0`) — the
|
the latest release tag (semver, e.g. `v0.14.0`) — the
|
||||||
[release list](https://gitea.101010.cloud/stwaidele/dorfteich/releases)
|
[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
|
Unattended installs skip the wizard by pre-seeding: set the
|
||||||
`SETUP_ADMIN_*` variables in `.env` before the first start (see
|
`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
|
## 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
|
mutations fail with 403 `csrf_origin_mismatch` → `APP_BASE_URL` does not
|
||||||
match the URL in the browser (scheme and host must be identical).
|
match the URL in the browser (scheme and host must be identical).
|
||||||
E-mail links point at the wrong host → same variable.
|
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
|
- Wizard reappears after a restart → the database volume was not
|
||||||
persisted; never run without the `db-data` volume.
|
persisted; never run without the `db-data` volume.
|
||||||
- `docker compose ps` shows `unhealthy` → that container's liveness check
|
- `docker compose ps` shows `unhealthy` → that container's liveness check
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user