dorfteich/apps/web/src/pages/AdminSettingsPage.tsx
Claude Opus 5 76a5e92f2e
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m10s
CI / Build container images (pull_request) Successful in 4m12s
CI / Auth e2e pack (pull_request) Successful in 9m33s
CI / Import/export fidelity gate (pull_request) Successful in 1m16s
An instance had no way to look like itself: the top bar said "Dorfteich"
whatever the operator called their instance, `instance.name` was never
rendered in the running app at all, and there was no favicon anywhere —
`index.html` had no `<link rel="icon">` and `public/` held only fonts and
theme-init.js.

Where the line is drawn, and why:

- **The api never decodes an image.** Cropping, scaling and the conversion
  to PNG happen on a canvas in the browser; the api checks the PNG
  signature, reads the IHDR dimensions at their fixed offsets and enforces
  the caps. An image library would put a decoder in front of
  attacker-supplied bytes AND would have to be carried through the
  `--network none` offline build. Reading two big-endian integers is not
  decoding.
- **SVG is refused**, with its own error message rather than a generic
  "not a PNG": it can carry script, and serving it from our own origin
  would be a cross-site-scripting vector. An operator who tried one should
  learn that it is deliberate.
- **The crop is driven by number inputs, not by dragging.** A drag-only
  cropper excludes keyboard and switch users outright; a number input is
  arrow-key operable and screen-reader readable without any custom aria.
  The resulting pixel size is stated in text, not only drawn as a frame.
- **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has
  already resolved `data-theme` before first paint, so the correct logo is
  the one painted rather than the one that appears after a flash. Without a
  dark variant the LIGHT logo carries both themes — the operator's own
  asset shown unchanged beats one they did not choose (the rule #307
  extends to ponds). The settings screen warns; it never blocks.
- **The favicon link is static, its resource dynamic.** index.html stays a
  static file and the api answers with the uploaded icon or a shipped
  default — that route must never 404, or the browser keeps its generic
  icon for good. The default is generated by a script from Node's own zlib
  (`gen-default-favicon.mjs`), for the same offline-build reason.
- Both favicon sizes are uploaded together: one source, one crop, so the
  tab icon and the home-screen icon can never disagree.
- Branding is served WITHOUT a session, because the login screen carries it
  and the browser fetches the favicon before anyone signs in. The admin
  screen says so — an operator may not expect their logo to be public.
- The metadata is not writable through the settings endpoint: it describes
  bytes on disk, and hand-writing it would claim an asset that is not
  there.

`./data/branding` follows the three-step rule #303 paid for: env default +
`data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the
`mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and
closes the hole that made #303's variant invisible: the nightly archive
skips a missing directory WORDLESSLY, so the fence now demands that every
`*_DIR` the backup env declares actually travels in the archive. Verified
against the real defect — removing the line fails it by name.

Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start
so #307 is the same event with a different scope, not a second id.

Verified: api suite 103 files green (a lone `public-api` ECONNRESET under
local parallel load, green in isolation — the documented local flake);
branding suite 12 tests against a real directory; crop arithmetic unit
tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the
new section (overflow 0); and the whole flow walked in the browser: upload
→ crop 780×180 → stored as 512×118 → logo in the sidebar linking home with
the instance name as its accessible name → topbar wordmark following
`instance.name` → light logo still shown under `data-theme="dark"`.
2026-08-01 19:28:11 +02:00

546 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 { 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 { 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'),
});
const form = useForm<InstanceSettings>({ values: settings.data });
const vsNfd = useVsNfdMarking();
const onSubmit = form.handleSubmit(async (input) => {
setError(null);
setSaved(false);
try {
await apiPatch('/admin/settings', input);
await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
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('instance.name')} />
</Field>
<Field label={t('settings:admin.defaultLocale')}>
<select {...form.register('instance.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'),
)}
>
<select {...form.register('auth.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('classification.newPageDefault'),
)}
>
<select {...form.register('classification.newPageDefault')}>
{!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('classification.uploadPolicy'),
)}
>
<select {...form.register('classification.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>
{(
[
'quota.editorsPerPond',
'quota.readersPerPond',
'quota.additionalPonds',
'quota.storageBytes',
'quota.maxFileBytes',
] 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 })} />
</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;