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(null); const [uploadError, setUploadError] = useState(null); const plugins = useQuery({ queryKey: ['admin', 'plugins'], queryFn: () => apiGet('/admin/plugins'), }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'plugins'] }); const install = useMutation({ mutationFn: (file: File) => apiUploadFile('/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(`/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>('/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 (

{t('admin.title')}

{t('admin.intro')}

{allowlist.length === 0 ? t('admin.pinning.hintOff') : t('admin.pinning.hintOn')}

{plugins.data && plugins.data.length === 0 ?

{t('admin.empty')}

: null}
    {plugins.data?.map((plugin) => (
  • {plugin.name} v{plugin.version} {plugin.kind}
    {t('admin.permissions')}:{' '} {plugin.permissions.length === 0 ? ( {t('admin.noPermissions')} ) : ( plugin.permissions.map((permission) => ( {permission} )) )}
    {t('admin.pinning.state')}:{' '} {t(`admin.pinning.verdict.${plugin.pinning}`)}
    {t('admin.pinning.observed')}:{' '} {plugin.bundleSha256 ? ( {plugin.bundleSha256} ) : ( {t('admin.pinning.unknownHash')} )} {pinnedHashOf(plugin.id) && pinnedHashOf(plugin.id) !== plugin.bundleSha256 && ( <>
    {t('admin.pinning.pinnedHash')}:{' '} {pinnedHashOf(plugin.id)} )}
    {plugin.bundleSha256 && pinnedHashOf(plugin.id) !== plugin.bundleSha256 && ( )} {pinnedHashOf(plugin.id) && ( )}
    {t(`mode.hint.${plugin.mode}`)} {t('admin.preview')} uninstall.mutate(plugin.id)} >
  • ))}
); }