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
- 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
139 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|