dorfteich/apps/web/src/pages/AdminSettingsPage.tsx
Claude Fable 5 9f754649d4
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
Fix admin general settings form: dot-free field names, flat PATCH keys (#322)
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

558 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useQuery, useQueryClient } from '@tanstack/react-query';
import { docToHtml, markdownToDoc } from '@dorfteich/shared';
import { useState } from 'react';
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';
import { QuotaManager } from './QuotaManager';
import { UserManager } from './UserManager';
import { useDocumentTitle } from '../lib/use-document-title';
interface InstanceSettings {
'auth.registrationMode': 'open' | 'closed';
'instance.name': string;
'instance.defaultLocale': 'de' | 'en';
'quota.editorsPerPond': number;
'quota.readersPerPond': number;
'quota.additionalPonds': number;
'quota.storageBytes': number;
'quota.maxFileBytes': number;
'api.enabled': boolean;
'mcp.enabled': boolean;
'feeds.enabled': boolean;
'plugins.enabled': boolean;
'upload.allowedExtensions': string[];
'upload.svgPolicy': 'reject' | 'sanitize';
'classification.newPageDefault': 'unclassified' | 'vs_nfd';
'classification.uploadPolicy': 'warn' | 'block';
'legal.imprint': string;
'legal.privacyPolicy': string;
'home.content': string;
}
export function AdminSettingsPage(): React.JSX.Element {
const { t } = useTranslation();
useDocumentTitle(t('settings:admin.title'));
const { t: tQuotas } = useTranslation('quotas');
const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const settings = useQuery({
queryKey: ['admin', 'settings'],
queryFn: () => apiGet<InstanceSettings>('/admin/settings'),
});
// 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', 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);
}
});
if (!settings.data) return <h1>{t('settings:admin.title')}</h1>;
return (
<>
<h1>{t('settings:admin.title')}</h1>
<p>
<Link to="/admin/system">{t('system:settingsLink')} </Link>
</p>
<SettingsLayout>
<VsNfdProfileSection />
<section className="settings-section">
<h2>{t('settings:admin.general')}</h2>
{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) ||
vsNfd.hides(
'classification.newPageDefault',
settings.data['classification.newPageDefault'],
) ||
vsNfd.hides(
'classification.uploadPolicy',
settings.data['classification.uploadPolicy'],
)) && <VsNfdHiddenNote />}
<form onSubmit={onSubmit} noValidate>
<FormError error={error} />
<FormSuccess message={saved ? t('settings:admin.saved') : null} />
<Field label={t('settings:admin.instanceName')}>
<input type="text" {...form.register('instanceName')} />
</Field>
<Field label={t('settings:admin.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('registrationMode'))}
>
<select {...form.register('registrationMode')}>
{!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && (
<option value="open">{t('settings:admin.registrationOpen')}</option>
)}
<option value="closed">{t('settings:admin.registrationClosed')}</option>
</select>
</Field>
<Field
label={t('settings:admin.newPageClassification')}
hint={t('settings:admin.newPageClassificationHelp')}
marking={vsNfd.markingFor(
'classification.newPageDefault',
form.watch('newPageClassification'),
)}
>
<select {...form.register('newPageClassification')}>
{!vsNfd.hides(
'classification.newPageDefault',
settings.data['classification.newPageDefault'],
) && (
<option value="unclassified">
{t('settings:admin.classificationUnclassified')}
</option>
)}
<option value="vs_nfd">{t('settings:admin.classificationVsNfd')}</option>
</select>
</Field>
<Field
label={t('settings:admin.uploadPolicy')}
hint={t('settings:admin.uploadPolicyHelp')}
marking={vsNfd.markingFor('classification.uploadPolicy', form.watch('uploadPolicy'))}
>
<select {...form.register('uploadPolicy')}>
{!vsNfd.hides(
'classification.uploadPolicy',
settings.data['classification.uploadPolicy'],
) && <option value="warn">{t('settings:admin.uploadPolicyWarn')}</option>}
<option value="block">{t('settings:admin.uploadPolicyBlock')}</option>
</select>
</Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('settings:admin.save')}
</button>
</form>
</section>
<section className="settings-section">
<h2>{tQuotas('defaults.title')}</h2>
<form onSubmit={onSubmit} noValidate>
{(
[
'quotaEditorsPerPond',
'quotaReadersPerPond',
'quotaAdditionalPonds',
'quotaStorageBytes',
'quotaMaxFileBytes',
] as const
).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}>
{tQuotas('defaults.save')}
</button>
</form>
</section>
<UploadSettingsForm settings={settings.data} />
<PublicApiSettingsForm settings={settings.data} />
<LandingSettingsForm settings={settings.data} />
<LegalSettingsForm settings={settings.data} />
<BrandingManager />
<CustomFontManager />
<PluginManager />
<QuotaManager />
<UserManager />
</SettingsLayout>
</>
);
}
/**
* VS-NfD hardening-profile card (issue #243, ADR 0027): active mode and
* the catalog verdict for the running configuration. Renders nothing in
* mode `off` — outside a VS context the profile is not a topic. Display
* only; the mode treatments land with #244#246. Text carries the whole
* meaning (never colour alone, ADR 0017).
*/
function VsNfdProfileSection(): React.JSX.Element | null {
const { t } = useTranslation('settings');
const { view } = useVsNfdMarking();
if (!view || view.mode === 'off') return null;
const violations = view.entries.filter((entry) => !entry.compliant);
return (
<section className="settings-section">
<h2>{t('admin.vsNfd.title')}</h2>
<p>{t('admin.vsNfd.intro')}</p>
<p>
{t('admin.vsNfd.modeLabel')}: <strong>{t(`admin.vsNfd.modes.${view.mode}`)}</strong>
</p>
{violations.length === 0 ? (
<p>{t('admin.vsNfd.compliant')}</p>
) : (
<>
<p className="vs-nfd-summary">
<span aria-hidden="true"> </span>
{t('admin.vsNfd.violations', { count: violations.length })}
</p>
<ul>
{violations.map((entry) => (
<li key={`${entry.scope}:${entry.key}`}>
<code>{entry.key}</code> {' '}
{t('admin.vsNfd.referenceValue', { value: entry.compliantValue })} (
{t('admin.vsNfd.guideRef', { section: entry.hardeningRef })})
</li>
))}
</ul>
</>
)}
<p className="field__hint">
{t('admin.vsNfd.guideNote')} <code>docs/vs-nfd/50-haertungsleitfaden.md</code>
</p>
</section>
);
}
/**
* Upload allowlist + SVG policy (issue #61). The allowlist is an array in the
* api but edited here as a comma-separated field; images are always allowed
* and are not part of this list.
*/
function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
const { t } = useTranslation('files');
const queryClient = useQueryClient();
const vsNfd = useVsNfdMarking();
const [extensions, setExtensions] = useState(settings['upload.allowedExtensions'].join(', '));
const [svgPolicy, setSvgPolicy] = useState(settings['upload.svgPolicy']);
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const [busy, setBusy] = useState(false);
async function onSubmit(event: React.FormEvent): Promise<void> {
event.preventDefault();
setError(null);
setSaved(false);
setBusy(true);
try {
await apiPatch('/admin/settings', {
'upload.allowedExtensions': extensions
.split(',')
.map((e) => e.trim())
.filter(Boolean),
'upload.svgPolicy': svgPolicy,
});
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
setSaved(true);
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
}
return (
<section className="settings-section">
<h2>{t('settings.title')}</h2>
<form onSubmit={(event) => void onSubmit(event)} noValidate>
<FormError error={error} />
<FormSuccess message={saved ? t('settings.save') : null} />
<Field label={t('settings.allowedExtensions')} hint={t('settings.allowedExtensionsHelp')}>
<input
type="text"
value={extensions}
onChange={(event) => setExtensions(event.target.value)}
/>
</Field>
{vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && <VsNfdHiddenNote />}
<Field
label={t('settings.svgPolicy')}
marking={vsNfd.markingFor('upload.svgPolicy', svgPolicy)}
>
<select
value={svgPolicy}
onChange={(event) => setSvgPolicy(event.target.value as 'reject' | 'sanitize')}
>
{!vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && (
<option value="sanitize">{t('settings.svgSanitize')}</option>
)}
<option value="reject">{t('settings.svgReject')}</option>
</select>
</Field>
<button type="submit" className="button" disabled={busy}>
{t('settings.save')}
</button>
</form>
</section>
);
}
/**
* Public REST API master switch (issue #104, default off). Users create
* their tokens in the user settings; ponds opt in individually.
*/
function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
const { t } = useTranslation('apiTokens');
const queryClient = useQueryClient();
const vsNfd = useVsNfdMarking();
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
async function save(patch: Record<string, boolean>): Promise<void> {
setError(null);
setSaved(false);
try {
await apiPatch('/admin/settings', patch);
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
setSaved(true);
} catch (err) {
setError(err);
}
}
return (
<section className="settings-section">
<h2>{t('admin.title')}</h2>
<FormError error={error} />
<FormSuccess message={saved ? t('admin.saved') : null} />
{(['api.enabled', 'mcp.enabled', 'feeds.enabled', 'plugins.enabled'] as const).some((key) =>
vsNfd.hides(key, settings[key]),
) && <VsNfdHiddenNote />}
{(
[
{ key: 'api.enabled', label: t('admin.label'), hint: t('admin.hint') },
{ key: 'mcp.enabled', label: t('admin.mcpLabel'), hint: t('admin.mcpHint') },
{ key: 'feeds.enabled', label: t('admin.feedsLabel'), hint: t('admin.feedsHint') },
{ key: 'plugins.enabled', label: t('admin.pluginsLabel'), hint: t('admin.pluginsHint') },
] as const
).map((row) => {
if (vsNfd.hides(row.key, settings[row.key])) return null;
const marking = vsNfd.markingFor(row.key, settings[row.key]);
return (
<div key={row.key}>
<label className="api-opt-in__label">
<input
type="checkbox"
checked={settings[row.key]}
aria-describedby={marking ? `vs-nfd-mark-${row.key}` : undefined}
onChange={(event) => void save({ [row.key]: event.target.checked })}
/>
{row.label}
</label>
{marking && <VsNfdMark id={`vs-nfd-mark-${row.key}`} text={marking} />}
<p className="api-opt-in__hint">{row.hint}</p>
</div>
);
})}
</section>
);
}
/**
* Editable landing page: the Site Admin's Markdown for the public home page
* (`/`), rendered through the same sanitizing pipeline as the legal pages.
* Empty falls back to the built-in welcome text.
*/
function LandingSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
const { t } = useTranslation('settings');
const queryClient = useQueryClient();
const [content, setContent] = useState(settings['home.content']);
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const [busy, setBusy] = useState(false);
async function onSubmit(event: React.FormEvent): Promise<void> {
event.preventDefault();
setError(null);
setSaved(false);
setBusy(true);
try {
await apiPatch('/admin/settings', { 'home.content': content });
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
await queryClient.invalidateQueries({ queryKey: ['home-content'] });
setSaved(true);
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
}
return (
<section className="settings-section">
<h2>{t('landing.title')}</h2>
<p className="field__hint">{t('landing.hint')}</p>
<form onSubmit={(event) => void onSubmit(event)} noValidate>
<FormError error={error} />
<FormSuccess message={saved ? t('landing.saved') : null} />
<MarkdownTextField
wrapperClass="markdown-field"
label={t('landing.label')}
value={content}
onChange={setContent}
/>
<button type="submit" className="button" disabled={busy}>
{t('landing.save')}
</button>
</form>
</section>
);
}
/**
* Legal pages (issue #82): imprint and privacy policy as Markdown, shown
* publicly at /legal/imprint and /legal/privacy. The preview renders through
* the same shared pipeline the api uses (markdown → schema doc → HTML), so
* what the admin sees is what visitors get.
*/
function LegalSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
const { t } = useTranslation('legal');
const queryClient = useQueryClient();
const vsNfd = useVsNfdMarking();
const [imprint, setImprint] = useState(settings['legal.imprint']);
const [privacy, setPrivacy] = useState(settings['legal.privacyPolicy']);
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const [busy, setBusy] = useState(false);
async function onSubmit(event: React.FormEvent): Promise<void> {
event.preventDefault();
setError(null);
setSaved(false);
setBusy(true);
try {
await apiPatch('/admin/settings', {
'legal.imprint': imprint,
'legal.privacyPolicy': privacy,
});
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
await queryClient.invalidateQueries({ queryKey: ['legal'] });
setSaved(true);
} catch (err) {
setError(err);
} finally {
setBusy(false);
}
}
return (
// Named class so the e2e can scope its success-message assertion to this
// form: /admin has more than one live region since #304 (upload progress),
// and a page-wide getByRole('status') became ambiguous.
<section className="settings-section legal-settings">
<h2>{t('admin.title')}</h2>
<p className="field__hint">{t('admin.hint')}</p>
<form onSubmit={(event) => void onSubmit(event)} noValidate>
<FormError error={error} />
<FormSuccess message={saved ? t('admin.save') : null} />
<MarkdownTextField
label={t('admin.imprint')}
value={imprint}
onChange={setImprint}
marking={vsNfd.markingFor('legal.imprint', imprint)}
/>
<MarkdownTextField
label={t('admin.privacyPolicy')}
value={privacy}
onChange={setPrivacy}
marking={vsNfd.markingFor('legal.privacyPolicy', privacy)}
/>
<button type="submit" className="button" disabled={busy}>
{t('admin.save')}
</button>
</form>
</section>
);
}
/**
* One Markdown textarea with a toggleable rendered preview. `wrapperClass`
* distinguishes instances on the page: the legal editors keep `legal-editor`
* (the legal e2e selects them by that class and index), the landing editor
* gets its own so it does not shift those indices.
*/
function MarkdownTextField({
label,
value,
onChange,
marking,
wrapperClass = 'legal-editor',
}: {
label: string;
value: string;
onChange: (value: string) => void;
marking?: string;
wrapperClass?: string;
}): React.JSX.Element {
const { t } = useTranslation('legal');
const [preview, setPreview] = useState(false);
return (
<div className={wrapperClass}>
<Field label={label} marking={marking}>
<textarea
rows={10}
value={value}
onChange={(event) => onChange(event.target.value)}
spellCheck={false}
/>
</Field>
<button type="button" className="linklike" onClick={() => setPreview(!preview)}>
{preview ? t('admin.hidePreview') : t('admin.preview')}
</button>
{preview && (
// Same sanitizing pipeline as the api's public rendering — safe.
<div
className={`${wrapperClass}__preview legal-page__body`}
dangerouslySetInnerHTML={{ __html: docToHtml(markdownToDoc(value)) }}
/>
)}
</div>
);
}
/** Map the instance-setting key to the shared quota key its label lives under. */
const SETTING_TO_QUOTA_KEY = {
'quota.editorsPerPond': 'editors_per_pond',
'quota.readersPerPond': 'readers_per_pond',
'quota.additionalPonds': 'additional_ponds',
'quota.storageBytes': 'storage_bytes',
'quota.maxFileBytes': 'max_file_bytes',
} as const;