dorfteich/apps/web/src/pages/AdminSettingsPage.tsx
Claude Fable 5 04e21a0aac
All checks were successful
CD / Build and push images (push) Successful in 3m50s
CI / Lint, typecheck, test (push) Successful in 4m2s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m37s
CI / Import/export fidelity gate (push) Successful in 47s
Built-in MCP endpoint (Streamable HTTP) on top of the public API (#105)
AI clients talk to the instance directly at /api/mcp — under the /api/
path (deviation from the issue's literal /mcp) so every existing reverse
proxy already routes it; no deployment changes anywhere.

- Transport: official @modelcontextprotocol/sdk server, STATELESS — each
  POST builds a fresh server+transport pair, no session store, replicas
  stay trivial; GET/DELETE answer 405. Auth per PAT bearer (#104 tokens),
  per-token rate limit (429 + Retry-After).
- Own switches, independent of REST: instance mcp.enabled (admin
  settings, default off; off = 404, feature invisible) + pond setting
  mcpEnabled (pond-settings toggle, default off) — pinned independent in
  both directions by tests.
- Tools (thin wrappers over the #104 services, same permission gates,
  audit-logged writes): list_ponds, list_pages, read_page, search,
  create_page, update_page (replace semantics through the collab-owned
  restore path — open editors converge), add_comment, list_labels,
  set_page_labels (exact replace), export_pond (link to the REST ZIP).
  Tool errors carry the api error codes; results carry stable slugs/ids.
  MCP resources stay the documented stage-2 stretch goal.
- Deliberately on the SDK's low-level Server API with a hand-written tool
  table (mcp-tools.ts): the typed registerTool generics drove tsc out of
  memory in a program this size; manual Zod validation keeps the wire
  behavior explicit.
- PublicApiService exposure filtering parameterized ('api' | 'mcp',
  shared pondFeatureEnabled helper) — one implementation, two switches.
- Docs: "Connect Claude Code / MCP clients" section in public-api.md
  (claude mcp add one-liner + mcp-remote bridge for stdio clients).

Verification: 8-test e2e pack driving the real MCP SDK client over
Streamable HTTP against a listening api (initialize + tools/list, switch
independence in both directions, anonymous/garbage 401, opt-in 404
semantics, page roundtrip incl. restore-NOTIFY, labels/comments, read
scope blocked from writes with scope_required); live check through the
web proxy against the seeded stack (tools list, create, read, update,
search — LIVE CHECK PASSED); full api suite 61/61 files green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 11:36:02 +02:00

334 lines
11 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 { 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<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 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>
<section className="settings-section">
<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')}>
<select {...form.register('auth.registrationMode')}>
<option value="open">{t('settings:admin.registrationOpen')}</option>
<option value="closed">{t('settings:admin.registrationClosed')}</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} />
<LegalSettingsForm settings={settings.data} />
<PluginManager />
<QuotaManager />
<UserManager />
</>
);
}
/**
* 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<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>
<Field label={t('settings.svgPolicy')}>
<select
value={svgPolicy}
onChange={(event) => setSvgPolicy(event.target.value as 'reject' | 'sanitize')}
>
<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 [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} />
<label className="api-opt-in__label">
<input
type="checkbox"
checked={settings['api.enabled']}
onChange={(event) => void save({ 'api.enabled': event.target.checked })}
/>
{t('admin.label')}
</label>
<p className="api-opt-in__hint">{t('admin.hint')}</p>
<label className="api-opt-in__label">
<input
type="checkbox"
checked={settings['mcp.enabled']}
onChange={(event) => void save({ 'mcp.enabled': event.target.checked })}
/>
{t('admin.mcpLabel')}
</label>
<p className="api-opt-in__hint">{t('admin.mcpHint')}</p>
</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 [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} />
<LegalTextField label={t('admin.imprint')} value={imprint} onChange={setImprint} />
<LegalTextField label={t('admin.privacyPolicy')} value={privacy} onChange={setPrivacy} />
<button type="submit" className="button" disabled={busy}>
{t('admin.save')}
</button>
</form>
</section>
);
}
/** 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 (
<div className="legal-editor">
<Field label={label}>
<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="legal-editor__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;