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 { apiGet, apiPatch } from '../lib/api'; import { PluginManager } from './PluginManager'; import { QuotaManager } from './QuotaManager'; import { UserManager } from './UserManager'; 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; 'upload.allowedExtensions': string[]; 'upload.svgPolicy': 'reject' | 'sanitize'; 'legal.imprint': string; 'legal.privacyPolicy': string; } export function AdminSettingsPage(): React.JSX.Element { const { t } = useTranslation(); 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 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')} →

{tQuotas('defaults.title')}

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

{t('admin.hint')}

{t('admin.mcpHint')}

); } /** * 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 [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 (

{t('admin.title')}

{t('admin.hint')}

void onSubmit(event)} noValidate>
); } /** One Markdown textarea with a toggleable rendered preview. */ function LegalTextField({ label, value, onChange, }: { label: string; value: string; onChange: (value: string) => void; }): React.JSX.Element { const { t } = useTranslation('legal'); const [preview, setPreview] = useState(false); return (