Files
webmail/components/plugins/plugin-consent-dialog.tsx
T
Linus Rath e720be7e17 feat: let users install their own plugins, with admin review of the actual code
The admin page offered "User Plugin Uploads" and "Require Admin
Approval", but nothing in Settings used them: there was no upload, no
uninstall and no way to ask for approval, and the admin had no list of
requests. The approval code that existed pinned a hash of the code only
and showed the admin a manifest the browser sent, never the code.

Now:
- Settings > Plugins has an install button (when uploads are allowed),
  a remove button for the user's own plugins, and lists in plain
  language what each plugin may do, high-risk items first
- an install sends the ZIP to the server, which reads the manifest and
  code itself and pins the request to a hash of both
- the admin Plugins tab lists requests with the code and approves or
  denies them; a user may have 10 open requests
- a user plugin is checked again on every start, so a revoked approval
  or turning user plugins off takes effect
- the consent dialog and the plugin settings are translated, use the
  theme colours, and show the sites a plugin may send data to
2026-10-14 02:03:26 +02:00

67 lines
2.7 KiB
TypeScript

'use client';
// Modal shown the first time a plugin is enabled, listing every permission
// the plugin's manifest declares. Accepting persists the grant on the
// plugin record so future enables skip the prompt.
import React, { useId, useSyncExternalStore } from 'react';
import { useTranslations } from 'next-intl';
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/consent';
import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { Button } from '@/components/ui/button';
import { PluginPermissionList } from './plugin-permission-list';
export function PluginConsentDialog(): React.JSX.Element | null {
const current = useSyncExternalStore(subscribe, head, () => null);
const t = useTranslations('plugins.consent');
const id = useId();
const dialogRef = useFocusTrap({
isActive: !!current,
onEscape: () => resolveHead(false),
restoreFocus: true,
});
if (!current) return null;
const risky = current.permissions.some((p) => HIGH_RISK_PERMISSIONS.has(p));
return (
<div
className="fixed inset-0 z-[100001] flex items-center justify-center bg-black/50 p-4"
onMouseDown={(e) => { if (e.target === e.currentTarget) resolveHead(false); }}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
className="flex max-h-[90vh] w-full max-w-lg flex-col rounded-lg border border-border bg-background shadow-xl"
>
<div className="p-5 pb-3">
<h2 id={`${id}-title`} className="text-base font-semibold text-foreground">
{t('title', { name: current.pluginName })}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('intro')}</p>
{risky && (
<p className="mt-2 rounded-md bg-warning/10 px-3 py-2 text-xs text-foreground">{t('high_risk_note')}</p>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5">
<PluginPermissionList
permissions={current.permissions}
httpOrigins={current.httpOrigins}
apiPostPaths={current.apiPostPaths}
/>
</div>
<div className="flex flex-col gap-3 p-5 pt-4 sm:flex-row sm:items-center sm:justify-between">
<span className="text-xs text-muted-foreground">{t('revoke_hint')}</span>
<div className="flex justify-end gap-2">
<Button variant="outline" className="whitespace-nowrap" onClick={() => resolveHead(false)}>{t('deny')}</Button>
<Button autoFocus className="whitespace-nowrap" onClick={() => resolveHead(true)}>{t('allow')}</Button>
</div>
</div>
</div>
</div>
);
}