dorfteich/apps/web/src/pages/PluginManager.tsx
Claude Fable 5 46292c7447
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m56s
CD / Build and push images (push) Successful in 3m11s
CD / Deploy to Test (push) Successful in 8s
CI / Auth e2e pack (push) Successful in 4m12s
CD / Smoke tests against Test (push) Successful in 1m6s
CD / Promote to Int (push) Successful in 13s
CI / Import/export fidelity gate (push) Successful in 42s
CI / Build container images (push) Has been skipped
Add plugin administration UI: instance modes and pond activation (#72)
- api: PATCH /admin/plugins/:id/mode (Site Admin) switches
  disabled/optional/required; new PluginPondController exposes
  GET /ponds/:id/plugins (effective list: required + optional-enabled,
  pond read access — the SPA loads it per pond), GET .../plugins/settings
  and PUT .../plugins/:pluginId (Pond Admin) to toggle optional plugins.
  Toggling a non-optional plugin is refused (plugin_not_optional).
  Install/uninstall/mode/toggle are audit-logged.
- web: PluginManager in the admin area lists installed plugins with their
  declared permissions surfaced prominently (security.md), an upload
  control that shows validation errors, a mode switch with an impact
  hint, and a link to the sandbox preview. PondPluginSettings adds a
  per-pond optional-plugin toggle section to pond settings.
- shared: PondPluginSetting, mode/toggle input schemas, plugin_not_optional
  error code + de/en messages, plugins i18n (admin/mode/pond).
- tests: api db test covers mode switching, per-pond activation, the
  required-everywhere and disabled-nowhere propagation, and the
  not-optional guard; e2e plugin-admin pack drives the admin list,
  permission display, mode switch, and pond toggle end to end.

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

139 lines
5.0 KiB
TypeScript

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';
/**
* 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),
});
return (
<section className="settings-section plugin-manager">
<h2>{t('admin.title')}</h2>
<p className="plugin-manager__intro">{t('admin.intro')}</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__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>
<button
type="button"
className="button button--danger"
disabled={plugin.mode === 'required'}
title={plugin.mode === 'required' ? t('admin.requiredLocked') : undefined}
onClick={() => uninstall.mutate(plugin.id)}
>
{t('admin.uninstall')}
</button>
</div>
</li>
))}
</ul>
</section>
);
}