All checks were successful
CI / Lint, typecheck, test (push) Successful in 6m24s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m34s
CI / Import/export fidelity gate (push) Successful in 1m1s
CI / Build container images (pull_request) Successful in 1m13s
CI / Lint, typecheck, test (pull_request) Successful in 6m14s
CI / Auth e2e pack (pull_request) Successful in 8m31s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
In hidden (and later enforced) mode, catalog-listed controls whose only purpose is enabling a violation are not rendered while their saved value is compliant (the four master switches, the Nextcloud backup block); value-listed selects keep only their compliant choices (registration mode, new-page classification, upload policy, SVG policy). Every affected section shows one accessible policy note (i18n de+en) so policy is distinguishable from missing features. A value that was already violating is surfaced exactly like in marked — never silently hidden. The API stays unchanged; enforcement is #246. e2e: hidden half of the marking pack (rows disappear, note visible, already-violating row stays marked, axe WCAG A/AA clean) — verified live locally; CI runs it against a second api (VS_NFD_MODE=hidden, same database) behind its own static server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
539 lines
19 KiB
TypeScript
539 lines
19 KiB
TypeScript
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 { 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} />
|
||
|
||
<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 (
|
||
<section className="settings-section">
|
||
<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;
|