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(null); const [saved, setSaved] = useState(false); const settings = useQuery({ queryKey: ['admin', 'settings'], queryFn: () => apiGet('/admin/settings'), }); const form = useForm({ 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

{t('settings:admin.title')}

; return ( <>

{t('settings:admin.title')}

{t('system:settingsLink')} →

{t('settings:admin.general')}

{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) || vsNfd.hides( 'classification.newPageDefault', settings.data['classification.newPageDefault'], ) || vsNfd.hides( 'classification.uploadPolicy', settings.data['classification.uploadPolicy'], )) && }

{tQuotas('defaults.title')}

{( [ 'quota.editorsPerPond', 'quota.readersPerPond', 'quota.additionalPonds', 'quota.storageBytes', 'quota.maxFileBytes', ] as const ).map((key) => ( ))}
); } /** * 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 (

{t('admin.vsNfd.title')}

{t('admin.vsNfd.intro')}

{t('admin.vsNfd.modeLabel')}: {t(`admin.vsNfd.modes.${view.mode}`)}

{violations.length === 0 ? (

{t('admin.vsNfd.compliant')}

) : ( <>

{t('admin.vsNfd.violations', { count: violations.length })}

    {violations.map((entry) => (
  • {entry.key} —{' '} {t('admin.vsNfd.referenceValue', { value: entry.compliantValue })} ( {t('admin.vsNfd.guideRef', { section: entry.hardeningRef })})
  • ))}
)}

{t('admin.vsNfd.guideNote')} docs/vs-nfd/50-haertungsleitfaden.md

); } /** * 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(null); const [saved, setSaved] = useState(false); const [busy, setBusy] = useState(false); async function onSubmit(event: React.FormEvent): Promise { 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 (

{t('settings.title')}

void onSubmit(event)} noValidate> setExtensions(event.target.value)} /> {vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && }
); } /** * 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(null); const [saved, setSaved] = useState(false); async function save(patch: Record): Promise { setError(null); setSaved(false); try { await apiPatch('/admin/settings', patch); await queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] }); setSaved(true); } catch (err) { setError(err); } } return (

{t('admin.title')}

{(['api.enabled', 'mcp.enabled', 'feeds.enabled', 'plugins.enabled'] as const).some((key) => vsNfd.hides(key, settings[key]), ) && } {( [ { 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 (
{marking && }

{row.hint}

); })}
); } /** * 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(null); const [saved, setSaved] = useState(false); const [busy, setBusy] = useState(false); async function onSubmit(event: React.FormEvent): Promise { 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 (

{t('landing.title')}

{t('landing.hint')}

void onSubmit(event)} noValidate>
); } /** * 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(null); const [saved, setSaved] = useState(false); const [busy, setBusy] = useState(false); async function onSubmit(event: React.FormEvent): Promise { 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.

{t('admin.title')}

{t('admin.hint')}

void onSubmit(event)} noValidate>
); } /** * 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 (