dorfteich/apps/web/src/pages/PluginManager.tsx
Claude Opus 5 5a4a99196e
All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m36s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Build container images (pull_request) Successful in 3m51s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m32s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 58s
#300: route icon-only controls through IconButton/IconLink
The notification bell sat higher and larger than search and the theme
toggle next to it. The cause was not the glyph: `.notifications-bell__button`
carried its own rules with neither flex centring nor an icon size, so the
svg was laid out inline on the text baseline and rendered at lucide's
24px default instead of the 1.15rem the shared `.icon-button` enforces.

Route every icon-only control through the shared components instead:

- `IconLink` joins `IconButton`, sharing one class helper. Three controls
  navigate (pond settings, graph, trash) and are links, not buttons —
  without a link twin they would have stayed the one group gluing the
  class on by hand.
- 17 hand-applied `className="icon-button …"` usages across nine files
  now go through the components, which is what enforces the accessible
  name on a control that shows only an icon.
- The bell's unread count reaches assistive technology. The badge sits
  inside the control, so `aria-label` hid it and a screen reader
  announced "Notifications" without ever saying how many.

An ESLint rule keeps it that way: `icon-button` on a raw button, anchor
or Link is now an error, in both string and template-literal form.

The plugin uninstall button keeps a title that differs from its name (it
explains why a required plugin is locked); IconButton spreads rest last,
so the explicit title still wins.

Also drops the graphify block from CLAUDE.md — it duplicates the
workspace-level instructions.
2026-08-01 06:56:13 +02:00

215 lines
8.2 KiB
TypeScript

import { Trash2 } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PLUGIN_INSTANCE_MODES, type PluginInstanceMode, type PluginView } from '@dorfteich/shared';
import { FormError } from '../components/forms';
import { apiDelete, apiGet, apiPatch, apiUploadFile } from '../lib/api';
import { IconButton } from '../components/IconButton';
/**
* Site Admin plugin administration (issue #72): the installed-plugin list with
* each plugin's declared permissions surfaced prominently (security.md), an
* upload dialog that shows validation errors, and the instance-mode switch
* (disabled / optional / required). Mode changes and installs are audit-logged
* server-side.
*/
export function PluginManager(): React.JSX.Element {
const { t } = useTranslation('plugins');
const queryClient = useQueryClient();
const fileRef = useRef<HTMLInputElement | null>(null);
const [uploadError, setUploadError] = useState<unknown>(null);
const plugins = useQuery({
queryKey: ['admin', 'plugins'],
queryFn: () => apiGet<PluginView[]>('/admin/plugins'),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'plugins'] });
const install = useMutation({
mutationFn: (file: File) => apiUploadFile<PluginView>('/admin/plugins', file),
onSuccess: () => {
setUploadError(null);
if (fileRef.current) fileRef.current.value = '';
void invalidate();
},
onError: (error) => setUploadError(error),
});
const setMode = useMutation({
mutationFn: ({ id, mode }: { id: string; mode: PluginInstanceMode }) =>
apiPatch<PluginView>(`/admin/plugins/${id}/mode`, { mode }),
onSuccess: () => void invalidate(),
});
const uninstall = useMutation({
mutationFn: (id: string) => apiDelete(`/admin/plugins/${id}`),
onSuccess: () => void invalidate(),
onError: (error) => setUploadError(error),
});
// Hash-pinning allowlist (#232, ADR 0025): stored in instance settings;
// pinning the FIRST plugin turns enforcement on for every plugin.
const settings = useQuery({
queryKey: ['admin', 'settings'],
queryFn: () => apiGet<Record<string, unknown>>('/admin/settings'),
});
const allowlist = (settings.data?.['plugins.allowlist'] ?? []) as {
id: string;
sha256: string;
}[];
const saveAllowlist = useMutation({
mutationFn: (next: { id: string; sha256: string }[]) =>
apiPatch('/admin/settings', { 'plugins.allowlist': next }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
void invalidate();
},
onError: (error) => setUploadError(error),
});
const pinnedHashOf = (id: string) => allowlist.find((entry) => entry.id === id)?.sha256;
return (
<section className="settings-section plugin-manager">
<h2>{t('admin.title')}</h2>
<p className="plugin-manager__intro">{t('admin.intro')}</p>
<p className="plugin-manager__intro">
{allowlist.length === 0 ? t('admin.pinning.hintOff') : t('admin.pinning.hintOn')}
</p>
<div className="plugin-manager__upload">
<label className="button">
{t('admin.upload')}
<input
ref={fileRef}
type="file"
accept=".zip"
className="plugin-manager__upload-input"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) install.mutate(file);
}}
/>
</label>
<FormError error={uploadError} />
</div>
{plugins.data && plugins.data.length === 0 ? <p>{t('admin.empty')}</p> : null}
<ul className="plugin-manager__list">
{plugins.data?.map((plugin) => (
<li key={plugin.id} className="plugin-manager__item" data-plugin-id={plugin.id}>
<div className="plugin-manager__head">
<span className="plugin-manager__name">{plugin.name}</span>
<span className="plugin-manager__version">v{plugin.version}</span>
<span className="plugin-manager__kind">{plugin.kind}</span>
</div>
<div className="plugin-manager__permissions">
<span>{t('admin.permissions')}:</span>{' '}
{plugin.permissions.length === 0 ? (
<em>{t('admin.noPermissions')}</em>
) : (
plugin.permissions.map((permission) => (
<code key={permission} className="plugin-manager__permission">
{permission}
</code>
))
)}
</div>
<div className="plugin-manager__pinning">
<span>{t('admin.pinning.state')}:</span>{' '}
<strong>{t(`admin.pinning.verdict.${plugin.pinning}`)}</strong>
<br />
<span>{t('admin.pinning.observed')}:</span>{' '}
{plugin.bundleSha256 ? (
<code className="plugin-manager__hash">{plugin.bundleSha256}</code>
) : (
<em>{t('admin.pinning.unknownHash')}</em>
)}
{pinnedHashOf(plugin.id) && pinnedHashOf(plugin.id) !== plugin.bundleSha256 && (
<>
<br />
<span>{t('admin.pinning.pinnedHash')}:</span>{' '}
<code className="plugin-manager__hash">{pinnedHashOf(plugin.id)}</code>
</>
)}
<br />
{plugin.bundleSha256 && pinnedHashOf(plugin.id) !== plugin.bundleSha256 && (
<button
type="button"
className="linklike"
onClick={() =>
saveAllowlist.mutate([
...allowlist.filter((entry) => entry.id !== plugin.id),
{ id: plugin.id, sha256: plugin.bundleSha256! },
])
}
>
{pinnedHashOf(plugin.id) ? t('admin.pinning.repin') : t('admin.pinning.pin')}
</button>
)}
{pinnedHashOf(plugin.id) && (
<button
type="button"
className="linklike"
onClick={() =>
saveAllowlist.mutate(allowlist.filter((entry) => entry.id !== plugin.id))
}
>
{t('admin.pinning.unpin')}
</button>
)}
</div>
<div className="plugin-manager__controls">
<label>
{t('admin.mode')}:{' '}
<select
className="plugin-manager__mode"
value={plugin.mode}
onChange={(event) =>
setMode.mutate({
id: plugin.id,
mode: event.target.value as PluginInstanceMode,
})
}
>
{PLUGIN_INSTANCE_MODES.map((mode) => (
<option key={mode} value={mode}>
{t(`mode.${mode}`)}
</option>
))}
</select>
</label>
<span className="plugin-manager__mode-hint">{t(`mode.hint.${plugin.mode}`)}</span>
<Link className="plugin-manager__preview" to={`/admin/plugins/${plugin.id}/preview`}>
{t('admin.preview')}
</Link>
<IconButton
className="plugin-manager__uninstall"
disabled={plugin.mode === 'required'}
label={t('admin.uninstall')}
// A required plugin explains in the tooltip why it cannot be
// removed, so the title deliberately differs from the name;
// IconButton spreads rest last, which lets it through.
title={
plugin.mode === 'required' ? t('admin.requiredLocked') : t('admin.uninstall')
}
onClick={() => uninstall.mutate(plugin.id)}
>
<Trash2 aria-hidden />
</IconButton>
</div>
</li>
))}
</ul>
</section>
);
}