Merge branch 'main' into fix/message-drag-blob-routing

This commit is contained in:
Linus Rath
2026-10-20 21:15:36 +02:00
134 changed files with 9493 additions and 6910 deletions
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { useEffect } from 'react';
import { useAdminLocation, navigateAdmin } from '@/hooks/use-admin-navigation';
import { adminPath, isAdminPageId } from '@/lib/admin/admin-nav';
import { DashboardPage } from '../_pages/dashboard';
import { SettingsPage } from '../_pages/settings';
import { BrandingPage } from '../_pages/branding';
import { AuthPage } from '../_pages/auth';
import { PolicyPage } from '../_pages/policy';
import { PluginsPage } from '../_pages/plugins';
import { ThemesPage } from '../_pages/themes';
import { MarketplacePage } from '../_pages/marketplace';
import { MarketplaceDetailPage } from '../_pages/marketplace-detail';
import { ExtensionDetailPage } from '../_pages/extension-detail';
import { VersionPage } from '../_pages/version';
import { TelemetryPage } from '../_pages/telemetry';
import { LogsPage } from '../_pages/logs';
/**
* Every admin page, picked from the address bar. One route for the whole
* panel: the sidebar switches pages with history.pushState and this
* component re-renders from `usePathname()`, so there is never a route
* transition between pages (lib/admin/admin-nav.ts).
*/
export default function AdminPage() {
const { page, detail } = useAdminLocation();
// Links from before the pages had URLs: /admin?tab=settings.
useEffect(() => {
const url = new URL(window.location.href);
const tab = url.searchParams.get('tab');
if (!isAdminPageId(tab)) return;
url.searchParams.delete('tab');
navigateAdmin(`${adminPath(tab)}${url.search}${url.hash}`, { replace: true });
}, []);
switch (page ?? 'dashboard') {
case 'dashboard': return <DashboardPage />;
case 'settings': return <SettingsPage />;
case 'branding': return <BrandingPage />;
case 'auth': return <AuthPage />;
case 'policy': return <PolicyPage />;
case 'plugins': return detail ? <ExtensionDetailPage key={detail} kind="plugin" id={detail} /> : <PluginsPage />;
case 'themes': return detail ? <ExtensionDetailPage key={detail} kind="theme" id={detail} /> : <ThemesPage />;
case 'marketplace': return detail ? <MarketplaceDetailPage key={detail} slug={detail} /> : <MarketplacePage />;
case 'version': return <VersionPage />;
case 'telemetry': return <TelemetryPage />;
case 'logs': return <LogsPage />;
}
}
@@ -14,7 +14,7 @@ vi.mock('@/lib/browser-navigation', () => ({
}));
vi.mock('next/link', () => ({ default: () => null }));
import { installVersion } from '../_tabs/marketplace';
import { installVersion } from '../_pages/marketplace';
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
const BODY = { slug: 'jitsi-meet', version: '2.0.9', type: 'plugin' as const };
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { AdminShell } from '@/components/admin/admin-shell';
import { UnsavedChangesGuard } from './_unsaved-changes-guard';
/**
* Gates the admin panel behind the admin session and draws its shell. The
* pages themselves are one route (./[[...segments]]) and switch through
* history.pushState, so this layout stays mounted across them.
*/
export function AdminLayoutClient({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAutoLogin, setIsStalwartAutoLogin] = useState(false);
const [configReadOnly, setConfigReadOnly] = useState(false);
useEffect(() => {
if (pathname === '/admin/login') return;
// One check per visit, not one per page: the pages switch without a
// route change, and the session does not change with them.
if (authenticated === true) return;
let cancelled = false;
async function checkAuth() {
try {
const jmapHeaders = getActiveAccountSlotHeaders();
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
if (cancelled) return;
// `stalwartAutoLogin` is only true in "auto" mode; in "password" mode
// a Stalwart admin still lands on the password form (#870).
const stalwartAutoLogin = data.stalwartAutoLogin === true;
setIsStalwartAutoLogin(stalwartAutoLogin);
// If neither password-based admin nor Stalwart auto-login, redirect away
if (!data.enabled && !stalwartAutoLogin) {
router.replace('/');
return;
}
if (data.authenticated) {
setAuthenticated(true);
return;
}
// If Stalwart admin (auto mode) but not yet authenticated, auto-login
if (stalwartAutoLogin) {
const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (cancelled) return;
if (loginRes.ok) {
setAuthenticated(true);
return;
}
const body = await loginRes.json().catch(() => ({}));
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
setAuthenticated(false);
return;
}
// Carry the page that was asked for through the login, so a connector
// link to a panel (/connector/admin_extension?slug=x) does not dump
// the admin on the overview after signing in.
const next = encodeURIComponent(`${pathname}${window.location.search}`);
router.replace(`/admin/login?next=${next}`);
} catch (err) {
if (cancelled) return;
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
setAuthenticated(false);
}
}
checkAuth();
return () => { cancelled = true; };
}, [pathname, router, authenticated]);
// With ADMIN_CONFIG_READONLY the panel still shows every setting, but
// nothing can be saved; say so up front instead of after a failed save (#1249).
useEffect(() => {
if (!authenticated) return;
let cancelled = false;
apiFetch('/api/setup/status')
.then(res => (res.ok ? res.json() : null))
.then(data => { if (!cancelled) setConfigReadOnly(data?.readOnly === true); })
.catch(() => {});
return () => { cancelled = true; };
}, [authenticated]);
function handleLogout() {
requestLeave(async () => {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login');
});
}
// Don't gate the login page
if (pathname === '/admin/login') {
return <>{children}</>;
}
return (
<>
<UnsavedChangesGuard />
<AdminShell
authenticated={authenticated === true}
isStalwartAutoLogin={isStalwartAutoLogin}
configReadOnly={configReadOnly}
onLogout={handleLogout}
>
{authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel…
</div>
) : authenticated ? children : null}
</AdminShell>
</>
);
}
@@ -1,8 +1,11 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from '@/components/icons';
import { Plus, Trash2, ChevronDown, ChevronRight } from '@/components/icons';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
import { Button } from '@/components/ui/button';
import { inputClass, RevertButton, SourceBadge } from '@/components/admin/config-items';
import { cn } from '@/lib/utils';
interface Props {
value: JmapServerEntry[];
@@ -93,6 +96,9 @@ function emptyDraft(): RowDraft {
};
}
const fieldLabelClass = 'block text-[11px] font-medium text-muted-foreground mb-1';
const fieldClass = cn(inputClass, 'w-full');
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
const lastEmittedRef = useRef(value);
@@ -132,38 +138,21 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
});
return (
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">Servers</span>
{source && source !== 'default' && (
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
{source}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP Server URL</code> above.
<div className="space-y-3 py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 sm:pe-4">
<span className="text-sm font-medium text-foreground">Servers</span>
<p className="text-xs text-muted-foreground mt-1">
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP server URL</code> above.
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{source === 'admin' && (
<button
onClick={onRevert}
className="text-muted-foreground hover:text-foreground"
title="Revert to default"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={add}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors"
>
<div className="flex items-center gap-1.5 shrink-0">
<SourceBadge source={source} />
{source === 'admin' && <RevertButton onClick={onRevert} />}
<Button type="button" variant="outline" size="sm" className="gap-1.5" onClick={add}>
<Plus className="w-3.5 h-3.5" />
Add server
</button>
</Button>
</div>
</div>
@@ -177,40 +166,42 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">ID</label>
<label className={fieldLabelClass}>ID</label>
<input
type="text"
value={d.id}
onChange={(e) => update(i, { id: e.target.value })}
placeholder="main"
className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`}
className={cn(fieldClass, isDuplicate && 'border-destructive')}
/>
{isDuplicate && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
</div>
<div className="sm:col-span-4">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Label</label>
<label className={fieldLabelClass}>Label</label>
<input
type="text"
value={d.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="Main server"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={fieldClass}
/>
</div>
<div className="sm:col-span-5">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">JMAP URL</label>
<label className={fieldLabelClass}>JMAP URL</label>
<div className="flex items-center gap-2">
<input
type="url"
value={d.url}
onChange={(e) => update(i, { url: e.target.value })}
placeholder="https://mail.example.com"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={fieldClass}
/>
<button
type="button"
onClick={() => remove(i)}
className="shrink-0 text-muted-foreground hover:text-destructive"
className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-destructive transition-colors"
title="Remove server"
aria-label="Remove server"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@@ -218,7 +209,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
<label className={fieldLabelClass}>
Email domains (comma-separated, used for auto-pick)
</label>
<input
@@ -226,7 +217,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
value={d.domains}
onChange={(e) => update(i, { domains: e.target.value })}
placeholder="example.com, example.org"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={fieldClass}
/>
</div>
<button
@@ -240,56 +231,56 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
{d.oauthExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
<label className={fieldLabelClass}>OAuth Client ID</label>
<input
type="text"
value={d.oauthClientId}
onChange={(e) => update(i, { oauthClientId: e.target.value })}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={fieldClass}
/>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Issuer URL</label>
<label className={fieldLabelClass}>OAuth Issuer URL</label>
<input
type="url"
value={d.oauthIssuerUrl}
onChange={(e) => update(i, { oauthIssuerUrl: e.target.value })}
placeholder="https://auth.example.com"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={fieldClass}
/>
</div>
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client Secret</label>
<label className={fieldLabelClass}>OAuth Client Secret</label>
<input
type="password"
value={d.oauthClientSecret}
onChange={(e) => update(i, { oauthClientSecret: e.target.value })}
placeholder={d.hasClientSecret ? '•••••••• (saved - type to replace)' : undefined}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={fieldClass}
/>
</div>
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Sign-in button label</label>
<label className={fieldLabelClass}>Sign-in button label</label>
<input
type="text"
value={d.oauthButtonLabel}
onChange={(e) => update(i, { oauthButtonLabel: e.target.value })}
placeholder="Sign in with Google"
maxLength={64}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={fieldClass}
/>
<p className="mt-1 text-[11px] text-muted-foreground">
With a client ID, this label gives the server its own sign-in button on the login page, whichever server is selected and even while OAuth is off globally.
</p>
</div>
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Provider logout URL</label>
<label className={fieldLabelClass}>Provider logout URL</label>
<input
type="url"
value={d.oauthEndSessionUrl}
onChange={(e) => update(i, { oauthEndSessionUrl: e.target.value })}
placeholder="https://auth.example.com/logout"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className={fieldClass}
/>
<p className="mt-1 text-[11px] text-muted-foreground">
Where signing out sends the browser to end the provider&apos;s session, instead of the end_session_endpoint from discovery. Set it for providers that advertise none, such as Stalwart. Must be https.
+324
View File
@@ -0,0 +1,324 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Sparkles } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { DEFAULT_OAUTH_SCOPES } from '@/lib/oauth/scopes';
import { SettingsSection, SettingItem, SectionNav } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, inputClass } from '@/components/admin/config-items';
import { cn } from '@/lib/utils';
interface ConfigEntry {
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
// `value` omitted and `hasValue` set instead - the server never echoes
// the raw secret to the client.
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
const PAGE = adminPageDef('auth');
export function AuthPage() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useFlash();
const [configReadOnly, setConfigReadOnly] = useState(false);
useEffect(() => {
fetchConfig();
apiFetch('/api/setup/status')
.then(res => (res.ok ? res.json() : null))
.then(data => setConfigReadOnly(data?.readOnly === true))
.catch(() => {});
}, []);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json());
setLoading(false);
}
function handleChange(key: string, value: unknown) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): unknown {
if (key in edits) return edits[key];
return config[key]?.value;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Authentication settings saved.' });
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleRevert(key: string) {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
await fetchConfig();
}
}
const [setupRunning, setSetupRunning] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const [setupOrigin, setSetupOrigin] = useState('');
const [setupIssuer, setSetupIssuer] = useState('');
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
function openSetupDialog() {
if (typeof window === 'undefined') return;
const origin = window.location.origin;
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
setSetupOrigin(origin);
setSetupIssuer(jmapUrl || origin);
setSetupOauthOnly(currentValue('oauthOnly') === true);
setSetupOpen(true);
}
async function handleAutoSetup() {
setSetupRunning(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/oauth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
origin: setupOrigin.trim().replace(/\/+$/, ''),
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
oauthOnly: setupOauthOnly,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({
type: 'success',
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
});
setEdits({});
setSetupOpen(false);
await fetchConfig();
} else {
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
}
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
} finally {
setSetupRunning(false);
}
}
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
const hasEdits = Object.keys(edits).length > 0;
useAdminUnsavedChanges('auth', hasEdits);
if (loading) {
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
</div>
);
}
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<Flash message={message} onDismiss={() => setMessage(null)} />
{setupOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-labelledby="oauth-setup-title"
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
>
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
<div className="px-5 py-4 border-b border-border">
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
<p className="text-xs text-muted-foreground mt-1">
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
</p>
</div>
<div className="px-5 py-4 space-y-4">
<div>
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
Webmail origin
</label>
<input
id="setup-origin"
type="url"
value={setupOrigin}
onChange={(e) => setSetupOrigin(e.target.value)}
disabled={setupRunning}
placeholder="https://webmail.example.com"
className={cn(inputClass, 'w-full')}
/>
<p className="text-[11px] text-muted-foreground mt-1">
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/&lt;locale&gt;/auth/callback</code>) on Stalwart.
</p>
{!setupOriginValid && setupOrigin.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<div>
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
Stalwart issuer URL
</label>
<input
id="setup-issuer"
type="url"
value={setupIssuer}
onChange={(e) => setSetupIssuer(e.target.value)}
disabled={setupRunning}
placeholder="https://mail.example.com"
className={cn(inputClass, 'w-full')}
/>
<p className="text-[11px] text-muted-foreground mt-1">
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
</p>
{!setupIssuerValid && setupIssuer.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
<input
type="checkbox"
checked={setupOauthOnly}
onChange={(e) => setSetupOauthOnly(e.target.checked)}
className="h-3.5 w-3.5 rounded border-input"
disabled={setupRunning}
/>
Also enable “OAuth only” (hide password login)
</label>
</div>
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
<Button type="button" variant="outline" size="sm" onClick={() => setSetupOpen(false)} disabled={setupRunning}>
Cancel
</Button>
<Button
type="button"
size="sm"
className="gap-2"
onClick={handleAutoSetup}
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Configure'}
</Button>
</div>
</div>
</div>
)}
<div className="space-y-10">
<SectionNav />
<SettingsSection id="oauth" title="OAuth / OpenID Connect">
<SettingItem
label="Set up with Stalwart"
description="Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here. Requires your Stalwart account to have admin permissions."
>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Button type="button" size="sm" className="gap-2" onClick={openSetupDialog} disabled={setupRunning || configReadOnly}>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Set up automagically'}
</Button>
{configReadOnly && (
<p className="text-xs text-destructive">
Not available while the configuration is read-only (ADMIN_CONFIG_READONLY).
</p>
)}
</div>
</SettingItem>
<ConfigToggleItem label="Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="OAuth only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigTextItem label="Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigTextItem label="Client secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
<ConfigTextItem label="Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" wide />
<ConfigToggleItem label="Allow private endpoints" description="Permit discovered endpoints on other hosts to resolve to RFC-1918 / loopback addresses. Endpoints on the issuer's own host are always allowed, so split-DNS setups rarely need this." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigTextItem label="Scopes" description={`Space-separated scopes that replace the defaults (${DEFAULT_OAUTH_SCOPES}). Leave blank to use them. offline_access is not requested by default; add it here or under Extra Scopes if your provider only issues refresh tokens for it, or sessions end when the access token expires.`} configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder={DEFAULT_OAUTH_SCOPES} wide />
<ConfigTextItem label="Extra scopes" description={`Additional space-separated scopes appended to the defaults (${DEFAULT_OAUTH_SCOPES}). Ignored when OAuth Scopes is set.`} configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." wide />
</SettingsSection>
<SettingsSection id="sso" title="Single sign-on">
<ConfigToggleItem label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="End provider session on sign-out" description="Signing out of an SSO account also signs out of the identity provider, when it advertises an end_session_endpoint or a logout URL is set below. Turn off if the provider is shared with apps that should stay signed in." configKey="oauthEndSession" value={currentValue('oauthEndSession') as boolean} source={config.oauthEndSession?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigTextItem label="Provider logout URL" description="For providers whose discovery has no end_session_endpoint, such as Stalwart. Used instead of the discovered endpoint when set, with the same parameters. Must be https. Applies to the global OAuth settings; servers in the server list set their own." configKey="oauthEndSessionUrl" value={currentValue('oauthEndSessionUrl') as string} source={config.oauthEndSessionUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com/logout" wide />
<ConfigTextItem label="Post-logout redirect URI" description="Where the provider sends users after signing them out. Register it with the provider as a post-logout redirect URI first, or it will refuse the logout. Leave blank to stay on the provider's signed-out page." configKey="oauthPostLogoutRedirectUri" value={currentValue('oauthPostLogoutRedirectUri') as string} source={config.oauthPostLogoutRedirectUri?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/en/login" wide />
</SettingsSection>
<SettingsSection id="admin-access" title="Admin access">
<ConfigSelectItem
label="Stalwart admin accounts"
description="What a Stalwart admin account grants in this dashboard. “Automatic” signs Stalwart admins in without the admin password; “Password required” keeps the shield but asks for the admin password; “Off” ignores Stalwart admin status entirely. The last two need an admin password to be configured."
configKey="stalwartAdminAccess"
value={currentValue('stalwartAdminAccess') as string}
source={config.stalwartAdminAccess?.source}
options={[
{ value: 'auto', label: 'Automatic (no password)' },
{ value: 'password', label: 'Password required' },
{ value: 'off', label: 'Off (separate admins)' },
]}
onChange={handleChange}
onRevert={handleRevert}
/>
</SettingsSection>
<SettingsSection id="session" title="Session and security">
<ConfigSelectItem label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
<ConfigTextItem label="Allowed frame ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." wide />
<ConfigTextItem label="Parent origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
</div>
<AdminSaveBar
dirty={hasEdits}
saving={saving}
onSave={handleSave}
onDiscard={() => setEdits({})}
note="Takes effect on the next page load"
/>
</div>
);
}
+639
View File
@@ -0,0 +1,639 @@
'use client';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Loader2, ImageIcon, Upload, Trash2, Plus, X } from '@/components/icons';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import {
BRANDING_OVERRIDE_KEYS,
parseDomainBranding,
type BrandingOverrideKey,
type DomainBrandingEntry,
} from '@/lib/admin/domain-branding';
import { SettingsSection, SettingItem, SectionNav } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { inputClass, RevertButton } from '@/components/admin/config-items';
import { cn } from '@/lib/utils';
interface ConfigEntry {
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
const IMAGE_FIELDS = [
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
{ key: 'appLogoLightUrl', label: 'App logo (light mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'appLogoDarkUrl', label: 'App logo (dark mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoLightUrl', label: 'Login logo (light mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoDarkUrl', label: 'Login logo (dark mode)', accept: '.svg,.png,.jpg,.webp' },
] as const;
const TEXT_FIELDS = [
{ key: 'loginCompanyName', label: 'Company name' },
{ key: 'loginImprintUrl', label: 'Imprint URL' },
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy policy URL' },
{ key: 'loginWebsiteUrl', label: 'Company website URL' },
] as const;
const PWA_IMAGE_FIELDS = [
{ key: 'pwaIconUrl', label: 'Icon', accept: '.svg,.png,.jpg,.webp' },
{ key: 'pwaScreenshotMobileUrl', label: 'Screenshot (mobile)', accept: '.png,.jpg,.webp' },
{ key: 'pwaScreenshotDesktopUrl', label: 'Screenshot (desktop)', accept: '.png,.jpg,.webp' },
] as const;
const PWA_TEXT_FIELDS = [
{ key: 'appShortName', label: 'Short name', placeholder: 'Shown on home screen (max ~12 chars)' },
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
] as const;
const PWA_COLOR_FIELDS = [
{ key: 'pwaThemeColor', label: 'Theme colour', defaultValue: '#ffffff' },
{ key: 'pwaBackgroundColor', label: 'Background colour', defaultValue: '#ffffff' },
] as const;
// Accepts exact hosts and one-level wildcards (e.g. *.example.com).
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
// Tighter rule for uploads: wildcards can only point to externally-hosted
// URLs, since we'd have no concrete subdomain to serve a file from.
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
const PAGE = adminPageDef('branding');
const chipClass = (selected: boolean) => cn(
'h-8 px-3 rounded-md text-sm font-medium transition-colors',
selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground hover:bg-accent hover:text-accent-foreground',
);
export function BrandingPage() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [message, setMessage] = useFlash();
const [selectedHost, setSelectedHost] = useState<string | null>(null);
const [addingHost, setAddingHost] = useState(false);
const [newHostInput, setNewHostInput] = useState('');
const [newHostError, setNewHostError] = useState<string | null>(null);
useEffect(() => {
fetchConfig();
}, []);
const domainEntries = useMemo<DomainBrandingEntry[]>(
() => parseDomainBranding(config['domainBranding']?.value),
[config],
);
// Drop selection if the host disappeared from the config (e.g. concurrent edit).
useEffect(() => {
if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) {
setSelectedHost(null);
setEdits({});
}
}, [domainEntries, selectedHost]);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json());
setLoading(false);
}
function selectedEntry(): DomainBrandingEntry | null {
if (!selectedHost) return null;
return domainEntries.find(e => e.host === selectedHost) ?? null;
}
function handleChange(key: string, value: string) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): string {
if (key in edits) return edits[key];
if (selectedHost) {
const entry = selectedEntry();
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
}
return (config[key]?.value as string) ?? '';
}
function isOverriddenInScope(key: string): boolean {
if (selectedHost) {
const entry = selectedEntry();
const v = entry?.[key as BrandingOverrideKey];
return typeof v === 'string' && v.length > 0;
}
return config[key]?.source === 'admin';
}
const isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
if (!selectedHost) return domainEntries;
const next = domainEntries.slice();
const idx = next.findIndex(e => e.host === selectedHost);
const base: DomainBrandingEntry =
idx === -1 ? { host: selectedHost } : { ...next[idx] };
const writable = base as unknown as Record<string, string | undefined>;
for (const [key, value] of Object.entries(merge)) {
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
if (typeof value === 'string' && value.length > 0) {
writable[key] = value;
} else {
delete writable[key];
}
}
if (idx === -1) next.push(base);
else next[idx] = base;
return next;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const payload = selectedHost
? { domainBranding: buildUpdatedDomainBranding(edits) }
: edits;
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
setMessage({
type: 'success',
text: selectedHost
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
: 'Branding updated. Changes visible on next page load.',
});
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleUpload(slot: string, file: File) {
if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) {
setMessage({
type: 'error',
text: 'Wildcard hosts cannot upload files. Enter a URL instead.',
});
return;
}
setUploading(slot);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
formData.append('slot', slot);
if (selectedHost) formData.append('host', selectedHost);
const res = await apiFetch('/api/admin/branding', {
method: 'POST',
body: formData,
});
if (res.ok) {
const data = await res.json();
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
setEdits(prev => {
const next = { ...prev };
delete next[slot];
return next;
});
// Refresh from server so domainBranding entries reflect the upload.
await fetchConfig();
void data;
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
setUploading(null);
}
async function handleDeleteUpload(slot: string) {
setMessage(null);
const body: { slot: string; host?: string } = { slot };
if (selectedHost) body.host = selectedHost;
const res = await apiFetch('/api/admin/branding', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
setEdits(prev => {
const next = { ...prev };
delete next[slot];
return next;
});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
}
}
async function handleRevert(key: string) {
if (selectedHost) {
// Domain scope: drop the field from the entry and PATCH the array.
const updated = buildUpdatedDomainBranding({ [key]: '' });
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: updated }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
}
return;
}
// Default scope: revert via DELETE /api/admin/config
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
}
}
async function handleAddDomain() {
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
if (!host) {
setNewHostError('Enter a hostname');
return;
}
if (!HOST_RE.test(host)) {
setNewHostError('Invalid hostname. Use foo.example.com or *.example.com');
return;
}
if (domainEntries.some(e => e.host === host)) {
setNewHostError('A branding entry for this host already exists');
return;
}
setNewHostError(null);
const next: DomainBrandingEntry[] = [...domainEntries, { host }];
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: next }),
});
if (res.ok) {
setNewHostInput('');
setAddingHost(false);
setSelectedHost(host);
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setNewHostError(data.error || 'Failed to add domain');
}
}
async function handleDeleteDomain() {
if (!selectedHost) return;
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
return;
}
const next = domainEntries.filter(e => e.host !== selectedHost);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: next }),
});
if (res.ok) {
setSelectedHost(null);
setEdits({});
await fetchConfig();
setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to remove domain' });
}
}
function handleScopeChange(host: string | null) {
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
setSelectedHost(host);
setEdits({});
setMessage(null);
}
const hasEdits = Object.keys(edits).length > 0;
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
useAdminUnsavedChanges('branding', hasEdits);
if (loading) {
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
</div>
);
}
/** The badge next to a field that is set in the scope being edited. */
const overrideBadge = (key: string) => (
isOverriddenInScope(key)
? <Badge tone="accent">{isUploadedFile(key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}</Badge>
: null
);
const imageRow = (field: { key: string; label: string; accept: string }) => (
<ImageItem
key={field.key}
label={field.label}
accept={field.accept}
value={currentValue(field.key)}
badge={overrideBadge(field.key)}
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
uploading={uploading === field.key}
uploadDisabled={wildcardScope}
uploaded={isUploadedFile(field.key)}
overridden={isOverriddenInScope(field.key)}
onChange={(value) => handleChange(field.key, value)}
onUpload={(file) => handleUpload(field.key, file)}
onDeleteUpload={() => handleDeleteUpload(field.key)}
onRevert={() => handleRevert(field.key)}
/>
);
const textRow = (field: { key: string; label: string }, placeholder: string) => (
<SettingItem key={field.key} label={field.label}>
<div className="flex w-full items-center gap-1.5 sm:w-auto">
{overrideBadge(field.key)}
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={placeholder}
aria-label={field.label}
autoComplete="off"
spellCheck={false}
className={cn(inputClass, 'w-full min-w-0 sm:w-72')}
/>
{isOverriddenInScope(field.key) && <RevertButton onClick={() => handleRevert(field.key)} />}
</div>
</SettingItem>
);
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<SectionNav />
<SettingsSection id="scope" title="Scope">
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => handleScopeChange(null)} aria-pressed={selectedHost === null} className={chipClass(selectedHost === null)}>
Default
</button>
{domainEntries.map(entry => (
<button
key={entry.host}
type="button"
onClick={() => handleScopeChange(entry.host)}
aria-pressed={selectedHost === entry.host}
className={chipClass(selectedHost === entry.host)}
>
{entry.host}
</button>
))}
{!addingHost && (
<button
type="button"
onClick={() => { setAddingHost(true); setNewHostError(null); }}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add domain
</button>
)}
</div>
{addingHost && (
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
autoFocus
value={newHostInput}
onChange={(e) => { setNewHostInput(e.target.value); setNewHostError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
placeholder="mail.example.com or *.example.com"
aria-label="Hostname"
autoComplete="off"
spellCheck={false}
className={cn(inputClass, 'w-64')}
/>
<Button type="button" size="sm" onClick={handleAddDomain}>
Add
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}>
Cancel
</Button>
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
</div>
)}
{selectedHost ? (
<div className="flex items-center justify-between gap-3 text-xs">
<p className="text-muted-foreground">
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
Unset fields fall back to the Default values.
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
</p>
<button
type="button"
onClick={handleDeleteDomain}
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
>
<X className="w-3.5 h-3.5" />
Remove domain
</button>
</div>
) : (
<p className="text-xs text-muted-foreground">
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
</p>
)}
</SettingsSection>
<SettingsSection id="images" title="Images and logos" description="Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)">
{IMAGE_FIELDS.map(imageRow)}
</SettingsSection>
<SettingsSection id="pwa" title="Progressive web app" description="Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.">
{PWA_IMAGE_FIELDS.map(imageRow)}
{PWA_TEXT_FIELDS.map(field => textRow(field, field.placeholder))}
{PWA_COLOR_FIELDS.map(field => {
const value = currentValue(field.key) || field.defaultValue;
return (
<SettingItem key={field.key} label={field.label}>
<div className="flex w-full items-center gap-1.5 sm:w-auto">
{overrideBadge(field.key)}
<input
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
onChange={(e) => handleChange(field.key, e.target.value)}
aria-label={`${field.label} picker`}
className="h-9 w-10 shrink-0 cursor-pointer rounded-md border border-input bg-background p-0.5"
title="Pick a color"
/>
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.defaultValue}
aria-label={field.label}
autoComplete="off"
spellCheck={false}
className={cn(inputClass, 'w-full min-w-0 font-mono sm:w-32')}
/>
{isOverriddenInScope(field.key) && <RevertButton onClick={() => handleRevert(field.key)} />}
</div>
</SettingItem>
);
})}
</SettingsSection>
<SettingsSection id="company" title="Company information">
{TEXT_FIELDS.map(field => textRow(field, field.key.includes('Url') ? 'https://...' : 'Enter value'))}
</SettingsSection>
</div>
<AdminSaveBar
dirty={hasEdits}
saving={saving}
onSave={handleSave}
onDiscard={() => setEdits({})}
note="Takes effect on the next page load"
/>
</div>
);
}
interface ImageItemProps {
label: string;
accept: string;
value: string;
badge: ReactNode;
placeholder: string;
uploading: boolean;
uploadDisabled: boolean;
uploaded: boolean;
overridden: boolean;
onChange: (value: string) => void;
onUpload: (file: File) => void;
onDeleteUpload: () => void;
onRevert: () => void;
}
/** One image slot: a URL field, an upload button, and a preview of what is set. */
function ImageItem({ label, accept, value, badge, placeholder, uploading, uploadDisabled, uploaded, overridden, onChange, onUpload, onDeleteUpload, onRevert }: ImageItemProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<SettingItem label={label}>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:items-end">
<div className="flex w-full items-center gap-1.5 sm:w-auto">
{badge}
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label={label}
autoComplete="off"
spellCheck={false}
className={cn(inputClass, 'w-full min-w-0 sm:w-64')}
/>
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onUpload(file);
e.target.value = '';
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="w-9 shrink-0 px-0"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || uploadDisabled}
title={uploadDisabled ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
aria-label={uploadDisabled ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
>
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
</Button>
{uploaded && (
<button
type="button"
onClick={onDeleteUpload}
className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-destructive transition-colors"
title="Remove uploaded file"
aria-label="Remove uploaded file"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{overridden && !uploaded && <RevertButton onClick={onRevert} />}
</div>
{value && (
<div className="flex items-center gap-2">
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img
src={withBasePath(value)}
alt={label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
</div>
)}
</div>
</SettingItem>
);
}
+264
View File
@@ -0,0 +1,264 @@
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import { AlertTriangle } from '@/components/icons';
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminLink } from '@/components/admin/admin-link';
import { AdminList, AdminListRow } from '@/components/admin/list';
import { Badge } from '@/components/admin/badge';
import type { AuditEntry } from '@/lib/admin/types';
import { adminPageDef, adminPath } from '@/lib/admin/admin-nav';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus } from '@/lib/version-check/types';
import { cn } from '@/lib/utils';
interface AdminStatus {
enabled: boolean;
authenticated: boolean;
lastLogin: string | null;
passwordChangedAt: string | null;
}
interface ConfigData {
appName?: string;
jmapServerUrl?: string;
settingsSyncEnabled?: boolean;
stalwartFeaturesEnabled?: boolean;
oauthEnabled?: boolean;
devMode?: boolean;
}
interface VersionData {
current: string;
status: UpdateStatus | null;
}
const PAGE = adminPageDef('dashboard');
export function DashboardPage() {
const [status, setStatus] = useState<AdminStatus | null>(null);
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
const [config, setConfig] = useState<ConfigData | null>(null);
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [pendingApprovals, setPendingApprovals] = useState(0);
const [themeCount, setThemeCount] = useState(0);
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
const [version, setVersion] = useState<VersionData | null>(null);
useEffect(() => {
fetchDashboardData();
}, []);
async function fetchDashboardData() {
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, approvalsRes, themeRes, telemetryRes, versionRes] = await Promise.all([
apiFetch('/api/admin/auth'),
apiFetch('/api/admin/audit?limit=10'),
apiFetch('/api/config'),
apiFetch('/api/admin/config'),
apiFetch('/api/admin/plugins').catch(() => null),
apiFetch('/api/admin/plugin-approvals').catch(() => null),
apiFetch('/api/admin/themes').catch(() => null),
apiFetch('/api/admin/telemetry').catch(() => null),
apiFetch('/api/admin/version').catch(() => null),
]);
if (statusRes.ok) setStatus(await statusRes.json());
if (auditRes.ok) {
const data = await auditRes.json();
setRecentActivity(data.entries || []);
}
let configData: ConfigData | null = null;
if (configRes.ok) {
configData = await configRes.json();
setConfig(configData);
}
if (pluginRes?.ok) {
const plugins = await pluginRes.json();
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
}
if (approvalsRes?.ok) {
const data = await approvalsRes.json();
const entries: { status?: string }[] = Array.isArray(data?.entries) ? data.entries : [];
setPendingApprovals(entries.filter((e) => e.status === 'pending').length);
}
if (themeRes?.ok) {
const themes = await themeRes.json();
setThemeCount(Array.isArray(themes) ? themes.length : 0);
}
if (telemetryRes?.ok) {
const telemetry = await telemetryRes.json();
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
setAccountCounts(telemetry.accountCounts);
}
}
if (versionRes?.ok) {
const data = await versionRes.json();
if (typeof data?.current === 'string') setVersion({ current: data.current, status: data.status ?? null });
}
const w: string[] = [];
if (adminConfigRes.ok) {
const sources = await adminConfigRes.json();
setConfigSources(sources);
const sessionSecret = sources?.sessionSecret;
// Server redacts the raw value for sensitive keys; rely on hasValue,
// which is false when unset or matching a known placeholder default.
if (!sessionSecret?.hasValue) {
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
}
const adminPassword = sources?.adminPassword;
if (adminPassword?.value && adminPassword.source === 'env') {
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
}
}
setWarnings(w);
}
const jmapUrl = config?.jmapServerUrl || '-';
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
const newer = version?.status?.latest && version.status.latest !== version.current ? version.status.latest : null;
const severity = version?.status?.severity ?? 'none';
const versionSub: { text?: string; tone: StatTone } = !version
? { tone: 'muted' }
: newer
? { text: `v${newer} available`, tone: severity === 'security' || severity === 'deprecated' ? 'danger' : 'warning' }
: { text: 'Up to date', tone: 'muted' };
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<div className="space-y-10">
{warnings.length > 0 && (
<div className="space-y-3">
{warnings.map((msg, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">{msg}</p>
</div>
))}
</div>
)}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile
label="Accounts"
value={accountCounts?.total ?? '-'}
sub={accountCounts ? `${accountCounts.active7d} active this week` : undefined}
/>
<StatTile
label="Plugins"
value={pluginCount}
href={adminPath('plugins')}
sub={pendingApprovals > 0 ? `${pendingApprovals} awaiting approval` : undefined}
subTone="warning"
/>
<StatTile label="Themes" value={themeCount} href={adminPath('themes')} />
<StatTile
label="Version"
value={version?.current ?? '-'}
href={adminPath('version')}
sub={versionSub.text}
subTone={versionSub.tone}
/>
</div>
<SettingsSection id="server" title="Server">
<SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
</SettingItem>
<SettingItem label="JMAP server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem>
<SettingItem label="Last admin login">
<span className="text-sm text-foreground">
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
</span>
</SettingItem>
<SettingItem label="Settings sync" description="Synchronize user settings across devices">
<OnOff on={!!config?.settingsSyncEnabled} />
</SettingItem>
<SettingItem label="OAuth" description="OAuth authentication provider">
<OnOff on={!!config?.oauthEnabled} />
</SettingItem>
<SettingItem label="Stalwart integration" description="Stalwart mail server features">
<OnOff on={config?.stalwartFeaturesEnabled !== false} />
</SettingItem>
</SettingsSection>
<SettingsSection id="activity" title="Recent activity">
{recentActivity.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">No activity recorded yet</p>
) : (
<AdminList>
{recentActivity.map((entry, i) => (
<AdminListRow
key={i}
title={<code className="font-mono text-xs">{entry.action}</code>}
description={formatDetail(entry.detail) || undefined}
meta={<>{entry.ip} &middot; {new Date(entry.ts).toLocaleString()}</>}
/>
))}
</AdminList>
)}
<AdminLink href={adminPath('logs')} className="inline-block text-sm font-medium text-primary hover:underline">
All entries
</AdminLink>
</SettingsSection>
</div>
</div>
);
}
type StatTone = 'muted' | 'warning' | 'danger';
interface StatTileProps {
label: string;
value: ReactNode;
sub?: ReactNode;
subTone?: StatTone;
/** Makes the tile a link to that admin page. */
href?: string;
}
const SUB_TONE: Record<StatTone, string> = {
muted: 'text-muted-foreground',
warning: 'text-amber-600 dark:text-amber-400',
danger: 'text-destructive',
};
function StatTile({ label, value, sub, subTone = 'muted', href }: StatTileProps) {
const body = (
<>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 truncate text-2xl font-semibold text-foreground">{value}</div>
{sub && <div className={cn('mt-1 text-xs', SUB_TONE[subTone])}>{sub}</div>}
</>
);
const className = 'block min-w-0 rounded-lg border border-border p-4';
if (href) {
return (
<AdminLink href={href} className={cn(className, 'transition-colors hover:bg-muted/40')}>
{body}
</AdminLink>
);
}
return <div className={className}>{body}</div>;
}
function OnOff({ on }: { on: boolean }) {
return <Badge tone={on ? 'success' : 'muted'}>{on ? 'On' : 'Off'}</Badge>;
}
function formatDetail(detail: Record<string, unknown>): string {
if (!detail || Object.keys(detail).length === 0) return '';
if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
if (detail.reason) return String(detail.reason);
if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
return JSON.stringify(detail).slice(0, 80);
}
@@ -0,0 +1,494 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Puzzle, SwatchBook, Loader2, Eye, EyeOff, Lock, RotateCcw, Trash2, Shield, ShieldAlert, Store, Upload } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPath } from '@/lib/admin/admin-nav';
import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { navigateAdmin } from '@/hooks/use-admin-navigation';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminLink } from '@/components/admin/admin-link';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { inputClass } from '@/components/admin/config-items';
import { ExtensionIcon } from '@/components/admin/extension-icon';
import { cn } from '@/lib/utils';
import { useExtensionIcons } from './use-extension-icons';
export interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
}
/** A plugin or theme as /api/admin/plugins and /api/admin/themes list it. */
export interface ExtensionEntry {
id: string;
name: string;
version: string;
author: string;
description: string;
/** Plugins: hook, ui-extension, ... */
type?: string;
/** Themes: the colour modes it ships. */
variants?: string[];
enabled: boolean;
forceEnabled?: boolean;
permissions?: string[];
installedAt: string;
updatedAt: string;
source?: { kind: 'marketplace'; slug: string; directoryUrl: string } | { kind: 'upload' };
/** Versions kept on this server for a rollback, most recently replaced first. */
previousVersions?: string[];
/** Loaded from the dev plugins directory rather than installed. */
dev?: boolean;
configSchema?: Record<string, ConfigField>;
}
type Kind = 'plugin' | 'theme';
const KIND_LABEL: Record<Kind, { one: string; many: string; endpoint: string }> = {
plugin: { one: 'plugin', many: 'Plugins', endpoint: '/api/admin/plugins' },
theme: { one: 'theme', many: 'Themes', endpoint: '/api/admin/themes' },
};
interface Props {
kind: Kind;
id: string;
}
/**
* One installed plugin or theme: its settings first, then whether users
* can turn it off, what it may do, and the versions kept for a rollback.
*/
export function ExtensionDetailPage({ kind, id }: Props) {
const words = KIND_LABEL[kind];
const listHref = adminPath(kind === 'plugin' ? 'plugins' : 'themes');
const [entry, setEntry] = useState<ExtensionEntry | null>(null);
const [loading, setLoading] = useState(true);
const [config, setConfig] = useState<Record<string, unknown>>({});
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
const [saving, setSaving] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useFlash();
const iconFor = useExtensionIcons();
const load = useCallback(async () => {
const [listRes, configRes] = await Promise.all([
apiFetch(words.endpoint),
kind === 'plugin' ? apiFetch(`/api/admin/plugins/${encodeURIComponent(id)}/config`) : Promise.resolve(null),
]);
if (listRes.ok) {
const entries: ExtensionEntry[] = await listRes.json();
setEntry(entries.find((e) => e.id === id) ?? null);
}
if (configRes?.ok) setConfig(await configRes.json());
}, [id, kind, words.endpoint]);
useEffect(() => {
let cancelled = false;
setLoading(true);
load().finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [load]);
const schema = entry?.configSchema;
const hasSchema = !!schema && Object.keys(schema).length > 0;
const savedValues = useMemo(() => {
const initial: Record<string, string> = {};
if (!schema) return initial;
for (const [key, field] of Object.entries(schema)) {
const stored = config[key];
if (stored !== undefined && stored !== null) initial[key] = String(stored);
else if (field.default !== undefined) initial[key] = String(field.default);
else initial[key] = '';
}
return initial;
}, [schema, config]);
useEffect(() => {
if (!schema) return;
setFormValues(savedValues);
}, [schema, savedValues]);
const dirty = Object.keys(savedValues).some((key) => (formValues[key] ?? '') !== savedValues[key]);
useAdminUnsavedChanges(`${kind}-config`, dirty);
async function saveConfig() {
if (!schema) return;
for (const [key, field] of Object.entries(schema)) {
if (field.required && !formValues[key]?.trim()) {
setMessage({ type: 'error', text: `"${field.label}" is required` });
return;
}
}
setSaving(true);
setMessage(null);
try {
let firstError: string | null = null;
let failed = false;
const noteError = async (res: Response) => {
failed = true;
if (firstError) return;
const data = await res.json().catch(() => null);
if (typeof data?.error === 'string') firstError = data.error;
};
const endpoint = `/api/admin/plugins/${encodeURIComponent(id)}/config`;
for (const [key, field] of Object.entries(schema)) {
const newVal = formValues[key] ?? '';
const oldVal = config[key] !== undefined ? String(config[key]) : '';
if (newVal === oldVal) continue;
if (field.type === 'secret' && !newVal && config[key]) continue;
if (!newVal && !field.required) {
const res = await apiFetch(endpoint, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) setConfig((prev) => { const next = { ...prev }; delete next[key]; return next; });
else await noteError(res);
continue;
}
let value: unknown = newVal;
if (field.type === 'boolean') value = newVal === 'true';
else if (field.type === 'number') value = Number(newVal);
const res = await apiFetch(endpoint, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
if (res.ok) setConfig((prev) => ({ ...prev, [key]: value }));
else await noteError(res);
}
setMessage(failed
? { type: 'error', text: firstError ?? 'Some settings could not be saved' }
: { type: 'success', text: 'Settings saved' });
} catch {
setMessage({ type: 'error', text: 'Could not save the settings' });
} finally {
setSaving(false);
}
}
async function patch(body: Record<string, unknown>, what: string): Promise<boolean> {
setBusy(what);
setMessage(null);
try {
const res = await apiFetch(words.endpoint, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...body }),
});
if (res.ok) return true;
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || `Could not change the ${words.one}` });
return false;
} finally {
setBusy(null);
}
}
async function setEnabled(enabled: boolean) {
if (await patch({ enabled }, 'enabled')) setEntry((prev) => (prev ? { ...prev, enabled } : prev));
}
async function setForced(forceEnabled: boolean) {
const body: Record<string, unknown> = { forceEnabled };
if (forceEnabled) body.enabled = true;
if (await patch(body, 'forced')) {
setEntry((prev) => (prev ? { ...prev, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : prev));
}
}
async function rollback(version: string) {
if (!entry) return;
if (!confirm(`Put v${version} of "${entry.name}" back in place of v${entry.version}? The copy kept on this server is used.`)) return;
setBusy(`rollback:${version}`);
setMessage(null);
try {
const res = await apiFetch(`/api/admin/extensions/${kind}/${encodeURIComponent(id)}/rollback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
setMessage({ type: 'success', text: `"${entry.name}" rolled back to v${version}` });
await load();
} else {
setMessage({ type: 'error', text: data.error || 'Rollback failed' });
}
} finally {
setBusy(null);
}
}
async function remove() {
if (!entry) return;
if (!confirm(`Remove "${entry.name}" for every user? Its kept versions are removed too; this cannot be undone.`)) return;
setBusy('remove');
setMessage(null);
const res = await apiFetch(words.endpoint, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
navigateAdmin(listHref);
return;
}
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Could not remove it' });
setBusy(null);
}
const back = { href: listHref, label: words.many };
const Icon = kind === 'plugin' ? Puzzle : SwatchBook;
if (loading && !entry) {
return (
<div>
<AdminPageHeader back={back} title={<span className="text-muted-foreground">Loading</span>} />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
</div>
);
}
if (!entry) {
return (
<div>
<AdminPageHeader back={back} title={`${words.one[0].toUpperCase()}${words.one.slice(1)} not found`} description={<>Nothing is installed under the id <code className="font-mono text-xs">{id}</code>.</>} />
</div>
);
}
const perms = [...new Set(entry.permissions ?? [])]
.sort((a, b) => Number(HIGH_RISK_PERMISSIONS.has(b)) - Number(HIGH_RISK_PERMISSIONS.has(a)));
const previous = entry.previousVersions ?? [];
return (
<div>
<AdminPageHeader
back={back}
leading={(
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground">
<ExtensionIcon url={iconFor(entry)} fallback={Icon} className={iconFor(entry) ? 'h-full w-full object-cover' : 'h-5 w-5'} />
</div>
)}
title={(
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
{entry.name}
<span className="text-sm font-normal text-muted-foreground">v{entry.version}</span>
{entry.forceEnabled && <Badge tone="warning" icon={<Lock className="h-3 w-3" />}>Forced on</Badge>}
{entry.dev && <Badge tone="info" title="Loaded from the dev plugins directory">Dev</Badge>}
</span>
)}
description={(
<>
by {entry.author || 'unknown'}
{entry.type && <> &middot; {entry.type}</>}
{entry.variants && entry.variants.length > 0 && <> &middot; {entry.variants.join(', ')}</>}
{entry.installedAt && <> &middot; installed {new Date(entry.installedAt).toLocaleDateString()}</>}
</>
)}
actions={(
<>
<label className="flex h-9 items-center gap-2.5 rounded-md border border-input bg-background px-3 text-sm">
<span className={cn('font-medium', entry.enabled ? 'text-foreground' : 'text-muted-foreground')}>{entry.enabled ? 'On' : 'Off'}</span>
<ToggleSwitch checked={entry.enabled} disabled={busy === 'enabled'} onChange={(v) => void setEnabled(v)} ariaLabel={`${entry.name} enabled`} />
</label>
<Button variant="outline" size="sm" className="gap-2 text-destructive hover:text-destructive" disabled={busy === 'remove'} onClick={() => void remove()}>
{busy === 'remove' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Remove
</Button>
</>
)}
/>
{entry.description && <p className="-mt-2 mb-6 text-sm text-foreground">{entry.description}</p>}
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<SettingsSection id="availability" title="Availability" description={`Whether users get this ${words.one} and whether they can turn it off.`}>
<SettingItem
label="Force on for everyone"
description={kind === 'plugin'
? 'Users cannot turn it off in their settings. Turning this on also enables the plugin.'
: 'Users cannot deactivate it. Turning this on also enables the theme.'}
>
<ToggleSwitch checked={!!entry.forceEnabled} disabled={busy === 'forced'} onChange={(v) => void setForced(v)} />
</SettingItem>
<SettingItem label="Source" description={entry.source?.kind === 'marketplace' ? 'Installed from the extension directory; updates show up in the marketplace.' : 'Installed from an uploaded ZIP.'}>
{entry.source?.kind === 'marketplace' ? (
<AdminLink href={adminPath('marketplace', entry.source.slug)} className="inline-flex items-center gap-1.5 text-sm text-foreground hover:underline">
<Store className="h-4 w-4 text-muted-foreground" /> Open in marketplace
</AdminLink>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"><Upload className="h-4 w-4" /> Upload</span>
)}
</SettingItem>
</SettingsSection>
{kind === 'plugin' && (
<SettingsSection id="permissions" title="Permissions" description={perms.length === 0 ? 'This plugin asks for no permissions.' : 'What the plugin may do in each user\'s mailbox. High-risk ones first.'}>
{perms.length > 0 && (
<ul className="divide-y divide-border rounded-lg border border-border">
{perms.map((perm) => {
const risky = HIGH_RISK_PERMISSIONS.has(perm);
return (
<li key={perm} className="flex items-center gap-2.5 px-3 py-2 text-sm">
{risky
? <ShieldAlert className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-label="High risk" />
: <Shield className="h-4 w-4 shrink-0 text-muted-foreground/60" />}
<code className="font-mono text-xs text-foreground">{perm}</code>
{risky && <Badge tone="warning">High risk</Badge>}
</li>
);
})}
</ul>
)}
</SettingsSection>
)}
<SettingsSection id="versions" title="Versions" description={previous.length === 0 ? 'No earlier version is kept on this server.' : 'Earlier versions kept on this server can be put back without the directory.'}>
<SettingItem label="Installed">
<span className="font-mono text-sm text-foreground">v{entry.version}</span>
</SettingItem>
{previous.map((version) => (
<SettingItem key={version} label={`v${version}`} description="Kept on this server">
<Button variant="outline" size="sm" className="gap-1.5" disabled={busy !== null} onClick={() => void rollback(version)}>
{busy === `rollback:${version}` ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
Roll back
</Button>
</SettingItem>
))}
</SettingsSection>
{kind === 'plugin' && <PluginAdminSection pluginId={id} />}
{/* The plugin's own settings come last: they are the long part. */}
{kind === 'plugin' && hasSchema && schema && (
<SettingsSection id="settings" title="Settings" description="What this plugin reads from the server. Saved for every user.">
{Object.entries(schema).map(([key, field]) => (
<SettingItem
key={key}
label={field.required ? `${field.label} *` : field.label}
description={field.description}
>
<ConfigFieldControl
field={field}
value={formValues[key] ?? ''}
stored={config[key] !== undefined && config[key] !== null && config[key] !== ''}
revealed={!!revealSecrets[key]}
onReveal={() => setRevealSecrets((prev) => ({ ...prev, [key]: !prev[key] }))}
onChange={(v) => setFormValues((prev) => ({ ...prev, [key]: v }))}
/>
</SettingItem>
))}
</SettingsSection>
)}
</div>
<AdminSaveBar
dirty={dirty}
saving={saving}
onSave={saveConfig}
onDiscard={() => setFormValues(savedValues)}
saveLabel="Save settings"
/>
</div>
);
}
function ConfigFieldControl({ field, value, stored, revealed, onReveal, onChange }: {
field: ConfigField;
value: string;
stored: boolean;
revealed: boolean;
onReveal: () => void;
onChange: (value: string) => void;
}) {
const width = 'w-full sm:w-64';
if (field.type === 'boolean') {
return (
<ToggleSwitch checked={(value || String(field.default ?? 'false')) === 'true'} onChange={(v) => onChange(v ? 'true' : 'false')} />
);
}
if (field.type === 'select' && field.options) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} aria-label={field.label} className={cn(inputClass, width, 'cursor-pointer')}>
<option value="">Choose</option>
{field.options.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
);
}
if (field.type === 'secret') {
return (
<div className={cn('relative', width)}>
<input
type={revealed ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={stored ? 'Saved. Type to replace' : field.placeholder || ''}
aria-label={field.label}
autoComplete="off"
className={cn(inputClass, 'w-full pe-9 font-mono')}
/>
<button
type="button"
onClick={onReveal}
className="absolute end-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
aria-label={revealed ? 'Hide' : 'Show'}
>
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
);
}
return (
<input
type={field.type === 'number' ? 'number' : 'text'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder || ''}
aria-label={field.label}
autoComplete="off"
className={cn(inputClass, width)}
/>
);
}
/**
* The plugin's own `admin-plugin-page` slot, if it offers one. Sandboxed
* plugins ship a React component under `slots['admin-plugin-page']` and the
* host gives it an iframe here.
*/
function PluginAdminSection({ pluginId }: { pluginId: string }) {
const offers = usePluginSlotOffers('admin-plugin-page');
const offer = offers.find((o) => o.pluginId === pluginId);
if (!offer) return null;
return (
<SettingsSection id="plugin-panel" title="Plugin panel" description="Provided by the plugin itself.">
<div className="overflow-hidden rounded-lg border border-border">
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
</div>
</SettingsSection>
);
}
+167
View File
@@ -0,0 +1,167 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { RefreshCw, Loader2, ScrollText } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { Badge } from '@/components/admin/badge';
import { EmptyState } from '@/components/admin/empty-state';
import { inputClass } from '@/components/admin/config-items';
import type { AuditEntry } from '@/lib/admin/types';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { apiFetch } from '@/lib/browser-navigation';
import { cn } from '@/lib/utils';
const PAGE = adminPageDef('logs');
export function LogsPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [actionFilter, setActionFilter] = useState('');
const limit = 50;
const fetchLogs = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (actionFilter) params.set('action', actionFilter);
const res = await apiFetch(`/api/admin/audit?${params}`);
if (res.ok) {
const data = await res.json();
setEntries(data.entries || []);
setTotal(data.total || 0);
}
setLoading(false);
}, [page, actionFilter]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
const totalPages = Math.max(1, Math.ceil(total / limit));
const initialLoading = loading && entries.length === 0;
return (
<div>
<AdminPageHeader
title={PAGE.label}
description={<>{PAGE.description} &middot; {total} total entries</>}
actions={(
<>
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
aria-label="Filter by action"
className={cn(inputClass, 'pe-8 cursor-pointer')}
>
<option value="">All actions</option>
<option value="admin.login">Login</option>
<option value="admin.logout">Logout</option>
<option value="admin.login_failed">Login Failed</option>
<option value="admin.login_blocked">Login Blocked</option>
<option value="admin.change-password">Password Change</option>
<option value="config.update">Config Update</option>
<option value="config.revert">Config Revert</option>
<option value="policy.update">Policy Update</option>
</select>
<Button type="button" variant="outline" size="sm" className="gap-2" onClick={fetchLogs} disabled={loading}>
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />
Refresh
</Button>
</>
)}
/>
{initialLoading ? (
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
) : entries.length === 0 ? (
<EmptyState
icon={ScrollText}
title="No entries found"
hint={actionFilter ? 'Nothing matches this action filter.' : 'Admin actions show up here as they happen.'}
/>
) : (
<>
<div className="sm:hidden space-y-2">
{entries.map((entry, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<Badge className="font-mono text-xs truncate">{entry.action}</Badge>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</span>
</div>
<div className="text-xs text-foreground break-words">
{formatDetail(entry.detail)}
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{entry.ip}
</div>
</div>
))}
</div>
<div className="hidden sm:block rounded-lg border border-border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-start px-4 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-start px-4 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-start px-4 py-2 text-xs font-medium text-muted-foreground">Details</th>
<th className="text-start px-4 py-2 text-xs font-medium text-muted-foreground whitespace-nowrap">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{entries.map((entry, i) => (
<tr key={i} className="hover:bg-muted/20">
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</td>
<td className="px-4 py-2">
<Badge className="font-mono text-xs">{entry.action}</Badge>
</td>
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
{formatDetail(entry.detail)}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
{entry.ip}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>
Previous
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>
Next
</Button>
</div>
</div>
)}
</div>
);
}
function formatDetail(detail: Record<string, unknown>): string {
if (!detail || Object.keys(detail).length === 0) return '-';
if (detail.reason) return String(detail.reason);
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
if (detail.changes && Array.isArray(detail.changes)) {
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
}
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
return JSON.stringify(detail).slice(0, 100);
}
@@ -0,0 +1,838 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
ArrowUpCircle,
Download,
Loader2,
Puzzle,
SwatchBook,
Star,
Trash2,
Check,
Settings as SettingsIcon,
ExternalLink,
Shield,
ShieldAlert,
ShieldCheck,
AlertTriangle,
FileCode,
ChevronDown,
ChevronUp,
RotateCcw,
Ban,
Info,
Eye,
} from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
import type { MarketplaceListing } from '@/lib/admin/marketplace-directory';
import { adminPath } from '@/lib/admin/admin-nav';
import { cn } from '@/lib/utils';
import { SettingsSection } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminLink } from '@/components/admin/admin-link';
import { AdminButtonLink } from '@/components/admin/button-link';
import { Badge } from '@/components/admin/badge';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { installVersion } from './marketplace';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface VersionRow {
version: string;
channel: 'stable' | 'beta';
publishedAt: string | null;
minAppVersion: string | null;
maxAppVersion: string | null;
permissions: string[];
size: number;
sha256: string;
yanked: { at: string; reason: string | null } | null;
changelog: string | null;
downloads: number;
signed: boolean;
commit: string | null;
}
interface Advisory {
id: string;
affected: string;
fixedIn: string | null;
severity: string;
summary: string;
details: string | null;
withdrawn: boolean;
}
interface HistoryEntry {
version: string;
savedAt: string;
contentHash: string;
}
interface PreviewData {
extension: MarketplaceListing & {
longDescription: string | null;
newestVersion: string | null;
newestMinAppVersion: string | null;
versions: VersionRow[];
screenshots: Array<{ url: string; altText: string | null }>;
themePreviews: Array<{ variant: 'light' | 'dark'; url: string | null; colors: Record<string, string> | null }>;
advisories: Advisory[];
permissionDetails: Array<{ id: string; title: string; body: string; highRisk: boolean }>;
source: { host: string; repo: string | null; subpath: string | null };
createdAt: string | null;
updatedAt: string | null;
};
preview: { version: string; channel: 'stable' | 'beta'; yanked: VersionRow['yanked']; minAppVersion: string | null; maxAppVersion: string | null } | null;
requestedVersionMissing: string | null;
bundle: {
manifest: Record<string, unknown> | null;
source: { name: string; content: string; truncated: boolean } | null;
size: number;
error: string | null;
signature: { status: 'verified' | 'unsigned' | 'invalid'; keyId?: string; reason?: string } | null;
};
installed: boolean;
installedVersion: string | null;
installedSource: { kind: 'marketplace' | 'upload' } | null;
history: HistoryEntry[];
channel: 'stable' | 'beta';
appVersion: string | null;
directoryUrl: string;
}
const RISKY_PERMISSIONS = new Set([
'mail:write',
'mail:delete',
'storage:write',
'network',
'admin',
]);
/** Whether this Bulwark can run a version, given what the directory says it needs. */
function fits(v: { minAppVersion: string | null; maxAppVersion: string | null }): boolean {
if (!isVersionSatisfied(CURRENT_APP_VERSION, v.minAppVersion)) return false;
if (v.maxAppVersion && compareVersions(CURRENT_APP_VERSION, v.maxAppVersion) > 0) return false;
return true;
}
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : '');
const fmtSize = (bytes: number) => (bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${(bytes / 1024).toFixed(1)} KB`);
const BACK = { href: adminPath('marketplace'), label: 'Marketplace' };
const thClass = 'px-4 py-2 text-start text-xs font-medium text-muted-foreground';
const tdClass = 'px-4 py-2 align-top';
const smallButtonClass = 'h-8 gap-1 text-xs';
export function MarketplaceDetailPage({ slug }: { slug: string }) {
const [data, setData] = useState<PreviewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [uninstalling, setUninstalling] = useState(false);
const [message, setMessage] = useFlash();
const [showSource, setShowSource] = useState(false);
const [showManifest, setShowManifest] = useState(false);
// The version being previewed, from the install link (?version=) or a row
// the admin picked. Read from the address bar, like the connector does.
const [selected, setSelected] = useState<string | null | undefined>(undefined);
useEffect(() => {
setSelected(new URLSearchParams(window.location.search).get('version'));
}, []);
const fetchPreview = useCallback(async (version: string | null) => {
setLoading(true);
setError(null);
try {
const query = version ? `?version=${encodeURIComponent(version)}` : '';
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}${query}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body.error || 'Failed to load preview');
return;
}
setData(await res.json());
} catch {
setError('Failed to connect to extension directory');
} finally {
setLoading(false);
}
}, [slug]);
useEffect(() => {
if (selected === undefined) return;
fetchPreview(selected);
}, [fetchPreview, selected]);
function pick(version: string | null) {
const url = new URL(window.location.href);
if (version) url.searchParams.set('version', version);
else url.searchParams.delete('version');
window.history.replaceState(null, '', url.toString());
setSelected(version);
setMessage(null);
}
async function handleInstall(version: string) {
if (!data) return;
const isUpdate = data.installed;
setBusy(`install:${version}`);
setMessage(null);
const result = await installVersion({ slug: data.extension.slug, version, type: data.extension.type });
setBusy(null);
if (!result.ok) {
setMessage({ type: 'error', text: result.error });
return;
}
const warnings = result.warnings.length ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : '';
const signed = result.signature === 'verified' ? ', signed by the directory' : '';
setMessage({
type: 'success',
text: isUpdate
? `"${data.extension.name}" is now v${version}${signed}${warnings}`
: `"${data.extension.name}" installed (v${version})${signed}${warnings}`,
});
fetchPreview(selected ?? null);
}
async function handleRollback(version: string) {
if (!data) return;
if (!confirm(`Put v${version} of "${data.extension.name}" back in place of v${data.installedVersion}? The copy kept on this server is used; the directory is not asked.`)) return;
setBusy(`rollback:${version}`);
setMessage(null);
try {
const res = await apiFetch(`/api/admin/extensions/${data.extension.type}/${encodeURIComponent(data.extension.slug)}/rollback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) {
setMessage({ type: 'success', text: `"${data.extension.name}" rolled back to v${version}` });
fetchPreview(selected ?? null);
} else {
setMessage({ type: 'error', text: body.error || 'Rollback failed' });
}
} catch {
setMessage({ type: 'error', text: 'Rollback failed - network error' });
} finally {
setBusy(null);
}
}
async function handleUninstall() {
if (!data) return;
if (!confirm(`Remove "${data.extension.name}"? Its kept versions are removed too; this cannot be undone.`)) return;
setUninstalling(true);
setMessage(null);
try {
const endpoint = data.extension.type === 'theme'
? '/api/admin/themes'
: '/api/admin/plugins';
const res = await apiFetch(endpoint, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: data.extension.slug }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) {
setMessage({ type: 'success', text: `"${data.extension.name}" removed` });
fetchPreview(selected ?? null);
} else {
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
}
} catch {
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
} finally {
setUninstalling(false);
}
}
if (loading && !data) {
return (
<div>
<AdminPageHeader back={BACK} title={<span className="text-muted-foreground">Loading</span>} />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
</div>
);
}
if (error || !data) {
return (
<div>
<AdminPageHeader
back={BACK}
title={error ? 'Could not load the extension' : 'Extension not found'}
description={error ?? <>Nothing in the directory is listed under <code className="font-mono text-xs">{slug}</code>.</>}
/>
</div>
);
}
const ext = data.extension;
const bundle = data.bundle;
const preview = data.preview;
const isPlugin = ext.type === 'plugin';
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || preview && ext.versions.find((v) => v.version === preview.version)?.permissions || ext.permissions || [];
const permissionText = new Map(ext.permissionDetails.map((p) => [p.id, p]));
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
const previewFits = preview ? fits(preview) : false;
const previewIsInstalled = !!preview && data.installed && data.installedVersion === preview.version;
const previewDirection = preview && data.installed && data.installedVersion
? compareVersions(preview.version, data.installedVersion)
: 0;
const installedRow = data.installedVersion ? ext.versions.find((v) => v.version === data.installedVersion) ?? null : null;
const suspended = ext.status === 'suspended';
const historyVersions = new Set(data.history.map((h) => h.version));
const canInstallPreview = !!preview && !bundle.error && previewFits && !suspended && bundle.signature?.status !== 'invalid';
// Versions no longer in the directory's list but still kept here.
const extraHistory = data.history.filter((h) => !ext.versions.some((v) => v.version === h.version));
const installLabel = !preview
? 'Install'
: previewIsInstalled
? `Reinstall v${preview.version}`
: !data.installed
? `Install v${preview.version}`
: previewDirection > 0
? `Update to v${preview.version}`
: `Install v${preview.version} (older)`;
const updateAvailable = data.installed && !!ext.latest && !!data.installedVersion && compareVersions(ext.latest.version, data.installedVersion) > 0;
return (
<div>
<AdminPageHeader
back={BACK}
leading={(
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground">
{ext.iconUrl ? (
<img src={ext.iconUrl} alt="" className="h-11 w-11 object-cover" loading="lazy" />
) : isPlugin ? (
<Puzzle className="h-5 w-5" />
) : (
<SwatchBook className="h-5 w-5" />
)}
</div>
)}
title={(
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="min-w-0 break-words">{ext.name}</span>
{ext.featured && <Star className="h-4 w-4 shrink-0 fill-warning text-warning" />}
{data.installed && (
<Badge tone="success" icon={<Check className="h-3 w-3" />}>Installed v{data.installedVersion}</Badge>
)}
{updateAvailable && (
<button type="button" onClick={() => pick(ext.latest!.version)} title={`Preview v${ext.latest!.version}`} className="rounded hover:underline">
<Badge tone="info" icon={<ArrowUpCircle className="h-3 w-3" />}>v{ext.latest!.version} available</Badge>
</button>
)}
{ext.deprecated && <Badge>Deprecated</Badge>}
{suspended && <Badge tone="danger" icon={<Ban className="h-3 w-3" />}>Suspended</Badge>}
</span>
)}
description={(
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Badge tone={isPlugin ? 'info' : 'accent'}>{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}</Badge>
{ext.author && (
<span className="inline-flex items-center gap-1">
by {ext.author.displayName}
{ext.author.verified && <ShieldCheck className="h-3.5 w-3.5" aria-label="verified author" />}
</span>
)}
{ext.license && <span>{ext.license}</span>}
<span className="inline-flex items-center gap-1">
<Download className="h-3 w-3" />
{ext.totalDownloads.toLocaleString()}
</span>
{ext.rating.count > 0 && (
<span className="inline-flex items-center gap-1" title={`${ext.rating.average} out of 5 from ${ext.rating.count} rating${ext.rating.count === 1 ? '' : 's'}`}>
<Star className="h-3 w-3 fill-current" />
{ext.rating.average.toFixed(1)} ({ext.rating.count})
</span>
)}
</span>
)}
actions={(
<>
{preview && (
<Button
size="sm"
variant={previewIsInstalled ? 'outline' : 'default'}
className={cn('gap-1.5', previewDirection > 0 && !previewIsInstalled && 'bg-blue-600 text-white hover:bg-blue-700')}
onClick={() => handleInstall(preview.version)}
disabled={!!busy || !canInstallPreview}
title={!previewFits
? `v${preview.version} needs Bulwark ${preview.minAppVersion ?? 'any'}${preview.maxAppVersion ? ` to ${preview.maxAppVersion}` : ' or later'}. You are running v${CURRENT_APP_VERSION}.`
: preview.yanked ? 'This version was withdrawn by its publisher; you will be asked to confirm.' : undefined}
>
{busy === `install:${preview.version}` ? <Loader2 className="h-4 w-4 animate-spin" /> : previewDirection > 0 ? <ArrowUpCircle className="h-4 w-4" /> : <Download className="h-4 w-4" />}
{installLabel}
</Button>
)}
{data.installed && (
<>
<AdminButtonLink href={isPlugin ? adminPath('plugins', ext.slug) : adminPath('themes', ext.slug)}>
<SettingsIcon className="h-4 w-4" />
Manage
</AdminButtonLink>
<Button variant="outline" size="sm" className="gap-1.5 text-destructive hover:text-destructive" onClick={handleUninstall} disabled={uninstalling}>
{uninstalling ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Uninstall
</Button>
</>
)}
<AdminButtonLink external href={ext.directoryUrl} target="_blank" rel="noopener noreferrer" title="Open this extension's page in the directory">
<ExternalLink className="h-4 w-4" />
Directory
</AdminButtonLink>
</>
)}
/>
{ext.bannerUrl && (
<div className="mb-6 overflow-hidden rounded-lg border border-border bg-muted">
<img
src={ext.bannerUrl}
alt=""
className="block w-full max-h-64 object-cover"
loading="lazy"
/>
</div>
)}
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="mb-6 space-y-3 empty:hidden">
{data.requestedVersionMissing && (
<Notice tone="warning" title={`The link asked for v${data.requestedVersionMissing}, which the directory no longer offers`}>
Showing v{preview?.version} instead.
</Notice>
)}
{suspended && (
<Notice tone="danger" title="This extension was suspended by the directory">
It cannot be installed. {data.installed ? 'Consider removing it.' : ''}
</Notice>
)}
{ext.deprecated && (
<Notice tone="info" title="This extension is deprecated">
{ext.deprecated.message || 'Its publisher no longer maintains it.'}
{ext.deprecated.replacement && <> Use <AdminLink href={adminPath('marketplace', ext.deprecated.replacement)} className="underline">{ext.deprecated.replacement}</AdminLink> instead.</>}
</Notice>
)}
{ext.advisories.filter((a) => !a.withdrawn).map((a) => (
<Notice key={a.id} tone="danger" title={`Security advisory ${a.id} (${a.severity})`}>
{a.summary} Affects {a.affected}{a.fixedIn ? `; fixed in v${a.fixedIn}` : '; no fixed version yet'}.
{installedRow && data.installedVersion && a.fixedIn && compareVersions(data.installedVersion, a.fixedIn) < 0 && (
<> <b>Your installed v{data.installedVersion} is affected.</b></>
)}
</Notice>
))}
{installedRow?.yanked && (
<Notice tone="warning" title={`The installed v${installedRow.version} was withdrawn by its publisher`}>
{installedRow.yanked.reason || 'No reason was given.'} Pick another version below.
</Notice>
)}
{preview?.yanked && !previewIsInstalled && (
<Notice tone="warning" title={`v${preview.version} was withdrawn by its publisher`}>
{preview.yanked.reason || 'No reason was given.'} It can still be installed, but you will be asked to confirm.
</Notice>
)}
{preview && !previewFits && (
<Notice tone="warning" title={preview.maxAppVersion && compareVersions(CURRENT_APP_VERSION, preview.maxAppVersion) > 0 ? 'This version is for an older Bulwark' : 'Update Bulwark to install this version'}>
v{preview.version} needs Bulwark {preview.minAppVersion ?? 'any version'}{preview.maxAppVersion ? ` up to ${preview.maxAppVersion}` : ' or later'}. You are running v{CURRENT_APP_VERSION}.
{!preview.yanked && ext.latest && ext.latest.version !== preview.version && (
<> <button type="button" className="underline" onClick={() => pick(ext.latest!.version)}>v{ext.latest.version}</button> runs here.</>
)}
</Notice>
)}
{!ext.latest && !preview && (
<Notice tone="warning" title="No version runs on this Bulwark">
{ext.newestVersion ? `The newest, v${ext.newestVersion}, needs Bulwark ${ext.newestMinAppVersion ?? 'a newer version'}.` : 'Nothing has been published yet.'}
</Notice>
)}
{bundle.error && (
<Notice tone="warning" title="Could not preview bundle">{bundle.error}</Notice>
)}
{bundle.signature?.status === 'invalid' && (
<Notice tone="danger" title="The directory's signature on this bundle does not check out">
{bundle.signature.reason}. It will not be installed.
</Notice>
)}
</div>
<div className="space-y-10">
<SettingsSection id="about" title="About">
<div className="space-y-3">
<p className="text-sm text-foreground">{ext.description}</p>
{ext.longDescription && ext.longDescription !== ext.description && (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{ext.longDescription}</p>
)}
{ext.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{ext.tags.map(tag => <Badge key={tag}>{tag}</Badge>)}
</div>
)}
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3 text-xs text-muted-foreground">
{preview && <span>Previewing v{preview.version}{preview.channel === 'beta' ? ' (beta)' : ''}</span>}
{bundle.size > 0 && <span>Bundle: {fmtSize(bundle.size)}</span>}
{bundle.signature?.status === 'verified' && (
<span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-400" title={`Signed by the directory, key ${bundle.signature.keyId}`}>
<ShieldCheck className="h-3 w-3" /> Signed by the directory
</span>
)}
{bundle.signature?.status === 'unsigned' && (
<span className="inline-flex items-center gap-1" title="No directory root key is configured, so the signature is not checked">
<Info className="h-3 w-3" /> Signature not checked
</span>
)}
{ext.source.repo && (
<a
href={`https://${ext.source.host}/${ext.source.repo}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:text-foreground"
>
<ExternalLink className="h-3 w-3" />
{ext.source.host === 'github.com' ? '' : `${ext.source.host}/`}{ext.source.repo}
</a>
)}
</div>
</div>
</SettingsSection>
<SettingsSection
id="versions"
title="Versions"
description="Every published version. Versions this server still has a copy of can be put back without the directory."
>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className={thClass}>Version</th>
<th className={thClass}>Released</th>
<th className={thClass}>Needs Bulwark</th>
<th className={thClass}>Size</th>
<th className={thClass}>Status</th>
<th className={cn(thClass, 'text-end')}><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{ext.versions.map((v) => {
const isInstalled = data.installed && data.installedVersion === v.version;
const isPreview = preview?.version === v.version;
const kept = historyVersions.has(v.version);
const runs = fits(v);
const key = `install:${v.version}`;
return (
<tr key={v.version} className={isPreview ? 'bg-muted/40' : undefined}>
<td className={tdClass}>
<div className="flex flex-wrap items-center gap-1.5">
<code className="font-mono text-xs text-foreground">v{v.version}</code>
{v.channel === 'beta' && <Badge tone="info">beta</Badge>}
{v.signed && <ShieldCheck className="h-3 w-3 text-muted-foreground" aria-label="signed" />}
</div>
{v.changelog && <p className="mt-0.5 line-clamp-2 max-w-xs whitespace-pre-wrap text-xs text-muted-foreground">{v.changelog}</p>}
</td>
<td className={cn(tdClass, 'whitespace-nowrap text-xs text-muted-foreground')}>{fmtDate(v.publishedAt)}</td>
<td className={cn(tdClass, 'whitespace-nowrap text-xs text-muted-foreground')}>
{v.minAppVersion ?? 'any'}{v.maxAppVersion ? ` to ${v.maxAppVersion}` : v.minAppVersion ? '+' : ''}
</td>
<td className={cn(tdClass, 'whitespace-nowrap text-xs text-muted-foreground')}>{fmtSize(v.size)}</td>
<td className={cn(tdClass, 'text-xs')}>
<div className="flex flex-col items-start gap-1">
{isInstalled && <span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-400"><Check className="h-3 w-3" /> Installed</span>}
{v.yanked && <span className="inline-flex items-center gap-1 text-amber-800 dark:text-amber-300" title={v.yanked.reason ?? undefined}><AlertTriangle className="h-3 w-3" /> Withdrawn</span>}
{!runs && <span className="text-muted-foreground">Not for v{CURRENT_APP_VERSION}</span>}
{kept && !isInstalled && <span className="inline-flex items-center gap-1 text-muted-foreground"><RotateCcw className="h-3 w-3" /> Kept here</span>}
</div>
</td>
<td className={tdClass}>
<div className="flex flex-wrap items-center justify-end gap-1.5">
{!isPreview && (
<Button variant="ghost" size="sm" className={cn(smallButtonClass, 'text-muted-foreground hover:text-foreground')} onClick={() => pick(v.version)} title="Preview this version">
<Eye className="h-3 w-3" /> Preview
</Button>
)}
{kept && !isInstalled && data.installed && (
<Button variant="outline" size="sm" className={smallButtonClass} onClick={() => handleRollback(v.version)} disabled={!!busy}>
{busy === `rollback:${v.version}` ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
Roll back
</Button>
)}
{!isInstalled && !kept && runs && !suspended && (
<Button
variant="outline"
size="sm"
className={smallButtonClass}
onClick={() => handleInstall(v.version)}
disabled={!!busy}
title={v.yanked ? 'Withdrawn; you will be asked to confirm' : undefined}
>
{busy === key ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
{v.yanked ? 'Install anyway' : 'Install'}
</Button>
)}
</div>
</td>
</tr>
);
})}
{extraHistory.map((h) => (
<tr key={`kept-${h.version}`}>
<td className={tdClass}>
<code className="font-mono text-xs text-foreground">v{h.version}</code>
<p className="mt-0.5 text-xs text-muted-foreground">No longer in the directory</p>
</td>
<td className={cn(tdClass, 'whitespace-nowrap text-xs text-muted-foreground')}>replaced {fmtDate(h.savedAt)}</td>
<td className={cn(tdClass, 'text-xs text-muted-foreground')}>–</td>
<td className={cn(tdClass, 'text-xs text-muted-foreground')}>–</td>
<td className={cn(tdClass, 'text-xs')}>
<span className="inline-flex items-center gap-1 text-muted-foreground"><RotateCcw className="h-3 w-3" /> Kept here</span>
</td>
<td className={tdClass}>
<div className="flex justify-end">
{data.installed && (
<Button variant="outline" size="sm" className={smallButtonClass} onClick={() => handleRollback(h.version)} disabled={!!busy}>
{busy === `rollback:${h.version}` ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
Roll back
</Button>
)}
</div>
</td>
</tr>
))}
{ext.versions.length === 0 && extraHistory.length === 0 && (
<tr><td colSpan={6} className="px-4 py-6 text-center text-sm text-muted-foreground">No versions published.</td></tr>
)}
</tbody>
</table>
</div>
</SettingsSection>
{ext.screenshots.length > 0 && (
<SettingsSection id="screenshots" title="Screenshots">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{ext.screenshots.map((s, i) => (
<img
key={i}
src={s.url}
alt={s.altText || `Screenshot ${i + 1}`}
className="w-full rounded-md border border-border bg-muted"
loading="lazy"
/>
))}
</div>
</SettingsSection>
)}
{!isPlugin && ext.themePreviews.length > 0 && (
<SettingsSection id="theme-preview" title="Theme preview">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{ext.themePreviews.map(preview => (
preview.url
? (
<figure key={preview.variant} className="overflow-hidden rounded-md border border-border">
<img src={preview.url} alt={`${ext.name} in ${preview.variant} mode`} className="block w-full bg-muted" loading="lazy" />
<figcaption className="border-t border-border bg-muted/30 px-3 py-2 text-xs font-medium capitalize text-muted-foreground">{preview.variant}</figcaption>
</figure>
)
: <ThemeColorSwatch key={preview.variant} preview={preview} />
))}
</div>
</SettingsSection>
)}
{isPlugin && (
<SettingsSection
id="permissions"
title="Permissions"
description={manifestPerms.length === 0
? 'This plugin requests no permissions.'
: `What ${preview ? `v${preview.version}` : 'the plugin'} may do in each user's mailbox.`}
>
{manifestPerms.length > 0 && (
<ul className="space-y-1.5">
{manifestPerms.map(perm => {
const text = permissionText.get(perm);
const risky = text?.highRisk || RISKY_PERMISSIONS.has(perm);
return (
<li
key={perm}
className={cn(
'flex items-start gap-2 rounded-md px-2 py-1.5 text-sm',
risky
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
: 'bg-muted/50 text-foreground',
)}
>
{risky ? <ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : <Shield className="mt-0.5 h-3.5 w-3.5 shrink-0 opacity-50" />}
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{text && <span className="font-medium">{text.title}</span>}
<code className="font-mono text-xs opacity-80">{perm}</code>
</div>
{text?.body && <p className="mt-0.5 text-xs opacity-90">{text.body}</p>}
</div>
</li>
);
})}
</ul>
)}
{frameOrigins.length > 0 && (
<div className="border-t border-border pt-3">
<h4 className="text-xs font-medium text-foreground">Iframe origins</h4>
<p className="mt-0.5 text-xs text-muted-foreground">
The plugin will be allowed to embed content from these origins.
</p>
<ul className="mt-2 space-y-1">
{frameOrigins.map(origin => (
<li key={origin} className="rounded bg-muted/50 px-2 py-1 font-mono text-xs text-foreground">
{origin}
</li>
))}
</ul>
</div>
)}
</SettingsSection>
)}
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
<SettingsSection id="user-settings" title="User settings" description="Settings users will be able to configure after install.">
<ul className="divide-y divide-border">
{Object.entries(settingsSchema).map(([key, field]) => (
<li key={key} className="py-2">
<div className="flex items-center gap-2">
<code className="font-mono text-xs text-foreground">{key}</code>
<Badge>{field.type}</Badge>
</div>
<div className="mt-0.5 text-sm text-foreground">{field.label}</div>
{field.description && (
<div className="mt-0.5 text-xs text-muted-foreground">{field.description}</div>
)}
</li>
))}
</ul>
</SettingsSection>
)}
{bundle.manifest && (
<SettingsSection id="manifest" title="Manifest">
<div className="rounded-lg border border-border">
<button
type="button"
onClick={() => setShowManifest(v => !v)}
aria-expanded={showManifest}
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/30"
>
<span className="flex items-center gap-2">
<FileCode className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">manifest.json</span>
</span>
{showManifest ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</button>
{showManifest && (
<pre className="overflow-x-auto whitespace-pre px-4 pb-4 font-mono text-xs text-foreground">
{JSON.stringify(bundle.manifest, null, 2)}
</pre>
)}
</div>
</SettingsSection>
)}
{bundle.source && (
<SettingsSection id="source" title="Source">
<div className="rounded-lg border border-border">
<button
type="button"
onClick={() => setShowSource(v => !v)}
aria-expanded={showSource}
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-start transition-colors hover:bg-muted/30"
>
<span className="flex items-center gap-2">
<FileCode className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{bundle.source.name}</span>
{bundle.source.truncated && <Badge tone="warning">truncated</Badge>}
</span>
{showSource ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
</button>
{showSource && (
<pre className="max-h-[600px] overflow-x-auto overflow-y-auto whitespace-pre px-4 pb-4 font-mono text-xs text-foreground">
{bundle.source.content}
</pre>
)}
</div>
</SettingsSection>
)}
</div>
</div>
);
}
const NOTICE_TONES = {
danger: 'border-destructive/30 bg-destructive/10 text-destructive',
warning: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300',
info: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-300',
} as const;
function Notice({ tone, title, children }: { tone: 'info' | 'warning' | 'danger'; title: string; children?: React.ReactNode }) {
const Icon = tone === 'danger' ? ShieldAlert : tone === 'warning' ? AlertTriangle : Info;
return (
<div className={cn('flex items-start gap-2.5 rounded-md border px-3 py-2.5 text-sm', NOTICE_TONES[tone])}>
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">{title}</p>
{children && <p className="mt-0.5 text-xs opacity-90">{children}</p>}
</div>
</div>
);
}
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | null } }) {
const colors = preview.colors || {};
const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff');
const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a');
const accent = colors.primary || colors.accent || '#7c5cff';
const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5');
const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5');
return (
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
{preview.variant}
</div>
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
<div className="flex items-center gap-2">
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
</div>
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
<span style={{ color: fg }}>Card surface</span>
</div>
<div className="flex flex-wrap gap-1">
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
<span
key={key}
title={`${key}: ${value}`}
className="inline-block w-4 h-4 rounded border"
style={{ background: value, borderColor: border }}
/>
))}
</div>
</div>
</div>
);
}
@@ -1,7 +1,6 @@
'use client';
import { useEffect, useState, useCallback, useMemo } from 'react';
import Link from 'next/link';
import {
Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye, AlertTriangle,
ArrowUpCircle, ExternalLink, RefreshCw, Ban, ShieldAlert, Info, Upload,
@@ -9,6 +8,18 @@ import {
import { apiFetch } from '@/lib/browser-navigation';
import { compareVersions } from '@/lib/version-compare';
import type { MarketplaceListing } from '@/lib/admin/marketplace-directory';
import { adminPageDef, adminPath } from '@/lib/admin/admin-nav';
import { cn } from '@/lib/utils';
import { SettingsSection } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminLink } from '@/components/admin/admin-link';
import { AdminButtonLink } from '@/components/admin/button-link';
import { AdminList, AdminListRow } from '@/components/admin/list';
import { Badge } from '@/components/admin/badge';
import { EmptyState } from '@/components/admin/empty-state';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { ConfigNote, inputClass } from '@/components/admin/config-items';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
@@ -52,8 +63,17 @@ const SORTS: Array<{ value: Sort; label: string }> = [
{ value: 'name', label: 'Name' },
];
const PAGE = adminPageDef('marketplace');
export const detailHref = (slug: string, version?: string | null) =>
`/admin/marketplace/${encodeURIComponent(slug)}${version ? `?version=${encodeURIComponent(version)}` : ''}`;
`${adminPath('marketplace', slug)}${version ? `?version=${encodeURIComponent(version)}` : ''}`;
/** The segmented Browse / Installed and All / Plugins / Themes switches. */
const pillGroupClass = 'flex items-center gap-0.5 rounded-md bg-muted p-0.5 self-start';
const pillClass = (active: boolean) => cn(
'h-8 rounded px-3 text-sm font-medium transition-colors',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
);
/**
* Installs one version of an extension. A version the publisher withdrew,
@@ -92,7 +112,7 @@ export async function installVersion(
}
}
export function MarketplaceTab() {
export function MarketplacePage() {
const [extensions, setExtensions] = useState<MarketplaceListing[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
@@ -105,7 +125,7 @@ export function MarketplaceTab() {
const [directoryUrl, setDirectoryUrl] = useState<string | null>(null);
const [channel, setChannel] = useState<'stable' | 'beta'>('stable');
const [installing, setInstalling] = useState<Set<string>>(new Set());
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [message, setMessage] = useFlash();
const [error, setError] = useState<string | null>(null);
const [updates, setUpdates] = useState<UpdatesResult | null>(null);
const [updatesLoading, setUpdatesLoading] = useState(true);
@@ -217,195 +237,161 @@ export function MarketplaceTab() {
: 0;
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Marketplace</h1>
<p className="text-sm text-muted-foreground mt-1">
Plugins and themes from the extension directory, installed for every user of this Bulwark
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{channel === 'beta' && (
<span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400" title="Pre-release versions are offered (extensionDirectoryChannel)">
Beta channel
</span>
)}
{directoryUrl && (
<a
href={directoryUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted"
>
<ExternalLink className="w-3.5 h-3.5" />
Open directory
</a>
)}
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{updates && noticeCount > 0 && (
<UpdatesStrip
updates={updates}
installing={installing}
onUpdate={(u) => {
const entry = updates.installed.find((e) => e.id === u.slug);
if (entry) install({ slug: u.slug, name: entry.name, type: entry.kind }, u.version, true);
}}
onUpdateAll={updateAll}
/>
)}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start">
{([['browse', 'Browse'], ['installed', `Installed${installedCount ? ` (${installedCount})` : ''}`]] as const).map(([v, label]) => (
<button
key={v}
onClick={() => setView(v)}
className={`h-8 px-3 rounded text-sm font-medium transition-colors ${view === v ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
{label}
</button>
))}
</div>
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder={view === 'browse' ? 'Search extensions...' : 'Filter installed...'}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
/>
</div>
{view === 'browse' && (
<div>
<AdminPageHeader
title={PAGE.label}
description={PAGE.description}
actions={(
<>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
{(['all', 'plugin', 'theme'] as const).map((t) => (
<button
key={t}
onClick={() => { setTypeFilter(t); setPage(1); }}
className={`h-8 px-3 rounded text-sm font-medium transition-colors ${
typeFilter === t
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
{channel === 'beta' && (
<Badge tone="info" title="Pre-release versions are offered (extensionDirectoryChannel)">Beta channel</Badge>
)}
{directoryUrl && (
<AdminButtonLink external href={directoryUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
Open directory
</AdminButtonLink>
)}
</>
)}
/>
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
{updates && noticeCount > 0 && (
<UpdatesSection
updates={updates}
installing={installing}
onUpdate={(u) => {
const entry = updates.installed.find((e) => e.id === u.slug);
if (entry) install({ slug: u.slug, name: entry.name, type: entry.kind }, u.version, true);
}}
onUpdateAll={updateAll}
/>
)}
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className={pillGroupClass}>
{([['browse', 'Browse'], ['installed', `Installed${installedCount ? ` (${installedCount})` : ''}`]] as const).map(([v, label]) => (
<button key={v} type="button" onClick={() => setView(v)} aria-pressed={view === v} className={pillClass(view === v)}>
{label}
</button>
))}
</div>
<select
value={sort}
onChange={(e) => { setSort(e.target.value as Sort); setPage(1); }}
aria-label="Sort"
className="h-9 px-2 rounded-md border border-input bg-background text-sm text-foreground self-start sm:self-auto"
>
{SORTS.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</>
)}
</div>
{view === 'installed' ? (
<InstalledView
updates={updates}
loading={updatesLoading}
filter={query}
installing={installing}
onUpdate={(entry, version) => install({ slug: entry.id, name: entry.name, type: entry.kind }, version, true)}
/>
) : (
<>
{error && (
<div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">{error}</p>
<p className="text-xs text-muted-foreground mt-1">
The directory needs its v2 API (extensions.bulwarkmail.org has it; a self-hosted one must be from October 2026 or later)
</p>
<button
onClick={fetchExtensions}
className="mt-4 inline-flex items-center gap-2 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
>
Retry
</button>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder={view === 'browse' ? 'Search extensions...' : 'Filter installed...'}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
aria-label={view === 'browse' ? 'Search extensions' : 'Filter installed'}
className={cn(inputClass, 'w-full ps-9')}
/>
</div>
)}
{loading && !error && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
</div>
)}
{!loading && !error && extensions.length === 0 && (
<div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No extensions found</p>
{query && (
<p className="text-xs text-muted-foreground mt-1">
Try a different search term
</p>
)}
</div>
)}
{!loading && !error && extensions.length > 0 && (
<>
<div className="text-xs text-muted-foreground">
{total} extension{total !== 1 ? 's' : ''} found
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{extensions.map((ext) => (
<ExtensionCard
key={ext.slug}
extension={ext}
installing={installing.has(ext.slug)}
onInstall={() => ext.latestVersion && install(ext, ext.latestVersion, ext.installed)}
/>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
{view === 'browse' && (
<>
<div className={cn(pillGroupClass, 'sm:self-auto')}>
{(['all', 'plugin', 'theme'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => { setTypeFilter(t); setPage(1); }}
aria-pressed={typeFilter === t}
className={pillClass(typeFilter === t)}
>
{t === 'all' ? 'All' : t === 'plugin' ? 'Plugins' : 'Themes'}
</button>
))}
</div>
<select
value={sort}
onChange={(e) => { setSort(e.target.value as Sort); setPage(1); }}
aria-label="Sort"
className={cn(inputClass, 'cursor-pointer self-start pe-8 sm:self-auto')}
>
{SORTS.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</>
)}
</div>
{view === 'installed' ? (
<InstalledView
updates={updates}
loading={updatesLoading}
filter={query}
installing={installing}
onUpdate={(entry, version) => install({ slug: entry.id, name: entry.name, type: entry.kind }, version, true)}
/>
) : (
<>
{error && (
<EmptyState
icon={Store}
title={error}
hint="The directory needs its v2 API (extensions.bulwarkmail.org has it; a self-hosted one must be from October 2026 or later)"
action={<Button size="sm" onClick={fetchExtensions}>Retry</Button>}
/>
)}
{loading && !error && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
)}
{!loading && !error && extensions.length === 0 && (
<EmptyState
icon={Store}
title="No extensions found"
hint={query ? 'Try a different search term' : undefined}
/>
)}
{!loading && !error && extensions.length > 0 && (
<>
<div className="text-xs text-muted-foreground">
{total} extension{total !== 1 ? 's' : ''} found
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{extensions.map((ext) => (
<ExtensionCard
key={ext.slug}
extension={ext}
installing={installing.has(ext.slug)}
onInstall={() => ext.latestVersion && install(ext, ext.latestVersion, ext.installed)}
/>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button variant="outline" size="sm" onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>
Next
</Button>
</div>
)}
</>
)}
</>
)}
</>
)}
</div>
</div>
</div>
);
}
/** Updates and warnings for what is installed, from one directory request. */
function UpdatesStrip({
function UpdatesSection({
updates,
installing,
onUpdate,
@@ -419,59 +405,57 @@ function UpdatesStrip({
const names = new Map(updates.installed.map((e) => [e.id, e.name]));
const name = (slug: string) => names.get(slug) ?? slug;
const busy = installing.size > 0;
const hasNotices = updates.advisories.length > 0 || updates.yanked.length > 0 || updates.suspended.length > 0 || updates.deprecated.length > 0;
const title = updates.updates.length > 0
? `${updates.updates.length} update${updates.updates.length === 1 ? '' : 's'} available`
: 'Notices';
return (
<div className="border border-border rounded-lg divide-y divide-border">
<SettingsSection id="updates" title={title}>
{updates.updates.length > 0 && (
<div className="p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<ArrowUpCircle className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<h2 className="text-sm font-medium text-foreground">
{updates.updates.length} update{updates.updates.length === 1 ? '' : 's'} available
</h2>
</div>
{updates.updates.length > 1 && (
<button
onClick={onUpdateAll}
disabled={busy}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50"
>
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
<div className="space-y-3">
{updates.updates.length > 1 && (
<div className="flex justify-end">
<Button size="sm" className="gap-1.5" onClick={onUpdateAll} disabled={busy}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
Update all
</button>
)}
</div>
<ul className="mt-3 space-y-2">
</Button>
</div>
)}
<ul className="divide-y divide-border rounded-lg border border-border">
{updates.updates.map((u) => (
<li key={u.slug} className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<li key={u.slug} className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 text-sm">
<Link href={detailHref(u.slug, u.version)} className="font-medium text-foreground hover:underline">{name(u.slug)}</Link>
<span className="text-muted-foreground"> v{u.currentVersion} → v{u.version}</span>
{u.channel === 'beta' && <span className="ms-2 text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400">beta</span>}
{u.changelog && <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2 whitespace-pre-wrap">{u.changelog}</p>}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<AdminLink href={detailHref(u.slug, u.version)} className="font-medium text-foreground hover:underline">{name(u.slug)}</AdminLink>
<span className="text-muted-foreground">v{u.currentVersion} → v{u.version}</span>
{u.channel === 'beta' && <Badge tone="info">beta</Badge>}
</div>
{u.changelog && <p className="mt-0.5 line-clamp-2 whitespace-pre-wrap text-xs text-muted-foreground">{u.changelog}</p>}
</div>
<button
<Button
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1.5 self-start text-xs"
onClick={() => onUpdate(u)}
disabled={installing.has(u.slug)}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50 shrink-0 self-start"
>
{installing.has(u.slug) ? <Loader2 className="w-3 h-3 animate-spin" /> : <ArrowUpCircle className="w-3 h-3" />}
{installing.has(u.slug) ? <Loader2 className="h-3 w-3 animate-spin" /> : <ArrowUpCircle className="h-3 w-3" />}
Update
</button>
</Button>
</li>
))}
</ul>
</div>
)}
{(updates.advisories.length > 0 || updates.yanked.length > 0 || updates.suspended.length > 0 || updates.deprecated.length > 0) && (
<ul className="p-4 space-y-2 text-sm">
{hasNotices && (
<ul className="space-y-2 text-sm">
{updates.advisories.map((a) => (
<li key={a.id} className="flex items-start gap-2 text-destructive">
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<span>
<Link href={detailHref(a.extension)} className="font-medium hover:underline">{name(a.extension)}</Link>
<AdminLink href={detailHref(a.extension)} className="font-medium hover:underline">{name(a.extension)}</AdminLink>
{' '}has a security advisory ({a.severity}): {a.summary}
{a.fixedIn ? ` Fixed in v${a.fixedIn}.` : ' No fixed version yet.'}
</span>
@@ -479,36 +463,36 @@ function UpdatesStrip({
))}
{updates.suspended.map((slug) => (
<li key={slug} className="flex items-start gap-2 text-destructive">
<Ban className="w-4 h-4 shrink-0 mt-0.5" />
<Ban className="mt-0.5 h-4 w-4 shrink-0" />
<span>
<Link href={detailHref(slug)} className="font-medium hover:underline">{name(slug)}</Link>
<AdminLink href={detailHref(slug)} className="font-medium hover:underline">{name(slug)}</AdminLink>
{' '}was suspended by the directory. Consider removing it.
</span>
</li>
))}
{updates.yanked.map((y) => (
<li key={`${y.slug}@${y.version}`} className="flex items-start gap-2 text-amber-800 dark:text-amber-300">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
The installed v{y.version} of{' '}
<Link href={detailHref(y.slug)} className="font-medium hover:underline">{name(y.slug)}</Link>
<AdminLink href={detailHref(y.slug)} className="font-medium hover:underline">{name(y.slug)}</AdminLink>
{' '}was withdrawn by its publisher{y.reason ? `: ${y.reason}` : '.'}
</span>
</li>
))}
{updates.deprecated.map((d) => (
<li key={d.slug} className="flex items-start gap-2 text-muted-foreground">
<Info className="w-4 h-4 shrink-0 mt-0.5" />
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>
<Link href={detailHref(d.slug)} className="font-medium text-foreground hover:underline">{name(d.slug)}</Link>
<AdminLink href={detailHref(d.slug)} className="font-medium text-foreground hover:underline">{name(d.slug)}</AdminLink>
{' '}is deprecated{d.message ? `: ${d.message}` : '.'}
{d.replacement && <> Use <Link href={detailHref(d.replacement)} className="underline">{d.replacement}</Link> instead.</>}
{d.replacement && <> Use <AdminLink href={detailHref(d.replacement)} className="underline">{d.replacement}</AdminLink> instead.</>}
</span>
</li>
))}
</ul>
)}
</div>
</SettingsSection>
);
}
@@ -539,73 +523,64 @@ function InstalledView({
if (loading && !updates) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
);
}
if (rows.length === 0) {
return (
<div className="border border-border rounded-lg p-12 text-center">
<Store className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">{filter ? 'Nothing installed matches' : 'Nothing installed yet'}</p>
</div>
<EmptyState icon={Store} title={filter ? 'Nothing installed matches' : 'Nothing installed yet'} />
);
}
return (
<div className="border border-border rounded-lg divide-y divide-border">
<div className="space-y-3">
{updates?.error && (
<div className="px-4 py-2 text-xs text-amber-800 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/30">
Could not check for updates: {updates.error}
</div>
<ConfigNote tone="warning">Could not check for updates: {updates.error}</ConfigNote>
)}
{rows.map(({ entry, newer, yanked, advisory }) => {
const fromMarketplace = entry.source?.kind === 'marketplace' || entry.source === null;
const manageHref = entry.kind === 'plugin' ? `/admin/plugins/${encodeURIComponent(entry.id)}` : '/admin/themes';
return (
<div key={`${entry.kind}:${entry.id}`} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{entry.kind === 'plugin'
? <Puzzle className="w-4 h-4 text-muted-foreground" />
: <SwatchBook className="w-4 h-4 text-muted-foreground" />}
{fromMarketplace ? (
<Link href={detailHref(entry.id)} className="text-sm font-medium text-foreground hover:underline">{entry.name}</Link>
) : (
<span className="text-sm font-medium text-foreground">{entry.name}</span>
)}
<span className="text-xs text-muted-foreground">v{entry.version}</span>
{!entry.enabled && <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">Disabled</span>}
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground inline-flex items-center gap-1">
{entry.source?.kind === 'upload' ? <><Upload className="w-3 h-3" /> Uploaded</> : <><Store className="w-3 h-3" /> Marketplace</>}
</span>
{advisory && <span className="text-[10px] px-1.5 py-0.5 rounded bg-destructive/10 text-destructive inline-flex items-center gap-1"><ShieldAlert className="w-3 h-3" /> Advisory</span>}
{yanked && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 inline-flex items-center gap-1"><AlertTriangle className="w-3 h-3" /> Withdrawn</span>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{newer && (
<button
onClick={() => onUpdate(entry, newer)}
disabled={installing.has(entry.id)}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50"
>
{installing.has(entry.id) ? <Loader2 className="w-3 h-3 animate-spin" /> : <ArrowUpCircle className="w-3 h-3" />}
Update to v{newer}
</button>
<AdminList>
{rows.map(({ entry, newer, yanked, advisory }) => {
const fromMarketplace = entry.source?.kind === 'marketplace' || entry.source === null;
const manageHref = entry.kind === 'plugin' ? adminPath('plugins', entry.id) : adminPath('themes', entry.id);
return (
<AdminListRow
key={`${entry.kind}:${entry.id}`}
href={fromMarketplace ? detailHref(entry.id) : undefined}
icon={entry.kind === 'plugin' ? <Puzzle /> : <SwatchBook />}
title={entry.name}
titleExtra={(
<>
<span className="text-xs text-muted-foreground">v{entry.version}</span>
{!entry.enabled && <Badge>Off</Badge>}
{entry.source?.kind === 'upload'
? <Badge icon={<Upload className="h-3 w-3" />}>Uploaded</Badge>
: <Badge icon={<Store className="h-3 w-3" />}>Marketplace</Badge>}
{advisory && <Badge tone="danger" icon={<ShieldAlert className="h-3 w-3" />}>Advisory</Badge>}
{yanked && <Badge tone="warning" icon={<AlertTriangle className="h-3 w-3" />}>Withdrawn</Badge>}
</>
)}
<Link
href={manageHref}
className="inline-flex items-center h-7 px-2.5 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted"
>
Manage
</Link>
</div>
</div>
);
})}
control={(
<>
{newer && (
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => onUpdate(entry, newer)}
disabled={installing.has(entry.id)}
>
{installing.has(entry.id) ? <Loader2 className="h-3 w-3 animate-spin" /> : <ArrowUpCircle className="h-3 w-3" />}
Update to v{newer}
</Button>
)}
<AdminButtonLink href={manageHref} className="h-8 text-xs">Manage</AdminButtonLink>
</>
)}
/>
);
})}
</AdminList>
</div>
);
}
@@ -641,16 +616,16 @@ function ExtensionCard({
return (
<div className="group relative border border-border rounded-lg overflow-hidden hover:border-ring/30 transition-colors">
{extension.bannerUrl && (
<Link href={previewHref} className="block focus:outline-none">
<AdminLink href={previewHref} className="block focus:outline-none">
<img
src={extension.bannerUrl}
alt=""
className="block h-24 w-full object-cover border-b border-border"
loading="lazy"
/>
</Link>
</AdminLink>
)}
<Link href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
<AdminLink href={previewHref} className="block p-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 rounded-lg">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{extension.iconUrl ? (
@@ -676,19 +651,11 @@ function ExtensionCard({
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
<Badge tone={isPlugin ? 'info' : 'accent'}>
{isPlugin ? (extension.pluginType || 'plugin') : 'theme'}
</span>
{offered?.channel === 'beta' && (
<span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400">beta</span>
)}
{extension.deprecated && (
<span className="text-[10px] px-1.5 py-0.5 rounded font-medium bg-muted text-muted-foreground">deprecated</span>
)}
</Badge>
{offered?.channel === 'beta' && <Badge tone="info">beta</Badge>}
{extension.deprecated && <Badge>deprecated</Badge>}
{extension.author && (
<span className="text-xs text-muted-foreground truncate">
by {extension.author.displayName}
@@ -705,9 +672,7 @@ function ExtensionCard({
{extension.tags && extension.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{extension.tags.slice(0, 3).map(tag => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{tag}
</span>
<Badge key={tag}>{tag}</Badge>
))}
</div>
)}
@@ -730,15 +695,16 @@ function ExtensionCard({
Details
</span>
</div>
</Link>
</AdminLink>
<div className="px-4 pb-4 -mt-1 flex items-center gap-2 flex-wrap">
{extension.installed && updateAvailable && offered ? (
<button
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
title={`Update from v${extension.installedVersion} to v${offered.version}`}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
@@ -746,29 +712,30 @@ function ExtensionCard({
<ArrowUpCircle className="w-3 h-3" />
)}
Update to v{offered.version}
</button>
</Button>
) : extension.installed ? (
<span
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-xs font-medium"
<Badge
tone="success"
icon={<Check className="w-3 h-3" />}
title={extension.installedVersion ? `Installed: v${extension.installedVersion}` : undefined}
>
<Check className="w-3 h-3" />
Installed{extension.installedVersion ? ` v${extension.installedVersion}` : ''}
</span>
</Badge>
) : !offered ? (
<span
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300 text-xs font-medium"
<Badge
tone="warning"
icon={<AlertTriangle className="w-3 h-3" />}
title={`No published version runs on Bulwark v${CURRENT_APP_VERSION}. Open the details to see what the newest version needs.`}
>
<AlertTriangle className="w-3 h-3" />
Not for v{CURRENT_APP_VERSION}
</span>
</Badge>
) : (
<button
<Button
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onInstall(); }}
disabled={installing}
title={`Install v${offered.version}`}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{installing ? (
<Loader2 className="w-3 h-3 animate-spin" />
@@ -776,7 +743,7 @@ function ExtensionCard({
<Download className="w-3 h-3" />
)}
Quick install
</button>
</Button>
)}
</div>
</div>
@@ -0,0 +1,209 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { AlertTriangle, Check, Code, Loader2, X } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
import { SettingsSection } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { useAdminShell } from '@/components/admin/shell-context';
import { Badge, type BadgeTone } from '@/components/admin/badge';
interface ApprovalEntry {
pluginId: string;
bundleHash: string;
status: 'pending' | 'approved' | 'denied';
manifest: {
name?: string;
version?: string;
author?: string;
description?: string;
permissions?: string[];
httpOrigins?: string[];
apiPostPaths?: string[];
};
requestedBy: string;
requestedAt: string;
decidedAt?: string;
}
interface SourceView {
entry: ApprovalEntry;
code?: string;
warnings?: string[];
error?: string;
}
const STATUS_TONE: Record<ApprovalEntry['status'], BadgeTone> = {
pending: 'warning',
approved: 'success',
denied: 'muted',
};
/**
* Plugins users installed themselves and asked to have approved. The admin
* decides on the package the user uploaded: its manifest and code are read
* on the server, and the approval is pinned to that exact package. Shown
* only while there is something to decide or to look back on.
*/
export function PluginApprovalsPanel() {
const [entries, setEntries] = useState<ApprovalEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [source, setSource] = useState<SourceView | null>(null);
const [showPast, setShowPast] = useState(false);
const refreshBadges = useAdminShell()?.refreshBadges;
const load = useCallback(async () => {
try {
const res = await apiFetch('/api/admin/plugin-approvals');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json() as { entries: ApprovalEntry[] };
setEntries([...data.entries].sort((a, b) =>
Number(b.status === 'pending') - Number(a.status === 'pending') || b.requestedAt.localeCompare(a.requestedAt)));
setError(null);
} catch (e) {
setError(`Could not load approval requests (${(e as Error).message})`);
}
}, []);
useEffect(() => { void load(); }, [load]);
async function decide(entry: ApprovalEntry, decision: 'approved' | 'denied' | 'revoke') {
const key = `${entry.pluginId}:${entry.bundleHash}`;
setBusy(key);
try {
const res = decision === 'revoke'
? await apiFetch(`/api/admin/plugin-approvals?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`, { method: 'DELETE' })
: await apiFetch('/api/admin/plugin-approvals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await load();
refreshBadges?.();
} catch (e) {
setError(`Could not save the decision (${(e as Error).message})`);
} finally {
setBusy(null);
}
}
async function viewSource(entry: ApprovalEntry) {
setSource({ entry });
try {
const res = await apiFetch(`/api/admin/plugin-approvals/source?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
setSource({ entry, code: data.code, warnings: data.warnings });
} catch (e) {
setSource({ entry, error: (e as Error).message });
}
}
if (!error && (entries === null || entries.length === 0)) return null;
const pending = entries?.filter((e) => e.status === 'pending') ?? [];
const past = entries?.filter((e) => e.status !== 'pending') ?? [];
const renderEntry = (entry: ApprovalEntry) => {
const key = `${entry.pluginId}:${entry.bundleHash}`;
const perms = entry.manifest.permissions ?? [];
const sendsTo = [...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])];
return (
<div key={key} className="space-y-2 px-4 py-3">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{entry.manifest.name ?? entry.pluginId}</span>
<span className="text-xs text-muted-foreground">v{entry.manifest.version}</span>
<Badge tone={STATUS_TONE[entry.status]}>{entry.status}</Badge>
</div>
{entry.manifest.description && <p className="text-xs text-muted-foreground">{entry.manifest.description}</p>}
<div className="text-xs text-muted-foreground">
<code className="font-mono">{entry.pluginId}</code> &middot; by {entry.manifest.author ?? 'unknown'} &middot; requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()}
</div>
{perms.length > 0 && (
<div className="flex flex-wrap gap-1">
{perms.map((p) => (
<Badge key={p} tone={HIGH_RISK_PERMISSIONS.has(p) ? 'warning' : 'muted'} className="font-mono">{p}</Badge>
))}
</div>
)}
{sendsTo.length > 0 && (
<div className="break-all text-xs text-muted-foreground">Can send data to: {sendsTo.join(', ')}</div>
)}
<div className="flex flex-wrap gap-2 pt-1">
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => viewSource(entry)}>
<Code className="h-3.5 w-3.5" /> View code
</Button>
{entry.status !== 'approved' && (
<Button type="button" size="sm" className="h-8 gap-1.5 text-xs" disabled={busy === key} onClick={() => decide(entry, 'approved')}>
<Check className="h-3.5 w-3.5" /> Approve
</Button>
)}
{entry.status !== 'denied' && (
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" disabled={busy === key} onClick={() => decide(entry, 'denied')}>
<X className="h-3.5 w-3.5" /> Deny
</Button>
)}
{entry.status !== 'pending' && (
<Button type="button" variant="ghost" size="sm" className="h-8 text-xs text-muted-foreground hover:text-destructive" disabled={busy === key} onClick={() => decide(entry, 'revoke')}>
Forget decision
</Button>
)}
</div>
</div>
);
};
return (
<SettingsSection
id="approvals"
title={pending.length > 0 ? `Approval requests (${pending.length})` : 'Approval requests'}
description="Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user's mail."
>
{error && <p className="text-sm text-destructive">{error}</p>}
{pending.length > 0 && (
<div className="divide-y divide-border rounded-lg border border-amber-300/60 dark:border-amber-700/60">
{pending.map(renderEntry)}
</div>
)}
{past.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowPast((v) => !v)}
className="text-xs font-medium text-muted-foreground hover:text-foreground"
aria-expanded={showPast}
>
{showPast ? 'Hide' : 'Show'} {past.length} past decision{past.length === 1 ? '' : 's'}
</button>
{showPast && (
<div className="mt-2 divide-y divide-border rounded-lg border border-border">
{past.map(renderEntry)}
</div>
)}
</div>
)}
{source && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onMouseDown={(e) => { if (e.target === e.currentTarget) setSource(null); }}>
<div role="dialog" aria-modal="true" aria-label="Plugin code" className="flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border border-border bg-background shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-medium">{source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version}</span>
<button type="button" onClick={() => setSource(null)} aria-label="Close" className="rounded p-1 hover:bg-accent"><X className="h-4 w-4" /></button>
</div>
{source.warnings && source.warnings.length > 0 && (
<div className="flex items-start gap-2 border-b border-border px-4 py-2 text-xs text-foreground">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 text-warning" /> {source.warnings.join('; ')}
</div>
)}
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-all p-4 font-mono text-[11px] leading-relaxed">
{source.error ?? source.code ?? (<span className="inline-flex items-center gap-2"><Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading</span>)}
</pre>
</div>
</div>
)}
</SettingsSection>
);
}
+286
View File
@@ -0,0 +1,286 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Upload, Loader2, Puzzle, Lock, Store, Power, PowerOff, ShieldAlert } from '@/components/icons';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPath } from '@/lib/admin/admin-nav';
import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminButtonLink } from '@/components/admin/button-link';
import { AdminList, AdminListRow } from '@/components/admin/list';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { EmptyState } from '@/components/admin/empty-state';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { ExtensionIcon } from '@/components/admin/extension-icon';
import { PluginApprovalsPanel } from './plugin-approvals-panel';
import { useExtensionIcons } from './use-extension-icons';
import type { ExtensionEntry } from './extension-detail';
export function PluginsPage() {
const [plugins, setPlugins] = useState<ExtensionEntry[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [message, setMessage] = useFlash();
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [savingPolicy, setSavingPolicy] = useState(false);
const iconFor = useExtensionIcons();
const fetchPlugins = useCallback(async () => {
try {
const res = await apiFetch('/api/admin/plugins');
if (res.ok) setPlugins(await res.json());
} finally {
setLoading(false);
}
}, []);
const fetchPolicy = useCallback(async () => {
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy(data);
setSavedPolicy(data);
}
} catch { /* the defaults stay */ }
}, []);
useEffect(() => { fetchPlugins(); fetchPolicy(); }, [fetchPlugins, fetchPolicy]);
const features = policy.features;
const pluginsEnabled = features.pluginsEnabled ?? true;
const pluginsUploadEnabled = features.pluginsUploadEnabled ?? true;
const requirePluginApproval = features.requirePluginApproval ?? true;
const policyDirty = JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features);
useAdminUnsavedChanges('plugins', policyDirty);
function setFeature(key: 'pluginsEnabled' | 'pluginsUploadEnabled' | 'requirePluginApproval', value: boolean) {
setPolicy((prev) => ({ ...prev, features: { ...prev.features, [key]: value } }));
setMessage(null);
}
async function savePolicy() {
setSavingPolicy(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setSavedPolicy(policy);
setMessage({ type: 'success', text: 'Plugin policy saved. Users see the change on their next login.' });
} else {
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Could not save the plugin policy' });
}
} catch {
setMessage({ type: 'error', text: 'Could not save the plugin policy' });
} finally {
setSavingPolicy(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiFetch('/api/admin/plugins', { method: 'POST', body: formData });
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning${data.warnings.length === 1 ? '' : 's'})` : '';
setMessage({ type: 'success', text: `"${data.plugin.name}" installed${warnings}` });
await fetchPlugins();
} else {
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
} catch {
setMessage({ type: 'error', text: 'Upload failed' });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
async function setEnabled(id: string, enabled: boolean): Promise<boolean> {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
});
if (res.ok) {
setPlugins((prev) => prev.map((p) => (p.id === id ? { ...p, enabled } : p)));
return true;
}
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Could not change the plugin' });
return false;
}
async function setAll(enabled: boolean) {
const targets = plugins.filter((p) => p.enabled !== enabled);
if (targets.length === 0) return;
setBulkBusy(true);
setMessage(null);
let failed = 0;
for (const p of targets) {
if (!(await setEnabled(p.id, enabled))) failed++;
}
await fetchPlugins();
setBulkBusy(false);
setMessage(failed === 0
? { type: 'success', text: `${targets.length} plugin${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` }
: { type: 'error', text: `${failed} plugin${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` });
}
const enabledCount = plugins.filter((p) => p.enabled).length;
return (
<div>
<AdminPageHeader
title="Plugins"
description="Plugins installed for every user of this Bulwark. Open one to configure it."
actions={(
<>
<AdminButtonLink href={adminPath('marketplace')}>
<Store className="h-4 w-4" />
Marketplace
</AdminButtonLink>
<label className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground shadow-sm transition-all hover:bg-primary/90 hover:shadow focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Upload plugin
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</>
)}
/>
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<PluginApprovalsPanel />
<SettingsSection
id="installed"
title="Installed plugins"
description={loading || plugins.length === 0 ? undefined : `${enabledCount} of ${plugins.length} enabled. The switch turns a plugin on or off for everyone.`}
>
{loading ? (
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
) : plugins.length === 0 ? (
<EmptyState
icon={Puzzle}
title="No plugins installed"
hint="Install one from the marketplace or upload a plugin ZIP."
action={<AdminButtonLink href={adminPath('marketplace')}><Store className="h-4 w-4" /> Browse the marketplace</AdminButtonLink>}
/>
) : (
<>
{plugins.length > 1 && (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === plugins.length} onClick={() => setAll(true)}>
<Power className="h-3.5 w-3.5" /> Enable all
</Button>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === 0} onClick={() => setAll(false)}>
<PowerOff className="h-3.5 w-3.5" /> Disable all
</Button>
</div>
)}
<AdminList>
{plugins.map((plugin) => {
const perms = plugin.permissions ?? [];
const risky = perms.filter((p) => HIGH_RISK_PERMISSIONS.has(p));
return (
<AdminListRow
key={plugin.id}
data-testid={`admin-plugin-${plugin.id}`}
href={adminPath('plugins', plugin.id)}
icon={<ExtensionIcon url={iconFor(plugin)} fallback={Puzzle} />}
title={plugin.name}
titleExtra={(
<>
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
{!plugin.enabled && <Badge>Off</Badge>}
{plugin.forceEnabled && <Badge tone="warning" icon={<Lock className="h-3 w-3" />}>Forced on</Badge>}
{plugin.dev && <Badge tone="info" title="Loaded from the dev plugins directory">Dev</Badge>}
{risky.length > 0 && (
<Badge tone="warning" icon={<ShieldAlert className="h-3 w-3" />} title={risky.join(', ')}>
{risky.length} high-risk permission{risky.length === 1 ? '' : 's'}
</Badge>
)}
</>
)}
description={plugin.description}
meta={(
<>
by {plugin.author || 'unknown'}
{plugin.type && <> &middot; {plugin.type}</>}
{perms.length > 0 && <> &middot; {perms.length} permission{perms.length === 1 ? '' : 's'}</>}
{plugin.source?.kind === 'marketplace' && <> &middot; Marketplace</>}
</>
)}
control={(
<ToggleSwitch
checked={plugin.enabled}
onChange={(checked) => { setMessage(null); void setEnabled(plugin.id, checked); }}
ariaLabel={`${plugin.name} enabled`}
/>
)}
/>
);
})}
</AdminList>
</>
)}
</SettingsSection>
<SettingsSection id="policy" title="Plugin policy" description="What users may do with plugins on their own.">
<SettingItem label="Plugins" description="Load and run plugins for users at all. Off hides the plugin system everywhere.">
<ToggleSwitch checked={pluginsEnabled} onChange={(v) => setFeature('pluginsEnabled', v)} />
</SettingItem>
<SettingItem label="Users may upload plugins" description="Let users install plugin ZIPs for their own account from their settings.">
<ToggleSwitch checked={pluginsUploadEnabled} disabled={!pluginsEnabled} onChange={(v) => setFeature('pluginsUploadEnabled', v)} />
</SettingItem>
<SettingItem
label="Require approval"
description="A plugin a user uploads stays off until an admin has approved that exact package here."
>
<ToggleSwitch checked={requirePluginApproval} disabled={!pluginsEnabled || !pluginsUploadEnabled} onChange={(v) => setFeature('requirePluginApproval', v)} />
</SettingItem>
</SettingsSection>
</div>
<AdminSaveBar
dirty={policyDirty}
saving={savingPolicy}
onSave={savePolicy}
onDiscard={() => setPolicy(savedPolicy)}
saveLabel="Save policy"
note="Users see the change on their next login"
/>
</div>
);
}
+628
View File
@@ -0,0 +1,628 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Lock, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons';
import { iconForName } from '@/components/icons';
import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps';
import { generateUUID, cn } from '@/lib/utils';
import {
DEFAULT_RELAY_BASE_URL,
isValidRelayUrl,
normalizeRelayUrl,
resolveDefaultRelayUrl,
} from '@/lib/push-relays';
import { SettingsSection, SettingItem, ToggleSwitch, SectionNav } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { inputClass } from '@/components/admin/config-items';
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
// forward on policy load), so it is hidden from the admin UI.
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. Mailbox passwords are stored only when the Passwords option below is on. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' },
accountArchivePasswordsEnabled: { label: 'Account List Backup: Passwords', description: 'With Account List Backup on, offer to include the mailbox passwords of accounts signed in with a password, so a restore signs in to them by itself. They are encrypted in the browser with the backup password together with the list; the server never sees them in clear, but anyone who learns the backup password gets every mailbox in it. Accounts signed in with OAuth or a TOTP code are saved without.' },
customFromEnabled: { label: 'Custom From Address', description: 'Let users type any sender address in the composer instead of picking one of their identities, and let replies to a catch-all address on their domain send from that address. Turn this off when users may only send as their configured identities.' },
archiveActionEnabled: { label: 'Archive Action', description: 'Offer "Archive" on messages: the toolbar button, the context menu and selection bar, the hover and swipe actions, the E shortcut and archiving after an export. Turn this off when mail is archived elsewhere, for example by a mail gateway; existing Archive folders stay as ordinary folders and Bulwark no longer creates one.' },
};
const RESTRICTABLE_SETTINGS = [
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
{ key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'] },
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
{ key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'] },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
];
const PAGE = adminPageDef('policy');
const fieldLabelClass = 'mb-1 block text-[11px] font-medium text-muted-foreground';
const cardClass = 'rounded-md border border-border bg-muted/30 p-3';
const subLabelClass = 'mt-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground';
/** Mirrors the sanitizer's URL rule so the admin sees the drop before saving. */
function isValidDefaultAppUrl(raw: string): boolean {
try {
const parsed = new URL(raw.trim());
return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !!parsed.host;
} catch {
return false;
}
}
export function PolicyPage() {
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useFlash();
const [dirty, setDirty] = useState(false);
useEffect(() => { fetchPolicy(); }, []);
async function fetchPolicy() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy(data);
setSavedPolicy(data);
}
} finally {
setLoading(false);
}
}
function toggleFeature(key: keyof FeatureGates) {
setPolicy(prev => ({
...prev,
features: { ...prev.features, [key]: !prev.features[key] },
}));
setDirty(true);
setMessage(null);
}
function setPushRelayUrl(value: string) {
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
setDirty(true);
setMessage(null);
}
function updatePushRelay(index: number, patch: Partial<PushRelayOption>) {
setPolicy(prev => ({
...prev,
pushRelays: (prev.pushRelays ?? []).map((relay, i) => (i === index ? { ...relay, ...patch } : relay)),
}));
setDirty(true);
setMessage(null);
}
function addPushRelay() {
setPolicy(prev => ({ ...prev, pushRelays: [...(prev.pushRelays ?? []), { label: '', url: '' }] }));
setDirty(true);
setMessage(null);
}
function removePushRelay(index: number) {
setPolicy(prev => {
const remaining = (prev.pushRelays ?? []).filter((_, i) => i !== index);
const removedUrl = normalizeRelayUrl((prev.pushRelays ?? [])[index]?.url);
// Dropping the relay users are defaulted to would leave a dangling
// default, so fall back to the built-in one.
const pushRelayUrl = normalizeRelayUrl(prev.pushRelayUrl) === removedUrl ? '' : prev.pushRelayUrl;
return { ...prev, pushRelays: remaining, pushRelayUrl };
});
setDirty(true);
setMessage(null);
}
function togglePushRelayLocked() {
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
setDirty(true);
setMessage(null);
}
function updateDefaultApp(index: number, patch: Partial<AdminSidebarApp>) {
setPolicy(prev => ({
...prev,
defaultSidebarApps: (prev.defaultSidebarApps ?? []).map((app, i) => (i === index ? { ...app, ...patch } : app)),
}));
setDirty(true);
setMessage(null);
}
function addDefaultApp() {
setPolicy(prev => {
const apps = prev.defaultSidebarApps ?? [];
if (apps.length >= MAX_DEFAULT_SIDEBAR_APPS) return prev;
return {
...prev,
defaultSidebarApps: [
...apps,
{
id: `${DEFAULT_SIDEBAR_APP_ID_PREFIX}${generateUUID()}`,
name: '',
url: '',
icon: 'Globe',
openMode: 'tab' as const,
showOnMobile: false,
},
],
};
});
setDirty(true);
setMessage(null);
}
function removeDefaultApp(index: number) {
setPolicy(prev => ({
...prev,
defaultSidebarApps: (prev.defaultSidebarApps ?? []).filter((_, i) => i !== index),
}));
setDirty(true);
setMessage(null);
}
/** Rail order follows this list, so admins need to be able to reorder it. */
function moveDefaultApp(index: number, delta: number) {
setPolicy(prev => {
const apps = [...(prev.defaultSidebarApps ?? [])];
const target = index + delta;
if (target < 0 || target >= apps.length) return prev;
[apps[index], apps[target]] = [apps[target], apps[index]];
return { ...prev, defaultSidebarApps: apps };
});
setDirty(true);
setMessage(null);
}
function toggleLocked(settingKey: string) {
setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {};
const newRestrictions = { ...prev.restrictions };
if (existing.locked) {
delete newRestrictions[settingKey];
} else {
newRestrictions[settingKey] = { ...existing, locked: true };
}
return { ...prev, restrictions: newRestrictions };
});
setDirty(true);
setMessage(null);
}
function toggleHidden(settingKey: string) {
setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {};
const newRestrictions = { ...prev.restrictions };
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
delete newRestrictions[settingKey];
}
return { ...prev, restrictions: newRestrictions };
});
setDirty(true);
setMessage(null);
}
function discard() {
setPolicy(savedPolicy);
setDirty(false);
setMessage(null);
}
async function handleSave() {
setSaving(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setSavedPolicy(policy);
setMessage({ type: 'success', text: 'Policy saved. Users see the change on their next login.' });
setDirty(false);
} else {
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Could not save the policy' });
}
} catch {
setMessage({ type: 'error', text: 'Could not save the policy' });
} finally {
setSaving(false);
}
}
useAdminUnsavedChanges('policy', dirty);
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
const defaultRelayUrl = resolveDefaultRelayUrl(policy);
const defaultSidebarApps = policy.defaultSidebarApps ?? [];
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<Flash message={message} onDismiss={() => setMessage(null)} />
{loading ? (
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
) : (
<div className="space-y-10">
<SectionNav />
<SettingsSection
id="features"
title="Features"
description="Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages."
>
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
.map(key => {
const meta = FEATURE_GATE_LABELS[key];
if (!meta) return null;
const { label, description } = meta;
return (
<SettingItem key={key} label={label} description={description}>
<ToggleSwitch checked={!!policy.features[key]} onChange={() => toggleFeature(key)} />
</SettingItem>
);
})}
</SettingsSection>
<SettingsSection
id="push-relays"
title="Push relays"
description="Relays users can pick from in notification settings. The built-in Bulwark relay is always offered; add your own here. Users choose from this list - they cannot enter a URL."
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<span className="text-sm font-medium text-foreground">Relays</span>
<p className="mt-0.5 text-xs text-muted-foreground">
Mark one as the default. Relays without a valid URL are not offered to users.
</p>
</div>
<Button type="button" variant="outline" size="sm" className="h-8 shrink-0 gap-1.5 text-xs" onClick={addPushRelay}>
<Plus className="h-3.5 w-3.5" />
Add relay
</Button>
</div>
<div className={cardClass}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">Bulwark relay</span>
<Badge>built-in</Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{DEFAULT_RELAY_BASE_URL}</p>
</div>
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="radio"
name="default-push-relay"
checked={defaultRelayUrl === normalizeRelayUrl(DEFAULT_RELAY_BASE_URL)}
onChange={() => setPushRelayUrl('')}
className="border-input"
/>
Default
</label>
</div>
</div>
{(policy.pushRelays ?? []).map((relay, index) => (
<div key={index} className={cn(cardClass, 'space-y-2')}>
<div className="grid grid-cols-1 items-start gap-2 sm:grid-cols-12">
<div className="sm:col-span-4">
<label className={fieldLabelClass}>Name</label>
<input
type="text"
autoComplete="off"
spellCheck={false}
value={relay.label}
onChange={(e) => updatePushRelay(index, { label: e.target.value })}
placeholder="Company relay"
className={cn(inputClass, 'w-full')}
/>
</div>
<div className="sm:col-span-8">
<label className={fieldLabelClass}>Relay URL</label>
<div className="flex items-center gap-2">
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={relay.url}
onChange={(e) => updatePushRelay(index, { url: e.target.value })}
placeholder="https://notifications.relay.example.com"
className={cn(inputClass, 'w-full')}
/>
<button
type="button"
onClick={() => removePushRelay(index)}
className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-destructive"
title="Remove relay"
aria-label="Remove relay"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
<label className="flex w-fit cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="radio"
name="default-push-relay"
checked={isValidRelayUrl(relay.url) && defaultRelayUrl === normalizeRelayUrl(relay.url)}
disabled={!isValidRelayUrl(relay.url)}
onChange={() => setPushRelayUrl(normalizeRelayUrl(relay.url))}
className="border-input disabled:opacity-40"
/>
Default
</label>
</div>
))}
<SettingItem label="Pin users to the default relay" description="Lock the relay choice: users cannot pick another relay in their notification settings.">
<ToggleSwitch checked={!!policy.pushRelayUrlLocked} onChange={togglePushRelayLocked} />
</SettingItem>
</SettingsSection>
<SettingsSection
id="sidebar-apps"
title="Default sidebar apps"
description="Apps every user gets in the navigation rail without configuring anything. They appear above the user's own apps and cannot be edited or removed by users. Independent of the Sidebar Apps feature gate above, which only governs user-added apps."
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<span className="text-sm font-medium text-foreground">Apps</span>
<p className="mt-0.5 text-xs text-muted-foreground">
Entries without a name or a valid http(s) URL are dropped when saving. Inline apps
are framed inside the webmail, so the target must allow being embedded.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1.5 text-xs"
onClick={addDefaultApp}
disabled={defaultSidebarApps.length >= MAX_DEFAULT_SIDEBAR_APPS}
>
<Plus className="h-3.5 w-3.5" />
Add app
</Button>
</div>
{defaultSidebarApps.length === 0 && (
<p className="py-2 text-xs text-muted-foreground">
No default apps. Users only see the apps they add themselves.
</p>
)}
{defaultSidebarApps.map((app, index) => {
const AppIcon = iconForName(app.icon);
const urlInvalid = app.url.trim().length > 0 && !isValidDefaultAppUrl(app.url);
return (
<div key={app.id} className={cn(cardClass, 'space-y-2')}>
<div className="grid grid-cols-1 items-start gap-2 sm:grid-cols-12">
<div className="sm:col-span-4">
<label className={fieldLabelClass}>Name</label>
<input
type="text"
autoComplete="off"
spellCheck={false}
maxLength={50}
value={app.name}
onChange={(e) => updateDefaultApp(index, { name: e.target.value })}
placeholder="Intranet"
className={cn(inputClass, 'w-full')}
/>
</div>
<div className="sm:col-span-5">
<label className={fieldLabelClass}>URL</label>
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
maxLength={2048}
value={app.url}
onChange={(e) => updateDefaultApp(index, { url: e.target.value })}
placeholder="https://intranet.example.com"
className={cn(inputClass, 'w-full', urlInvalid && 'border-destructive')}
/>
{urlInvalid && (
<p className="mt-1 text-[11px] text-destructive">Enter a valid http or https URL</p>
)}
</div>
<div className="sm:col-span-3">
<label className={fieldLabelClass}>
Icon (
<a href="https://tabler.io/icons" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground">Tabler</a>
)
</label>
<div className="flex items-center gap-2">
<input
type="text"
autoComplete="off"
spellCheck={false}
maxLength={64}
value={app.icon}
onChange={(e) => updateDefaultApp(index, { icon: e.target.value })}
placeholder="world"
className={cn(inputClass, 'w-full')}
/>
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground"
title={AppIcon ? app.icon : 'Unknown icon - falls back to Globe'}
>
{AppIcon ? <AppIcon className="h-4 w-4" /> : <span className="text-[10px]">?</span>}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-3">
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="radio"
name={`default-app-mode-${app.id}`}
checked={app.openMode !== 'inline'}
onChange={() => updateDefaultApp(index, { openMode: 'tab' })}
className="border-input"
/>
New tab
</label>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="radio"
name={`default-app-mode-${app.id}`}
checked={app.openMode === 'inline'}
onChange={() => updateDefaultApp(index, { openMode: 'inline' })}
className="border-input"
/>
Inline
</label>
</div>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={app.showOnMobile}
onChange={() => updateDefaultApp(index, { showOnMobile: !app.showOnMobile })}
className="rounded border-input"
/>
Show on mobile
</label>
<div className="ms-auto flex items-center gap-1">
<button
type="button"
onClick={() => moveDefaultApp(index, -1)}
disabled={index === 0}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
title="Move up"
aria-label="Move up"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => moveDefaultApp(index, 1)}
disabled={index === defaultSidebarApps.length - 1}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
title="Move down"
aria-label="Move down"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => removeDefaultApp(index)}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-destructive"
title="Remove app"
aria-label="Remove app"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
})}
</SettingsSection>
<SettingsSection
id="restrictions"
title="Locked settings"
description="Lock a setting so users cannot change it, or hide it from their settings entirely."
>
{categories.map(category => (
<div key={category}>
<p className={subLabelClass}>{category}</p>
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
const restriction = policy.restrictions[setting.key] || {};
return (
<SettingItem key={setting.key} label={setting.label}>
<div className="flex items-center gap-3">
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
className="rounded border-input" />
<Lock className="h-3 w-3" /> Lock
</label>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
className="rounded border-input" />
Hide
</label>
</div>
</SettingItem>
);
})}
</div>
))}
</SettingsSection>
</div>
)}
<AdminSaveBar
dirty={dirty}
saving={saving}
onSave={handleSave}
onDiscard={discard}
saveLabel="Save policy"
note="Users see the change on their next login"
/>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { SettingsSection, SectionNav } from '@/components/settings/settings-section';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { ConfigTextItem, ConfigToggleItem, ConfigSelectItem, ConfigNote } from '@/components/admin/config-items';
import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface ConfigEntry {
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
const PAGE = adminPageDef('settings');
export function SettingsPage() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useFlash();
useEffect(() => {
fetchConfig();
}, []);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) {
setConfig(await res.json());
}
setLoading(false);
}
function handleChange(key: string, value: unknown) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): unknown {
if (key in edits) return edits[key];
return config[key]?.value;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleRevert(key: string) {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
setMessage({ type: 'success', text: `${key} reverted to default` });
}
}
const hasEdits = Object.keys(edits).length > 0;
useAdminUnsavedChanges('settings', hasEdits);
if (loading) {
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
</div>
);
}
const jmapServers = currentValue('jmapServers');
return (
<div>
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<SectionNav />
<SettingsSection id="general" title="General">
<ConfigTextItem label="Application name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigTextItem label="JMAP server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" wide />
<ConfigToggleItem label="Allow custom JMAP endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
{!!currentValue('allowCustomJmapEndpoint') && (
<ConfigNote tone="warning">
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
</ConfigNote>
)}
<ConfigToggleItem label="Stalwart features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Demo mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Search engine indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection id="login" title="Login page">
<ConfigToggleItem label="Show version" description="Show the webmail version in the login page footer. Turn off to keep the exact version hidden from visitors who are not signed in." configKey="loginShowVersion" value={currentValue('loginShowVersion') as boolean} source={config.loginShowVersion?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show 2FA code option" description="Show the &quot;I have a 2FA code&quot; toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show access token sign-in" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show remember me" description="Let people choose whether to stay signed in. Off (the default) remembers every sign-in. Either way this needs SESSION_SECRET to be set." configKey="loginShowRememberMe" value={currentValue('loginShowRememberMe') as boolean} source={config.loginShowRememberMe?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show heading" description="Show the application name below the login logo" configKey="loginShowHeading" value={currentValue('loginShowHeading') as boolean} source={config.loginShowHeading?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show subtitle" description="Show the subtitle below the login heading" configKey="loginShowSubtitle" value={currentValue('loginShowSubtitle') as boolean} source={config.loginShowSubtitle?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection id="servers" title="JMAP servers">
<ConfigToggleItem
label="Auto-pick server by email domain"
description="When users type their email, automatically select the matching server from the list below."
configKey="jmapServerAutoPickByDomain"
value={currentValue('jmapServerAutoPickByDomain') as boolean}
source={config.jmapServerAutoPickByDomain?.source}
onChange={handleChange}
onRevert={handleRevert}
/>
<JmapServersSection
value={(jmapServers as JmapServerEntry[]) ?? []}
source={config.jmapServers?.source}
onChange={(next) => handleChange('jmapServers', next)}
onRevert={() => handleRevert('jmapServers')}
/>
{Array.isArray(jmapServers) && (jmapServers as JmapServerEntry[]).length > 0 && (
<ConfigNote tone="warning">
<strong>CORS warning:</strong> Each JMAP server must allow this webmail&apos;s origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
</ConfigNote>
)}
</SettingsSection>
<SettingsSection id="wopi" title="Document editing">
<ConfigTextItem label="WOPI client URL" configKey="wopiClientUrl" value={currentValue('wopiClientUrl') as string} source={config.wopiClientUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://office.example.com" wide />
<ConfigTextItem label="WOPI host URL override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="http://webmail:3000" wide />
<ConfigNote>
Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The client URL may be an internal address: the browser loads the editor from the address its discovery advertises. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks); give the address alone, the webmail&apos;s base path is added to it. The editor must also allow this webmail as a WOPI host.
</ConfigNote>
</SettingsSection>
<SettingsSection id="logging" title="Logging">
<ConfigSelectItem label="Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
<ConfigSelectItem label="Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection id="sync" title="Settings sync">
<ConfigToggleItem label="Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
</div>
<AdminSaveBar
dirty={hasEdits}
saving={saving}
onSave={handleSave}
onDiscard={() => setEdits({})}
note="Takes effect on the next page load"
/>
</div>
);
}
+251
View File
@@ -0,0 +1,251 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Send, ExternalLink } from '@/components/icons';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { ConfigNote, inputClass } from '@/components/admin/config-items';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { cn } from '@/lib/utils';
interface TelemetryStatus {
consent: 'pending' | 'on' | 'off';
consentSource: 'env' | 'file';
endpoint: string;
defaultEndpoint: string;
consentedAt: string | null;
lastSentAt: string | null;
nextScheduledAt: string | null;
payloadPreview: Record<string, unknown>;
accountCounts: { total: number; active7d: number };
}
const PAGE = adminPageDef('telemetry');
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
const days = Math.floor(h / 24);
return `${days} days ago`;
}
export function TelemetryPage() {
const [status, setStatus] = useState<TelemetryStatus | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [endpointDraft, setEndpointDraft] = useState('');
const [message, setMessage] = useFlash();
const endpointDirty = status !== null && endpointDraft !== status.endpoint;
useAdminUnsavedChanges('telemetry', endpointDirty);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/telemetry');
if (!r.ok) throw new Error('failed to load');
const data = (await r.json()) as TelemetryStatus;
setStatus(data);
setEndpointDraft(data.endpoint);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function setConsent(consent: 'on' | 'off'): Promise<void> {
setBusy('consent');
setMessage(null);
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-consent', consent }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
setMessage({ type: 'error', text: j.error ?? 'failed' });
}
await refresh();
} finally { setBusy(null); }
}
async function saveEndpoint(): Promise<void> {
setBusy('endpoint');
setMessage(null);
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
setMessage({ type: 'error', text: j.error ?? 'failed' });
} else {
setMessage({ type: 'success', text: 'Endpoint saved.' });
}
await refresh();
} finally { setBusy(null); }
}
async function sendNow(): Promise<void> {
setBusy('send');
setMessage(null);
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'send-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
setMessage(j.ok
? { type: 'success', text: `sent (HTTP ${j.status ?? '?'})` }
: { type: 'error', text: `failed: ${j.error ?? 'unknown'}` });
await refresh();
} finally { setBusy(null); }
}
const header = <AdminPageHeader title={PAGE.label} description={PAGE.description} />;
if (loading || !status) {
return (
<div>
{header}
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
</div>
);
}
const envOverridden = status.consentSource === 'env';
const isOn = status.consent === 'on';
const consentDescription = [
status.consent === 'pending' && 'Initialising - no heartbeats sent yet.',
status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!',
status.consent === 'off' && 'Heartbeats are off (default).',
envOverridden && 'Locked by the BULWARK_TELEMETRY env var.',
].filter(Boolean).join(' ');
return (
<div>
{header}
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<ConfigNote>
Bulwark can send one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. It&apos;s <strong>off by
default</strong>; one click below enables it and helps us make the product better. No
email addresses, no hostnames, no IPs are sent.{' '}
<a
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
target="_blank"
rel="noreferrer"
className="underline inline-flex items-center gap-1"
>
Full schema and policy <ExternalLink className="h-3 w-3" />
</a>
</ConfigNote>
<SettingsSection id="status" title="Status">
<SettingItem label="Send anonymous heartbeats" description={consentDescription}>
<ToggleSwitch
checked={isOn}
disabled={busy === 'consent' || envOverridden}
onChange={(checked) => void setConsent(checked ? 'on' : 'off')}
/>
</SettingItem>
<SettingItem label="Last sent">
<span className="text-sm text-foreground">{timeAgo(status.lastSentAt)}</span>
</SettingItem>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{timeAgo(status.nextScheduledAt)}</span>
</SettingItem>
<SettingItem label="Consented at">
<span className="text-sm text-foreground">{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</span>
</SettingItem>
</SettingsSection>
<SettingsSection
id="accounts"
title="Account activity"
description="Unique accounts that have logged in over the last 90 days. Identities are stored as a per-instance HMAC, never as plaintext usernames. These are the numbers reported in the heartbeat as bucketed ranges."
>
<SettingItem label="Total (90 days)">
<span className="text-sm font-mono text-foreground">{status.accountCounts?.total ?? 0}</span>
</SettingItem>
<SettingItem label="Active (7 days)">
<span className="text-sm font-mono text-foreground">{status.accountCounts?.active7d ?? 0}</span>
</SettingItem>
</SettingsSection>
<SettingsSection
id="endpoint"
title="Endpoint"
description="Where heartbeats are sent. Defaults to the project's collector. Point at your own collector (open source at bulwarkmail/dashboard) or clear this field to disable sending."
>
<SettingItem label="Collector URL">
<input
type="url"
value={endpointDraft}
onChange={(e) => { setEndpointDraft(e.target.value); setMessage(null); }}
placeholder={status.defaultEndpoint}
aria-label="Collector URL"
autoComplete="off"
spellCheck={false}
className={cn(inputClass, 'w-full sm:w-80')}
/>
</SettingItem>
</SettingsSection>
<SettingsSection
id="payload"
title="Payload preview"
description="Exactly what the next heartbeat would send from this install, right now."
>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
disabled={busy === 'send' || !isOn}
onClick={() => void sendNow()}
>
{busy === 'send' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Send now
</Button>
</div>
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
{JSON.stringify(status.payloadPreview, null, 2)}
</pre>
</SettingsSection>
</div>
<AdminSaveBar
dirty={endpointDirty}
saving={busy === 'endpoint'}
onSave={() => void saveEndpoint()}
onDiscard={() => setEndpointDraft(status.endpoint)}
saveLabel="Save endpoint"
/>
</div>
);
}
+404
View File
@@ -0,0 +1,404 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Upload, Loader2, SwatchBook, Lock, Store, Power, PowerOff } from '@/components/icons';
import type { ColorMode, SettingsPolicy, ThemePolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { adminPath, adminPageDef } from '@/lib/admin/admin-nav';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { AdminButtonLink } from '@/components/admin/button-link';
import { AdminList, AdminListRow } from '@/components/admin/list';
import { AdminSaveBar } from '@/components/admin/save-bar';
import { Badge } from '@/components/admin/badge';
import { EmptyState } from '@/components/admin/empty-state';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { inputClass } from '@/components/admin/config-items';
import { cn } from '@/lib/utils';
import { ExtensionIcon } from '@/components/admin/extension-icon';
import { useExtensionIcons } from './use-extension-icons';
import type { ExtensionEntry } from './extension-detail';
// Derive from the single source of truth so newly added built-in themes show
// up here automatically (was previously a hardcoded subset — see #496).
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
const PAGE = adminPageDef('themes');
/** Policy as the page edits it: the theme policy is always fully merged. */
function withThemeDefaults(data: SettingsPolicy): SettingsPolicy {
return {
...data,
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
};
}
export function ThemesPage() {
const [themes, setThemes] = useState<ExtensionEntry[]>([]);
const iconFor = useExtensionIcons();
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [message, setMessage] = useFlash();
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>(() => withThemeDefaults({ ...DEFAULT_POLICY }));
const [savedPolicy, setSavedPolicy] = useState<SettingsPolicy>(() => withThemeDefaults({ ...DEFAULT_POLICY }));
const [savingPolicy, setSavingPolicy] = useState(false);
const fetchThemes = useCallback(async () => {
try {
const res = await apiFetch('/api/admin/themes');
if (res.ok) setThemes(await res.json());
} finally {
setLoading(false);
}
}, []);
const fetchPolicy = useCallback(async () => {
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = withThemeDefaults(await res.json());
setPolicy(data);
setSavedPolicy(data);
}
} catch { /* the defaults stay */ }
}, []);
useEffect(() => { fetchThemes(); fetchPolicy(); }, [fetchThemes, fetchPolicy]);
const policyDirty =
JSON.stringify(policy.themePolicy) !== JSON.stringify(savedPolicy.themePolicy) ||
JSON.stringify(policy.features) !== JSON.stringify(savedPolicy.features);
useAdminUnsavedChanges('themes', policyDirty);
function setFeature(key: 'themesEnabled' | 'userThemesEnabled', value: boolean) {
setPolicy(prev => ({ ...prev, features: { ...prev.features, [key]: value } }));
setMessage(null);
}
function toggleBuiltinTheme(themeId: string) {
setPolicy(prev => {
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
const isDisabled = disabled.includes(themeId);
return {
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
disabledBuiltinThemes: isDisabled
? disabled.filter((id: string) => id !== themeId)
: [...disabled, themeId],
},
};
});
setMessage(null);
}
function toggleAdminTheme(themeId: string) {
setPolicy(prev => {
const disabled = prev.themePolicy?.disabledThemes || [];
const isDisabled = disabled.includes(themeId);
return {
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
disabledThemes: isDisabled
? disabled.filter((id: string) => id !== themeId)
: [...disabled, themeId],
},
};
});
setMessage(null);
}
function setDefaultTheme(themeId: string | null) {
setPolicy(prev => ({
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
defaultThemeId: themeId,
},
}));
setMessage(null);
}
function setColorModePolicy(patch: Partial<Pick<ThemePolicy, 'defaultColorMode' | 'lockColorMode'>>) {
setPolicy(prev => ({
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
...patch,
},
}));
setMessage(null);
}
async function savePolicy() {
setSavingPolicy(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setSavedPolicy(policy);
setMessage({ type: 'success', text: 'Theme policy saved. Users see the change on their next login.' });
} else {
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Could not save the theme policy' });
}
} catch {
setMessage({ type: 'error', text: 'Could not save the theme policy' });
} finally {
setSavingPolicy(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiFetch('/api/admin/themes', { method: 'POST', body: formData });
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning${data.warnings.length === 1 ? '' : 's'})` : '';
setMessage({ type: 'success', text: `"${data.theme.name}" installed${warnings}` });
await fetchThemes();
} else {
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
} catch {
setMessage({ type: 'error', text: 'Upload failed' });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
async function setEnabled(id: string, enabled: boolean): Promise<boolean> {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
});
if (res.ok) {
setThemes(prev => prev.map(t => (t.id === id ? { ...t, enabled } : t)));
return true;
}
const data = await res.json().catch(() => ({}));
setMessage({ type: 'error', text: data.error || 'Could not change the theme' });
return false;
}
async function setAll(enabled: boolean) {
const targets = themes.filter(t => t.enabled !== enabled);
if (targets.length === 0) return;
setBulkBusy(true);
setMessage(null);
let failed = 0;
for (const t of targets) {
if (!(await setEnabled(t.id, enabled))) failed++;
}
await fetchThemes();
setBulkBusy(false);
setMessage(failed === 0
? { type: 'success', text: `${targets.length} theme${targets.length === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}` }
: { type: 'error', text: `${failed} theme${failed === 1 ? '' : 's'} could not be ${enabled ? 'enabled' : 'disabled'}` });
}
const enabledCount = themes.filter(t => t.enabled).length;
const themesEnabled = policy.features.themesEnabled ?? true;
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
const lockColorMode = policy.themePolicy?.lockColorMode === true;
const disabledBuiltin = policy.themePolicy?.disabledBuiltinThemes || [];
const disabledInstalled = policy.themePolicy?.disabledThemes || [];
return (
<div>
<AdminPageHeader
title={PAGE.label}
description={PAGE.description}
actions={(
<>
<AdminButtonLink href={adminPath('marketplace')}>
<Store className="h-4 w-4" />
Marketplace
</AdminButtonLink>
<label className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground shadow-sm transition-all hover:bg-primary/90 hover:shadow focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
Upload theme
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</>
)}
/>
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<SettingsSection
id="installed"
title="Installed themes"
description={loading || themes.length === 0 ? undefined : `${enabledCount} of ${themes.length} enabled. The switch turns a theme on or off for everyone.`}
>
{loading ? (
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
) : themes.length === 0 ? (
<EmptyState
icon={SwatchBook}
title="No themes installed"
hint="Install one from the marketplace or upload a theme ZIP."
action={<AdminButtonLink href={adminPath('marketplace')}><Store className="h-4 w-4" /> Browse the marketplace</AdminButtonLink>}
/>
) : (
<>
{themes.length > 1 && (
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === themes.length} onClick={() => setAll(true)}>
<Power className="h-3.5 w-3.5" /> Enable all
</Button>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs" disabled={bulkBusy || enabledCount === 0} onClick={() => setAll(false)}>
<PowerOff className="h-3.5 w-3.5" /> Disable all
</Button>
</div>
)}
<AdminList>
{themes.map(theme => (
<AdminListRow
key={theme.id}
data-testid={`admin-theme-${theme.id}`}
href={adminPath('themes', theme.id)}
icon={<ExtensionIcon url={iconFor(theme)} fallback={SwatchBook} />}
title={theme.name}
titleExtra={(
<>
<span className="text-xs text-muted-foreground">v{theme.version}</span>
{!theme.enabled && <Badge>Off</Badge>}
{theme.forceEnabled && <Badge tone="warning" icon={<Lock className="h-3 w-3" />}>Forced on</Badge>}
</>
)}
description={theme.description}
meta={(
<>
by {theme.author || 'unknown'}
{theme.variants && theme.variants.length > 0 && <> &middot; {theme.variants.join(', ')}</>}
{theme.source?.kind === 'marketplace' && <> &middot; Marketplace</>}
</>
)}
control={(
<ToggleSwitch
checked={theme.enabled}
onChange={(checked) => { setMessage(null); void setEnabled(theme.id, checked); }}
ariaLabel={`${theme.name} enabled`}
/>
)}
/>
))}
</AdminList>
</>
)}
</SettingsSection>
<SettingsSection id="policy" title="Theme policy" description="Control theme availability and defaults for users.">
<SettingItem label="Themes" description="Allow users to select and apply themes">
<ToggleSwitch checked={themesEnabled} onChange={(v) => setFeature('themesEnabled', v)} />
</SettingItem>
<SettingItem label="Users may upload themes" description="Allow users to upload their own theme files">
<ToggleSwitch checked={userThemesEnabled} disabled={!themesEnabled} onChange={(v) => setFeature('userThemesEnabled', v)} />
</SettingItem>
<SettingItem label="Default theme" description="Theme applied when users have not chosen one">
<select
value={policy.themePolicy?.defaultThemeId || ''}
onChange={(e) => setDefaultTheme(e.target.value || null)}
aria-label="Default theme"
className={cn(inputClass, 'w-full pe-8 cursor-pointer sm:w-auto')}
>
<option value="">System default</option>
<optgroup label="Built-in">
{BUILTIN_THEME_OPTIONS
.filter(t => !disabledBuiltin.includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</optgroup>
{themes.length > 0 && (
<optgroup label="Installed">
{themes
.filter(t => !disabledInstalled.includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</optgroup>
)}
</select>
</SettingItem>
<SettingItem label="Default colour mode" description="Light, dark or system for users who have not picked one">
<select
value={policy.themePolicy?.defaultColorMode || 'system'}
onChange={(e) => setColorModePolicy({ defaultColorMode: e.target.value === 'system' ? null : e.target.value as ColorMode })}
aria-label="Default colour mode"
className={cn(inputClass, 'w-full pe-8 cursor-pointer sm:w-auto')}
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</SettingItem>
<SettingItem label="Lock colour mode" description="Everyone gets the default colour mode and cannot switch it in settings, on the login page or in the command palette">
<ToggleSwitch checked={lockColorMode} onChange={(v) => setColorModePolicy({ lockColorMode: v })} />
</SettingItem>
</SettingsSection>
<SettingsSection id="offered" title="Offered to users" description="Themes users can pick. Turn one off to hide it from the picker.">
{BUILTIN_THEME_OPTIONS.map(theme => (
<SettingItem key={theme.id} label={theme.name}>
<ToggleSwitch checked={!disabledBuiltin.includes(theme.id)} onChange={() => toggleBuiltinTheme(theme.id)} />
</SettingItem>
))}
{themes.length > 0 && (
<>
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Installed</p>
{themes.map(theme => (
<SettingItem key={theme.id} label={theme.name}>
<ToggleSwitch checked={!disabledInstalled.includes(theme.id)} onChange={() => toggleAdminTheme(theme.id)} />
</SettingItem>
))}
</>
)}
</SettingsSection>
</div>
<AdminSaveBar
dirty={policyDirty}
saving={savingPolicy}
onSave={savePolicy}
onDiscard={() => setPolicy(savedPolicy)}
saveLabel="Save policy"
note="Users see the change on their next login"
/>
</div>
);
}
@@ -0,0 +1,53 @@
'use client';
import { useEffect, useState } from 'react';
import { apiFetch } from '@/lib/browser-navigation';
import type { MarketplaceListing } from '@/lib/admin/marketplace-directory';
import type { ExtensionEntry } from './extension-detail';
/**
* Brand icons for installed plugins and themes. An installed bundle carries
* no image (the manifest's `icon` is marketplace media the directory
* serves), so the icons come from the directory's listing, matched by slug.
* Fetched once per session; an unreachable directory just means the
* generic icons stay.
*/
let cache: Map<string, string> | null = null;
let pending: Promise<Map<string, string>> | null = null;
async function loadIcons(): Promise<Map<string, string>> {
const icons = new Map<string, string>();
for (let page = 1; page <= 5; page++) {
const res = await apiFetch(`/api/admin/marketplace?page=${page}&perPage=100`);
if (!res.ok) break;
const data = await res.json() as { data?: MarketplaceListing[]; meta?: { total?: number; perPage?: number } };
for (const ext of data.data ?? []) {
if (ext.iconUrl) icons.set(ext.slug, ext.iconUrl);
}
const seen = page * (data.meta?.perPage ?? 100);
if (!data.data?.length || seen >= (data.meta?.total ?? 0)) break;
}
return icons;
}
export function useExtensionIcons(): (entry: Pick<ExtensionEntry, 'id' | 'source'>) => string | null {
const [icons, setIcons] = useState<Map<string, string> | null>(cache);
useEffect(() => {
if (cache) return;
let cancelled = false;
pending ??= loadIcons().then((map) => { cache = map; return map; }).catch(() => new Map<string, string>());
pending.then((map) => { if (!cancelled) setIcons(map); });
return () => { cancelled = true; };
}, []);
return (entry) => {
if (!icons) return null;
const slug = entry.source?.kind === 'marketplace' ? entry.source.slug : entry.id;
return icons.get(slug) ?? null;
};
}
/** Test hook: forget what was fetched. */
export function resetExtensionIconCache(): void {
cache = null;
pending = null;
}
+213
View File
@@ -0,0 +1,213 @@
'use client';
import { useEffect, useState } from 'react';
import {
Loader2,
RefreshCw,
CheckCircle2,
AlertTriangle,
ShieldAlert,
ExternalLink,
} from '@/components/icons';
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { Badge, type BadgeTone } from '@/components/admin/badge';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { adminPageDef } from '@/lib/admin/admin-nav';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
interface VersionAdminStatus {
current: string;
build: string;
endpoint: string;
defaultEndpoint: string;
disabledByEnv: boolean;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
status: UpdateStatus | null;
}
const PAGE = adminPageDef('version');
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
return `${Math.floor(h / 24)} days ago`;
}
function severityChip(severity: UpdateSeverity): { label: string; tone: BadgeTone; Icon: typeof CheckCircle2 } {
switch (severity) {
case 'security':
return { label: 'Security update', tone: 'danger', Icon: ShieldAlert };
case 'deprecated':
return { label: 'Deprecated', tone: 'danger', Icon: ShieldAlert };
case 'normal':
return { label: 'Update available', tone: 'warning', Icon: AlertTriangle };
case 'unknown':
return { label: 'Unknown', tone: 'muted', Icon: AlertTriangle };
case 'none':
default:
return { label: 'Up to date', tone: 'success', Icon: CheckCircle2 };
}
}
export function VersionPage() {
const [data, setData] = useState<VersionAdminStatus | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [message, setMessage] = useFlash();
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/version');
if (!r.ok) throw new Error('failed to load');
setData((await r.json()) as VersionAdminStatus);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function checkNow(): Promise<void> {
setChecking(true);
setMessage(null);
try {
const r = await apiFetch('/api/admin/version', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'check-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
setMessage(j.ok
? { type: 'success', text: 'Update check completed.' }
: { type: 'error', text: `Failed: ${j.error ?? 'unknown'}` });
await refresh();
} finally {
setChecking(false);
}
}
const header = (
<AdminPageHeader
title={PAGE.label}
description={PAGE.description}
actions={(
<Button type="button" size="sm" className="gap-2" disabled={checking || loading} onClick={() => void checkNow()}>
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Check now
</Button>
)}
/>
);
if (loading || !data) {
return (
<div>
{header}
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
</div>
);
}
const status = data.status;
const chip = severityChip(status?.severity ?? 'none');
const ChipIcon = chip.Icon;
const releaseUrl = status?.url ?? null;
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
return (
<div>
{header}
<Flash message={message} onDismiss={() => setMessage(null)} />
<div className="space-y-10">
<SettingsSection id="status" title="Status">
<SettingItem label="Severity">
<Badge tone={chip.tone} icon={<ChipIcon className="h-3 w-3" />}>{chip.label}</Badge>
</SettingItem>
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
<span className="text-sm font-mono text-foreground">{data.current}</span>
</SettingItem>
{newer && (
<SettingItem label="Latest release">
{releaseUrl ? (
<a
href={releaseUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
>
{newer} <ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-sm font-mono text-foreground">{newer}</span>
)}
</SettingItem>
)}
{status?.advisory && (
<SettingItem label="Advisory">
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection id="schedule" title="Schedule" description="Hourly polling with ±5 minute jitter.">
<SettingItem label="Last checked">
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
</SettingItem>
<SettingItem label="Last success">
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
</SettingItem>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
</SettingItem>
{status?.checkedAt && (
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection
id="source"
title="Source"
description="Hourly check against the Bulwark version server. Severity is decided server-side and disable with BULWARK_UPDATE_CHECK=off."
>
<SettingItem
label="Endpoint"
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
>
<a
href={data.endpoint}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
>
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
</SettingItem>
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
{data.disabledByEnv ? 'Yes' : 'No'}
</span>
</SettingItem>
</SettingsSection>
</div>
</div>
);
}
-423
View File
@@ -1,423 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, RotateCcw, Sparkles } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { DEFAULT_OAUTH_SCOPES } from '@/lib/oauth/scopes';
interface ConfigEntry {
// Sensitive keys (sessionSecret, oauthClientSecret) come back with
// `value` omitted and `hasValue` set instead - the server never echoes
// the raw secret to the client.
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
export function AuthTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [configReadOnly, setConfigReadOnly] = useState(false);
useEffect(() => {
fetchConfig();
apiFetch('/api/setup/status')
.then(res => (res.ok ? res.json() : null))
.then(data => setConfigReadOnly(data?.readOnly === true))
.catch(() => {});
}, []);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json());
setLoading(false);
}
function handleChange(key: string, value: unknown) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): unknown {
if (key in edits) return edits[key];
return config[key]?.value;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Authentication settings saved.' });
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleRevert(key: string) {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => { const next = { ...prev }; delete next[key]; return next; });
await fetchConfig();
}
}
const [setupRunning, setSetupRunning] = useState(false);
const [setupOpen, setSetupOpen] = useState(false);
const [setupOrigin, setSetupOrigin] = useState('');
const [setupIssuer, setSetupIssuer] = useState('');
const [setupOauthOnly, setSetupOauthOnly] = useState(false);
function openSetupDialog() {
if (typeof window === 'undefined') return;
const origin = window.location.origin;
const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || '';
setSetupOrigin(origin);
setSetupIssuer(jmapUrl || origin);
setSetupOauthOnly(currentValue('oauthOnly') === true);
setSetupOpen(true);
}
async function handleAutoSetup() {
setSetupRunning(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/oauth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
origin: setupOrigin.trim().replace(/\/+$/, ''),
issuerUrl: setupIssuer.trim().replace(/\/+$/, ''),
oauthOnly: setupOauthOnly,
}),
});
const data = await res.json();
if (res.ok) {
setMessage({
type: 'success',
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
});
setEdits({});
setSetupOpen(false);
await fetchConfig();
} else {
const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : '';
setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail });
}
} catch (err) {
setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' });
} finally {
setSetupRunning(false);
}
}
const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, ''));
const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, ''));
const hasEdits = Object.keys(edits).length > 0;
useAdminUnsavedChanges('auth', hasEdits);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Authentication</h1>
<p className="text-sm text-muted-foreground mt-1">OAuth, SSO, and session configuration</p>
</div>
{hasEdits && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save changes
</button>
)}
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary shrink-0" />
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
</div>
<p className="text-xs text-muted-foreground mt-1">
Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here.
Requires your Stalwart account to have admin permissions.
</p>
{configReadOnly && (
<p className="text-xs text-destructive mt-1">
Not available while the configuration is read-only (ADMIN_CONFIG_READONLY).
</p>
)}
</div>
<button
onClick={openSetupDialog}
disabled={setupRunning || configReadOnly}
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Set up automagically'}
</button>
</div>
</div>
{setupOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-labelledby="oauth-setup-title"
onClick={(e) => { if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }}
>
<div className="w-full max-w-md rounded-lg border border-border bg-background shadow-xl">
<div className="px-5 py-4 border-b border-border">
<h3 id="oauth-setup-title" className="text-base font-medium text-foreground">Auto-configure OAuth</h3>
<p className="text-xs text-muted-foreground mt-1">
Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.
</p>
</div>
<div className="px-5 py-4 space-y-4">
<div>
<label htmlFor="setup-origin" className="block text-xs font-medium text-foreground mb-1">
Webmail origin
</label>
<input
id="setup-origin"
type="url"
value={setupOrigin}
onChange={(e) => setSetupOrigin(e.target.value)}
disabled={setupRunning}
placeholder="https://webmail.example.com"
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Used to register redirect URIs (one per locale: <code>{setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/&lt;locale&gt;/auth/callback</code>) on Stalwart.
</p>
{!setupOriginValid && setupOrigin.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<div>
<label htmlFor="setup-issuer" className="block text-xs font-medium text-foreground mb-1">
Stalwart issuer URL
</label>
<input
id="setup-issuer"
type="url"
value={setupIssuer}
onChange={(e) => setSetupIssuer(e.target.value)}
disabled={setupRunning}
placeholder="https://mail.example.com"
className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground mt-1">
Where Stalwart serves <code>/.well-known/oauth-authorization-server</code>. Saved as <code>OAUTH_ISSUER_URL</code>. Pre-filled from your JMAP server URL.
</p>
{!setupIssuerValid && setupIssuer.length > 0 && (
<p className="text-[11px] text-destructive mt-1">Must be like https://host with no path.</p>
)}
</div>
<label className="inline-flex items-center gap-2 text-xs text-foreground select-none cursor-pointer">
<input
type="checkbox"
checked={setupOauthOnly}
onChange={(e) => setSetupOauthOnly(e.target.checked)}
className="h-3.5 w-3.5 rounded border-input"
disabled={setupRunning}
/>
Also enable “OAuth only” (hide password login)
</label>
</div>
<div className="px-5 py-3 border-t border-border flex items-center justify-end gap-2 bg-muted/30 rounded-b-lg">
<button
onClick={() => setSetupOpen(false)}
disabled={setupRunning}
className="h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleAutoSetup}
disabled={setupRunning || !setupOriginValid || !setupIssuerValid}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Configure'}
</button>
</div>
</div>
</div>
)}
<Section title="OAuth / OpenID Connect">
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<Toggle label="OAuth Only" description="Hide password login form when enabled" configKey="oauthOnly" value={currentValue('oauthOnly') as boolean} source={config.oauthOnly?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client ID" configKey="oauthClientId" value={currentValue('oauthClientId') as string} source={config.oauthClientId?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Client Secret" configKey="oauthClientSecret" value={currentValue('oauthClientSecret') as string} source={config.oauthClientSecret?.source} onChange={handleChange} onRevert={handleRevert} type="password" placeholder={config.oauthClientSecret?.hasValue ? '•••••••• (saved - type to replace)' : undefined} />
<Text label="OAuth Issuer URL" configKey="oauthIssuerUrl" value={currentValue('oauthIssuerUrl') as string} source={config.oauthIssuerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com" />
<Toggle label="Allow private OAuth endpoints" description="Permit discovered endpoints on other hosts to resolve to RFC-1918 / loopback addresses. Endpoints on the issuer's own host are always allowed, so split-DNS setups rarely need this." configKey="oauthAllowPrivateEndpoints" value={currentValue('oauthAllowPrivateEndpoints') as boolean} source={config.oauthAllowPrivateEndpoints?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="OAuth Scopes" description={`Space-separated scopes that replace the defaults (${DEFAULT_OAUTH_SCOPES}). Leave blank to use them. offline_access is not requested by default; add it here or under Extra Scopes if your provider only issues refresh tokens for it, or sessions end when the access token expires.`} configKey="oauthScopes" value={currentValue('oauthScopes') as string} source={config.oauthScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder={DEFAULT_OAUTH_SCOPES} />
<Text label="OAuth Extra Scopes" description={`Additional space-separated scopes appended to the defaults (${DEFAULT_OAUTH_SCOPES}). Ignored when OAuth Scopes is set.`} configKey="oauthExtraScopes" value={currentValue('oauthExtraScopes') as string} source={config.oauthExtraScopes?.source} onChange={handleChange} onRevert={handleRevert} placeholder="urn:ietf:params:oauth:..." />
</Section>
<Section title="Single Sign-On">
<Toggle label="Auto SSO" description="Automatically redirect to SSO provider on load" configKey="autoSsoEnabled" value={currentValue('autoSsoEnabled') as boolean} source={config.autoSsoEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<Toggle label="End provider session on sign-out" description="Signing out of an SSO account also signs out of the identity provider, when it advertises an end_session_endpoint or a logout URL is set below. Turn off if the provider is shared with apps that should stay signed in." configKey="oauthEndSession" value={currentValue('oauthEndSession') as boolean} source={config.oauthEndSession?.source} onChange={handleChange} onRevert={handleRevert} />
<Text label="Provider logout URL" description="For providers whose discovery has no end_session_endpoint, such as Stalwart. Used instead of the discovered endpoint when set, with the same parameters. Must be https. Applies to the global OAuth settings; servers in the server list set their own." configKey="oauthEndSessionUrl" value={currentValue('oauthEndSessionUrl') as string} source={config.oauthEndSessionUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://auth.example.com/logout" />
<Text label="Post-logout redirect URI" description="Where the provider sends users after signing them out. Register it with the provider as a post-logout redirect URI first, or it will refuse the logout. Leave blank to stay on the provider's signed-out page." configKey="oauthPostLogoutRedirectUri" value={currentValue('oauthPostLogoutRedirectUri') as string} source={config.oauthPostLogoutRedirectUri?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com/en/login" />
</Section>
<Section title="Admin Dashboard">
<Select
label="Stalwart admin access"
description="What a Stalwart admin account grants in this dashboard. “Automatic” signs Stalwart admins in without the admin password; “Password required” keeps the shield but asks for the admin password; “Off” ignores Stalwart admin status entirely. The last two need an admin password to be configured."
configKey="stalwartAdminAccess"
value={currentValue('stalwartAdminAccess') as string}
source={config.stalwartAdminAccess?.source}
options={['auto', 'password', 'off']}
optionLabels={{ auto: 'Automatic (no password)', password: 'Password required', off: 'Off (separate admins)' }}
onChange={handleChange}
onRevert={handleRevert}
/>
</Section>
<Section title="Session & Security">
<Select label="Cookie SameSite" configKey="cookieSameSite" value={currentValue('cookieSameSite') as string} source={config.cookieSameSite?.source} options={['lax', 'strict', 'none']} onChange={handleChange} onRevert={handleRevert} />
<Text label="Allowed Frame Ancestors" configKey="allowedFrameAncestors" value={currentValue('allowedFrameAncestors') as string} source={config.allowedFrameAncestors?.source} onChange={handleChange} onRevert={handleRevert} placeholder="'none' or https://..." />
<Text label="Parent Origin" description="For embedded mode communication" configKey="parentOrigin" value={currentValue('parentOrigin') as string} source={config.parentOrigin?.source} onChange={handleChange} onRevert={handleRevert} />
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
</div>
<div className="divide-y divide-border">{children}</div>
</div>
);
}
function SourceBadge({ source }: { source?: string }) {
if (!source || source === 'default') return null;
return (
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
{source}
</span>
);
}
function Text({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text' }: {
label: string; description?: string; configKey: string; value: string; source?: string;
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; placeholder?: string; type?: string;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input type={type} value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)} placeholder={placeholder}
className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)}
</div>
</div>
);
}
function Toggle({ label, description, configKey, value, source, onChange, onRevert }: {
label: string; description?: string; configKey: string; value: boolean; source?: string;
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<button type="button" role="switch" aria-checked={value} aria-label={label}
onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)}
</div>
</div>
);
}
function Select({ label, description, configKey, value, source, options, optionLabels, onChange, onRevert }: {
label: string; description?: string; configKey: string; value: string; source?: string; options: string[];
optionLabels?: Record<string, string>;
onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<select value={value ?? ''} onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{options.map(o => <option key={o} value={o}>{optionLabels?.[o] ?? o}</option>)}
</select>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert"><RotateCcw className="w-3.5 h-3.5" /></button>
)}
</div>
</div>
);
}
-724
View File
@@ -1,724 +0,0 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from '@/components/icons';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import {
BRANDING_OVERRIDE_KEYS,
parseDomainBranding,
type BrandingOverrideKey,
type DomainBrandingEntry,
} from '@/lib/admin/domain-branding';
interface ConfigEntry {
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
const IMAGE_FIELDS = [
{ key: 'faviconUrl', label: 'Favicon', accept: '.svg,.png,.ico,.webp' },
{ key: 'appLogoLightUrl', label: 'App Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' },
{ key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' },
] as const;
const TEXT_FIELDS = [
{ key: 'loginCompanyName', label: 'Company Name' },
{ key: 'loginImprintUrl', label: 'Imprint URL' },
{ key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' },
{ key: 'loginWebsiteUrl', label: 'Company Website URL' },
] as const;
const PWA_IMAGE_FIELDS = [
{ key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' },
{ key: 'pwaScreenshotMobileUrl', label: 'PWA Screenshot (Mobile)', accept: '.png,.jpg,.webp' },
{ key: 'pwaScreenshotDesktopUrl', label: 'PWA Screenshot (Desktop)', accept: '.png,.jpg,.webp' },
] as const;
const PWA_TEXT_FIELDS = [
{ key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' },
{ key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' },
] as const;
const PWA_COLOR_FIELDS = [
{ key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' },
{ key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' },
] as const;
// Accepts exact hosts and one-level wildcards (e.g. *.example.com).
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
// Tighter rule for uploads: wildcards can only point to externally-hosted
// URLs, since we'd have no concrete subdomain to serve a file from.
const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
export function BrandingTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [selectedHost, setSelectedHost] = useState<string | null>(null);
const [addingHost, setAddingHost] = useState(false);
const [newHostInput, setNewHostInput] = useState('');
const [newHostError, setNewHostError] = useState<string | null>(null);
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
useEffect(() => {
fetchConfig();
}, []);
const domainEntries = useMemo<DomainBrandingEntry[]>(
() => parseDomainBranding(config['domainBranding']?.value),
[config],
);
// Drop selection if the host disappeared from the config (e.g. concurrent edit).
useEffect(() => {
if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) {
setSelectedHost(null);
setEdits({});
}
}, [domainEntries, selectedHost]);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) setConfig(await res.json());
setLoading(false);
}
function selectedEntry(): DomainBrandingEntry | null {
if (!selectedHost) return null;
return domainEntries.find(e => e.host === selectedHost) ?? null;
}
function handleChange(key: string, value: string) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): string {
if (key in edits) return edits[key];
if (selectedHost) {
const entry = selectedEntry();
return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? '';
}
return (config[key]?.value as string) ?? '';
}
function isOverriddenInScope(key: string): boolean {
if (selectedHost) {
const entry = selectedEntry();
const v = entry?.[key as BrandingOverrideKey];
return typeof v === 'string' && v.length > 0;
}
return config[key]?.source === 'admin';
}
const isUploadedFile = (key: string): boolean => {
const val = currentValue(key);
return val.startsWith('/api/admin/branding/');
};
function buildUpdatedDomainBranding(merge: Record<string, string>): DomainBrandingEntry[] {
if (!selectedHost) return domainEntries;
const next = domainEntries.slice();
const idx = next.findIndex(e => e.host === selectedHost);
const base: DomainBrandingEntry =
idx === -1 ? { host: selectedHost } : { ...next[idx] };
const writable = base as unknown as Record<string, string | undefined>;
for (const [key, value] of Object.entries(merge)) {
if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue;
if (typeof value === 'string' && value.length > 0) {
writable[key] = value;
} else {
delete writable[key];
}
}
if (idx === -1) next.push(base);
else next[idx] = base;
return next;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const payload = selectedHost
? { domainBranding: buildUpdatedDomainBranding(edits) }
: edits;
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
setMessage({
type: 'success',
text: selectedHost
? `Branding for ${selectedHost} updated. Changes visible on next page load.`
: 'Branding updated. Changes visible on next page load.',
});
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleUpload(slot: string, file: File) {
if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) {
setMessage({
type: 'error',
text: 'Wildcard hosts cannot upload files. Enter a URL instead.',
});
return;
}
setUploading(slot);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
formData.append('slot', slot);
if (selectedHost) formData.append('host', selectedHost);
const res = await apiFetch('/api/admin/branding', {
method: 'POST',
body: formData,
});
if (res.ok) {
const data = await res.json();
setMessage({ type: 'success', text: `Uploaded ${file.name} successfully.` });
setEdits(prev => {
const next = { ...prev };
delete next[slot];
return next;
});
// Refresh from server so domainBranding entries reflect the upload.
await fetchConfig();
void data;
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
setUploading(null);
}
async function handleDeleteUpload(slot: string) {
setMessage(null);
const body: { slot: string; host?: string } = { slot };
if (selectedHost) body.host = selectedHost;
const res = await apiFetch('/api/admin/branding', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Uploaded file removed. Reverted to default.' });
setEdits(prev => {
const next = { ...prev };
delete next[slot];
return next;
});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to remove' });
}
}
async function handleRevert(key: string) {
if (selectedHost) {
// Domain scope: drop the field from the entry and PATCH the array.
const updated = buildUpdatedDomainBranding({ [key]: '' });
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: updated }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
}
return;
}
// Default scope: revert via DELETE /api/admin/config
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
}
}
async function handleAddDomain() {
const host = newHostInput.trim().toLowerCase().replace(/\.+$/, '');
if (!host) {
setNewHostError('Enter a hostname');
return;
}
if (!HOST_RE.test(host)) {
setNewHostError('Invalid hostname. Use foo.example.com or *.example.com');
return;
}
if (domainEntries.some(e => e.host === host)) {
setNewHostError('A branding entry for this host already exists');
return;
}
setNewHostError(null);
const next: DomainBrandingEntry[] = [...domainEntries, { host }];
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: next }),
});
if (res.ok) {
setNewHostInput('');
setAddingHost(false);
setSelectedHost(host);
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setNewHostError(data.error || 'Failed to add domain');
}
}
async function handleDeleteDomain() {
if (!selectedHost) return;
if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) {
return;
}
const next = domainEntries.filter(e => e.host !== selectedHost);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domainBranding: next }),
});
if (res.ok) {
setSelectedHost(null);
setEdits({});
await fetchConfig();
setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to remove domain' });
}
}
function handleScopeChange(host: string | null) {
if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return;
setSelectedHost(host);
setEdits({});
setMessage(null);
}
const hasEdits = Object.keys(edits).length > 0;
const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost);
useAdminUnsavedChanges('branding', hasEdits);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Branding</h1>
<p className="text-sm text-muted-foreground mt-1">Customize logos, favicon, and company information</p>
</div>
{hasEdits && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save changes
</button>
)}
</div>
{/* Scope picker */}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Scope</h2>
</div>
<div className="px-4 py-3 space-y-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleScopeChange(null)}
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
selectedHost === null
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/70'
}`}
>
Default
</button>
{domainEntries.map(entry => (
<button
key={entry.host}
type="button"
onClick={() => handleScopeChange(entry.host)}
className={`h-8 px-3 rounded-md text-sm font-medium transition-colors ${
selectedHost === entry.host
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground hover:bg-muted/70'
}`}
>
{entry.host}
</button>
))}
{!addingHost && (
<button
type="button"
onClick={() => { setAddingHost(true); setNewHostError(null); }}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add domain
</button>
)}
</div>
{addingHost && (
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
autoFocus
value={newHostInput}
onChange={(e) => { setNewHostInput(e.target.value); setNewHostError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }}
placeholder="mail.example.com or *.example.com"
className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
type="button"
onClick={handleAddDomain}
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
Add
</button>
<button
type="button"
onClick={() => { setAddingHost(false); setNewHostInput(''); setNewHostError(null); }}
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
{newHostError && <span className="text-xs text-destructive">{newHostError}</span>}
</div>
)}
{selectedHost ? (
<div className="flex items-center justify-between gap-3 text-xs">
<p className="text-muted-foreground">
Editing overrides for <span className="font-mono text-foreground">{selectedHost}</span>.
Unset fields fall back to the Default values.
{wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'}
</p>
<button
type="button"
onClick={handleDeleteDomain}
className="inline-flex items-center gap-1 text-destructive hover:underline whitespace-nowrap"
>
<X className="w-3.5 h-3.5" />
Remove domain
</button>
</div>
) : (
<p className="text-xs text-muted-foreground">
Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname.
</p>
)}
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Images & Logos</h2>
<p className="text-xs text-muted-foreground mt-0.5">Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)</p>
</div>
<div className="divide-y divide-border">
{IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
ref={el => { fileInputRefs.current[field.key] = el; }}
type="file"
accept={field.accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(field.key, file);
e.target.value = '';
}}
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key || wildcardScope}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
{isUploadedFile(field.key) && (
<button
onClick={() => handleDeleteUpload(field.key)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Remove uploaded file"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{currentValue(field.key) && (
<div className="mt-2 flex items-center gap-2">
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img
src={withBasePath(currentValue(field.key))}
alt={field.label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
</div>
)}
</div>
))}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Progressive Web App</h2>
<p className="text-xs text-muted-foreground mt-0.5">Shown when users install the webmail to their home screen. Leave fields blank to fall back to the favicon and app name.</p>
</div>
<div className="divide-y divide-border">
{PWA_IMAGE_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'}
className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
ref={el => { fileInputRefs.current[field.key] = el; }}
type="file"
accept={field.accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(field.key, file);
e.target.value = '';
}}
/>
<button
onClick={() => fileInputRefs.current[field.key]?.click()}
disabled={uploading === field.key || wildcardScope}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
title={wildcardScope ? 'Uploads disabled for wildcard hosts' : 'Upload file'}
>
{uploading === field.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
</button>
{isUploadedFile(field.key) && (
<button
onClick={() => handleDeleteUpload(field.key)}
className="text-muted-foreground hover:text-destructive transition-colors"
title="Remove uploaded file"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{isOverriddenInScope(field.key) && !isUploadedFile(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{currentValue(field.key) && (
<div className="mt-2 flex items-center gap-2">
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
<div className="h-8 w-auto bg-muted rounded flex items-center justify-center px-2">
<img
src={withBasePath(currentValue(field.key))}
alt={field.label}
className="max-h-6 max-w-[200px] object-contain"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
</div>
)}
</div>
))}
{PWA_TEXT_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.placeholder}
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
{PWA_COLOR_FIELDS.map(field => {
const value = currentValue(field.key) || field.defaultValue;
return (
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="color"
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : field.defaultValue}
onChange={(e) => handleChange(field.key, e.target.value)}
className="h-8 w-10 cursor-pointer rounded-md border border-input bg-background p-0.5"
title="Pick a color"
/>
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.defaultValue}
className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Company Information</h2>
</div>
<div className="divide-y divide-border">
{TEXT_FIELDS.map(field => (
<div key={field.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{field.label}</label>
{isOverriddenInScope(field.key) && (
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{selectedHost ? 'domain' : 'admin'}
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={currentValue(field.key)}
onChange={(e) => handleChange(field.key, e.target.value)}
placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'}
className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{isOverriddenInScope(field.key) && (
<button onClick={() => handleRevert(field.key)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
-194
View File
@@ -1,194 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { AlertTriangle } from '@/components/icons';
import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
interface AdminStatus {
enabled: boolean;
authenticated: boolean;
lastLogin: string | null;
passwordChangedAt: string | null;
}
interface ConfigData {
appName?: string;
jmapServerUrl?: string;
settingsSyncEnabled?: boolean;
stalwartFeaturesEnabled?: boolean;
oauthEnabled?: boolean;
devMode?: boolean;
}
export function DashboardTab() {
const [status, setStatus] = useState<AdminStatus | null>(null);
const [recentActivity, setRecentActivity] = useState<AuditEntry[]>([]);
const [config, setConfig] = useState<ConfigData | null>(null);
const [, setConfigSources] = useState<Record<string, { value?: unknown; source: string; hasValue?: boolean }> | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [themeCount, setThemeCount] = useState(0);
const [policyRuleCount, setPolicyRuleCount] = useState(0);
const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null);
useEffect(() => {
fetchDashboardData();
}, []);
async function fetchDashboardData() {
const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([
apiFetch('/api/admin/auth'),
apiFetch('/api/admin/audit?limit=10'),
apiFetch('/api/config'),
apiFetch('/api/admin/config'),
apiFetch('/api/admin/plugins').catch(() => null),
apiFetch('/api/admin/themes').catch(() => null),
apiFetch('/api/admin/policy').catch(() => null),
apiFetch('/api/admin/telemetry').catch(() => null),
]);
if (statusRes.ok) setStatus(await statusRes.json());
if (auditRes.ok) {
const data = await auditRes.json();
setRecentActivity(data.entries || []);
}
let configData: ConfigData | null = null;
if (configRes.ok) {
configData = await configRes.json();
setConfig(configData);
}
if (pluginRes?.ok) {
const plugins = await pluginRes.json();
setPluginCount(Array.isArray(plugins) ? plugins.length : 0);
}
if (themeRes?.ok) {
const themes = await themeRes.json();
setThemeCount(Array.isArray(themes) ? themes.length : 0);
}
if (policyRes?.ok) {
const policy = await policyRes.json();
const restrictionCount = policy.restrictions ? Object.keys(policy.restrictions).length : 0;
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
setPolicyRuleCount(restrictionCount + disabledGates);
}
if (telemetryRes?.ok) {
const telemetry = await telemetryRes.json();
if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') {
setAccountCounts(telemetry.accountCounts);
}
}
const w: string[] = [];
if (adminConfigRes.ok) {
const sources = await adminConfigRes.json();
setConfigSources(sources);
const sessionSecret = sources?.sessionSecret;
// Server redacts the raw value for sensitive keys; rely on hasValue,
// which is false when unset or matching a known placeholder default.
if (!sessionSecret?.hasValue) {
w.push('SESSION_SECRET is not set or using a default value. Sessions are insecure.');
}
const adminPassword = sources?.adminPassword;
if (adminPassword?.value && adminPassword.source === 'env') {
w.push('ADMIN_PASSWORD is still set in environment variables. Remove it now that the hash is stored securely.');
}
}
setWarnings(w);
}
const jmapUrl = config?.jmapServerUrl || '-';
const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-';
return (
<div className="max-w-3xl space-y-8">
{warnings.map((msg, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg border border-warning/20 bg-warning/10 p-4">
<AlertTriangle className="w-5 h-5 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">{msg}</p>
</div>
))}
<SettingsSection title="Server" description="Application and connection details">
<SettingItem label="Application">
<span className="text-sm text-foreground">{config?.appName || '-'}</span>
</SettingItem>
<SettingItem label="JMAP Server" description={jmapUrl !== '-' ? jmapUrl : undefined}>
<span className="text-sm text-foreground">{jmapHostname}</span>
</SettingItem>
<SettingItem label="Last Login">
<span className="text-sm text-foreground">
{status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'}
</span>
</SettingItem>
</SettingsSection>
<SettingsSection title="Features" description="Enabled integrations and modules">
<SettingItem label="Admin Panel" description="Administrative access to server configuration">
<ToggleSwitch checked={!!status?.enabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="Settings Sync" description="Synchronize user settings across devices">
<ToggleSwitch checked={!!config?.settingsSyncEnabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="OAuth" description="OAuth authentication provider">
<ToggleSwitch checked={!!config?.oauthEnabled} onChange={() => {}} disabled />
</SettingItem>
<SettingItem label="Stalwart Integration" description="Stalwart mail server features">
<ToggleSwitch checked={config?.stalwartFeaturesEnabled !== false} onChange={() => {}} disabled />
</SettingItem>
</SettingsSection>
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
</SettingItem>
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
</SettingItem>
</SettingsSection>
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
<SettingItem label="Plugins">
<span className="text-sm text-foreground">{pluginCount}</span>
</SettingItem>
<SettingItem label="Themes">
<span className="text-sm text-foreground">{themeCount}</span>
</SettingItem>
<SettingItem label="Policy Rules">
<span className="text-sm text-foreground">{policyRuleCount}</span>
</SettingItem>
</SettingsSection>
<SettingsSection title="Recent Activity" description="Latest administrative actions">
{recentActivity.length === 0 ? (
<div className="py-4 text-sm text-muted-foreground">
No activity recorded yet
</div>
) : (
recentActivity.map((entry, i) => (
<SettingItem
key={i}
label={entry.action}
description={formatDetail(entry.detail) || undefined}
>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{entry.ip}</span>
<span>{new Date(entry.ts).toLocaleString()}</span>
</div>
</SettingItem>
))
)}
</SettingsSection>
</div>
);
}
function formatDetail(detail: Record<string, unknown>): string {
if (!detail || Object.keys(detail).length === 0) return '';
if (detail.key) return `${detail.key}: ${detail.old} → ${detail.new}`;
if (detail.reason) return String(detail.reason);
if (detail.changes && Array.isArray(detail.changes)) return `${detail.changes.length} setting(s) changed`;
return JSON.stringify(detail).slice(0, 80);
}
-174
View File
@@ -1,174 +0,0 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { RefreshCw } from '@/components/icons';
import type { AuditEntry } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
export function LogsTab() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [actionFilter, setActionFilter] = useState('');
const limit = 50;
const fetchLogs = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (actionFilter) params.set('action', actionFilter);
const res = await apiFetch(`/api/admin/audit?${params}`);
if (res.ok) {
const data = await res.json();
setEntries(data.entries || []);
setTotal(data.total || 0);
}
setLoading(false);
}, [page, actionFilter]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
const totalPages = Math.max(1, Math.ceil(total / limit));
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Audit Log</h1>
<p className="text-sm text-muted-foreground mt-1">{total} total entries</p>
</div>
<button
onClick={fetchLogs}
className="inline-flex items-center gap-2 h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground hover:bg-accent transition-colors"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
<div className="flex items-center gap-3">
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
className="h-8 w-full sm:w-auto rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">All actions</option>
<option value="admin.login">Login</option>
<option value="admin.logout">Logout</option>
<option value="admin.login_failed">Login Failed</option>
<option value="admin.login_blocked">Login Blocked</option>
<option value="admin.change-password">Password Change</option>
<option value="config.update">Config Update</option>
<option value="config.revert">Config Revert</option>
<option value="policy.update">Policy Update</option>
</select>
</div>
<div className="sm:hidden space-y-2">
{loading && entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">Loading...</div>
) : entries.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">No entries found</div>
) : (
entries.map((entry, i) => (
<div key={i} className="rounded-lg border border-border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground truncate">
{entry.action}
</span>
<span className="text-[11px] text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</span>
</div>
<div className="text-xs text-foreground break-words">
{formatDetail(entry.detail)}
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{entry.ip}
</div>
</div>
))
)}
</div>
<div className="hidden sm:block border border-border rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && entries.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">Loading...</td>
</tr>
) : entries.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">No entries found</td>
</tr>
) : (
entries.map((entry, i) => (
<tr key={i} className="hover:bg-muted/20">
<td className="px-4 py-2 text-xs text-muted-foreground whitespace-nowrap">
{new Date(entry.ts).toLocaleString()}
</td>
<td className="px-4 py-2">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground">
{entry.action}
</span>
</td>
<td className="px-4 py-2 text-xs text-foreground max-w-xs truncate">
{formatDetail(entry.detail)}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground font-mono">
{entry.ip}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
>
Previous
</button>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="h-8 px-3 rounded-md border border-input bg-background text-sm disabled:opacity-50 hover:bg-accent transition-colors"
>
Next
</button>
</div>
</div>
)}
</div>
);
}
function formatDetail(detail: Record<string, unknown>): string {
if (!detail || Object.keys(detail).length === 0) return '-';
if (detail.reason) return String(detail.reason);
if (detail.key) return `${detail.key}: ${JSON.stringify(detail.old)} → ${JSON.stringify(detail.new)}`;
if (detail.changes && Array.isArray(detail.changes)) {
return detail.changes.map((c: Record<string, unknown>) => `${c.key}`).join(', ');
}
if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`;
return JSON.stringify(detail).slice(0, 100);
}
@@ -1,192 +0,0 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { AlertTriangle, Check, Code, Loader2, ShieldCheck, X } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
interface ApprovalEntry {
pluginId: string;
bundleHash: string;
status: 'pending' | 'approved' | 'denied';
manifest: {
name?: string;
version?: string;
author?: string;
description?: string;
permissions?: string[];
httpOrigins?: string[];
apiPostPaths?: string[];
};
requestedBy: string;
requestedAt: string;
decidedAt?: string;
}
interface SourceView {
entry: ApprovalEntry;
code?: string;
warnings?: string[];
error?: string;
}
const STATUS_STYLE: Record<ApprovalEntry['status'], string> = {
pending: 'bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400',
approved: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400',
denied: 'bg-muted text-muted-foreground',
};
/**
* Plugins users installed themselves and asked to have approved. The admin
* decides on the package the user uploaded: its manifest and code are read
* on the server, and the approval is pinned to that exact package.
*/
export function PluginApprovalsPanel() {
const [entries, setEntries] = useState<ApprovalEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [source, setSource] = useState<SourceView | null>(null);
const load = useCallback(async () => {
try {
const res = await apiFetch('/api/admin/plugin-approvals');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json() as { entries: ApprovalEntry[] };
setEntries([...data.entries].sort((a, b) =>
Number(b.status === 'pending') - Number(a.status === 'pending') || b.requestedAt.localeCompare(a.requestedAt)));
setError(null);
} catch (e) {
setError(`Could not load approval requests (${(e as Error).message})`);
}
}, []);
useEffect(() => { void load(); }, [load]);
async function decide(entry: ApprovalEntry, decision: 'approved' | 'denied' | 'revoke') {
const key = `${entry.pluginId}:${entry.bundleHash}`;
setBusy(key);
try {
const res = decision === 'revoke'
? await apiFetch(`/api/admin/plugin-approvals?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`, { method: 'DELETE' })
: await apiFetch('/api/admin/plugin-approvals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await load();
} catch (e) {
setError(`Could not save the decision (${(e as Error).message})`);
} finally {
setBusy(null);
}
}
async function viewSource(entry: ApprovalEntry) {
setSource({ entry });
try {
const res = await apiFetch(`/api/admin/plugin-approvals/source?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
setSource({ entry, code: data.code, warnings: data.warnings });
} catch (e) {
setSource({ entry, error: (e as Error).message });
}
}
const pending = entries?.filter((e) => e.status === 'pending').length ?? 0;
return (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Approval Requests</h2>
{pending > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400">{pending} waiting</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user&apos;s mail.</p>
</div>
{error && <div className="px-4 py-2 text-sm text-destructive">{error}</div>}
{entries === null ? (
<div className="p-6 text-center text-sm text-muted-foreground"><Loader2 className="inline w-4 h-4 animate-spin" /></div>
) : entries.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">No requests yet</div>
) : (
<div className="divide-y divide-border">
{entries.map((entry) => {
const key = `${entry.pluginId}:${entry.bundleHash}`;
const perms = entry.manifest.permissions ?? [];
return (
<div key={key} className="px-4 py-3 space-y-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{entry.manifest.name ?? entry.pluginId}</span>
<span className="text-xs text-muted-foreground">v{entry.manifest.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${STATUS_STYLE[entry.status]}`}>{entry.status}</span>
</div>
{entry.manifest.description && <p className="text-xs text-muted-foreground">{entry.manifest.description}</p>}
<div className="text-xs text-muted-foreground">
{entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()}
</div>
{perms.length > 0 && (
<div className="flex flex-wrap gap-1">
{perms.map((p) => (
<span key={p} className={`text-[11px] px-1.5 py-0.5 rounded font-mono ${HIGH_RISK_PERMISSIONS.has(p) ? 'bg-warning/15 text-foreground' : 'bg-muted text-muted-foreground'}`}>{p}</span>
))}
</div>
)}
{[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].length > 0 && (
<div className="text-xs text-muted-foreground break-all">
Can send data to: {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].join(', ')}
</div>
)}
<div className="flex flex-wrap gap-2 pt-1">
<button type="button" onClick={() => viewSource(entry)} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md border border-border text-xs hover:bg-accent">
<Code className="w-3.5 h-3.5" /> View code
</button>
{entry.status !== 'approved' && (
<button type="button" disabled={busy === key} onClick={() => decide(entry, 'approved')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 disabled:opacity-50">
<Check className="w-3.5 h-3.5" /> Approve
</button>
)}
{entry.status !== 'denied' && (
<button type="button" disabled={busy === key} onClick={() => decide(entry, 'denied')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-foreground text-xs font-medium hover:bg-accent disabled:opacity-50">
<X className="w-3.5 h-3.5" /> Deny
</button>
)}
{entry.status !== 'pending' && (
<button type="button" disabled={busy === key} onClick={() => decide(entry, 'revoke')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md text-xs text-muted-foreground hover:text-destructive disabled:opacity-50">
Forget decision
</button>
)}
</div>
</div>
);
})}
</div>
)}
{source && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onMouseDown={(e) => { if (e.target === e.currentTarget) setSource(null); }}>
<div role="dialog" aria-modal="true" aria-label="Plugin code" className="flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border border-border bg-background shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-medium">{source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version}</span>
<button type="button" onClick={() => setSource(null)} aria-label="Close" className="rounded p-1 hover:bg-accent"><X className="h-4 w-4" /></button>
</div>
{source.warnings && source.warnings.length > 0 && (
<div className="flex items-start gap-2 border-b border-border px-4 py-2 text-xs text-foreground">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 text-warning" /> {source.warnings.join('; ')}
</div>
)}
<pre className="min-h-0 flex-1 overflow-auto p-4 text-[11px] leading-relaxed font-mono whitespace-pre-wrap break-all">
{source.error ?? source.code ?? 'Loading…'}
</pre>
</div>
</div>
)}
</div>
);
}
@@ -1,332 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges, useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
interface ConfigField {
type: 'string' | 'secret' | 'boolean' | 'number' | 'select';
label: string;
description?: string;
required?: boolean;
default?: unknown;
placeholder?: string;
options?: { label: string; value: string }[];
}
interface PluginConfig {
[key: string]: unknown;
}
interface PluginInfo {
id: string;
name: string;
description: string;
version: string;
author: string;
type: string;
permissions: string[];
enabled: boolean;
configSchema?: Record<string, ConfigField>;
}
interface Props {
pluginId: string;
onBack: () => void;
}
export function PluginConfigPanel({ pluginId, onBack }: Props) {
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
const [config, setConfig] = useState<PluginConfig>({});
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setLoading(true);
try {
const [pluginsRes, configRes] = await Promise.all([
apiFetch('/api/admin/plugins'),
apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`),
]);
if (cancelled) return;
if (pluginsRes.ok) {
const plugins: PluginInfo[] = await pluginsRes.json();
setPlugin(plugins.find(p => p.id === pluginId) || null);
}
if (configRes.ok) {
setConfig(await configRes.json());
}
} finally {
if (!cancelled) setLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, [pluginId]);
const savedValues = useMemo(() => {
const initial: Record<string, string> = {};
if (!plugin?.configSchema) return initial;
for (const [key, field] of Object.entries(plugin.configSchema)) {
const stored = config[key];
if (stored !== undefined && stored !== null) {
initial[key] = String(stored);
} else if (field.default !== undefined) {
initial[key] = String(field.default);
} else {
initial[key] = '';
}
}
return initial;
}, [plugin, config]);
useEffect(() => {
if (!plugin?.configSchema) return;
setFormValues(savedValues);
}, [plugin, savedValues]);
const dirty = Object.keys(savedValues).some(key => (formValues[key] ?? '') !== savedValues[key]);
useAdminUnsavedChanges('plugin-config', dirty);
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
const handleBack = () => requestLeave(onBack, 'plugin-config');
async function handleSaveAll() {
if (!plugin?.configSchema) return;
setSaving(true);
setMessage(null);
for (const [key, field] of Object.entries(plugin.configSchema)) {
if (field.required && !formValues[key]?.trim()) {
setMessage({ type: 'error', text: `"${field.label}" is required` });
setSaving(false);
return;
}
}
try {
let hasError = false;
let firstError: string | null = null;
const noteError = async (res: Response) => {
hasError = true;
if (firstError) return;
const data = await res.json().catch(() => null);
if (typeof data?.error === 'string') firstError = data.error;
};
for (const [key, field] of Object.entries(plugin.configSchema)) {
const newVal = formValues[key] ?? '';
const oldVal = config[key] !== undefined ? String(config[key]) : '';
if (newVal === oldVal) continue;
if (field.type === 'secret' && !newVal && config[key]) continue;
let value: unknown = newVal;
if (field.type === 'boolean') value = newVal === 'true';
else if (field.type === 'number') value = Number(newVal);
if (!newVal && !field.required) {
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setConfig(prev => { const next = { ...prev }; delete next[key]; return next; });
} else {
await noteError(res);
}
continue;
}
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
if (res.ok) {
setConfig(prev => ({ ...prev, [key]: value }));
} else {
await noteError(res);
}
}
setMessage(hasError
? { type: 'error', text: firstError ?? 'Some settings failed to save' }
: { type: 'success', text: 'Configuration saved' }
);
} catch {
setMessage({ type: 'error', text: 'Failed to save configuration' });
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin me-2" />
Loading...
</div>
);
}
if (!plugin) {
return (
<div className="space-y-4">
<button
type="button"
onClick={handleBack}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Plugins
</button>
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
</div>
);
}
const schema = plugin.configSchema;
const hasSchema = schema && Object.keys(schema).length > 0;
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleBack}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
aria-label="Back to Plugins"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
<Puzzle className="w-5 h-5" />
{plugin.name} Configuration
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
v{plugin.version} by {plugin.author}
</p>
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{hasSchema ? (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Settings</h2>
</div>
<div className="p-4 space-y-5">
{Object.entries(schema).map(([key, field]) => (
<div key={key}>
<label className="text-sm font-medium text-foreground block mb-1">
{field.label}
{field.required && <span className="text-destructive ms-0.5">*</span>}
</label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
)}
{field.type === 'boolean' ? (
<select
value={formValues[key] ?? String(field.default ?? 'false')}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
) : field.type === 'select' && field.options ? (
<select
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">- Select -</option>
{field.options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : field.type === 'secret' ? (
<div className="relative">
<input
type={revealSecrets[key] ? 'text' : 'password'}
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
/>
<button
type="button"
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
>
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
) : (
<input
type={field.type === 'number' ? 'number' : 'text'}
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={field.placeholder || ''}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
)}
</div>
))}
<button
onClick={handleSaveAll}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
Save Configuration
</button>
</div>
</div>
) : (
<div className="border border-border rounded-lg p-8 text-center">
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
</div>
)}
<PluginAdminSection pluginId={pluginId} />
</div>
);
}
/**
* Renders the plugin's own `admin-plugin-page` slot, if the plugin offers
* one. Sandboxed plugins ship a React component under `slots['admin-plugin-page']`
* and the host gives it a dedicated iframe inside the admin panel.
*/
function PluginAdminSection({ pluginId }: { pluginId: string }) {
const offers = usePluginSlotOffers('admin-plugin-page');
const offer = offers.find((o) => o.pluginId === pluginId);
if (!offer) return null;
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="bg-muted/40 px-4 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Plugin admin panel
</div>
<PluginIframeSlot pluginId={pluginId} slot="admin-plugin-page" />
</div>
);
}
-507
View File
@@ -1,507 +0,0 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import Link from 'next/link';
import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings, RotateCcw, Store } from '@/components/icons';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { PluginConfigPanel } from './plugin-config-panel';
import { PluginApprovalsPanel } from './plugin-approvals-panel';
interface PluginEntry {
id: string;
name: string;
version: string;
author: string;
description: string;
type: string;
enabled: boolean;
forceEnabled?: boolean;
permissions: string[];
installedAt: string;
updatedAt: string;
source?: { kind: 'marketplace'; slug: string; directoryUrl: string } | { kind: 'upload' };
/** Versions kept on this server for a rollback, most recently replaced first. */
previousVersions?: string[];
}
export function PluginsTab() {
const [plugins, setPlugins] = useState<PluginEntry[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
const [configuringId, setConfiguringId] = useState<string | null>(null);
useEffect(() => { fetchPlugins(); fetchPolicy(); }, []);
async function fetchPolicy() {
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy(data);
}
} catch { /* ignore */ }
}
function togglePluginsEnabled() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, pluginsEnabled: !prev.features.pluginsEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function togglePluginsUploadEnabled() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, pluginsUploadEnabled: !prev.features.pluginsUploadEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleRequirePluginApproval() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, requirePluginApproval: !prev.features.requirePluginApproval },
}));
setPolicyDirty(true);
setMessage(null);
}
async function handleSavePolicy() {
setSavingPolicy(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Plugin policy saved. Users will see changes on next login.' });
setPolicyDirty(false);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save policy' });
} finally {
setSavingPolicy(false);
}
}
async function fetchPlugins() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/plugins');
if (res.ok) setPlugins(await res.json());
} finally {
setLoading(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiFetch('/api/admin/plugins', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `Plugin "${data.plugin.name}" installed${warnings}` });
await fetchPlugins();
} else {
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
} catch {
setMessage({ type: 'error', text: 'Upload failed' });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
async function togglePlugin(id: string, enabled: boolean) {
setMessage(null);
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
});
if (res.ok) {
setPlugins(prev => prev.map(p => p.id === id ? { ...p, enabled } : p));
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
setMessage(null);
const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true;
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
setPlugins(prev => prev.map(p => p.id === id ? { ...p, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : p));
setPolicy(prev => {
const current = prev.forceEnabledPlugins || [];
return {
...prev,
forceEnabledPlugins: forceEnabled
? [...current.filter(pid => pid !== id), id]
: current.filter(pid => pid !== id),
};
});
setPolicyDirty(true);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function forceEnableAll() {
setMessage(null);
const disabled = plugins.filter(p => !p.enabled);
if (disabled.length === 0) {
setMessage({ type: 'success', text: 'All plugins are already enabled' });
return;
}
let failed = 0;
for (const p of disabled) {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, enabled: true }),
});
if (!res.ok) failed++;
}
setPlugins(prev => prev.map(p => failed === 0 ? { ...p, enabled: true } : p));
if (failed === 0) {
await fetchPlugins();
setMessage({ type: 'success', text: `All ${disabled.length} plugin(s) enabled` });
} else {
await fetchPlugins();
setMessage({ type: 'error', text: `${failed} plugin(s) failed to enable` });
}
}
async function forceDisableAll() {
setMessage(null);
const enabled = plugins.filter(p => p.enabled);
if (enabled.length === 0) {
setMessage({ type: 'success', text: 'All plugins are already disabled' });
return;
}
let failed = 0;
for (const p of enabled) {
const res = await apiFetch('/api/admin/plugins', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, enabled: false }),
});
if (!res.ok) failed++;
}
if (failed === 0) {
await fetchPlugins();
setMessage({ type: 'success', text: `All ${enabled.length} plugin(s) disabled` });
} else {
await fetchPlugins();
setMessage({ type: 'error', text: `${failed} plugin(s) failed to disable` });
}
}
async function rollbackPlugin(plugin: PluginEntry) {
const version = plugin.previousVersions?.[0];
if (!version) return;
if (!confirm(`Put v${version} of "${plugin.name}" back in place of v${plugin.version}? The copy kept on this server is used.`)) return;
setMessage(null);
const res = await apiFetch(`/api/admin/extensions/plugin/${encodeURIComponent(plugin.id)}/rollback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
setMessage({ type: 'success', text: `"${plugin.name}" rolled back to v${version}` });
await fetchPlugins();
} else {
setMessage({ type: 'error', text: data.error || 'Rollback failed' });
}
}
async function deletePlugin(id: string, name: string) {
if (!confirm(`Remove plugin "${name}"? This cannot be undone.`)) return;
setMessage(null);
const res = await apiFetch('/api/admin/plugins', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
setPlugins(prev => prev.filter(p => p.id !== id));
setMessage({ type: 'success', text: `Plugin "${name}" removed` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Delete failed' });
}
}
useAdminUnsavedChanges('plugins', policyDirty);
if (configuringId) {
return <PluginConfigPanel pluginId={configuringId} onBack={() => { setConfiguringId(null); fetchPlugins(); }} />;
}
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const pluginsEnabled = policy.features.pluginsEnabled ?? true;
const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true;
const requirePluginApproval = policy.features.requirePluginApproval ?? true;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Plugins</h1>
<p className="text-sm text-muted-foreground mt-1">Manage plugins and plugin policy for all users</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
disabled={savingPolicy}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Policy
</button>
)}
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload Plugin
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Plugin Policy</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Control plugin availability for users</p>
</div>
<div className="divide-y divide-border">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
</div>
<button type="button" role="switch" aria-checked={pluginsEnabled} aria-label="Plugins Enabled"
onClick={togglePluginsEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
</div>
<button type="button" role="switch" aria-checked={pluginsUploadEnabled} aria-label="User Plugin Uploads"
onClick={togglePluginsUploadEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
</div>
<button type="button" role="switch" aria-checked={requirePluginApproval} aria-label="Require Admin Approval"
onClick={toggleRequirePluginApproval}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{plugins.length > 0 && (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed plugins at once</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
>
<Power className="w-3.5 h-3.5" />
Enable All
</button>
<button
onClick={forceDisableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
>
<PowerOff className="w-3.5 h-3.5" />
Disable All
</button>
</div>
</div>
)}
</div>
</div>
{pluginsUploadEnabled && <PluginApprovalsPanel />}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Deployed Plugins</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded plugins for all users</p>
</div>
{plugins.length === 0 ? (
<div className="p-12 text-center">
<Package className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No plugins installed</p>
<p className="text-xs text-muted-foreground mt-1">Upload a plugin ZIP file to get started</p>
</div>
) : (
<div className="divide-y divide-border">
{plugins.map(plugin => (
<div key={plugin.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{plugin.name}</span>
<span className="text-xs text-muted-foreground">v{plugin.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${plugin.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
{plugin.enabled ? 'Enabled' : 'Disabled'}
</span>
{plugin.forceEnabled && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
<Lock className="w-3 h-3" /> Forced
</span>
)}
</div>
{plugin.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{plugin.description}</p>
)}
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
<span>by {plugin.author} &middot; {plugin.type} &middot; installed {new Date(plugin.installedAt).toLocaleDateString()}</span>
{plugin.source?.kind === 'marketplace' && (
<Link href={`/admin/marketplace/${encodeURIComponent(plugin.source.slug)}`} className="inline-flex items-center gap-1 hover:text-foreground hover:underline">
&middot; <Store className="w-3 h-3" /> Marketplace
</Link>
)}
</div>
{plugin.permissions.length > 0 && (
<div className="flex items-center gap-1 mt-1">
<AlertTriangle className="w-3 h-3 text-warning" />
<span className="text-xs text-warning">
Permissions: {plugin.permissions.join(', ')}
</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
{plugin.previousVersions && plugin.previousVersions.length > 0 && (
<button
type="button"
onClick={() => rollbackPlugin(plugin)}
title={`Roll back to v${plugin.previousVersions[0]}`}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<RotateCcw className="w-4 h-4" />
</button>
)}
<button
type="button"
onClick={() => setConfiguringId(plugin.id)}
title="Configure"
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Settings className="w-4 h-4" />
</button>
<button
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
className={`p-2 rounded-md transition-colors ${plugin.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
>
{plugin.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
</button>
<button
onClick={() => togglePlugin(plugin.id, !plugin.enabled)}
title={plugin.enabled ? 'Disable' : 'Enable'}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => deletePlugin(plugin.id, plugin.name)}
title="Remove"
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
-625
View File
@@ -1,625 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, Lock, Plus, Trash2, ArrowUp, ArrowDown } from '@/components/icons';
import { iconForName } from '@/components/icons';
import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps';
import { generateUUID } from '@/lib/utils';
import {
DEFAULT_RELAY_BASE_URL,
isValidRelayUrl,
normalizeRelayUrl,
resolveDefaultRelayUrl,
} from '@/lib/push-relays';
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
// forward on policy load), so it is hidden from the admin UI.
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
settingsExportEnabled: { label: 'Settings Export/Import', description: 'Allow users to export and import settings JSON' },
customKeywordsEnabled: { label: 'Custom Keywords', description: 'Allow user-created labels and tags' },
templatesEnabled: { label: 'Email Templates', description: 'Allow email template creation and library' },
calendarEnabled: { label: 'Calendar', description: 'Enable calendar features and views' },
calendarTasksEnabled: { label: 'Calendar Tasks', description: 'Show task panel in calendar view' },
contactsEnabled: { label: 'Contacts', description: 'Enable contacts/address book features' },
smimeEnabled: { label: 'S/MIME', description: 'Enable certificate management and email signing' },
externalContentEnabled: { label: 'External Content', description: 'Allow users to choose external content loading policy' },
debugModeEnabled: { label: 'Debug Mode', description: 'Allow users to enable debug/diagnostic mode' },
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
tabTitleSubjectEnabled: { label: 'Subject in Tab Title', description: 'Show the open message\'s subject in the browser tab title. Browsers also keep the title in history and screen shares show it; turn this off when mailboxes hold confidential correspondence, and the tab shows the folder instead.' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
accountArchiveEnabled: { label: 'Account List Backup', description: 'Let users save their list of accounts (address, server, label, colour) on this server, encrypted in the browser with a password of their choice, and restore it on another device. Mailbox passwords are stored only when the Passwords option below is on. Archives live in SETTINGS_DATA_DIR/account-archives; requires SESSION_SECRET.' },
accountArchivePasswordsEnabled: { label: 'Account List Backup: Passwords', description: 'With Account List Backup on, offer to include the mailbox passwords of accounts signed in with a password, so a restore signs in to them by itself. They are encrypted in the browser with the backup password together with the list; the server never sees them in clear, but anyone who learns the backup password gets every mailbox in it. Accounts signed in with OAuth or a TOTP code are saved without.' },
customFromEnabled: { label: 'Custom From Address', description: 'Let users type any sender address in the composer instead of picking one of their identities, and let replies to a catch-all address on their domain send from that address. Turn this off when users may only send as their configured identities.' },
archiveActionEnabled: { label: 'Archive Action', description: 'Offer "Archive" on messages: the toolbar button, the context menu and selection bar, the hover and swipe actions, the E shortcut and archiving after an export. Turn this off when mail is archived elsewhere, for example by a mail gateway; existing Archive folders stay as ordinary folders and Bulwark no longer creates one.' },
};
const RESTRICTABLE_SETTINGS = [
{ key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'] },
{ key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'] },
{ key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'] },
{ key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean' },
{ key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number' },
{ key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'] },
{ key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' },
{ key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'] },
{ key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number' },
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
{ key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'] },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean' },
{ key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' },
];
/** Mirrors the sanitizer's URL rule so the admin sees the drop before saving. */
function isValidDefaultAppUrl(raw: string): boolean {
try {
const parsed = new URL(raw.trim());
return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && !!parsed.host;
} catch {
return false;
}
}
export function PolicyTab() {
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => { fetchPolicy(); }, []);
async function fetchPolicy() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy(data);
}
} finally {
setLoading(false);
}
}
function toggleFeature(key: keyof FeatureGates) {
setPolicy(prev => ({
...prev,
features: { ...prev.features, [key]: !prev.features[key] },
}));
setDirty(true);
setMessage(null);
}
function setPushRelayUrl(value: string) {
setPolicy(prev => ({ ...prev, pushRelayUrl: value }));
setDirty(true);
setMessage(null);
}
function updatePushRelay(index: number, patch: Partial<PushRelayOption>) {
setPolicy(prev => ({
...prev,
pushRelays: (prev.pushRelays ?? []).map((relay, i) => (i === index ? { ...relay, ...patch } : relay)),
}));
setDirty(true);
setMessage(null);
}
function addPushRelay() {
setPolicy(prev => ({ ...prev, pushRelays: [...(prev.pushRelays ?? []), { label: '', url: '' }] }));
setDirty(true);
setMessage(null);
}
function removePushRelay(index: number) {
setPolicy(prev => {
const remaining = (prev.pushRelays ?? []).filter((_, i) => i !== index);
const removedUrl = normalizeRelayUrl((prev.pushRelays ?? [])[index]?.url);
// Dropping the relay users are defaulted to would leave a dangling
// default, so fall back to the built-in one.
const pushRelayUrl = normalizeRelayUrl(prev.pushRelayUrl) === removedUrl ? '' : prev.pushRelayUrl;
return { ...prev, pushRelays: remaining, pushRelayUrl };
});
setDirty(true);
setMessage(null);
}
function togglePushRelayLocked() {
setPolicy(prev => ({ ...prev, pushRelayUrlLocked: !prev.pushRelayUrlLocked }));
setDirty(true);
setMessage(null);
}
function updateDefaultApp(index: number, patch: Partial<AdminSidebarApp>) {
setPolicy(prev => ({
...prev,
defaultSidebarApps: (prev.defaultSidebarApps ?? []).map((app, i) => (i === index ? { ...app, ...patch } : app)),
}));
setDirty(true);
setMessage(null);
}
function addDefaultApp() {
setPolicy(prev => {
const apps = prev.defaultSidebarApps ?? [];
if (apps.length >= MAX_DEFAULT_SIDEBAR_APPS) return prev;
return {
...prev,
defaultSidebarApps: [
...apps,
{
id: `${DEFAULT_SIDEBAR_APP_ID_PREFIX}${generateUUID()}`,
name: '',
url: '',
icon: 'Globe',
openMode: 'tab' as const,
showOnMobile: false,
},
],
};
});
setDirty(true);
setMessage(null);
}
function removeDefaultApp(index: number) {
setPolicy(prev => ({
...prev,
defaultSidebarApps: (prev.defaultSidebarApps ?? []).filter((_, i) => i !== index),
}));
setDirty(true);
setMessage(null);
}
/** Rail order follows this list, so admins need to be able to reorder it. */
function moveDefaultApp(index: number, delta: number) {
setPolicy(prev => {
const apps = [...(prev.defaultSidebarApps ?? [])];
const target = index + delta;
if (target < 0 || target >= apps.length) return prev;
[apps[index], apps[target]] = [apps[target], apps[index]];
return { ...prev, defaultSidebarApps: apps };
});
setDirty(true);
setMessage(null);
}
function toggleLocked(settingKey: string) {
setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {};
const newRestrictions = { ...prev.restrictions };
if (existing.locked) {
delete newRestrictions[settingKey];
} else {
newRestrictions[settingKey] = { ...existing, locked: true };
}
return { ...prev, restrictions: newRestrictions };
});
setDirty(true);
setMessage(null);
}
function toggleHidden(settingKey: string) {
setPolicy(prev => {
const existing = prev.restrictions[settingKey] || {};
const newRestrictions = { ...prev.restrictions };
newRestrictions[settingKey] = { ...existing, hidden: !existing.hidden };
if (!newRestrictions[settingKey].hidden && !newRestrictions[settingKey].locked) {
delete newRestrictions[settingKey];
}
return { ...prev, restrictions: newRestrictions };
});
setDirty(true);
setMessage(null);
}
async function handleSave() {
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Policy saved. Users will see changes on next login.' });
setDirty(false);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
useAdminUnsavedChanges('policy', dirty);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))];
const defaultRelayUrl = resolveDefaultRelayUrl(policy);
const defaultSidebarApps = policy.defaultSidebarApps ?? [];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">User Policy</h1>
<p className="text-sm text-muted-foreground mt-1">Control which features and settings users can access</p>
</div>
{dirty && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save policy
</button>
)}
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Feature Gates</h2>
<p className="text-xs text-muted-foreground mt-0.5">Toggle entire features on or off for all users. Plugin and theme gates are on their respective admin pages.</p>
</div>
<div className="divide-y divide-border">
{(Object.keys(DEFAULT_FEATURE_GATES) as (keyof FeatureGates)[])
.filter(key => !EXCLUDED_FEATURE_GATES.includes(key))
.map(key => {
const meta = FEATURE_GATE_LABELS[key];
if (!meta) return null;
const { label, description } = meta;
const enabled = policy.features[key];
return (
<div key={key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">{label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<button type="button" role="switch" aria-checked={enabled} aria-label={label}
onClick={() => toggleFeature(key)}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Push Relays</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Relays users can pick from in notification settings. The built-in Bulwark relay is always
offered; add your own here. Users choose from this list - they cannot enter a URL.
</p>
</div>
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="text-sm font-medium text-foreground">Relays</span>
<p className="text-xs text-muted-foreground mt-0.5">
Mark one as the default. Relays without a valid URL are not offered to users.
</p>
</div>
<button
onClick={addPushRelay}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors shrink-0"
>
<Plus className="w-3.5 h-3.5" />
Add relay
</button>
</div>
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">Bulwark relay</span>
<span className="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
built-in
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{DEFAULT_RELAY_BASE_URL}</p>
</div>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer shrink-0">
<input
type="radio"
name="default-push-relay"
checked={defaultRelayUrl === normalizeRelayUrl(DEFAULT_RELAY_BASE_URL)}
onChange={() => setPushRelayUrl('')}
className="border-input"
/>
Default
</label>
</div>
</div>
{(policy.pushRelays ?? []).map((relay, index) => (
<div key={index} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
<div className="sm:col-span-4">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Name</label>
<input
type="text"
autoComplete="off"
spellCheck={false}
value={relay.label}
onChange={(e) => updatePushRelay(index, { label: e.target.value })}
placeholder="Company relay"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="sm:col-span-8">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Relay URL</label>
<div className="flex items-center gap-2">
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={relay.url}
onChange={(e) => updatePushRelay(index, { url: e.target.value })}
placeholder="https://notifications.relay.example.com"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
onClick={() => removePushRelay(index)}
className="shrink-0 text-muted-foreground hover:text-destructive"
title="Remove relay"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer w-fit">
<input
type="radio"
name="default-push-relay"
checked={isValidRelayUrl(relay.url) && defaultRelayUrl === normalizeRelayUrl(relay.url)}
disabled={!isValidRelayUrl(relay.url)}
onChange={() => setPushRelayUrl(normalizeRelayUrl(relay.url))}
className="border-input disabled:opacity-40"
/>
Default
</label>
</div>
))}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer w-fit">
<input
type="checkbox"
checked={!!policy.pushRelayUrlLocked}
onChange={togglePushRelayLocked}
className="rounded border-input"
/>
<Lock className="w-3 h-3" /> Lock - users are pinned to the default relay
</label>
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Default Sidebar Apps</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Apps every user gets in the navigation rail without configuring anything. They appear
above the user&apos;s own apps and cannot be edited or removed by users. Independent of
the Sidebar Apps feature gate above, which only governs user-added apps.
</p>
</div>
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<span className="text-sm font-medium text-foreground">Apps</span>
<p className="text-xs text-muted-foreground mt-0.5">
Entries without a name or a valid http(s) URL are dropped when saving. Inline apps
are framed inside the webmail, so the target must allow being embedded.
</p>
</div>
<button
onClick={addDefaultApp}
disabled={defaultSidebarApps.length >= MAX_DEFAULT_SIDEBAR_APPS}
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors shrink-0 disabled:opacity-50 disabled:pointer-events-none"
>
<Plus className="w-3.5 h-3.5" />
Add app
</button>
</div>
{defaultSidebarApps.length === 0 && (
<p className="text-xs text-muted-foreground py-2">
No default apps. Users only see the apps they add themselves.
</p>
)}
{defaultSidebarApps.map((app, index) => {
const AppIcon = iconForName(app.icon);
const urlInvalid = app.url.trim().length > 0 && !isValidDefaultAppUrl(app.url);
return (
<div key={app.id} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
<div className="sm:col-span-4">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Name</label>
<input
type="text"
autoComplete="off"
spellCheck={false}
maxLength={50}
value={app.name}
onChange={(e) => updateDefaultApp(index, { name: e.target.value })}
placeholder="Intranet"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="sm:col-span-5">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">URL</label>
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
maxLength={2048}
value={app.url}
onChange={(e) => updateDefaultApp(index, { url: e.target.value })}
placeholder="https://intranet.example.com"
className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${urlInvalid ? 'border-destructive' : 'border-input'}`}
/>
{urlInvalid && (
<p className="text-[11px] text-destructive mt-1">Enter a valid http or https URL</p>
)}
</div>
<div className="sm:col-span-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
Icon (
<a href="https://tabler.io/icons" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground">Tabler</a>
)
</label>
<div className="flex items-center gap-2">
<input
type="text"
autoComplete="off"
spellCheck={false}
maxLength={64}
value={app.icon}
onChange={(e) => updateDefaultApp(index, { icon: e.target.value })}
placeholder="world"
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<span
className="shrink-0 w-8 h-8 rounded-md border border-border bg-background flex items-center justify-center text-muted-foreground"
title={AppIcon ? app.icon : 'Unknown icon - falls back to Globe'}
>
{AppIcon ? <AppIcon className="w-4 h-4" /> : <span className="text-[10px]">?</span>}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="radio"
name={`default-app-mode-${app.id}`}
checked={app.openMode !== 'inline'}
onChange={() => updateDefaultApp(index, { openMode: 'tab' })}
className="border-input"
/>
New tab
</label>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="radio"
name={`default-app-mode-${app.id}`}
checked={app.openMode === 'inline'}
onChange={() => updateDefaultApp(index, { openMode: 'inline' })}
className="border-input"
/>
Inline
</label>
</div>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={app.showOnMobile}
onChange={() => updateDefaultApp(index, { showOnMobile: !app.showOnMobile })}
className="rounded border-input"
/>
Show on mobile
</label>
<div className="flex items-center gap-1 ms-auto">
<button
onClick={() => moveDefaultApp(index, -1)}
disabled={index === 0}
className="text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:pointer-events-none p-1"
title="Move up"
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
onClick={() => moveDefaultApp(index, 1)}
disabled={index === defaultSidebarApps.length - 1}
className="text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:pointer-events-none p-1"
title="Move down"
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
<button
onClick={() => removeDefaultApp(index)}
className="text-muted-foreground hover:text-destructive p-1"
title="Remove app"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
{categories.map(category => (
<div key={category} className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">{category}</h2>
</div>
<div className="divide-y divide-border">
{RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => {
const restriction = policy.restrictions[setting.key] || {};
return (
<div key={setting.key} className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span className="text-sm text-foreground">{setting.label}</span>
<div className="flex items-center gap-3 shrink-0">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.locked} onChange={() => toggleLocked(setting.key)}
className="rounded border-input" />
<Lock className="w-3 h-3" /> Lock
</label>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={!!restriction.hidden} onChange={() => toggleHidden(setting.key)}
className="rounded border-input" />
Hide
</label>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
-300
View File
@@ -1,300 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, RotateCcw, Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { JmapServersSection } from './_jmap-servers-section';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
interface ConfigEntry {
value?: unknown;
source: 'admin' | 'env' | 'default';
hasValue?: boolean;
}
export function SettingsTab() {
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
const [edits, setEdits] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
fetchConfig();
}, []);
async function fetchConfig() {
setLoading(true);
const res = await apiFetch('/api/admin/config');
if (res.ok) {
setConfig(await res.json());
}
setLoading(false);
}
function handleChange(key: string, value: unknown) {
setEdits(prev => ({ ...prev, [key]: value }));
setMessage(null);
}
function currentValue(key: string): unknown {
if (key in edits) return edits[key];
return config[key]?.value;
}
async function handleSave() {
if (Object.keys(edits).length === 0) return;
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/config', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edits),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Settings saved. Changes take effect on next page load.' });
setEdits({});
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
async function handleRevert(key: string) {
const res = await apiFetch('/api/admin/config', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (res.ok) {
setEdits(prev => {
const next = { ...prev };
delete next[key];
return next;
});
await fetchConfig();
setMessage({ type: 'success', text: `${key} reverted to default` });
}
}
const hasEdits = Object.keys(edits).length > 0;
useAdminUnsavedChanges('settings', hasEdits);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Server Settings</h1>
<p className="text-sm text-muted-foreground mt-1">General server configuration</p>
</div>
{hasEdits && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save changes
</button>
)}
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
<SettingsSection title="General">
<TextSetting label="Application Name" configKey="appName" value={currentValue('appName') as string} source={config.appName?.source} onChange={handleChange} onRevert={handleRevert} />
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
{!!currentValue('allowCustomJmapEndpoint') && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
</p>
</div>
)}
<ToggleSetting label="Stalwart Features" description="Enable Stalwart Mail Server-specific features" configKey="stalwartFeaturesEnabled" value={currentValue('stalwartFeaturesEnabled') as boolean} source={config.stalwartFeaturesEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Search Engine Indexing" description="Allow search engines to index this webmail. Off (the default) sends noindex/nofollow in the page head, recommended for private deployments." configKey="searchEngineIndexing" value={currentValue('searchEngineIndexing') as boolean} source={config.searchEngineIndexing?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection title="Login Page">
<ToggleSetting label="Show Version" description="Show the webmail version in the login page footer. Turn off to keep the exact version hidden from visitors who are not signed in." configKey="loginShowVersion" value={currentValue('loginShowVersion') as boolean} source={config.loginShowVersion?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show 2FA Code Option" description="Show the &quot;I have a 2FA code&quot; toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show Access Token Sign-In" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show Remember Me" description="Let people choose whether to stay signed in. Off (the default) remembers every sign-in. Either way this needs SESSION_SECRET to be set." configKey="loginShowRememberMe" value={currentValue('loginShowRememberMe') as boolean} source={config.loginShowRememberMe?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show Heading" description="Show the application name below the login logo" configKey="loginShowHeading" value={currentValue('loginShowHeading') as boolean} source={config.loginShowHeading?.source} onChange={handleChange} onRevert={handleRevert} />
<ToggleSetting label="Show Subtitle" description="Show the subtitle below the login heading" configKey="loginShowSubtitle" value={currentValue('loginShowSubtitle') as boolean} source={config.loginShowSubtitle?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection title="JMAP Servers (multi-server)">
<ToggleSetting
label="Auto-pick server by email domain"
description="When users type their email, automatically select the matching server from the list below."
configKey="jmapServerAutoPickByDomain"
value={currentValue('jmapServerAutoPickByDomain') as boolean}
source={config.jmapServerAutoPickByDomain?.source}
onChange={handleChange}
onRevert={handleRevert}
/>
<JmapServersSection
value={(currentValue('jmapServers') as JmapServerEntry[]) ?? []}
source={config.jmapServers?.source}
onChange={(next) => handleChange('jmapServers', next)}
onRevert={() => handleRevert('jmapServers')}
/>
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
</p>
</div>
)}
</SettingsSection>
<SettingsSection title="Document Editing (WOPI)">
<TextSetting label="WOPI Client URL" configKey="wopiClientUrl" value={currentValue('wopiClientUrl') as string} source={config.wopiClientUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://office.example.com" />
<TextSetting label="WOPI Host URL Override" configKey="wopiHostUrl" value={currentValue('wopiHostUrl') as string} source={config.wopiHostUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="http://webmail:3000" />
<div className="px-4 py-2.5">
<p className="text-xs text-muted-foreground leading-relaxed">
Point the client URL at a WOPI-compatible office suite (Collabora Online, OnlyOffice, EuroOffice, …) to let users edit documents in Files. The client URL may be an internal address: the browser loads the editor from the address its discovery advertises. The host override is only needed when the editor reaches this webmail under a different address than the browser does (e.g. Docker networks); give the address alone, the webmail&apos;s base path is added to it. The editor must also allow this webmail as a WOPI host.
</p>
</div>
</SettingsSection>
<SettingsSection title="Logging">
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
<SettingsSection title="Settings Sync">
<ToggleSetting label="Settings Sync Enabled" description="Requires SESSION_SECRET to be set" configKey="settingsSyncEnabled" value={currentValue('settingsSyncEnabled') as boolean} source={config.settingsSyncEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
</SettingsSection>
</div>
);
}
function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
</div>
<div className="divide-y divide-border">
{children}
</div>
</div>
);
}
function SourceBadge({ source }: { source?: string }) {
if (!source || source === 'default') return null;
return (
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
{source}
</span>
);
}
function TextSetting({ label, configKey, value, source, onChange, onRevert, placeholder }: {
label: string; configKey: string; value: string; source?: string;
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void; placeholder?: string;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<label className="text-sm text-foreground">{label}</label>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
placeholder={placeholder}
className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="shrink-0 text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
function ToggleSetting({ label, description, configKey, value, source, onChange, onRevert }: {
label: string; description?: string; configKey: string; value: boolean; source?: string;
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
role="switch"
aria-checked={value}
aria-label={label}
onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
function SelectSetting({ label, configKey, value, source, options, onChange, onRevert }: {
label: string; configKey: string; value: string; source?: string; options: string[];
onChange: (key: string, value: unknown) => void; onRevert: (key: string) => void;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-foreground">{label}</span>
<SourceBadge source={source} />
</div>
<div className="flex items-center gap-2 shrink-0">
<select
value={value ?? ''}
onChange={(e) => onChange(configKey, e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
{source === 'admin' && (
<button onClick={() => onRevert(configKey)} className="text-muted-foreground hover:text-foreground" title="Revert to default">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
-251
View File
@@ -1,251 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
interface TelemetryStatus {
consent: 'pending' | 'on' | 'off';
consentSource: 'env' | 'file';
endpoint: string;
defaultEndpoint: string;
consentedAt: string | null;
lastSentAt: string | null;
nextScheduledAt: string | null;
payloadPreview: Record<string, unknown>;
accountCounts: { total: number; active7d: number };
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
const days = Math.floor(h / 24);
return `${days} days ago`;
}
export function TelemetryTab() {
const [status, setStatus] = useState<TelemetryStatus | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [endpointDraft, setEndpointDraft] = useState('');
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/telemetry');
if (!r.ok) throw new Error('failed to load');
const data = (await r.json()) as TelemetryStatus;
setStatus(data);
setEndpointDraft(data.endpoint);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function setConsent(consent: 'on' | 'off'): Promise<void> {
setBusy('consent');
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-consent', consent }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
alert(j.error ?? 'failed');
}
await refresh();
} finally { setBusy(null); }
}
async function saveEndpoint(): Promise<void> {
setBusy('endpoint');
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
alert(j.error ?? 'failed');
}
await refresh();
} finally { setBusy(null); }
}
async function sendNow(): Promise<void> {
setBusy('send');
setSendResult(null);
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'send-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
setSendResult({
ok: !!j.ok,
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally { setBusy(null); }
}
if (loading || !status) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading…
</div>
);
}
const envOverridden = status.consentSource === 'env';
const isOn = status.consent === 'on';
return (
<div className="space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
Bulwark can send one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. It&apos;s <strong>off by
default</strong>; one click below enables it and helps us make the product better. No
email addresses, no hostnames, no IPs are sent.{' '}
<a
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
target="_blank"
rel="noreferrer"
className="underline inline-flex items-center gap-1"
>
Full schema and policy <ExternalLink className="h-3 w-3" />
</a>
</p>
</header>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'}
{status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!'}
{status.consent === 'off' && 'Heartbeats are off (default).'}
{envOverridden && (
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
)}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={busy === 'consent' || envOverridden || isOn}
onClick={() => void setConsent('on')}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Enable
</button>
<button
type="button"
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
onClick={() => void setConsent('off')}
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
>
Disable
</button>
</div>
</div>
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
<dt className="text-muted-foreground">Last sent</dt>
<dd>{timeAgo(status.lastSentAt)}</dd>
<dt className="text-muted-foreground">Next scheduled</dt>
<dd>{timeAgo(status.nextScheduledAt)}</dd>
<dt className="text-muted-foreground">Consented at</dt>
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
</dl>
</section>
<section className="rounded-lg border p-4 space-y-2">
<div className="font-medium">Account activity</div>
<p className="text-sm text-muted-foreground">
Unique accounts that have logged in over the last 90 days. Identities are stored as a
per-instance HMAC, never as plaintext usernames. These are the numbers reported in the
heartbeat as bucketed ranges.
</p>
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
<dt className="text-muted-foreground">Total (90d)</dt>
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
<dt className="text-muted-foreground">Active (7d)</dt>
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
</dl>
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="font-medium">Endpoint</div>
<p className="text-sm text-muted-foreground">
Where heartbeats are sent. Defaults to the project&apos;s collector. Point at your own collector
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="url"
value={endpointDraft}
onChange={(e) => setEndpointDraft(e.target.value)}
placeholder={status.defaultEndpoint}
className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background"
/>
<button
type="button"
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
onClick={() => void saveEndpoint()}
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center justify-center gap-1"
>
<Save className="h-4 w-4" /> Save
</button>
</div>
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">Payload preview</div>
<div className="text-sm text-muted-foreground">
Exactly what the next heartbeat would send from this install, right now.
</div>
</div>
<button
type="button"
disabled={busy === 'send' || !isOn}
onClick={() => void sendNow()}
className="shrink-0 px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" /> Send now
</button>
</div>
{sendResult && (
<div
className={`text-sm flex items-center gap-2 ${
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
}`}
>
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
{sendResult.msg}
</div>
)}
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
{JSON.stringify(status.payloadPreview, null, 2)}
</pre>
</section>
</div>
);
}
-632
View File
@@ -1,632 +0,0 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import Link from 'next/link';
import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen, RotateCcw, Store } from '@/components/icons';
import type { ColorMode, SettingsPolicy, ThemePolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
// Derive from the single source of truth so newly added built-in themes show
// up here automatically (was previously a hardcoded subset — see #496).
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
interface ThemeEntry {
id: string;
name: string;
version: string;
author: string;
description: string;
variants: string[];
enabled: boolean;
forceEnabled?: boolean;
installedAt: string;
updatedAt: string;
source?: { kind: 'marketplace'; slug: string; directoryUrl: string } | { kind: 'upload' };
/** Versions kept on this server for a rollback, most recently replaced first. */
previousVersions?: string[];
}
export function ThemesTab() {
const [themes, setThemes] = useState<ThemeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [policy, setPolicy] = useState<SettingsPolicy>({ ...DEFAULT_POLICY });
const [policyDirty, setPolicyDirty] = useState(false);
const [savingPolicy, setSavingPolicy] = useState(false);
useEffect(() => { fetchThemes(); fetchPolicy(); }, []);
async function fetchPolicy() {
try {
const res = await apiFetch('/api/admin/policy');
if (res.ok) {
const data = await res.json();
setPolicy({
...data,
themePolicy: { ...DEFAULT_THEME_POLICY, ...(data.themePolicy || {}) },
});
}
} catch { /* ignore */ }
}
function toggleThemesEnabled() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, themesEnabled: !prev.features.themesEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleUserThemeUploads() {
setPolicy(prev => ({
...prev,
features: { ...prev.features, userThemesEnabled: !prev.features.userThemesEnabled },
}));
setPolicyDirty(true);
setMessage(null);
}
function toggleBuiltinTheme(themeId: string) {
setPolicy(prev => {
const disabled = prev.themePolicy?.disabledBuiltinThemes || [];
const isDisabled = disabled.includes(themeId);
return {
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
disabledBuiltinThemes: isDisabled
? disabled.filter((id: string) => id !== themeId)
: [...disabled, themeId],
},
};
});
setPolicyDirty(true);
setMessage(null);
}
function toggleAdminTheme(themeId: string) {
setPolicy(prev => {
const disabled = prev.themePolicy?.disabledThemes || [];
const isDisabled = disabled.includes(themeId);
return {
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
disabledThemes: isDisabled
? disabled.filter((id: string) => id !== themeId)
: [...disabled, themeId],
},
};
});
setPolicyDirty(true);
setMessage(null);
}
function setDefaultTheme(themeId: string | null) {
setPolicy(prev => ({
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
defaultThemeId: themeId,
},
}));
setPolicyDirty(true);
setMessage(null);
}
function setColorModePolicy(patch: Partial<Pick<ThemePolicy, 'defaultColorMode' | 'lockColorMode'>>) {
setPolicy(prev => ({
...prev,
themePolicy: {
...DEFAULT_THEME_POLICY,
...prev.themePolicy,
...patch,
},
}));
setPolicyDirty(true);
setMessage(null);
}
async function handleSavePolicy() {
setSavingPolicy(true);
setMessage(null);
try {
const res = await apiFetch('/api/admin/policy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policy),
});
if (res.ok) {
setMessage({ type: 'success', text: 'Theme policy saved. Users will see changes on next login.' });
setPolicyDirty(false);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save policy' });
}
} catch {
setMessage({ type: 'error', text: 'Failed to save policy' });
} finally {
setSavingPolicy(false);
}
}
async function fetchThemes() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/themes');
if (res.ok) setThemes(await res.json());
} finally {
setLoading(false);
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setMessage(null);
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiFetch('/api/admin/themes', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (res.ok) {
const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : '';
setMessage({ type: 'success', text: `Theme "${data.theme.name}" installed${warnings}` });
await fetchThemes();
} else {
setMessage({ type: 'error', text: data.error || 'Upload failed' });
}
} catch {
setMessage({ type: 'error', text: 'Upload failed' });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
async function toggleTheme(id: string, enabled: boolean) {
setMessage(null);
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
});
if (res.ok) {
setThemes(prev => prev.map(t => t.id === id ? { ...t, enabled } : t));
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function toggleForceEnabled(id: string, forceEnabled: boolean) {
setMessage(null);
const body: Record<string, unknown> = { id, forceEnabled };
if (forceEnabled) body.enabled = true;
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
setThemes(prev => prev.map(t => t.id === id ? { ...t, forceEnabled, ...(forceEnabled ? { enabled: true } : {}) } : t));
setPolicy(prev => {
const current = prev.forceEnabledThemes || [];
return {
...prev,
forceEnabledThemes: forceEnabled
? [...current.filter(tid => tid !== id), id]
: current.filter(tid => tid !== id),
};
});
setPolicyDirty(true);
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Update failed' });
}
}
async function forceEnableAll() {
setMessage(null);
const disabled = themes.filter(t => !t.enabled);
if (disabled.length === 0) {
setMessage({ type: 'success', text: 'All themes are already enabled' });
return;
}
let failed = 0;
for (const t of disabled) {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, enabled: true }),
});
if (!res.ok) failed++;
}
if (failed === 0) {
await fetchThemes();
setMessage({ type: 'success', text: `All ${disabled.length} theme(s) enabled` });
} else {
await fetchThemes();
setMessage({ type: 'error', text: `${failed} theme(s) failed to enable` });
}
}
async function forceDisableAll() {
setMessage(null);
const enabled = themes.filter(t => t.enabled);
if (enabled.length === 0) {
setMessage({ type: 'success', text: 'All themes are already disabled' });
return;
}
let failed = 0;
for (const t of enabled) {
const res = await apiFetch('/api/admin/themes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: t.id, enabled: false }),
});
if (!res.ok) failed++;
}
if (failed === 0) {
await fetchThemes();
setMessage({ type: 'success', text: `All ${enabled.length} theme(s) disabled` });
} else {
await fetchThemes();
setMessage({ type: 'error', text: `${failed} theme(s) failed to disable` });
}
}
async function rollbackTheme(theme: ThemeEntry) {
const version = theme.previousVersions?.[0];
if (!version) return;
if (!confirm(`Put v${version} of "${theme.name}" back in place of v${theme.version}? The copy kept on this server is used.`)) return;
setMessage(null);
const res = await apiFetch(`/api/admin/extensions/theme/${encodeURIComponent(theme.id)}/rollback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
setMessage({ type: 'success', text: `"${theme.name}" rolled back to v${version}` });
await fetchThemes();
} else {
setMessage({ type: 'error', text: data.error || 'Rollback failed' });
}
}
async function deleteTheme(id: string, name: string) {
if (!confirm(`Remove theme "${name}"? This cannot be undone.`)) return;
setMessage(null);
const res = await apiFetch('/api/admin/themes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
setThemes(prev => prev.filter(t => t.id !== id));
setMessage({ type: 'success', text: `Theme "${name}" removed` });
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Delete failed' });
}
}
useAdminUnsavedChanges('themes', policyDirty);
if (loading) {
return <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">Loading...</div>;
}
const themesEnabled = policy.features.themesEnabled ?? true;
const userThemesEnabled = policy.features.userThemesEnabled ?? true;
const lockColorMode = policy.themePolicy?.lockColorMode === true;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Themes</h1>
<p className="text-sm text-muted-foreground mt-1">Manage themes and theme policy for all users</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{policyDirty && (
<button
onClick={handleSavePolicy}
disabled={savingPolicy}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{savingPolicy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Policy
</button>
)}
<label className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 cursor-pointer transition-all shadow-sm">
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload Theme
<input
ref={fileInputRef}
type="file"
accept=".zip"
onChange={handleUpload}
disabled={uploading}
className="sr-only"
/>
</label>
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Theme Policy</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Control theme availability and defaults for users</p>
</div>
<div className="divide-y divide-border">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Themes Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
</div>
<button type="button" role="switch" aria-checked={themesEnabled} aria-label="Themes Enabled"
onClick={toggleThemesEnabled}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">User Theme Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
</div>
<button type="button" role="switch" aria-checked={userThemesEnabled} aria-label="User Theme Uploads"
onClick={toggleUserThemeUploads}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
{themes.length > 0 && (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Force Enable / Disable All</span>
<p className="text-xs text-muted-foreground mt-0.5">Bulk toggle all deployed themes at once</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={forceEnableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 transition-colors"
>
<Power className="w-3.5 h-3.5" />
Enable All
</button>
<button
onClick={forceDisableAll}
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-foreground transition-colors"
>
<PowerOff className="w-3.5 h-3.5" />
Disable All
</button>
</div>
</div>
)}
<div className="px-4 py-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Default Theme</span>
<p className="text-xs text-muted-foreground mt-0.5">Theme applied when users have not chosen one</p>
</div>
<select
value={policy.themePolicy?.defaultThemeId || ''}
onChange={(e) => setDefaultTheme(e.target.value || null)}
className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
>
<option value="">System Default</option>
<optgroup label="Built-in">
{BUILTIN_THEME_OPTIONS
.filter(t => !(policy.themePolicy?.disabledBuiltinThemes || []).includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</optgroup>
{themes.length > 0 && (
<optgroup label="Admin-deployed">
{themes
.filter(t => !(policy.themePolicy?.disabledThemes || []).includes(t.id))
.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</optgroup>
)}
</select>
</div>
</div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Default Color Mode</span>
<p className="text-xs text-muted-foreground mt-0.5">Light, dark or system for users who have not picked one</p>
</div>
<select
value={policy.themePolicy?.defaultColorMode || 'system'}
onChange={(e) => setColorModePolicy({ defaultColorMode: e.target.value === 'system' ? null : e.target.value as ColorMode })}
className="h-8 px-2 w-full sm:w-auto shrink-0 rounded-md border border-input bg-background text-sm text-foreground"
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Lock Color Mode</span>
<p className="text-xs text-muted-foreground mt-0.5">Everyone gets the default color mode and cannot switch it in settings, on the login page or in the command palette</p>
</div>
<button type="button" role="switch" aria-checked={lockColorMode} aria-label="Lock Color Mode"
onClick={() => setColorModePolicy({ lockColorMode: !lockColorMode })}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${lockColorMode ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${lockColorMode ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
<div className="px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Built-in Themes</span>
<div className="mt-2 space-y-2">
{BUILTIN_THEME_OPTIONS.map(theme => {
const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id);
return (
<div key={theme.id} className="flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{theme.name}</span>
<button type="button" role="switch" aria-checked={!disabled} aria-label={theme.name}
onClick={() => toggleBuiltinTheme(theme.id)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</div>
</div>
{themes.length > 0 && (
<div className="px-4 py-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Admin-deployed Themes</span>
<div className="mt-2 space-y-2">
{themes.map(theme => {
const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id);
return (
<div key={theme.id} className="flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{theme.name}</span>
<button type="button" role="switch" aria-checked={!disabled} aria-label={theme.name}
onClick={() => toggleAdminTheme(theme.id)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Deployed Themes</h2>
</div>
<p className="text-xs text-muted-foreground mt-0.5">Admin-uploaded themes available to all users</p>
</div>
{themes.length === 0 ? (
<div className="p-12 text-center">
<Palette className="w-10 h-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No themes installed</p>
<p className="text-xs text-muted-foreground mt-1">Upload a theme ZIP file to get started</p>
</div>
) : (
<div className="divide-y divide-border">
{themes.map(theme => (
<div key={theme.id} className="px-4 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{theme.name}</span>
<span className="text-xs text-muted-foreground">v{theme.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${theme.enabled ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}`}>
{theme.enabled ? 'Enabled' : 'Disabled'}
</span>
{theme.forceEnabled && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400 flex items-center gap-1">
<Lock className="w-3 h-3" /> Forced
</span>
)}
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{theme.description}</p>
)}
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
<span>by {theme.author} &middot; {theme.variants.join(', ')} &middot; installed {new Date(theme.installedAt).toLocaleDateString()}</span>
{theme.source?.kind === 'marketplace' && (
<Link href={`/admin/marketplace/${encodeURIComponent(theme.source.slug)}`} className="inline-flex items-center gap-1 hover:text-foreground hover:underline">
&middot; <Store className="w-3 h-3" /> Marketplace
</Link>
)}
</div>
</div>
<div className="flex items-center gap-2">
{theme.previousVersions && theme.previousVersions.length > 0 && (
<button
type="button"
onClick={() => rollbackTheme(theme)}
title={`Roll back to v${theme.previousVersions[0]}`}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<RotateCcw className="w-4 h-4" />
</button>
)}
<button
onClick={() => toggleForceEnabled(theme.id, !theme.forceEnabled)}
title={theme.forceEnabled ? 'Remove force-enable (users can deactivate)' : 'Force enable (users cannot deactivate)'}
className={`p-2 rounded-md transition-colors ${theme.forceEnabled ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-950/30 dark:text-amber-400 dark:hover:bg-amber-950/50' : 'hover:bg-accent text-muted-foreground hover:text-foreground'}`}
>
{theme.forceEnabled ? <Lock className="w-4 h-4" /> : <LockOpen className="w-4 h-4" />}
</button>
<button
onClick={() => toggleTheme(theme.id, !theme.enabled)}
title={theme.enabled ? 'Disable' : 'Enable'}
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<Power className="w-4 h-4" />
</button>
<button
onClick={() => deleteTheme(theme.id, theme.name)}
title="Remove"
className="p-2 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
-237
View File
@@ -1,237 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import {
Loader2,
RefreshCw,
CheckCircle2,
AlertTriangle,
ShieldAlert,
ExternalLink,
} from '@/components/icons';
import { SettingsSection, SettingItem } from '@/components/settings/settings-section';
import { apiFetch } from '@/lib/browser-navigation';
import type { UpdateStatus, UpdateSeverity } from '@/lib/version-check/types';
interface VersionAdminStatus {
current: string;
build: string;
endpoint: string;
defaultEndpoint: string;
disabledByEnv: boolean;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
status: UpdateStatus | null;
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
return `${Math.floor(h / 24)} days ago`;
}
function severityChip(severity: UpdateSeverity) {
switch (severity) {
case 'security':
return {
label: 'Security update',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'deprecated':
return {
label: 'Deprecated',
className: 'bg-red-500/10 text-red-700 dark:text-red-300 border-red-500/30',
Icon: ShieldAlert,
};
case 'normal':
return {
label: 'Update available',
className: 'bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-500/30',
Icon: AlertTriangle,
};
case 'unknown':
return {
label: 'Unknown',
className: 'bg-muted text-muted-foreground border-border',
Icon: AlertTriangle,
};
case 'none':
default:
return {
label: 'Up to date',
className: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
Icon: CheckCircle2,
};
}
}
export function VersionTab() {
const [data, setData] = useState<VersionAdminStatus | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/version');
if (!r.ok) throw new Error('failed to load');
setData((await r.json()) as VersionAdminStatus);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function checkNow(): Promise<void> {
setChecking(true);
setCheckResult(null);
try {
const r = await apiFetch('/api/admin/version', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'check-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; error?: string };
setCheckResult({
ok: !!j.ok,
msg: j.ok ? 'Update check completed.' : `Failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally {
setChecking(false);
}
}
if (loading || !data) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading…
</div>
);
}
const status = data.status;
const chip = severityChip(status?.severity ?? 'none');
const ChipIcon = chip.Icon;
const releaseUrl = status?.url ?? null;
const newer = status?.latest && status.latest !== data.current ? status.latest : null;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">Version</h1>
<p className="text-sm text-muted-foreground mt-1">
Hourly check against the Bulwark version server. Severity is decided server-side and
disable with <code>BULWARK_UPDATE_CHECK=off</code>.
</p>
</div>
<button
type="button"
disabled={checking}
onClick={() => void checkNow()}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{checking ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Check now
</button>
</div>
{checkResult && (
<div
className={`text-sm rounded-md px-3 py-2 ${
checkResult.ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'bg-destructive/10 text-destructive'
}`}
>
{checkResult.msg}
</div>
)}
<SettingsSection title="Status">
<SettingItem label="Severity">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${chip.className}`}
>
<ChipIcon className="h-3 w-3" />
{chip.label}
</span>
</SettingItem>
<SettingItem label="Running" description={data.build !== 'unknown' ? `Build ${data.build}` : undefined}>
<span className="text-sm font-mono text-foreground">{data.current}</span>
</SettingItem>
{newer && (
<SettingItem label="Latest release">
{releaseUrl ? (
<a
href={releaseUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm font-mono text-foreground hover:underline"
>
{newer} <ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-sm font-mono text-foreground">{newer}</span>
)}
</SettingItem>
)}
{status?.advisory && (
<SettingItem label="Advisory">
<span className="text-sm font-mono text-red-600 dark:text-red-400">{status.advisory}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Schedule" description="Hourly polling with ±5 minute jitter.">
<SettingItem label="Last checked">
<span className="text-sm text-foreground">{timeAgo(data.lastCheckedAt)}</span>
</SettingItem>
<SettingItem label="Last success">
<span className="text-sm text-foreground">{timeAgo(data.lastSuccessAt)}</span>
</SettingItem>
<SettingItem label="Next scheduled">
<span className="text-sm text-foreground">{timeAgo(data.nextScheduledAt)}</span>
</SettingItem>
{status?.checkedAt && (
<SettingItem label="Server timestamp" description="When the server last refreshed its release list.">
<span className="text-sm text-foreground">{new Date(status.checkedAt).toLocaleString()}</span>
</SettingItem>
)}
</SettingsSection>
<SettingsSection title="Source">
<SettingItem
label="Endpoint"
description={data.endpoint === data.defaultEndpoint ? 'Default endpoint.' : `Default: ${data.defaultEndpoint}`}
>
<a
href={data.endpoint}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-sm text-foreground hover:underline break-all"
>
{data.endpoint} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
</SettingItem>
<SettingItem label="Disabled by env" description="Set BULWARK_UPDATE_CHECK=off to disable.">
<span className={`text-sm font-medium ${data.disabledByEnv ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'}`}>
{data.disabledByEnv ? 'Yes' : 'No'}
</span>
</SettingItem>
</SettingsSection>
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=auth');
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=branding');
}
+36 -63
View File
@@ -2,29 +2,32 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Lock } from '@/components/icons';
import { Loader2 } from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { Button } from '@/components/ui/button';
import { AdminPageHeader } from '@/components/admin/page-header';
import { Flash, useFlash } from '@/components/admin/flash-message';
import { inputClass } from '@/components/admin/config-items';
import { cn } from '@/lib/utils';
export default function ChangePasswordPage() {
const router = useRouter();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [message, setMessage] = useFlash();
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSuccess(false);
setMessage(null);
if (newPassword.length < 8) {
setError('New password must be at least 8 characters.');
setMessage({ type: 'error', text: 'The new password must be at least 8 characters.' });
return;
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match.');
setMessage({ type: 'error', text: 'The new passwords do not match.' });
return;
}
@@ -36,81 +39,51 @@ export default function ChangePasswordPage() {
});
if (res.ok) {
setSuccess(true);
setMessage({ type: 'success', text: 'Password changed. Taking you back to the overview.' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => router.push('/admin'), 2000);
} else {
const data = await res.json().catch(() => ({}));
setError(data.error || 'Failed to change password.');
setMessage({ type: 'error', text: data.error || 'Could not change the password.' });
}
setLoading(false);
}
const fields = [
{ id: 'current', label: 'Current password', value: currentPassword, set: setCurrentPassword, autoComplete: 'current-password' },
{ id: 'new', label: 'New password', value: newPassword, set: setNewPassword, autoComplete: 'new-password' },
{ id: 'confirm', label: 'Confirm new password', value: confirmPassword, set: setConfirmPassword, autoComplete: 'new-password' },
] as const;
return (
<div className="max-w-md mx-auto space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Change Password</h1>
<p className="text-sm text-muted-foreground mt-1">Update your admin password.</p>
</div>
<div className="max-w-md">
<AdminPageHeader title="Change password" description="The password for this admin panel. At least 8 characters." back={{ href: '/admin', label: 'Overview' }} />
<Flash message={message} onDismiss={() => setMessage(null)} />
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Current Password</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
{fields.map((field) => (
<div key={field.id} className="space-y-1.5">
<label htmlFor={`password-${field.id}`} className="text-sm font-medium text-foreground">{field.label}</label>
<input
id={`password-${field.id}`}
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
value={field.value}
onChange={(e) => field.set(e.target.value)}
required
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoComplete="current-password"
minLength={field.id === 'current' ? undefined : 8}
autoComplete={field.autoComplete}
className={cn(inputClass, 'w-full')}
/>
</div>
</div>
))}
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">New Password</label>
<input
type="password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={8}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoComplete="new-password"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
minLength={8}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoComplete="new-password"
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{success && (
<p className="text-sm text-green-600">Password changed. Redirecting...</p>
)}
<button
type="submit"
disabled={loading}
className="w-full h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Changing...' : 'Change Password'}
</button>
<Button type="submit" size="sm" disabled={loading} className="gap-2">
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Change password
</Button>
</form>
</div>
);
+28 -513
View File
@@ -1,518 +1,33 @@
'use client';
import { getLocale } from 'next-intl/server';
import { IntlProvider } from '@/components/providers/intl-provider';
import { ThemeProvider } from '@/components/providers/theme-provider';
import { TourProvider } from '@/components/tour/tour-provider';
import { defaultLocale } from '@/i18n/routing';
import { AdminLayoutClient } from './_layout-client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import { useAdminTabStore, type AdminTabId } from '@/stores/admin-tab-store';
import {
LayoutDashboard,
Settings,
Palette,
Shield,
Scale,
ScrollText,
LogOut,
KeyRound,
Puzzle,
SwatchBook,
Activity,
Package,
Mail,
Calendar,
BookUser,
HardDrive,
Store,
Menu,
X,
} from '@/components/icons';
import { cn } from '@/lib/utils';
import { useConfig } from '@/hooks/use-config';
import { usePolicyStore } from '@/stores/policy-store';
import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { UnsavedChangesGuard } from './_unsaved-changes-guard';
// Single-page tab navigation: clicks update a Zustand store. The URL stays
// at /admin so React doesn't fire a route transition on every tab switch -
// matches the regular settings page pattern, fixes the dev-mode "Rendering…"
// hang we saw with both /admin/<segment> routes and ?tab= search params.
const NAV_GROUPS: ReadonlyArray<{
label: string;
items: ReadonlyArray<{ tab: AdminTabId; label: string; icon: typeof LayoutDashboard }>;
}> = [
{
label: 'Overview',
items: [
{ tab: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
],
},
{
label: 'Configuration',
items: [
{ tab: 'settings', label: 'Settings', icon: Settings },
{ tab: 'branding', label: 'Branding', icon: Palette },
{ tab: 'auth', label: 'Authentication', icon: Shield },
{ tab: 'policy', label: 'Policy', icon: Scale },
],
},
{
label: 'Extensions',
items: [
{ tab: 'plugins', label: 'Plugins', icon: Puzzle },
{ tab: 'themes', label: 'Themes', icon: SwatchBook },
{ tab: 'marketplace', label: 'Marketplace', icon: Store },
],
},
{
label: 'System',
items: [
{ tab: 'version', label: 'Version', icon: Package },
{ tab: 'telemetry', label: 'Telemetry', icon: Activity },
{ tab: 'logs', label: 'Audit Log', icon: ScrollText },
],
},
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const storeActiveTab = useAdminTabStore((s) => s.activeTab);
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
// Highlight the active tab only on /admin itself - on dynamic routes
// (e.g. /admin/plugins/[id]) no tab is "current".
const activeTab = pathname === '/admin' ? storeActiveTab : null;
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isStalwartAutoLogin, setIsStalwartAutoLogin] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [configReadOnly, setConfigReadOnly] = useState(false);
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const logoUrl = withBasePath(resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
// Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
useEffect(() => {
setMobileNavOpen(false);
}, [pathname]);
useEffect(() => {
if (!mobileNavOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [mobileNavOpen]);
useEffect(() => {
if (pathname === '/admin/login') return;
let cancelled = false;
async function checkAuth() {
try {
const jmapHeaders = getActiveAccountSlotHeaders();
const res = await apiFetch('/api/admin/auth', { headers: jmapHeaders });
const data = await res.json();
if (cancelled) return;
// `stalwartAutoLogin` is only true in "auto" mode; in "password" mode
// a Stalwart admin still lands on the password form (#870).
const stalwartAutoLogin = data.stalwartAutoLogin === true;
setIsStalwartAutoLogin(stalwartAutoLogin);
// If neither password-based admin nor Stalwart auto-login, redirect away
if (!data.enabled && !stalwartAutoLogin) {
router.replace('/');
return;
}
if (data.authenticated) {
setAuthenticated(true);
return;
}
// If Stalwart admin (auto mode) but not yet authenticated, auto-login
if (stalwartAutoLogin) {
const loginRes = await apiFetch('/api/admin/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...jmapHeaders },
body: JSON.stringify({ stalwartAuth: true }),
});
if (cancelled) return;
if (loginRes.ok) {
setAuthenticated(true);
return;
}
const body = await loginRes.json().catch(() => ({}));
setAuthError(body?.error || `Admin auto-login failed (HTTP ${loginRes.status})`);
setAuthenticated(false);
return;
}
// Carry the page that was asked for through the login, so a connector
// link to a panel (/connector/admin_extension?slug=x) does not dump
// the admin on the dashboard after signing in.
const next = encodeURIComponent(`${pathname}${window.location.search}`);
router.replace(`/admin/login?next=${next}`);
} catch (err) {
if (cancelled) return;
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
setAuthenticated(false);
}
}
checkAuth();
return () => { cancelled = true; };
}, [pathname, router]);
// With ADMIN_CONFIG_READONLY the panel still shows every setting, but
// nothing can be saved; say so up front instead of after a failed save (#1249).
useEffect(() => {
if (!authenticated) return;
let cancelled = false;
apiFetch('/api/setup/status')
.then(res => (res.ok ? res.json() : null))
.then(data => { if (!cancelled) setConfigReadOnly(data?.readOnly === true); })
.catch(() => {});
return () => { cancelled = true; };
}, [authenticated]);
function handleLogout() {
requestLeave(async () => {
await apiFetch('/api/admin/auth', { method: 'DELETE' });
router.replace('/admin/login');
});
/**
* /admin sits outside the [locale] tree, so it loads the message catalog
* itself: the shell reuses the webmail's navigation rail, which reads its
* labels through next-intl (and the tour context, for its shortcuts
* dialog). The locale comes from the request (cookie or Accept-Language,
* see i18n/request.ts) and the user's own choice takes over in the
* browser, like on every other page.
*/
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
let messages;
try {
messages = (await import(`@/locales/${locale}/common.json`)).default;
} catch {
messages = (await import(`@/locales/${defaultLocale}/common.json`)).default;
}
// Don't gate the login page
if (pathname === '/admin/login') {
return <>{children}</>;
}
// /admin lives outside the [locale] tree, so links back to the webmail
// apps are bare <a> tags (hard navigation). Next.js only auto-applies
// basePath to <Link>/router APIs - for these we prepend it manually so
// NEXT_PUBLIC_BASE_PATH=/webmail deployments don't redirect to "/".
const prefix = getPathPrefix();
const navContent = (
<>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-2 space-y-0.5">
{NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
<div className="px-3 pt-2.5 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</span>
</div>
{group.items.map(({ tab, label, icon: Icon }) => {
const active = activeTab === tab;
const showDot = tab === 'version' && hasUpdate;
const handleClick = () => {
if (active) return;
requestLeave(() => {
setActiveTab(tab);
// From a dynamic route (/admin/plugins/[id], /admin/marketplace/[slug])
// we still need a real navigation back to /admin so the page renders.
if (pathname !== '/admin') router.push('/admin');
});
};
return (
<button
key={tab}
type="button"
onClick={handleClick}
className={cn(
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<span className="relative shrink-0">
<Icon className={cn(
'w-4 h-4',
active ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
{showDot && (
<span
className={cn(
'absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ring-2',
active ? 'ring-accent' : 'ring-background',
updateImportant ? 'bg-red-500' : 'bg-amber-500',
)}
aria-label={updateImportant ? 'Important update available' : 'Update available'}
/>
)}
</span>
{label}
</button>
);
})}
</div>
))}
</div>
</div>
<div className="px-2 py-2 border-t border-border space-y-0.5 shrink-0">
{!isStalwartAutoLogin && (
<Link
href="/admin/change-password"
className={cn(
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
<KeyRound className={cn(
'w-4 h-4 shrink-0',
pathname === '/admin/change-password' ? 'text-accent-foreground' : 'text-muted-foreground'
)} />
Change Password
</Link>
)}
<button
onClick={handleLogout}
className="w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
</button>
</div>
</>
);
return (
<div className="min-h-screen flex bg-background">
<UnsavedChangesGuard />
{/* Slim webmail nav rail (desktop only) */}
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : (
<div className="w-7 h-7 mb-2" />
)}
<a
href={`${prefix}/`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Mail"
>
<Mail className="w-[18px] h-[18px]" />
</a>
<a
href={`${prefix}/calendar`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Calendar"
>
<Calendar className="w-[18px] h-[18px]" />
</a>
<a
href={`${prefix}/contacts`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Contacts"
>
<BookUser className="w-[18px] h-[18px]" />
</a>
{filesEnabled && (
<a
href={`${prefix}/files`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Files"
>
<HardDrive className="w-[18px] h-[18px]" />
</a>
)}
<div className="mt-auto flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary" title="Admin">
<Shield className="w-[18px] h-[18px]" />
</div>
<a
href={`${prefix}/settings`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
title="Settings"
>
<Settings className="w-[18px] h-[18px]" />
</a>
</div>
</nav>
{/* Admin Sidebar (desktop only) */}
<aside className="hidden md:flex w-60 border-e border-border bg-secondary flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
{navContent}
</aside>
{/* Mobile drawer overlay */}
{mobileNavOpen && (
<div
className="md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
onClick={() => setMobileNavOpen(false)}
aria-hidden="true"
/>
)}
{/* Mobile drawer */}
<aside
className={cn(
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-e border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
)}
aria-label="Admin navigation"
aria-hidden={!mobileNavOpen}
>
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
<button
type="button"
onClick={() => setMobileNavOpen(false)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label="Close navigation"
>
<X className="w-5 h-5" />
</button>
</div>
{navContent}
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 overflow-x-hidden">
{/* Mobile header */}
<div className="md:hidden sticky top-0 z-30 h-14 flex items-center gap-2 px-3 border-b border-border bg-background">
<button
type="button"
onClick={() => setMobileNavOpen(true)}
className="flex items-center justify-center w-9 h-9 rounded-md text-foreground hover:bg-muted transition-colors"
aria-label="Open navigation"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
</div>
<div className="max-w-4xl mx-auto p-4 md:p-6 pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-6">
{authError ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<p className="font-medium">Admin authentication failed</p>
<p className="mt-1 text-destructive/80">{authError}</p>
</div>
) : authenticated === null ? (
<div className="py-12 text-center text-sm text-muted-foreground animate-pulse">
Loading admin panel…
</div>
) : authenticated ? (
<>
{configReadOnly && (
<div role="status" className="mb-4 rounded-lg border border-border bg-muted p-4 text-sm">
<p className="font-medium text-foreground">The configuration is read-only</p>
<p className="mt-1 text-muted-foreground">
ADMIN_CONFIG_READONLY is set, so you can look at the settings here but not save changes.
Change them in the mounted config files or environment instead.
</p>
</div>
)}
{children}
</>
) : null}
</div>
</main>
{/* Mobile bottom nav (main webmail nav) */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 flex items-center bg-background border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Main navigation"
>
<a
href={`${prefix}/`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Mail"
>
<Mail className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Mail</span>
</a>
<a
href={`${prefix}/calendar`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Calendar"
>
<Calendar className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Calendar</span>
</a>
<a
href={`${prefix}/contacts`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Contacts"
>
<BookUser className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Contacts</span>
</a>
{filesEnabled && (
<a
href={`${prefix}/files`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Files"
>
<HardDrive className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Files</span>
</a>
)}
<div
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] text-primary"
title="Admin"
aria-current="page"
>
<div className="relative">
<Shield className="w-5 h-5" />
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
</div>
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Admin</span>
</div>
<a
href={`${prefix}/settings`}
className="flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px] transition-colors duration-150 text-muted-foreground hover:text-foreground"
title="Settings"
>
<Settings className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight truncate max-w-full">Settings</span>
</a>
</nav>
</div>
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
<TourProvider>
<AdminLayoutClient>{children}</AdminLayoutClient>
</TourProvider>
</ThemeProvider>
</IntlProvider>
);
}
+5 -16
View File
@@ -7,7 +7,6 @@ import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { safeAdminNext } from '@/lib/connector/admin-next';
import { isAdminTab, useAdminTabStore } from '@/stores/admin-tab-store';
export default function AdminLoginPage() {
const router = useRouter();
@@ -37,22 +36,12 @@ export default function AdminLoginPage() {
return;
}
// A connector link to an admin panel parks its destination in ?next=
// so logging in lands on the panel that was clicked, not the dashboard.
// A connector link to an admin page parks its destination in ?next=
// so logging in lands on the page that was clicked, not the overview.
// safeAdminNext() is the gate: it is the only path here that comes out
// of a URL rather than out of a fixed template.
const next = safeAdminNext(new URLSearchParams(window.location.search).get('next'));
if (next) {
const [path, query] = next.split('?', 2);
const search = new URLSearchParams(query ?? '');
const tab = search.get('tab');
if (tab && isAdminTab(tab)) useAdminTabStore.getState().setActiveTab(tab);
// The tab rides in the store; a version pick (an install link to one
// version of an extension) stays in the URL for the marketplace page.
router.push(search.has('version') ? next : path);
return;
}
router.push('/admin');
// of a URL rather than out of a fixed template. A version pick (an
// install link to one version of an extension) rides in its query.
router.push(safeAdminNext(new URLSearchParams(window.location.search).get('next')) ?? '/admin');
} catch {
setError('Network error. Please try again.');
} finally {
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=logs');
}
@@ -1,893 +0,0 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import {
ArrowLeft,
ArrowUpCircle,
Download,
Loader2,
Puzzle,
SwatchBook,
Star,
Trash2,
Check,
Settings as SettingsIcon,
ExternalLink,
Shield,
ShieldAlert,
ShieldCheck,
AlertTriangle,
FileCode,
ChevronDown,
ChevronUp,
RotateCcw,
Ban,
Info,
Eye,
} from '@/components/icons';
import { apiFetch } from '@/lib/browser-navigation';
import { compareVersions, isVersionSatisfied } from '@/lib/version-compare';
import type { MarketplaceListing } from '@/lib/admin/marketplace-directory';
import { installVersion } from '../../_tabs/marketplace';
const CURRENT_APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
interface VersionRow {
version: string;
channel: 'stable' | 'beta';
publishedAt: string | null;
minAppVersion: string | null;
maxAppVersion: string | null;
permissions: string[];
size: number;
sha256: string;
yanked: { at: string; reason: string | null } | null;
changelog: string | null;
downloads: number;
signed: boolean;
commit: string | null;
}
interface Advisory {
id: string;
affected: string;
fixedIn: string | null;
severity: string;
summary: string;
details: string | null;
withdrawn: boolean;
}
interface HistoryEntry {
version: string;
savedAt: string;
contentHash: string;
}
interface PreviewData {
extension: MarketplaceListing & {
longDescription: string | null;
newestVersion: string | null;
newestMinAppVersion: string | null;
versions: VersionRow[];
screenshots: Array<{ url: string; altText: string | null }>;
themePreviews: Array<{ variant: 'light' | 'dark'; url: string | null; colors: Record<string, string> | null }>;
advisories: Advisory[];
permissionDetails: Array<{ id: string; title: string; body: string; highRisk: boolean }>;
source: { host: string; repo: string | null; subpath: string | null };
createdAt: string | null;
updatedAt: string | null;
};
preview: { version: string; channel: 'stable' | 'beta'; yanked: VersionRow['yanked']; minAppVersion: string | null; maxAppVersion: string | null } | null;
requestedVersionMissing: string | null;
bundle: {
manifest: Record<string, unknown> | null;
source: { name: string; content: string; truncated: boolean } | null;
size: number;
error: string | null;
signature: { status: 'verified' | 'unsigned' | 'invalid'; keyId?: string; reason?: string } | null;
};
installed: boolean;
installedVersion: string | null;
installedSource: { kind: 'marketplace' | 'upload' } | null;
history: HistoryEntry[];
channel: 'stable' | 'beta';
appVersion: string | null;
directoryUrl: string;
}
const RISKY_PERMISSIONS = new Set([
'mail:write',
'mail:delete',
'storage:write',
'network',
'admin',
]);
/** Whether this Bulwark can run a version, given what the directory says it needs. */
function fits(v: { minAppVersion: string | null; maxAppVersion: string | null }): boolean {
if (!isVersionSatisfied(CURRENT_APP_VERSION, v.minAppVersion)) return false;
if (v.maxAppVersion && compareVersions(CURRENT_APP_VERSION, v.maxAppVersion) > 0) return false;
return true;
}
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : '');
const fmtSize = (bytes: number) => (bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${(bytes / 1024).toFixed(1)} KB`);
export default function MarketplacePreviewPage() {
const params = useParams();
const slug = params.slug as string;
const [data, setData] = useState<PreviewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [uninstalling, setUninstalling] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [showSource, setShowSource] = useState(false);
const [showManifest, setShowManifest] = useState(false);
// The version being previewed, from the install link (?version=) or a row
// the admin picked. Read from the address bar, like the connector does.
const [selected, setSelected] = useState<string | null | undefined>(undefined);
useEffect(() => {
setSelected(new URLSearchParams(window.location.search).get('version'));
}, []);
const fetchPreview = useCallback(async (version: string | null) => {
setLoading(true);
setError(null);
try {
const query = version ? `?version=${encodeURIComponent(version)}` : '';
const res = await apiFetch(`/api/admin/marketplace/${encodeURIComponent(slug)}${query}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body.error || 'Failed to load preview');
return;
}
setData(await res.json());
} catch {
setError('Failed to connect to extension directory');
} finally {
setLoading(false);
}
}, [slug]);
useEffect(() => {
if (selected === undefined) return;
fetchPreview(selected);
}, [fetchPreview, selected]);
function pick(version: string | null) {
const url = new URL(window.location.href);
if (version) url.searchParams.set('version', version);
else url.searchParams.delete('version');
window.history.replaceState(null, '', url.toString());
setSelected(version);
setMessage(null);
}
async function handleInstall(version: string) {
if (!data) return;
const isUpdate = data.installed;
setBusy(`install:${version}`);
setMessage(null);
const result = await installVersion({ slug: data.extension.slug, version, type: data.extension.type });
setBusy(null);
if (!result.ok) {
setMessage({ type: 'error', text: result.error });
return;
}
const warnings = result.warnings.length ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})` : '';
const signed = result.signature === 'verified' ? ', signed by the directory' : '';
setMessage({
type: 'success',
text: isUpdate
? `"${data.extension.name}" is now v${version}${signed}${warnings}`
: `"${data.extension.name}" installed (v${version})${signed}${warnings}`,
});
fetchPreview(selected ?? null);
}
async function handleRollback(version: string) {
if (!data) return;
if (!confirm(`Put v${version} of "${data.extension.name}" back in place of v${data.installedVersion}? The copy kept on this server is used; the directory is not asked.`)) return;
setBusy(`rollback:${version}`);
setMessage(null);
try {
const res = await apiFetch(`/api/admin/extensions/${data.extension.type}/${encodeURIComponent(data.extension.slug)}/rollback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) {
setMessage({ type: 'success', text: `"${data.extension.name}" rolled back to v${version}` });
fetchPreview(selected ?? null);
} else {
setMessage({ type: 'error', text: body.error || 'Rollback failed' });
}
} catch {
setMessage({ type: 'error', text: 'Rollback failed - network error' });
} finally {
setBusy(null);
}
}
async function handleUninstall() {
if (!data) return;
if (!confirm(`Remove "${data.extension.name}"? Its kept versions are removed too; this cannot be undone.`)) return;
setUninstalling(true);
setMessage(null);
try {
const endpoint = data.extension.type === 'theme'
? '/api/admin/themes'
: '/api/admin/plugins';
const res = await apiFetch(endpoint, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: data.extension.slug }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) {
setMessage({ type: 'success', text: `"${data.extension.name}" removed` });
fetchPreview(selected ?? null);
} else {
setMessage({ type: 'error', text: body.error || 'Uninstall failed' });
}
} catch {
setMessage({ type: 'error', text: 'Uninstall failed - network error' });
} finally {
setUninstalling(false);
}
}
if (loading && !data) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin me-2" />
Loading...
</div>
);
}
if (error || !data) {
return (
<div className="space-y-4">
<Link
href="/admin/marketplace"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
</Link>
<p className="text-sm text-destructive">{error || 'Extension not found'}</p>
</div>
);
}
const ext = data.extension;
const bundle = data.bundle;
const preview = data.preview;
const isPlugin = ext.type === 'plugin';
const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || preview && ext.versions.find((v) => v.version === preview.version)?.permissions || ext.permissions || [];
const permissionText = new Map(ext.permissionDetails.map((p) => [p.id, p]));
const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || [];
const settingsSchema = bundle.manifest?.settingsSchema as Record<string, { type: string; label: string; description?: string; default?: unknown }> | undefined;
const previewFits = preview ? fits(preview) : false;
const previewIsInstalled = !!preview && data.installed && data.installedVersion === preview.version;
const previewDirection = preview && data.installed && data.installedVersion
? compareVersions(preview.version, data.installedVersion)
: 0;
const installedRow = data.installedVersion ? ext.versions.find((v) => v.version === data.installedVersion) ?? null : null;
const suspended = ext.status === 'suspended';
const historyVersions = new Set(data.history.map((h) => h.version));
const canInstallPreview = !!preview && !bundle.error && previewFits && !suspended && bundle.signature?.status !== 'invalid';
// Versions no longer in the directory's list but still kept here.
const extraHistory = data.history.filter((h) => !ext.versions.some((v) => v.version === h.version));
const installLabel = !preview
? 'Install'
: previewIsInstalled
? `Reinstall v${preview.version}`
: !data.installed
? `Install v${preview.version}`
: previewDirection > 0
? `Update to v${preview.version}`
: `Install v${preview.version} (older)`;
return (
<div className="space-y-6 max-w-4xl">
{/* Back link */}
<Link
href="/admin/marketplace"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4" /> Back to Marketplace
</Link>
{/* Banner / hero */}
{ext.bannerUrl && (
<div className="mb-6 overflow-hidden rounded-lg border border-border bg-muted">
<img
src={ext.bannerUrl}
alt=""
className="block w-full max-h-64 object-cover"
loading="lazy"
/>
</div>
)}
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="w-14 h-14 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{ext.iconUrl ? (
<img
src={ext.iconUrl}
alt=""
className="w-14 h-14 object-cover"
loading="lazy"
/>
) : isPlugin ? (
<Puzzle className="w-7 h-7 text-muted-foreground" />
) : (
<SwatchBook className="w-7 h-7 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="text-2xl font-semibold text-foreground break-words min-w-0">{ext.name}</h1>
{ext.featured && <Star className="w-4 h-4 text-warning fill-warning shrink-0" />}
{data.installed && (
<span
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 font-medium"
>
<Check className="w-3 h-3" /> Installed v{data.installedVersion}
</span>
)}
{data.installed && ext.latest && data.installedVersion && compareVersions(ext.latest.version, data.installedVersion) > 0 && (
<button
onClick={() => pick(ext.latest!.version)}
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400 font-medium hover:underline"
title={`Preview v${ext.latest.version}`}
>
<ArrowUpCircle className="w-3 h-3" /> v{ext.latest.version} available
</button>
)}
{ext.deprecated && (
<span className="text-xs px-2 py-0.5 rounded-md bg-muted text-muted-foreground font-medium">Deprecated</span>
)}
{suspended && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-md bg-destructive/10 text-destructive font-medium"><Ban className="w-3 h-3" /> Suspended</span>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground flex-wrap">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
isPlugin
? 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400'
}`}>
{isPlugin ? (ext.pluginType || 'plugin') : 'theme'}
</span>
{ext.author && (
<span className="inline-flex items-center gap-1">
by {ext.author.displayName}
{ext.author.verified && <ShieldCheck className="w-3.5 h-3.5" aria-label="verified author" />}
</span>
)}
{ext.license && <span>{ext.license}</span>}
<span className="inline-flex items-center gap-1">
<Download className="w-3 h-3" />
{ext.totalDownloads.toLocaleString()}
</span>
{ext.rating.count > 0 && (
<span className="inline-flex items-center gap-1" title={`${ext.rating.average} out of 5 from ${ext.rating.count} rating${ext.rating.count === 1 ? '' : 's'}`}>
<Star className="w-3 h-3 fill-current" />
{ext.rating.average.toFixed(1)} ({ext.rating.count})
</span>
)}
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex flex-wrap items-center gap-2 shrink-0">
{preview && (
<button
onClick={() => handleInstall(preview.version)}
disabled={!!busy || !canInstallPreview}
title={!previewFits
? `v${preview.version} needs Bulwark ${preview.minAppVersion ?? 'any'}${preview.maxAppVersion ? ` to ${preview.maxAppVersion}` : ' or later'}. You are running v${CURRENT_APP_VERSION}.`
: preview.yanked ? 'This version was withdrawn by its publisher; you will be asked to confirm.' : undefined}
className={`inline-flex items-center gap-1.5 h-9 px-4 rounded-md text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
previewIsInstalled
? 'border border-border text-foreground hover:bg-muted'
: previewDirection > 0
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
}`}
>
{busy === `install:${preview.version}` ? <Loader2 className="w-4 h-4 animate-spin" /> : previewDirection > 0 ? <ArrowUpCircle className="w-4 h-4" /> : <Download className="w-4 h-4" />}
{installLabel}
</button>
)}
{data.installed && (
<>
<Link
href={isPlugin ? `/admin/plugins/${ext.slug}` : '/admin/themes'}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
<SettingsIcon className="w-4 h-4" />
Manage
</Link>
<button
onClick={handleUninstall}
disabled={uninstalling}
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{uninstalling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Uninstall
</button>
</>
)}
<a
href={ext.directoryUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
title="Open this extension's page in the directory"
>
<ExternalLink className="w-4 h-4" />
Directory
</a>
</div>
</div>
{message && (
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
{message.text}
</div>
)}
{/* Notices */}
{data.requestedVersionMissing && (
<Notice tone="warning" title={`The link asked for v${data.requestedVersionMissing}, which the directory no longer offers`}>
Showing v{preview?.version} instead.
</Notice>
)}
{suspended && (
<Notice tone="danger" title="This extension was suspended by the directory">
It cannot be installed. {data.installed ? 'Consider removing it.' : ''}
</Notice>
)}
{ext.deprecated && (
<Notice tone="info" title="This extension is deprecated">
{ext.deprecated.message || 'Its publisher no longer maintains it.'}
{ext.deprecated.replacement && <> Use <Link href={`/admin/marketplace/${encodeURIComponent(ext.deprecated.replacement)}`} className="underline">{ext.deprecated.replacement}</Link> instead.</>}
</Notice>
)}
{ext.advisories.filter((a) => !a.withdrawn).map((a) => (
<Notice key={a.id} tone="danger" title={`Security advisory ${a.id} (${a.severity})`}>
{a.summary} Affects {a.affected}{a.fixedIn ? `; fixed in v${a.fixedIn}` : '; no fixed version yet'}.
{installedRow && data.installedVersion && a.fixedIn && compareVersions(data.installedVersion, a.fixedIn) < 0 && (
<> <b>Your installed v{data.installedVersion} is affected.</b></>
)}
</Notice>
))}
{installedRow?.yanked && (
<Notice tone="warning" title={`The installed v${installedRow.version} was withdrawn by its publisher`}>
{installedRow.yanked.reason || 'No reason was given.'} Pick another version below.
</Notice>
)}
{preview?.yanked && !previewIsInstalled && (
<Notice tone="warning" title={`v${preview.version} was withdrawn by its publisher`}>
{preview.yanked.reason || 'No reason was given.'} It can still be installed, but you will be asked to confirm.
</Notice>
)}
{preview && !previewFits && (
<Notice tone="warning" title={preview.maxAppVersion && compareVersions(CURRENT_APP_VERSION, preview.maxAppVersion) > 0 ? 'This version is for an older Bulwark' : 'Update Bulwark to install this version'}>
v{preview.version} needs Bulwark {preview.minAppVersion ?? 'any version'}{preview.maxAppVersion ? ` up to ${preview.maxAppVersion}` : ' or later'}. You are running v{CURRENT_APP_VERSION}.
{!preview.yanked && ext.latest && ext.latest.version !== preview.version && (
<> <button className="underline" onClick={() => pick(ext.latest!.version)}>v{ext.latest.version}</button> runs here.</>
)}
</Notice>
)}
{!ext.latest && !preview && (
<Notice tone="warning" title="No version runs on this Bulwark">
{ext.newestVersion ? `The newest, v${ext.newestVersion}, needs Bulwark ${ext.newestMinAppVersion ?? 'a newer version'}.` : 'Nothing has been published yet.'}
</Notice>
)}
{bundle.error && (
<Notice tone="warning" title="Could not preview bundle">{bundle.error}</Notice>
)}
{bundle.signature?.status === 'invalid' && (
<Notice tone="danger" title="The directory's signature on this bundle does not check out">
{bundle.signature.reason}. It will not be installed.
</Notice>
)}
{/* Description */}
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">About</h2>
<p className="text-sm text-muted-foreground mt-2">{ext.description}</p>
{ext.longDescription && ext.longDescription !== ext.description && (
<p className="text-sm text-muted-foreground mt-3 whitespace-pre-wrap">{ext.longDescription}</p>
)}
{ext.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{ext.tags.map(tag => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{tag}
</span>
))}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-4 pt-3 border-t border-border flex-wrap">
{preview && <span>Previewing v{preview.version}{preview.channel === 'beta' ? ' (beta)' : ''}</span>}
{bundle.size > 0 && <span>Bundle: {fmtSize(bundle.size)}</span>}
{bundle.signature?.status === 'verified' && (
<span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-400" title={`Signed by the directory, key ${bundle.signature.keyId}`}>
<ShieldCheck className="w-3 h-3" /> Signed by the directory
</span>
)}
{bundle.signature?.status === 'unsigned' && (
<span className="inline-flex items-center gap-1" title="No directory root key is configured, so the signature is not checked">
<Info className="w-3 h-3" /> Signature not checked
</span>
)}
{ext.source.repo && (
<a
href={`https://${ext.source.host}/${ext.source.repo}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:text-foreground"
>
<ExternalLink className="w-3 h-3" />
{ext.source.host === 'github.com' ? '' : `${ext.source.host}/`}{ext.source.repo}
</a>
)}
</div>
</section>
{/* Versions */}
<section className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border">
<h2 className="text-sm font-medium text-foreground">Versions</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Every published version. Versions this server still has a copy of can be put back without the directory.
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-muted-foreground">
<tr className="border-b border-border">
<th className="text-start font-medium px-4 py-2">Version</th>
<th className="text-start font-medium px-4 py-2">Released</th>
<th className="text-start font-medium px-4 py-2">Needs Bulwark</th>
<th className="text-start font-medium px-4 py-2">Size</th>
<th className="text-start font-medium px-4 py-2">Status</th>
<th className="text-end font-medium px-4 py-2"><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{ext.versions.map((v) => {
const isInstalled = data.installed && data.installedVersion === v.version;
const isPreview = preview?.version === v.version;
const kept = historyVersions.has(v.version);
const runs = fits(v);
const key = `install:${v.version}`;
return (
<tr key={v.version} className={isPreview ? 'bg-muted/40' : undefined}>
<td className="px-4 py-2 align-top">
<div className="flex items-center gap-1.5 flex-wrap">
<code className="text-xs font-mono text-foreground">v{v.version}</code>
{v.channel === 'beta' && <span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400">beta</span>}
{v.signed && <ShieldCheck className="w-3 h-3 text-muted-foreground" aria-label="signed" />}
</div>
{v.changelog && <p className="text-xs text-muted-foreground mt-0.5 line-clamp-2 whitespace-pre-wrap max-w-xs">{v.changelog}</p>}
</td>
<td className="px-4 py-2 align-top text-xs text-muted-foreground whitespace-nowrap">{fmtDate(v.publishedAt)}</td>
<td className="px-4 py-2 align-top text-xs text-muted-foreground whitespace-nowrap">
{v.minAppVersion ?? 'any'}{v.maxAppVersion ? ` to ${v.maxAppVersion}` : v.minAppVersion ? '+' : ''}
</td>
<td className="px-4 py-2 align-top text-xs text-muted-foreground whitespace-nowrap">{fmtSize(v.size)}</td>
<td className="px-4 py-2 align-top text-xs">
<div className="flex flex-col gap-1 items-start">
{isInstalled && <span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-400"><Check className="w-3 h-3" /> Installed</span>}
{v.yanked && <span className="inline-flex items-center gap-1 text-amber-800 dark:text-amber-300" title={v.yanked.reason ?? undefined}><AlertTriangle className="w-3 h-3" /> Withdrawn</span>}
{!runs && <span className="text-muted-foreground">Not for v{CURRENT_APP_VERSION}</span>}
{kept && !isInstalled && <span className="inline-flex items-center gap-1 text-muted-foreground"><RotateCcw className="w-3 h-3" /> Kept here</span>}
</div>
</td>
<td className="px-4 py-2 align-top">
<div className="flex items-center justify-end gap-1.5 flex-wrap">
{!isPreview && (
<button
onClick={() => pick(v.version)}
className="inline-flex items-center gap-1 h-7 px-2 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-muted"
title="Preview this version"
>
<Eye className="w-3 h-3" /> Preview
</button>
)}
{kept && !isInstalled && data.installed && (
<button
onClick={() => handleRollback(v.version)}
disabled={!!busy}
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50"
>
{busy === `rollback:${v.version}` ? <Loader2 className="w-3 h-3 animate-spin" /> : <RotateCcw className="w-3 h-3" />}
Roll back
</button>
)}
{!isInstalled && !kept && runs && !suspended && (
<button
onClick={() => handleInstall(v.version)}
disabled={!!busy}
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50"
title={v.yanked ? 'Withdrawn; you will be asked to confirm' : undefined}
>
{busy === key ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
{v.yanked ? 'Install anyway' : 'Install'}
</button>
)}
</div>
</td>
</tr>
);
})}
{extraHistory.map((h) => (
<tr key={`kept-${h.version}`}>
<td className="px-4 py-2 align-top">
<code className="text-xs font-mono text-foreground">v{h.version}</code>
<p className="text-xs text-muted-foreground mt-0.5">No longer in the directory</p>
</td>
<td className="px-4 py-2 align-top text-xs text-muted-foreground whitespace-nowrap">replaced {fmtDate(h.savedAt)}</td>
<td className="px-4 py-2 align-top text-xs text-muted-foreground">–</td>
<td className="px-4 py-2 align-top text-xs text-muted-foreground">–</td>
<td className="px-4 py-2 align-top text-xs">
<span className="inline-flex items-center gap-1 text-muted-foreground"><RotateCcw className="w-3 h-3" /> Kept here</span>
</td>
<td className="px-4 py-2 align-top">
<div className="flex justify-end">
{data.installed && (
<button
onClick={() => handleRollback(h.version)}
disabled={!!busy}
className="inline-flex items-center gap-1 h-7 px-2.5 rounded-md border border-border text-xs font-medium text-foreground hover:bg-muted disabled:opacity-50"
>
{busy === `rollback:${h.version}` ? <Loader2 className="w-3 h-3 animate-spin" /> : <RotateCcw className="w-3 h-3" />}
Roll back
</button>
)}
</div>
</td>
</tr>
))}
{ext.versions.length === 0 && extraHistory.length === 0 && (
<tr><td colSpan={6} className="px-4 py-6 text-center text-sm text-muted-foreground">No versions published.</td></tr>
)}
</tbody>
</table>
</div>
</section>
{/* Screenshots */}
{ext.screenshots.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Screenshots</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
{ext.screenshots.map((s, i) => (
<img
key={i}
src={s.url}
alt={s.altText || `Screenshot ${i + 1}`}
className="w-full rounded-md border border-border bg-muted"
loading="lazy"
/>
))}
</div>
</section>
)}
{/* Theme preview */}
{!isPlugin && ext.themePreviews.length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">Theme preview</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
{ext.themePreviews.map(preview => (
preview.url
? (
<figure key={preview.variant} className="rounded-md border border-border overflow-hidden">
<img src={preview.url} alt={`${ext.name} in ${preview.variant} mode`} className="block w-full bg-muted" loading="lazy" />
<figcaption className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-t border-border capitalize">{preview.variant}</figcaption>
</figure>
)
: <ThemeColorSwatch key={preview.variant} preview={preview} />
))}
</div>
</section>
)}
{/* Permissions */}
{isPlugin && (
<section className="border border-border rounded-lg p-4">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Permissions</h2>
{preview && <span className="text-xs text-muted-foreground">of v{preview.version}</span>}
</div>
{manifestPerms.length === 0 ? (
<p className="text-sm text-muted-foreground mt-2">This plugin requests no permissions.</p>
) : (
<ul className="mt-3 space-y-1.5">
{manifestPerms.map(perm => {
const text = permissionText.get(perm);
const risky = text?.highRisk || RISKY_PERMISSIONS.has(perm);
return (
<li
key={perm}
className={`flex items-start gap-2 text-sm rounded-md px-2 py-1.5 ${
risky
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
: 'bg-muted/50 text-foreground'
}`}
>
{risky ? <ShieldAlert className="w-3.5 h-3.5 shrink-0 mt-0.5" /> : <Shield className="w-3.5 h-3.5 shrink-0 mt-0.5 opacity-50" />}
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{text && <span className="font-medium">{text.title}</span>}
<code className="font-mono text-xs opacity-80">{perm}</code>
</div>
{text?.body && <p className="text-xs mt-0.5 opacity-90">{text.body}</p>}
</div>
</li>
);
})}
</ul>
)}
{frameOrigins.length > 0 && (
<div className="mt-4 pt-3 border-t border-border">
<h3 className="text-xs font-medium text-foreground">Iframe origins</h3>
<p className="text-xs text-muted-foreground mt-0.5">
The plugin will be allowed to embed content from these origins.
</p>
<ul className="mt-2 space-y-1">
{frameOrigins.map(origin => (
<li key={origin} className="text-xs font-mono text-foreground bg-muted/50 px-2 py-1 rounded">
{origin}
</li>
))}
</ul>
</div>
)}
</section>
)}
{/* Settings schema preview */}
{isPlugin && settingsSchema && Object.keys(settingsSchema).length > 0 && (
<section className="border border-border rounded-lg p-4">
<h2 className="text-sm font-medium text-foreground">User settings</h2>
<p className="text-xs text-muted-foreground mt-0.5">Settings users will be able to configure after install.</p>
<ul className="mt-3 divide-y divide-border">
{Object.entries(settingsSchema).map(([key, field]) => (
<li key={key} className="py-2">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-foreground">{key}</code>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{field.type}</span>
</div>
<div className="text-sm text-foreground mt-0.5">{field.label}</div>
{field.description && (
<div className="text-xs text-muted-foreground mt-0.5">{field.description}</div>
)}
</li>
))}
</ul>
</section>
)}
{/* Source / manifest disclosure */}
{bundle.manifest && (
<section className="border border-border rounded-lg">
<button
onClick={() => setShowManifest(v => !v)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">manifest.json</h2>
</div>
{showManifest ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{showManifest && (
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre">
{JSON.stringify(bundle.manifest, null, 2)}
</pre>
)}
</section>
)}
{bundle.source && (
<section className="border border-border rounded-lg">
<button
onClick={() => setShowSource(v => !v)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">{bundle.source.name}</h2>
{bundle.source.truncated && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">truncated</span>
)}
</div>
{showSource ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</button>
{showSource && (
<pre className="px-4 pb-4 text-xs font-mono overflow-x-auto text-foreground whitespace-pre max-h-[600px] overflow-y-auto">
{bundle.source.content}
</pre>
)}
</section>
)}
</div>
);
}
function Notice({ tone, title, children }: { tone: 'info' | 'warning' | 'danger'; title: string; children?: React.ReactNode }) {
const styles = tone === 'danger'
? 'bg-destructive/10 text-destructive'
: tone === 'warning'
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/30 dark:text-amber-300'
: 'bg-muted text-foreground';
const Icon = tone === 'danger' ? ShieldAlert : tone === 'warning' ? AlertTriangle : Info;
return (
<div className={`flex items-start gap-2 text-sm rounded-md px-3 py-2 ${styles}`}>
<Icon className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<p className="font-medium">{title}</p>
{children && <p className="text-xs mt-0.5 opacity-90">{children}</p>}
</div>
</div>
);
}
function ThemeColorSwatch({ preview }: { preview: { variant: 'light' | 'dark'; colors: Record<string, string> | null } }) {
const colors = preview.colors || {};
const bg = colors.background || (preview.variant === 'dark' ? '#0f0f10' : '#ffffff');
const fg = colors.foreground || (preview.variant === 'dark' ? '#fafafa' : '#0a0a0a');
const accent = colors.primary || colors.accent || '#7c5cff';
const muted = colors.muted || (preview.variant === 'dark' ? '#1a1a1c' : '#f5f5f5');
const border = colors.border || (preview.variant === 'dark' ? '#27272a' : '#e5e5e5');
return (
<div className="rounded-md border border-border overflow-hidden">
<div className="px-3 py-2 text-xs font-medium text-muted-foreground bg-muted/30 border-b border-border capitalize">
{preview.variant}
</div>
<div className="p-3 space-y-2" style={{ background: bg, color: fg }}>
<div className="flex items-center gap-2">
<span className="inline-block w-6 h-6 rounded" style={{ background: accent }} />
<span className="text-sm font-medium" style={{ color: fg }}>Sample text</span>
</div>
<div className="rounded p-2 text-xs" style={{ background: muted, border: `1px solid ${border}` }}>
<span style={{ color: fg }}>Card surface</span>
</div>
<div className="flex flex-wrap gap-1">
{Object.entries(colors).slice(0, 6).map(([key, value]) => (
<span
key={key}
title={`${key}: ${value}`}
className="inline-block w-4 h-4 rounded border"
style={{ background: value, borderColor: border }}
/>
))}
</div>
</div>
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=marketplace');
}
-49
View File
@@ -1,49 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useAdminTabStore, isAdminTab } from '@/stores/admin-tab-store';
import { DashboardTab } from './_tabs/dashboard';
import { SettingsTab } from './_tabs/settings';
import { BrandingTab } from './_tabs/branding';
import { AuthTab } from './_tabs/auth';
import { PolicyTab } from './_tabs/policy';
import { PluginsTab } from './_tabs/plugins';
import { ThemesTab } from './_tabs/themes';
import { MarketplaceTab } from './_tabs/marketplace';
import { VersionTab } from './_tabs/version';
import { TelemetryTab } from './_tabs/telemetry';
import { LogsTab } from './_tabs/logs';
export default function AdminPage() {
const activeTab = useAdminTabStore((s) => s.activeTab);
const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
// Honour deep links from the old route structure: /admin?tab=settings
// (emitted by the redirect pages in /admin/<x>/page.tsx) sets the store
// once on mount, then strips the param so the URL stays at /admin and
// subsequent tab clicks don't accumulate query strings.
useEffect(() => {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
const fromUrl = url.searchParams.get('tab');
if (isAdminTab(fromUrl)) {
setActiveTab(fromUrl);
url.searchParams.delete('tab');
window.history.replaceState(null, '', url.pathname + url.search + url.hash);
}
}, [setActiveTab]);
switch (activeTab) {
case 'dashboard': return <DashboardTab />;
case 'settings': return <SettingsTab />;
case 'branding': return <BrandingTab />;
case 'auth': return <AuthTab />;
case 'policy': return <PolicyTab />;
case 'plugins': return <PluginsTab />;
case 'themes': return <ThemesTab />;
case 'marketplace': return <MarketplaceTab />;
case 'version': return <VersionTab />;
case 'telemetry': return <TelemetryTab />;
case 'logs': return <LogsTab />;
}
}
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from 'next/navigation';
// Inline panel handles plugin config now - see _tabs/plugin-config-panel.tsx.
// Old deep links land on the plugins tab; the user clicks the gear again.
export default function Page() {
redirect('/admin?tab=plugins');
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=plugins');
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=policy');
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=settings');
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=telemetry');
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=themes');
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=version');
}
@@ -0,0 +1,31 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Flash } from '../flash-message';
describe('Flash', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('renders nothing without a message', () => {
const { container } = render(<Flash message={null} onDismiss={() => {}} />);
expect(container.firstChild).toBeNull();
});
it('shows an error as an alert until dismissed', () => {
const onDismiss = vi.fn();
render(<Flash message={{ type: 'error', text: 'Save failed' }} onDismiss={onDismiss} />);
expect(screen.getByRole('alert').textContent).toContain('Save failed');
act(() => { vi.advanceTimersByTime(10_000); });
expect(onDismiss).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it('lets a success go away by itself', () => {
const onDismiss = vi.fn();
render(<Flash message={{ type: 'success', text: 'Saved' }} onDismiss={onDismiss} />);
expect(screen.getByRole('status').textContent).toContain('Saved');
act(() => { vi.advanceTimersByTime(6_000); });
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { AdminSaveBar } from '../save-bar';
import { AdminShellContext } from '../shell-context';
describe('AdminSaveBar', () => {
it('shows nothing while there is nothing to save', () => {
render(<AdminSaveBar dirty={false} onSave={() => {}} />);
expect(screen.queryByTestId('admin-save-bar')).toBeNull();
});
it('offers Save and Discard once something changed', () => {
const onSave = vi.fn();
const onDiscard = vi.fn();
render(<AdminSaveBar dirty onSave={onSave} onDiscard={onDiscard} saveLabel="Save policy" note="Users see it on login" />);
expect(screen.getByText('Unsaved changes')).toBeTruthy();
expect(screen.getByText(/Users see it on login/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Save policy' }));
expect(onSave).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
expect(onDiscard).toHaveBeenCalledTimes(1);
});
it('saves on Ctrl+S, but not while a save is running', () => {
const onSave = vi.fn();
const { rerender } = render(<AdminSaveBar dirty onSave={onSave} />);
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
expect(onSave).toHaveBeenCalledTimes(1);
rerender(<AdminSaveBar dirty saving onSave={onSave} />);
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
expect(onSave).toHaveBeenCalledTimes(1);
});
it('ignores Ctrl+S when nothing is dirty', () => {
const onSave = vi.fn();
render(<AdminSaveBar dirty={false} onSave={onSave} />);
fireEvent.keyDown(window, { key: 's', ctrlKey: true });
expect(onSave).not.toHaveBeenCalled();
});
it('renders into the shell host when there is one', () => {
const host = document.createElement('div');
document.body.appendChild(host);
render(
<AdminShellContext.Provider value={{ saveBarHost: host, refreshBadges: () => {} }}>
<div data-testid="page"><AdminSaveBar dirty onSave={() => {}} /></div>
</AdminShellContext.Provider>,
);
expect(host.querySelector('[data-testid="admin-save-bar"]')).not.toBeNull();
expect(screen.getByTestId('page').querySelector('[data-testid="admin-save-bar"]')).toBeNull();
host.remove();
});
});
+36
View File
@@ -0,0 +1,36 @@
'use client';
import { forwardRef, type AnchorHTMLAttributes, type MouseEvent } from 'react';
import { withBasePath } from '@/lib/browser-navigation';
import { navigateAdmin } from '@/hooks/use-admin-navigation';
interface AdminLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
/** App-relative admin path, e.g. `/admin/plugins/jitsi-meet`. */
href: string;
replace?: boolean;
}
/**
* A link between admin pages. A real anchor, so it can be opened in a new
* tab and the unsaved-changes guard sees it like any other link; a plain
* click moves within the panel through history.pushState instead of a
* route transition.
*/
export const AdminLink = forwardRef<HTMLAnchorElement, AdminLinkProps>(function AdminLink(
{ href, replace, onClick, target, children, ...rest },
ref,
) {
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (target && target !== '_self') return;
event.preventDefault();
navigateAdmin(href, { replace });
};
return (
<a ref={ref} href={withBasePath(href)} target={target} onClick={handleClick} {...rest}>
{children}
</a>
);
});
+444
View File
@@ -0,0 +1,444 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { usePathname } from 'next/navigation';
import {
ArrowLeft,
ArrowUpCircle,
Activity,
ChevronRight,
KeyRound,
LayoutDashboard,
LogOut,
Palette,
Puzzle,
Scale,
ScrollText,
Search,
Server,
Shield,
Store,
SwatchBook,
X,
type AppIcon,
} from '@/components/icons';
import { Input } from '@/components/ui/input';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { AboutFooter } from '@/components/settings/about-data-settings';
import { scrollToSettingsSection } from '@/components/settings/settings-section';
import { useIsDesktop } from '@/hooks/use-media-query';
import { useAdminLocation, navigateAdmin } from '@/hooks/use-admin-navigation';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
import { useAdminUnsavedStore } from '@/stores/admin-unsaved-store';
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
import { ADMIN_PAGES, adminPath, type AdminPageDef, type AdminPageId, type AdminSectionDef } from '@/lib/admin/admin-nav';
import { cn } from '@/lib/utils';
import { AdminLink } from './admin-link';
import { AdminShellContext } from './shell-context';
const PAGE_ICONS: Record<AdminPageId, AppIcon> = {
dashboard: LayoutDashboard,
settings: Server,
auth: Shield,
branding: Palette,
policy: Scale,
plugins: Puzzle,
themes: SwatchBook,
marketplace: Store,
version: ArrowUpCircle,
telemetry: Activity,
logs: ScrollText,
};
const GROUP_LABELS: Record<AdminPageDef['group'], string | null> = {
top: null,
extensions: 'Extensions',
system: 'System',
};
interface AdminShellProps {
/** Whether the admin session is in place; the sidebar counts load then. */
authenticated: boolean;
/** Stalwart admins signed in automatically have no password to change. */
isStalwartAutoLogin: boolean;
configReadOnly: boolean;
onLogout: () => void;
children: ReactNode;
}
interface Match {
page: AdminPageDef;
sections: AdminSectionDef[];
}
/**
* The admin panel's frame: the webmail's own navigation rail, the page list
* laid out like the settings sidebar (search included), the content column
* and, on phones, the settings' list-then-page flow.
*/
export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, onLogout, children }: AdminShellProps) {
const pathname = usePathname();
const location = useAdminLocation();
const isDesktop = useIsDesktop();
const requestLeave = useAdminUnsavedStore((s) => s.requestLeave);
const [query, setQuery] = useState('');
const [saveBarHost, setSaveBarHost] = useState<HTMLElement | null>(null);
const [pendingApprovals, setPendingApprovals] = useState(0);
const [pendingSection, setPendingSection] = useState<{ page: AdminPageId; id: string } | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const onChangePassword = pathname === '/admin/change-password';
const activePage: AdminPageId | null = location.page ?? (isDesktop && !onChangePassword ? 'dashboard' : null);
const activeDef = location.page ? ADMIN_PAGES.find((p) => p.id === location.page) ?? null : null;
// Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
const startUpdatePolling = useUpdateStore((s) => s.startPolling);
useEffect(() => { startUpdatePolling(); }, [startUpdatePolling]);
const updateImportant = updateSeverity === 'security' || updateSeverity === 'deprecated';
const refreshBadges = useCallback(() => {
if (!authenticated) return;
apiFetch('/api/admin/plugin-approvals')
.then((res) => (res.ok ? res.json() : null))
.then((data: { entries?: Array<{ status: string }> } | null) => {
setPendingApprovals(data?.entries?.filter((e) => e.status === 'pending').length ?? 0);
})
.catch(() => {});
}, [authenticated]);
useEffect(() => { refreshBadges(); }, [refreshBadges]);
// A new page starts at the top; the back button restores nothing finer.
const scrollKey = `${location.page ?? ''}/${location.detail ?? ''}`;
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 });
}, [scrollKey]);
// A section picked from the search scrolls into view once its page has
// rendered it; pages fetch first, so retry for a moment.
useEffect(() => {
if (!pendingSection || pendingSection.page !== location.page) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = Date.now() + 2000;
const attempt = () => {
if (cancelled) return;
if (scrollToSettingsSection(pendingSection.id)) { setPendingSection(null); return; }
if (Date.now() < deadline) timer = setTimeout(attempt, 80);
else setPendingSection(null);
};
const raf = window.requestAnimationFrame(attempt);
return () => {
cancelled = true;
window.cancelAnimationFrame(raf);
if (timer) clearTimeout(timer);
};
}, [pendingSection, location.page]);
const trimmed = query.trim().toLowerCase();
const matches = useMemo<Match[]>(() => {
if (!trimmed) return ADMIN_PAGES.map((page) => ({ page, sections: [] }));
const words = trimmed.split(/\s+/);
const hits = (text: string) => words.every((w) => text.includes(w));
return ADMIN_PAGES.flatMap((page) => {
const sections = page.sections.filter((s) => hits(`${s.title} ${s.keywords ?? ''}`.toLowerCase()));
const pageHit = hits(`${page.label} ${page.description} ${page.keywords}`.toLowerCase());
if (!pageHit && sections.length === 0) return [];
return [{ page, sections: sections.slice(0, 5) }];
});
}, [trimmed]);
const groups = useMemo(() => (
(['top', 'extensions', 'system'] as const)
.map((group) => ({ group, label: GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) }))
.filter((g) => g.items.length > 0)
), [matches]);
const goTo = (page: AdminPageId, section?: string) => {
requestLeave(() => {
setPendingSection(section ? { page, id: section } : null);
navigateAdmin(adminPath(page));
setQuery('');
});
};
const prefix = getPathPrefix();
const contextValue = useMemo(() => ({ saveBarHost, refreshBadges }), [saveBarHost, refreshBadges]);
const badgeFor = (id: AdminPageId): ReactNode => {
if (id === 'plugins' && pendingApprovals > 0) {
return (
<span className="ms-auto inline-flex min-w-[18px] items-center justify-center rounded-full bg-amber-500 px-1.5 text-[10px] font-semibold leading-[18px] text-white" title={`${pendingApprovals} plugin${pendingApprovals === 1 ? '' : 's'} waiting for approval`}>
{pendingApprovals}
</span>
);
}
if (id === 'version' && hasUpdate) {
return (
<span
className={cn('ms-auto h-2 w-2 rounded-full', updateImportant ? 'bg-red-500' : 'bg-amber-500')}
aria-label={updateImportant ? 'Important update available' : 'Update available'}
/>
);
}
return null;
};
const searchBox = (
<div className="relative">
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search admin"
aria-label="Search admin pages"
className={cn('h-9 ps-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none', query && 'pe-8')}
/>
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute end-2 top-1/2 -translate-y-1/2 rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
const noResults = (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches</div>
);
// The admin session is its own: a password admin signs out of the panel,
// not of their mailbox, so these stay with the panel rather than the rail.
const sessionLinks = (mobile: boolean) => (
<>
{!isStalwartAutoLogin && (
<AdminLink
href="/admin/change-password"
aria-current={onChangePassword ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-2.5 rounded-md text-sm transition-colors duration-150',
mobile ? 'px-3 py-2.5' : 'px-3 py-2',
onChangePassword ? 'bg-accent font-medium text-accent-foreground' : 'text-foreground hover:bg-muted',
)}
>
<KeyRound className={cn('h-4 w-4 shrink-0', onChangePassword ? 'text-accent-foreground' : 'text-muted-foreground')} />
Change password
</AdminLink>
)}
<button
type="button"
onClick={onLogout}
className={cn(
'flex w-full items-center gap-2.5 rounded-md text-sm text-foreground transition-colors duration-150 hover:bg-muted',
mobile ? 'px-3 py-2.5' : 'px-3 py-2',
)}
>
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
Sign out of admin
</button>
</>
);
const readOnlyBanner = configReadOnly && authenticated && (
<div role="status" className="mb-6 rounded-lg border border-border bg-muted px-4 py-3 text-sm">
<p className="font-medium text-foreground">The configuration is read-only</p>
<p className="mt-1 text-muted-foreground">
ADMIN_CONFIG_READONLY is set, so you can look at the settings here but not save changes.
Change them in the mounted config files or environment instead.
</p>
</div>
);
if (!isDesktop) {
const showIndex = location.page === null && !onChangePassword;
const title = onChangePassword ? 'Change password' : activeDef?.label ?? 'Admin';
const backHref = location.detail ? adminPath(location.page) : '/admin';
return (
<AdminShellContext.Provider value={contextValue}>
<div className="flex h-dvh flex-col bg-background pt-[env(safe-area-inset-top)]">
<div className="flex h-14 shrink-0 items-center gap-1 border-b border-border bg-background px-2">
{showIndex ? (
<a
href={`${prefix}/`}
className="flex h-10 w-10 items-center justify-center rounded-md text-foreground transition-colors hover:bg-muted"
aria-label="Back to mail"
>
<ArrowLeft className="h-5 w-5" />
</a>
) : (
<AdminLink
href={backHref}
className="flex h-10 w-10 items-center justify-center rounded-md text-foreground transition-colors hover:bg-muted"
aria-label="Back"
>
<ArrowLeft className="h-5 w-5" />
</AdminLink>
)}
<div className="flex min-w-0 items-center gap-2">
{showIndex && <Shield className="h-5 w-5 text-muted-foreground" />}
<h1 className="truncate text-lg font-semibold">{title}</h1>
</div>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{showIndex ? (
<>
<div className="px-4 pb-1 pt-3">{searchBox}</div>
<div className="py-2">
{groups.length === 0 && noResults}
{groups.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-5 my-2 border-t border-border" />}
{group.label && (
<div className="px-5 pb-1.5 pt-3">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{group.label}</span>
</div>
)}
{group.items.map(({ page, sections }) => {
const Icon = PAGE_ICONS[page.id];
return (
<div key={page.id}>
<AdminLink
href={adminPath(page.id)}
onClick={(e) => { e.preventDefault(); goTo(page.id); }}
data-testid={`admin-page-${page.id}`}
className="flex w-full items-center justify-between py-3.5 pe-5 ps-5 text-sm text-foreground transition-colors duration-150 hover:bg-muted"
>
<span className="flex items-center gap-3">
<Icon className="h-4 w-4 text-muted-foreground" />
{page.label}
{badgeFor(page.id)}
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</AdminLink>
{sections.map((section) => (
<button
key={section.id}
type="button"
onClick={() => goTo(page.id, section.id)}
className="flex w-full items-center pe-5 ps-12 py-2 text-start text-xs text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground"
>
<span className="truncate">{section.title}</span>
</button>
))}
</div>
);
})}
</div>
))}
</div>
<div className="border-t border-border px-2 py-2">{sessionLinks(true)}</div>
<div className="border-t border-border px-2 py-2"><AboutFooter /></div>
</>
) : (
<div className="p-4 pb-6">
{readOnlyBanner}
{children}
</div>
)}
</div>
<div ref={setSaveBarHost} className="shrink-0" />
<NavigationRail orientation="horizontal" adminActive />
</div>
</AdminShellContext.Provider>
);
}
return (
<AdminShellContext.Provider value={contextValue}>
<div className="flex h-dvh bg-background pt-[env(safe-area-inset-top)]">
{/* The same rail as mail and settings, with Admin as the current app. */}
<div className="flex w-14 shrink-0 flex-col bg-secondary" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
<NavigationRail collapsed adminActive />
</div>
{/* The page list, laid out like the settings sidebar. */}
<aside className="flex w-64 shrink-0 flex-col border-e border-border bg-secondary">
<div className="border-b border-border">
<div className="flex h-14 items-center px-2">
<a
href={`${prefix}/`}
className="flex h-9 w-full items-center rounded-md px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<ArrowLeft className="me-2 h-4 w-4" />
Back to Mail
</a>
</div>
</div>
<div className="flex-1 overflow-y-auto py-2">
<div className="px-3 pb-1 pt-1">{searchBox}</div>
<nav className="space-y-0.5 px-2" aria-label="Admin pages">
{groups.length === 0 && noResults}
{groups.map((group, groupIndex) => (
<div key={group.group}>
{groupIndex > 0 && <div className="mx-1 my-2 border-t border-border" />}
{group.label && (
<div className="px-3 pb-1 pt-2.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group.label}</span>
</div>
)}
{group.items.map(({ page, sections }) => {
const Icon = PAGE_ICONS[page.id];
const active = activePage === page.id;
return (
<div key={page.id}>
<AdminLink
href={adminPath(page.id)}
onClick={(e) => { e.preventDefault(); if (!active || location.detail) goTo(page.id); }}
aria-current={active ? 'page' : undefined}
data-testid={`admin-page-${page.id}`}
className={cn(
'flex w-full items-center gap-2.5 rounded-md py-2 pe-3 ps-3 text-start text-sm transition-colors duration-150',
active ? 'bg-accent font-medium text-accent-foreground' : 'text-foreground hover:bg-muted',
)}
>
<Icon className={cn('h-4 w-4 shrink-0', active ? 'text-accent-foreground' : 'text-muted-foreground')} />
<span className="truncate">{page.label}</span>
{badgeFor(page.id)}
</AdminLink>
{sections.map((section) => (
<button
key={section.id}
type="button"
onClick={() => goTo(page.id, section.id)}
className="w-full rounded-md py-1.5 pe-3 ps-9 text-start text-xs text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground"
>
<span className="block truncate">{section.title}</span>
</button>
))}
</div>
);
})}
</div>
))}
</nav>
</div>
<div className="border-t border-border px-1 py-1">
<div className="space-y-0.5 px-1 py-1">{sessionLinks(false)}</div>
<AboutFooter />
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl px-6 py-6">
{readOnlyBanner}
{children}
</div>
</div>
<div ref={setSaveBarHost} className="shrink-0" />
</main>
</div>
</AdminShellContext.Provider>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export type BadgeTone = 'success' | 'warning' | 'danger' | 'info' | 'muted' | 'accent';
const TONES: Record<BadgeTone, string> = {
success: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400',
warning: 'bg-amber-100 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400',
danger: 'bg-destructive/10 text-destructive',
info: 'bg-blue-100 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400',
muted: 'bg-muted text-muted-foreground',
accent: 'bg-primary/10 text-primary',
};
interface BadgeProps {
tone?: BadgeTone;
icon?: ReactNode;
children: ReactNode;
title?: string;
className?: string;
}
/** A small label next to a name: a state, a source, a count. */
export function Badge({ tone = 'muted', icon, children, title, className }: BadgeProps) {
return (
<span
title={title}
className={cn('inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] font-medium leading-4 whitespace-nowrap', TONES[tone], className)}
>
{icon}
{children}
</span>
);
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import type { AnchorHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
import { AdminLink } from './admin-link';
const VARIANTS = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
} as const;
interface AdminButtonLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
href: string;
variant?: keyof typeof VARIANTS;
/** A link out of the panel (a hard navigation) rather than to another admin page. */
external?: boolean;
}
/** A link that looks like a small Button, for actions that open a page. */
export function AdminButtonLink({ href, variant = 'outline', external, className, children, ...rest }: AdminButtonLinkProps) {
const classes = cn(
'inline-flex h-9 items-center justify-center gap-2 rounded-md px-3 text-sm font-medium transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
VARIANTS[variant],
className,
);
if (external) {
return <a href={href} className={classes} {...rest}>{children}</a>;
}
return <AdminLink href={href} className={classes} {...rest}>{children}</AdminLink>;
}
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useId, type ReactNode } from 'react';
import { RotateCcw } from '@/components/icons';
import { SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
import { cn } from '@/lib/utils';
import { Badge } from './badge';
/**
* Rows for the server configuration pages (Server, Authentication,
* Branding). Each shows where the value comes from and offers to drop an
* admin override; the page keeps the edits and saves them as one.
*/
export type ConfigSource = 'admin' | 'env' | 'default';
export const inputClass =
'h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground ' +
'hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring ' +
'disabled:cursor-not-allowed disabled:opacity-50 transition-colors';
export function SourceBadge({ source, label }: { source?: string; label?: string }) {
if (!source || source === 'default') return null;
return (
<Badge tone={source === 'admin' ? 'accent' : 'muted'} title={source === 'env' ? 'Set in the environment' : 'Set in this panel'}>
{label ?? source}
</Badge>
);
}
export function RevertButton({ onClick, title = 'Revert to default' }: { onClick: () => void; title?: string }) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
);
}
interface ConfigItemBase {
label: string;
description?: string;
configKey: string;
source?: string;
onChange: (key: string, value: unknown) => void;
onRevert?: (key: string) => void;
/** Rows that only matter in unusual setups, hidden behind the Advanced switch. */
advanced?: boolean;
disabled?: boolean;
}
interface ConfigTextItemProps extends ConfigItemBase {
value: string | undefined | null;
placeholder?: string;
type?: 'text' | 'url' | 'password' | 'number';
mono?: boolean;
/** Wider field, for URLs and lists. */
wide?: boolean;
}
export function ConfigTextItem({ label, description, configKey, value, source, onChange, onRevert, placeholder, type = 'text', mono, wide, advanced, disabled }: ConfigTextItemProps) {
const id = useId();
return (
<SettingItem label={label} description={description} advanced={advanced}>
<div className="flex w-full items-center gap-1.5 sm:w-auto">
<SourceBadge source={source} />
<input
id={id}
type={type}
value={value ?? ''}
disabled={disabled}
onChange={(e) => onChange(configKey, e.target.value)}
placeholder={placeholder}
aria-label={label}
autoComplete="off"
spellCheck={false}
className={cn(inputClass, 'w-full min-w-0', wide ? 'sm:w-80' : 'sm:w-64', mono && 'font-mono')}
/>
{source === 'admin' && onRevert && <RevertButton onClick={() => onRevert(configKey)} />}
</div>
</SettingItem>
);
}
interface ConfigToggleItemProps extends ConfigItemBase {
value: boolean | undefined | null;
}
export function ConfigToggleItem({ label, description, configKey, value, source, onChange, onRevert, advanced, disabled }: ConfigToggleItemProps) {
return (
<SettingItem label={label} description={description} advanced={advanced}>
<div className="flex items-center gap-1.5">
<SourceBadge source={source} />
<ToggleSwitch checked={!!value} disabled={disabled} onChange={(checked) => onChange(configKey, checked)} />
{source === 'admin' && onRevert && <RevertButton onClick={() => onRevert(configKey)} />}
</div>
</SettingItem>
);
}
interface ConfigSelectItemProps extends ConfigItemBase {
value: string | undefined | null;
options: ReadonlyArray<string | { value: string; label: string }>;
}
export function ConfigSelectItem({ label, description, configKey, value, source, options, onChange, onRevert, advanced, disabled }: ConfigSelectItemProps) {
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
return (
<SettingItem label={label} description={description} advanced={advanced}>
<div className="flex items-center gap-1.5">
<SourceBadge source={source} />
<select
value={value ?? ''}
disabled={disabled}
onChange={(e) => onChange(configKey, e.target.value)}
aria-label={label}
className={cn(inputClass, 'pe-8 cursor-pointer')}
>
{normalized.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
{source === 'admin' && onRevert && <RevertButton onClick={() => onRevert(configKey)} />}
</div>
</SettingItem>
);
}
/** A note under a group of rows, for a warning or an explanation. */
export function ConfigNote({ tone = 'info', children }: { tone?: 'info' | 'warning'; children: ReactNode }) {
return (
<div
className={cn(
'rounded-md px-3 py-2.5 text-xs leading-relaxed',
tone === 'warning'
? 'border-s-2 border-amber-400 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-950/30 dark:text-amber-300'
: 'bg-muted/50 text-muted-foreground',
)}
>
{children}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import type { AppIcon } from '@/components/icons';
import { cn } from '@/lib/utils';
interface EmptyStateProps {
icon?: AppIcon;
title: ReactNode;
hint?: ReactNode;
action?: ReactNode;
className?: string;
}
/** A list with nothing in it, with what to do about that. */
export function EmptyState({ icon: Icon, title, hint, action, className }: EmptyStateProps) {
return (
<div className={cn('rounded-lg border border-dashed border-border px-6 py-10 text-center', className)}>
{Icon && <Icon className="mx-auto mb-3 h-8 w-8 text-muted-foreground/50" />}
<p className="text-sm font-medium text-foreground">{title}</p>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
{action && <div className="mt-4 flex justify-center">{action}</div>}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { AppIcon } from '@/components/icons';
interface ExtensionIconProps {
/** The brand icon the extension directory serves, when known. */
url: string | null | undefined;
/** What to draw without one. */
fallback: AppIcon;
className?: string;
}
/** A plugin's or theme's icon, or the generic one when the directory has none. */
export function ExtensionIcon({ url, fallback: Fallback, className }: ExtensionIconProps) {
if (url) {
return <img src={url} alt="" loading="lazy" className={className ?? 'h-full w-full rounded-md object-cover'} />;
}
return <Fallback className={className} />;
}
+57
View File
@@ -0,0 +1,57 @@
'use client';
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
import { CheckCircle2, AlertTriangle, X } from '@/components/icons';
import { cn } from '@/lib/utils';
export type FlashMessage = { type: 'success' | 'error'; text: string } | null;
/** Page-level feedback state; the message is shown with `<Flash>`. */
export function useFlash(): [FlashMessage, Dispatch<SetStateAction<FlashMessage>>] {
return useState<FlashMessage>(null);
}
interface FlashProps {
message: FlashMessage;
onDismiss: () => void;
className?: string;
}
/**
* The outcome of the last action, under the page header. A success goes
* away by itself; an error stays until dismissed or the next action.
*/
export function Flash({ message, onDismiss, className }: FlashProps) {
useEffect(() => {
if (message?.type !== 'success') return;
const timer = setTimeout(onDismiss, 6000);
return () => clearTimeout(timer);
}, [message, onDismiss]);
if (!message) return null;
const success = message.type === 'success';
const Icon = success ? CheckCircle2 : AlertTriangle;
return (
<div
role={success ? 'status' : 'alert'}
className={cn(
'mb-6 flex items-start gap-2.5 rounded-md border px-3 py-2.5 text-sm',
success
? 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'border-destructive/30 bg-destructive/10 text-destructive',
className,
)}
>
<Icon className="w-4 h-4 shrink-0 mt-0.5" />
<span className="flex-1 min-w-0 break-words">{message.text}</span>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="shrink-0 -me-1 rounded p-0.5 opacity-70 hover:opacity-100 transition-opacity"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
'use client';
import type { ReactNode } from 'react';
import { ChevronRight } from '@/components/icons';
import { cn } from '@/lib/utils';
import { AdminLink } from './admin-link';
interface AdminListProps {
children: ReactNode;
className?: string;
}
/** Rows of things the admin manages: plugins, themes, requests. */
export function AdminList({ children, className }: AdminListProps) {
return (
<div className={cn('divide-y divide-border rounded-lg border border-border', className)}>
{children}
</div>
);
}
interface AdminListRowProps {
/** Makes the whole row open this admin path; the title becomes the link. */
href?: string;
icon?: ReactNode;
title: ReactNode;
/** Badges and other inline bits after the title. */
titleExtra?: ReactNode;
description?: ReactNode;
/** The small line under the description: author, version, dates. */
meta?: ReactNode;
/** Controls on the end side; they sit above the row link. */
control?: ReactNode;
className?: string;
'data-testid'?: string;
}
export function AdminListRow({ href, icon, title, titleExtra, description, meta, control, className, ...rest }: AdminListRowProps) {
return (
<div
data-testid={rest['data-testid']}
className={cn(
'relative flex items-center gap-3 px-4 py-3 transition-colors',
href && 'hover:bg-muted/40',
className,
)}
>
{icon && (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground [&>svg]:h-[18px] [&>svg]:w-[18px]">
{icon}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{href ? (
<AdminLink
href={href}
className="text-sm font-medium text-foreground after:absolute after:inset-0 after:rounded-lg focus-visible:outline-none focus-visible:after:ring-2 focus-visible:after:ring-ring"
>
{title}
</AdminLink>
) : (
<span className="text-sm font-medium text-foreground">{title}</span>
)}
{titleExtra}
</div>
{description && <p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{description}</p>}
{meta && <div className="mt-0.5 text-xs text-muted-foreground">{meta}</div>}
</div>
{control && <div className="relative z-10 flex shrink-0 items-center gap-2">{control}</div>}
{href && <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
import type { ReactNode } from 'react';
import { ArrowLeft } from '@/components/icons';
import { cn } from '@/lib/utils';
import { AdminLink } from './admin-link';
interface AdminPageHeaderProps {
title: ReactNode;
description?: ReactNode;
/** Buttons on the end side of the header. */
actions?: ReactNode;
/** A link back to the parent page, for detail pages. */
back?: { href: string; label: string };
/** Something between the back link and the title, such as an icon. */
leading?: ReactNode;
className?: string;
}
/**
* The header of an admin page. The mobile shell already shows the page name
* in its top bar, so the title itself is desktop-only there; the
* description and the actions stay.
*/
export function AdminPageHeader({ title, description, actions, back, leading, className }: AdminPageHeaderProps) {
return (
<div className={cn('mb-6 space-y-3', className)}>
{back && (
<AdminLink
href={back.href}
// The phone shell's top bar already has the back arrow.
className="hidden lg:inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="w-4 h-4" />
{back.label}
</AdminLink>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
{leading}
<div className="min-w-0">
<h1 className={cn('text-xl font-semibold text-foreground leading-tight', !back && 'hidden lg:block')}>{title}</h1>
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
</div>
</div>
{actions && <div className="flex flex-wrap items-center gap-2 shrink-0">{actions}</div>}
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
'use client';
import { useEffect, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { Loader2 } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { useAdminShell } from './shell-context';
interface AdminSaveBarProps {
dirty: boolean;
saving?: boolean;
onSave: () => void;
/** Drops the edits; without it the bar offers only Save. */
onDiscard?: () => void;
saveLabel?: string;
/** What the save does, e.g. "Users see the change on their next login". */
note?: ReactNode;
}
/**
* The one place a page saves from. It appears at the bottom of the panel
* while something is unsaved (through the shell's portal host, so it sits
* below the scrolling content and above the mobile rail) and takes Ctrl+S.
*/
export function AdminSaveBar({ dirty, saving = false, onSave, onDiscard, saveLabel = 'Save changes', note }: AdminSaveBarProps) {
const host = useAdminShell()?.saveBarHost ?? null;
useEffect(() => {
if (!dirty) return;
const onKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
if (!saving) onSave();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [dirty, saving, onSave]);
if (!dirty) return null;
const bar = (
<div
role="region"
aria-label="Unsaved changes"
data-testid="admin-save-bar"
className="border-t border-border bg-background px-4 py-3 sm:px-6"
>
<div className="mx-auto flex max-w-3xl flex-wrap items-center justify-between gap-3">
<div className="min-w-0 text-sm">
<span className="font-medium text-foreground">Unsaved changes</span>
{note && <span className="text-muted-foreground"> &middot; {note}</span>}
</div>
<div className="flex items-center gap-2">
{onDiscard && (
<Button type="button" variant="ghost" size="sm" onClick={onDiscard} disabled={saving}>
Discard
</Button>
)}
<Button type="button" size="sm" onClick={onSave} disabled={saving} className="gap-2">
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saveLabel}
</Button>
</div>
</div>
</div>
);
return host ? createPortal(bar, host) : bar;
}
+16
View File
@@ -0,0 +1,16 @@
'use client';
import { createContext, useContext } from 'react';
export interface AdminShellContextValue {
/** Where a page's save bar renders: below the scrolling content, above the mobile rail. */
saveBarHost: HTMLElement | null;
/** Re-reads the counts shown next to sidebar entries (pending approvals). */
refreshBadges: () => void;
}
export const AdminShellContext = createContext<AdminShellContextValue | null>(null);
export function useAdminShell(): AdminShellContextValue | null {
return useContext(AdminShellContext);
}
+2 -9
View File
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react';
import { resolveTarget, type ResolveResult } from '@/lib/connector/registry';
import { appPath } from '@/lib/deep-links';
import { replaceWindowLocation } from '@/lib/browser-navigation';
import { useAdminTabStore } from '@/stores/admin-tab-store';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '';
@@ -26,7 +25,6 @@ const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '';
*/
export function ConnectorClient({ target }: { target: string }) {
const [result, setResult] = useState<ResolveResult | null>(null);
const setAdminTab = useAdminTabStore((s) => s.setActiveTab);
useEffect(() => {
setResult(resolveTarget(target, new URLSearchParams(window.location.search)));
@@ -34,21 +32,16 @@ export function ConnectorClient({ target }: { target: string }) {
const destination = useMemo(() => {
if (result?.kind !== 'ok') return null;
const { path, adminTab } = result.resolution;
// The admin shell keeps its active tab in a store rather than in the URL,
// so a link to a specific panel selects it here. The store is persisted,
// so the choice survives an admin login in between.
return { url: appPath(path), adminTab: adminTab ?? null };
return { url: appPath(result.resolution.path) };
}, [result]);
useEffect(() => {
if (!destination) return;
if (destination.adminTab) setAdminTab(destination.adminTab);
// Nothing is appended to the URL. An admin who is not signed in gets
// bounced by the admin layout, which parks this destination in its own
// `?next=` - adding one here would nest inside that one and be refused.
replaceWindowLocation(destination.url);
}, [destination, setAdminTab]);
}, [destination]);
if (result === null || result.kind === 'ok') {
return <Shell>Opening…</Shell>;
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { act, createEvent, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
@@ -7,6 +7,7 @@ import { checkActions, resetActions, setActionDispatcher, setPluginActions } fro
import { clearOffers, lastSlotProps, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
import { useSettingsStore } from '@/stores/settings-store';
import { SLOT_CATALOG } from '@/lib/plugin-slot-catalog';
import { requestPanel, resetPanelRequests } from '@/lib/plugin-sandbox/panel-requests';
// Plugin surfaces of the composer: frames above and below the message text
// (fed the draft, at most every 2 seconds), and entries in the Send and
@@ -220,6 +221,7 @@ beforeEach(() => {
afterEach(() => {
clearOffers();
setActionDispatcher(null);
resetPanelRequests();
vi.useRealTimers();
});
@@ -413,6 +415,66 @@ describe('composer-attach-menu', () => {
});
});
describe('composer-selection-menu', () => {
const SURFACE = 'composer-selection-menu';
const rightClick = (el: HTMLElement) => {
const event = createEvent.contextMenu(el, { clientX: 40, clientY: 50 });
fireEvent(el, event);
return event;
};
beforeEach(() => { useSettingsStore.setState({ plainTextMode: true }); });
afterEach(() => { useSettingsStore.setState({ plainTextMode: false }); });
it('leaves the browser menu alone without plugin entries, and without a selection', () => {
const { container } = render(<EmailComposer initialData={{ ...DRAFT, body: 'Hello there' }} onClose={vi.fn()} />);
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
textarea.setSelectionRange(0, 5);
expect(rightClick(textarea).defaultPrevented).toBe(false);
expect(screen.queryByRole('menu')).toBeNull();
act(() => { setPluginActions('p1', 'P1', SURFACE, checkActions([{ id: 'improve', label: 'Improve' }])); });
textarea.setSelectionRange(3, 3);
expect(rightClick(textarea).defaultPrevented).toBe(false);
expect(screen.queryByRole('menu')).toBeNull();
});
it('offers Cut, Copy and the plugin entries on selected text, and hands the click the selection', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } });
try {
act(() => { setPluginActions('p1', 'P1', SURFACE, checkActions([{ id: 'improve', label: 'Improve', icon: 'sparkles' }])); });
const { container } = render(<EmailComposer initialData={{ ...DRAFT, body: 'Hello there' }} onClose={vi.fn()} />);
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
textarea.setSelectionRange(0, 5);
expect(rightClick(textarea).defaultPrevented).toBe(true);
const menu = screen.getByRole('menu');
expect(menu).toHaveTextContent('cut');
expect(menu).toHaveTextContent('copy');
fireEvent.click(screen.getByRole('menuitem', { name: 'Improve' }));
await act(async () => {});
expect(dispatch).toHaveBeenCalledWith('p1', SURFACE, 'improve', expect.objectContaining({ selection: { text: 'Hello' } }));
expect(typeof dispatch.mock.calls[0][3].composerId).toBe('string');
expect(screen.queryByRole('menu')).toBeNull();
textarea.setSelectionRange(0, 5);
rightClick(textarea);
fireEvent.click(screen.getByTestId('composer-selection-copy'));
expect(writeText).toHaveBeenCalledWith('Hello');
expect(textarea.value).toBe('Hello there');
textarea.setSelectionRange(0, 6);
rightClick(textarea);
fireEvent.click(screen.getByTestId('composer-selection-cut'));
expect(writeText).toHaveBeenLastCalledWith('Hello ');
expect(textarea.value).toBe('there');
} finally {
vi.unstubAllGlobals();
}
});
});
describe('composer-sidebar and composer-sidebar-right', () => {
function stubWidth(width: number) {
class FixedWidthObserver {
@@ -433,6 +495,8 @@ describe('composer-sidebar and composer-sidebar-right', () => {
try {
localStorage.removeItem('composerPluginSidebarCollapsed:left');
localStorage.removeItem('composerPluginSidebarCollapsed:right');
localStorage.removeItem('composerPluginSidebarWidth:left');
localStorage.removeItem('composerPluginSidebarWidth:right');
} catch { /* ignore */ }
});
@@ -449,6 +513,43 @@ describe('composer-sidebar and composer-sidebar-right', () => {
expect(panel('right')).toContainElement(screen.getByTestId('slot-composer-sidebar-right'));
});
it('let the user drag a panel wider or narrower, within limits, and remember it', () => {
stubWidth(1400);
offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']);
const { unmount } = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
// The right panel's handle is on its left edge: dragging left widens it.
const right = screen.getByTestId('composer-sidebar-resize-right');
fireEvent.mouseDown(right, { clientX: 1000 });
fireEvent.mouseMove(document, { clientX: 900 });
fireEvent.mouseUp(document);
expect(panel('right').style.width).toBe('360px');
// The left panel's handle is on its right edge: dragging left narrows it, down to the minimum.
const left = screen.getByTestId('composer-sidebar-resize-left');
fireEvent.mouseDown(left, { clientX: 300 });
fireEvent.mouseMove(document, { clientX: 100 });
fireEvent.mouseUp(document);
expect(panel('left').style.width).toBe('220px');
fireEvent.keyDown(left, { key: 'ArrowRight' });
expect(panel('left').style.width).toBe('230px');
fireEvent.doubleClick(left);
expect(panel('left').style.width).toBe('260px');
expect(localStorage.getItem('composerPluginSidebarWidth:right')).toBe('360');
unmount();
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(panel('right').style.width).toBe('360px');
});
it('folds a widened panel sooner, since it needs the room', () => {
localStorage.setItem('composerPluginSidebarWidth:right', '480');
stubWidth(1000);
offerSlots('p1', ['composer-sidebar-right']);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
expect(screen.queryByTestId('composer-sidebar-resize-right')).toBeNull();
});
it('fold the right panel, then both, when the composer would get too narrow', () => {
stubWidth(900);
offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']);
@@ -466,6 +567,117 @@ describe('composer-sidebar and composer-sidebar-right', () => {
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
});
it('let a plugin frame fill the panel, so it can pin a chat input at the bottom', () => {
stubWidth(1400);
offerSlots('p1', ['composer-sidebar-right'], { fill: ['composer-sidebar-right'] });
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
const frame = screen.getByTestId('slot-composer-sidebar-right');
expect(frame.dataset.fill).toBe('true');
expect(frame.closest('[data-plugin-slot]')!.className).toContain('h-full');
});
it('hand the panels the composer id, the draft and the selection', () => {
stubWidth(1400);
offerSlots('p1', ['composer-sidebar-right']);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
const props = slotProps(screen.getByTestId('slot-composer-sidebar-right'));
expect(typeof props.composerId).toBe('string');
expect((props.draft as { subject: string }).subject).toBe('Lunch');
expect(props.selection).toEqual({ text: '', empty: true });
});
it('send the panels the selected text a moment after it changes', () => {
useSettingsStore.setState({ plainTextMode: true });
try {
stubWidth(1400);
offerSlots('p1', ['composer-sidebar-right']);
const { container } = render(<EmailComposer initialData={{ ...DRAFT, body: 'Hello there' }} onClose={vi.fn()} />);
vi.useFakeTimers();
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
textarea.focus();
textarea.setSelectionRange(0, 5);
act(() => { document.dispatchEvent(new Event('selectionchange')); });
expect(slotProps(screen.getByTestId('slot-composer-sidebar-right')).selection).toEqual({ text: '', empty: true });
act(() => { vi.advanceTimersByTime(200); });
expect(slotProps(screen.getByTestId('slot-composer-sidebar-right')).selection).toEqual({ text: 'Hello', empty: false });
textarea.setSelectionRange(2, 2);
act(() => { document.dispatchEvent(new Event('selectionchange')); vi.advanceTimersByTime(200); });
expect(slotProps(screen.getByTestId('slot-composer-sidebar-right')).selection).toEqual({ text: '', empty: true });
} finally {
useSettingsStore.setState({ plainTextMode: false });
}
});
it('open, close and toggle on a plugin\'s request, for that composer only, and tell the slot to focus', () => {
stubWidth(600);
offerSlots('p1', ['composer-sidebar-right']);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
let open: boolean | null = false;
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'open', { composerId: 'someone-else' }); });
expect(open).toBeNull();
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'open', undefined); });
expect(open).toBe(true);
expect(panel('right').dataset.composerSidebarMode).toBe('overlay');
const props = slotProps(screen.getByTestId('slot-composer-sidebar-right'));
expect(props.focusRequest).toBe(1);
const composerId = props.composerId as string;
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'toggle', { composerId }); });
expect(open).toBe(false);
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'toggle', { composerId }); });
expect(open).toBe(true);
expect(slotProps(screen.getByTestId('slot-composer-sidebar-right')).focusRequest).toBe(2);
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'close', { composerId }); });
expect(open).toBe(false);
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
// The left panel is not on this composer: nobody answers.
act(() => { open = requestPanel('p1', 'composer-sidebar', 'open', { composerId }); });
expect(open).toBeNull();
});
it('start folded when the plugin asks for it, until the user or the plugin opens it', () => {
stubWidth(1400);
offerSlots('p1', ['composer-sidebar-right'], { startFolded: ['composer-sidebar-right'] });
const { unmount } = render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
act(() => { requestPanel('p1', 'composer-sidebar-right', 'open', undefined); });
expect(panel('right').dataset.composerSidebarMode).toBe('docked');
unmount();
// Opened once: it stays open for the next composer.
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(panel('right').dataset.composerSidebarMode).toBe('docked');
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_hide' }));
expect(panel('right').dataset.composerSidebarMode).toBe('folded');
});
it('answer no request on a phone, where the panels are not shown', () => {
const width = window.innerWidth;
window.innerWidth = 390;
try {
stubWidth(390);
offerSlots('p1', ['composer-sidebar-right']);
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
let open: boolean | null = true;
act(() => { open = requestPanel('p1', 'composer-sidebar-right', 'open', undefined); });
expect(open).toBeNull();
} finally {
window.innerWidth = width;
}
});
it('open a folded panel over the composer when there is no room beside it', () => {
stubWidth(600);
offerSlots('p1', ['composer-sidebar-right']);
@@ -1,9 +1,10 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { act, createEvent, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Email, Mailbox, ThreadGroup } from '@/lib/jmap/types';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
import { requestPanel } from '@/lib/plugin-sandbox/panel-requests';
// Plugin surfaces of the open message: frames above the subject, under the
// sender, between new text and quote, above a conversation; buttons in the
@@ -126,6 +127,54 @@ describe('email-detail-sidebar', () => {
expect(panel.className).toContain('absolute');
});
it('opens, closes and toggles on a plugin\'s request, and tells the slot to focus', () => {
stubWidth(584);
offerSlots('p1', ['email-detail-sidebar']);
viewer(textMail('Hi'));
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
let open: boolean | null = false;
act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'open', undefined); });
expect(open).toBe(true);
const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
expect(panel.getAttribute('data-plugin-detail-panel')).toBe('overlay');
expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).focusRequest).toBe(1);
act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'toggle', undefined); });
expect(open).toBe(false);
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'toggle', undefined); });
expect(open).toBe(true);
expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).focusRequest).toBe(2);
act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'close', undefined); });
expect(open).toBe(false);
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
});
it('answers no request on phones, where there is no panel', () => {
device.isMobile = true;
offerSlots('p1', ['email-detail-sidebar']);
viewer(textMail('Hi'));
let open: boolean | null = true;
act(() => { open = requestPanel('p1', 'email-detail-sidebar', 'open', undefined); });
expect(open).toBeNull();
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
});
it('starts folded when the plugin asks for it, and remembers the user opening it', () => {
stubWidth(1000);
offerSlots('p1', ['email-detail-sidebar'], { startFolded: ['email-detail-sidebar'] });
const { unmount } = viewer(textMail('Hi'));
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
expect(screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!.getAttribute('data-plugin-detail-panel')).toBe('docked');
unmount();
viewer(textMail('Hi'));
expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
});
it('docks next to the message when there is room', () => {
stubWidth(1000);
offerSlots('p1', ['email-detail-sidebar']);
@@ -136,7 +185,77 @@ describe('email-detail-sidebar', () => {
});
});
describe('reader-selection-menu and the side panel\'s selection', () => {
const SURFACE = 'reader-selection-menu';
const plainBody = () => document.querySelector<HTMLElement>('[data-plugin-source="email-body"]')!;
const selectBody = () => {
const range = document.createRange();
range.selectNodeContents(plainBody());
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
};
const rightClick = () => {
const event = createEvent.contextMenu(plainBody(), { clientX: 30, clientY: 40 });
fireEvent(plainBody(), event);
return event;
};
afterEach(() => {
vi.useRealTimers();
window.getSelection()?.removeAllRanges();
});
it('leaves the browser menu alone without plugin entries, and without a selection', () => {
viewer(textMail('Hi there'));
selectBody();
expect(rightClick().defaultPrevented).toBe(false);
expect(screen.queryByRole('menu')).toBeNull();
act(() => { setPluginActions('p1', 'P1', SURFACE, [{ id: 'explain', label: 'Explain' }]); });
window.getSelection()!.removeAllRanges();
expect(rightClick().defaultPrevented).toBe(false);
expect(screen.queryByRole('menu')).toBeNull();
});
it('offers Copy and the plugin entries on selected text, and hands the click the message and the text', async () => {
act(() => { setPluginActions('p1', 'P1', SURFACE, [{ id: 'explain', label: 'Explain', icon: 'sparkles' }]); });
viewer(textMail('Hi there'));
selectBody();
expect(rightClick().defaultPrevented).toBe(true);
expect(screen.getByRole('menu')).toHaveTextContent('copy');
fireEvent.click(screen.getByRole('menuitem', { name: 'Explain' }));
await act(async () => {});
expect(dispatch).toHaveBeenCalledWith('p1', SURFACE, 'explain', { emailId: 'e1', selection: { text: 'Hi there' } });
expect(screen.queryByRole('menu')).toBeNull();
});
it('sends the side panel the selected text a moment after it changes', () => {
offerSlots('p1', ['email-detail-sidebar']);
viewer(textMail('Hi there'));
expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: '' });
vi.useFakeTimers();
selectBody();
act(() => { document.dispatchEvent(new Event('selectionchange')); });
expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: '' });
act(() => { vi.advanceTimersByTime(200); });
expect(slotProps(screen.getByTestId('slot-email-detail-sidebar')).selection).toEqual({ text: 'Hi there' });
});
});
describe('reader-header and reader-below-subject', () => {
it('tell the frames which layout they are in', () => {
offerSlots('p1', ['email-banner']);
const { unmount } = viewer(textMail('Hi'));
expect(slotProps(screen.getByTestId('slot-email-banner')).deviceType).toBe('desktop');
unmount();
device.isMobile = true;
viewer(textMail('Hi'));
expect(slotProps(screen.getByTestId('slot-email-banner')).deviceType).toBe('mobile');
});
it('put the frames above the subject and under the sender, with the message', () => {
offerSlots('p1', ['reader-header', 'reader-below-subject']);
const { container } = viewer(textMail('Hi'));
@@ -235,12 +354,13 @@ describe('thread-summary', () => {
];
it('sits above the messages of the phone conversation view', () => {
device.isMobile = true;
offerSlots('p1', ['thread-summary']);
const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
render(<ThreadConversationView thread={thread} emails={[conversation[1], conversation[0]]} onBack={() => {}} />);
const frame = screen.getByTestId('slot-thread-summary');
expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m2', 'm1'] });
expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m2', 'm1'], deviceType: 'mobile' });
expect(follows(frame, screen.getAllByText('Alice')[0])).toBe(true);
});
@@ -249,7 +369,7 @@ describe('thread-summary', () => {
useSettingsStore.setState({ interfaceLayout: 'mountain-view', markAsReadDelay: 0 });
viewer(conversation[1], { mvConversation: conversation });
expect(slotProps(screen.getByTestId('slot-thread-summary'))).toEqual({ threadId: 't1', emailIds: ['m1', 'm2'] });
expect(slotProps(screen.getByTestId('slot-thread-summary'))).toEqual({ threadId: 't1', emailIds: ['m1', 'm2'], deviceType: 'desktop' });
});
it('draws nothing without a plugin, and nothing for a single message', () => {
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { uiHooks } from '@/lib/plugin-hooks';
import { bodyMenuPoint, selectedTextIn, watchBodySelection, watchElementSelection } from '../reader-selection-menu';
// Selected text inside a message body, for plugins. An HTML body is a
// sandboxed iframe: its selection never reaches the page's selectionchange,
// so the body document itself is watched.
function bodyFrame(html: string) {
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const doc = iframe.contentDocument!;
doc.body.innerHTML = html;
return { iframe, doc };
}
function selectAll(doc: Document, el: Element) {
const range = doc.createRange();
range.selectNodeContents(el);
const sel = doc.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
}
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
describe('watchBodySelection', () => {
it('reports the selected text a moment after it changes, once per change, and tells the plugins', async () => {
const { iframe, doc } = bodyFrame('<p>Quoted words</p>');
const onSelection = vi.fn();
const heard = vi.fn();
const handler = uiHooks.onTextSelectionChange.register('p1', heard);
try {
watchBodySelection(doc, iframe, { emailId: 'e1', onSelection });
selectAll(doc, doc.querySelector('p')!);
doc.dispatchEvent(new Event('selectionchange'));
doc.dispatchEvent(new Event('selectionchange'));
expect(onSelection).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(onSelection).toHaveBeenCalledTimes(1);
expect(onSelection).toHaveBeenCalledWith('Quoted words');
await Promise.resolve();
expect(heard).toHaveBeenCalledWith({ text: 'Quoted words', source: 'email-body', emailId: 'e1' });
// The same selection again is not reported again.
doc.dispatchEvent(new Event('selectionchange'));
vi.advanceTimersByTime(200);
expect(onSelection).toHaveBeenCalledTimes(1);
// Gone: reported as empty, but plugins are not told about nothing.
doc.getSelection()!.removeAllRanges();
doc.dispatchEvent(new Event('selectionchange'));
vi.advanceTimersByTime(200);
expect(onSelection).toHaveBeenLastCalledWith('');
await Promise.resolve();
expect(heard).toHaveBeenCalledTimes(1);
} finally {
handler.dispose();
}
});
it('opens the menu on a right-click on selected text, at the frame offset, and keeps the browser menu otherwise', () => {
const { iframe, doc } = bodyFrame('<p>Quoted words</p>');
iframe.getBoundingClientRect = () => ({ left: 100, top: 50, width: 600, height: 400, right: 700, bottom: 450, x: 100, y: 50, toJSON: () => ({}) });
Object.defineProperty(doc.documentElement, 'clientWidth', { value: 600, configurable: true });
const onContextMenu = vi.fn().mockReturnValue(true);
watchBodySelection(doc, iframe, { emailId: 'e1', onContextMenu });
// No selection: nothing happens.
const idle = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true });
doc.dispatchEvent(idle);
expect(onContextMenu).not.toHaveBeenCalled();
expect(idle.defaultPrevented).toBe(false);
selectAll(doc, doc.querySelector('p')!);
const click = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true });
doc.dispatchEvent(click);
expect(onContextMenu).toHaveBeenCalledWith({ x: 110, y: 70 }, 'Quoted words');
expect(click.defaultPrevented).toBe(true);
// The menu declined (no plugin entries): the browser's own menu stays.
onContextMenu.mockReturnValue(false);
const declined = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, cancelable: true, bubbles: true });
doc.dispatchEvent(declined);
expect(declined.defaultPrevented).toBe(false);
});
it('scales the point when the body was shrunk to fit a narrow screen', () => {
const { iframe, doc } = bodyFrame('<p>x</p>');
iframe.getBoundingClientRect = () => ({ left: 0, top: 100, width: 300, height: 400, right: 300, bottom: 500, x: 0, y: 100, toJSON: () => ({}) });
Object.defineProperty(doc.documentElement, 'clientWidth', { value: 600, configurable: true });
expect(bodyMenuPoint({ clientX: 200, clientY: 40 }, doc, iframe)).toEqual({ x: 100, y: 120 });
});
it('stops when told to', () => {
const { iframe, doc } = bodyFrame('<p>Quoted words</p>');
const onSelection = vi.fn();
const stop = watchBodySelection(doc, iframe, { emailId: 'e1', onSelection });
stop();
selectAll(doc, doc.querySelector('p')!);
doc.dispatchEvent(new Event('selectionchange'));
vi.advanceTimersByTime(200);
expect(onSelection).not.toHaveBeenCalled();
});
});
describe('a plain-text body in the page', () => {
it('counts only a selection inside the body', () => {
document.body.innerHTML = '<div id="other">elsewhere</div><div id="body"><p>Plain words</p></div>';
const body = document.getElementById('body')!;
selectAll(document, document.getElementById('other')!);
expect(selectedTextIn(body)).toBe('');
selectAll(document, body.querySelector('p')!);
expect(selectedTextIn(body)).toBe('Plain words');
});
it('reports changes of the page selection inside the body', () => {
document.body.innerHTML = '<div id="body"><p>Plain words</p></div>';
const body = document.getElementById('body')!;
const onSelection = vi.fn();
watchElementSelection(body, { emailId: 'e1', onSelection });
selectAll(document, body.querySelector('p')!);
document.dispatchEvent(new Event('selectionchange'));
vi.advanceTimersByTime(200);
expect(onSelection).toHaveBeenCalledWith('Plain words');
});
});
@@ -781,3 +781,31 @@ describe('ThreadListItem date (#891)', () => {
expect(container.textContent).not.toMatch(/2024/);
});
});
describe('ThreadListItem hover actions', () => {
// When the row above is deleted from its hover actions, the next row slides
// under the resting pointer. The browser then reports a pointerover whose
// relatedTarget is the list around the row, and React builds no
// onPointerEnter from that, so the row has to arm its actions on pointerover.
const renderInList = (thread: ReturnType<typeof groupEmailsByThread>[number]) =>
render(
<div data-testid="list">
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} onEmailSelect={() => {}} onDelete={() => {}} />
</div>,
);
it.each([
['a single message', () => groupEmailsByThread([makeEmail()])[0]],
['a conversation', () => groupEmailsByThread([
makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }),
makeEmail({ id: 'b', receivedAt: '2024-01-15T11:00:00Z' }),
])[0]],
])('mounts them for %s that slides under a resting pointer', (_name, makeThread) => {
const { container } = renderInList(makeThread());
expect(screen.queryByTestId('hover-action-delete')).toBeNull();
const row = screen.queryByTestId('email-list-item') ?? container.querySelector('[data-unread]');
expect(row).not.toBeNull();
fireEvent.pointerOver(row!, { relatedTarget: screen.getByTestId('list') });
expect(screen.getByTestId('hover-action-delete')).toBeTruthy();
});
});
@@ -0,0 +1,135 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, it, expect, vi } from 'vitest';
import React, { useEffect } from 'react';
import { cleanup } from '@testing-library/react';
import type { Email, Mailbox } from '@/lib/jmap/types';
// On a desktop the reader's top toolbar runs over the plugin side panel to
// the right edge; the panel starts below it. On a phone the toolbar stays in
// the message column. Its menus close when a click goes into a frame. The
// panel is the reader's, not the message's: its frame lives on from one
// message to the next, and its toggle sits at the end of the toolbar.
const device = { isMobile: false };
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
useMediaQuery: () => false,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
const mounts: Record<string, number> = {};
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: ({ name }: { name: string }) => {
useEffect(() => {
mounts[name] = (mounts[name] ?? 0) + 1;
// Once per mount on purpose: that is what the test counts.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return React.createElement('div', { 'data-slot': name });
},
}));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({
usePluginSlotOffers: (slot: string) => (slot === 'email-detail-sidebar' ? [{ pluginId: 'p', order: 100 }] : []),
}));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const email = {
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
textBody: [], htmlBody: [], attachments: [], bodyValues: {},
} as unknown as Email;
const show = () => render(
<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} />,
);
/** The plugin panel, open or folded to its toggle. */
const sidePanel = () =>
(document.querySelector('[data-plugin-detail-panel]') as HTMLElement | null)
?? (screen.getByLabelText('plugin_panel_show').closest('div') as HTMLElement);
/** An item of the top toolbar. */
const toolbarItem = () => document.querySelector('[data-overflow-item]') as HTMLElement;
afterEach(() => {
cleanup();
device.isMobile = false;
});
describe('the reader toolbar and the plugin side panel', () => {
it('runs the toolbar over the side panel on a desktop', () => {
show();
const panel = sidePanel();
const item = toolbarItem();
expect(item).toBeTruthy();
// The box that holds the panel does not hold the toolbar...
expect(panel.parentElement!.contains(item)).toBe(false);
// ...and the toolbar comes before it, at the top of the reader.
expect(item.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const root = document.querySelector('[data-tour="email-viewer"]')!;
expect(root.firstElementChild!.contains(item)).toBe(true);
expect(root.firstElementChild!.contains(panel)).toBe(false);
});
it('closes the More menu when a click goes into a frame', () => {
show();
act(() => { fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); });
expect(screen.getByRole('menu', { name: 'more_actions' })).toBeTruthy();
// A click in the message body or a plugin panel moves focus into that
// frame: this document sees no mousedown, only the window losing focus.
act(() => { window.dispatchEvent(new Event('blur')); });
expect(screen.queryByRole('menu', { name: 'more_actions' })).toBeNull();
});
it('keeps the side panel frame while the user reads on, and between messages', () => {
const other = { ...email, id: 'e2', blobId: 'b2', threadId: 't2', subject: 'Second' } as unknown as Email;
const view = (props: { email: Email | null; isLoading: boolean }) => (
<EmailViewer mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} {...props} />
);
mounts['email-detail-sidebar'] = 0;
const { rerender } = render(view({ email, isLoading: false }));
expect(mounts['email-detail-sidebar']).toBe(1);
// The next message loading, then open; then none at all.
rerender(view({ email: null, isLoading: true }));
rerender(view({ email: other, isLoading: false }));
rerender(view({ email: null, isLoading: false }));
expect(screen.getByText('no_conversation_selected')).toBeTruthy();
expect(document.querySelector('[data-slot="email-detail-sidebar"]')).toBeTruthy();
expect(mounts['email-detail-sidebar']).toBe(1);
});
it('folds and unfolds the side panel from the toolbar', () => {
show();
const toggle = document.querySelector('[data-plugin-panel-toggle]') as HTMLElement;
// At the end of the toolbar, not in the panel.
expect(toggle.closest('[data-plugin-detail-panel]')).toBeNull();
expect(toggle.getAttribute('aria-label')).toBe('plugin_panel_hide');
act(() => { fireEvent.click(toggle); });
expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull();
// Folded, the panel leaves no strip of its own: the toolbar button brings it back.
expect(screen.getAllByLabelText('plugin_panel_show')).toHaveLength(1);
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); });
expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy();
});
it('keeps the toolbar in the message column on a phone', () => {
device.isMobile = true;
show();
expect(document.querySelector('[data-viewer-top-toolbar="full-width"]')).toBeNull();
expect(document.querySelector('[data-viewer-top-toolbar="column"]')).toBeTruthy();
});
});
+121 -25
View File
@@ -1,22 +1,29 @@
"use client";
// The composer-sidebar and composer-sidebar-right plugin panels. Each gets a
// fixed width and a toggle to fold it to a thin strip. When the composer is
// too narrow for an open panel next to it, the panel folds by itself (right
// first); opening it anyway lays it over the composer instead of squeezing
// the message into a column nobody can write in.
// The composer-sidebar and composer-sidebar-right plugin panels. Each has a
// width the user can drag (kept across composers) and a toggle to fold it
// to a thin strip. When the composer is too narrow for an open panel next
// to it, the panel folds by itself (right first); opening it anyway lays it
// over the composer instead of squeezing the message into a column nobody
// can write in.
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { onPanelRequest, type PanelRequest } from "@/lib/plugin-sandbox/panel-requests";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { cn } from "@/lib/utils";
/** Width of an open panel. */
/** Width of an open panel until the user drags it. */
export const COMPOSER_SIDEBAR_WIDTH = 260;
export const MIN_COMPOSER_SIDEBAR_WIDTH = 220;
export const MAX_COMPOSER_SIDEBAR_WIDTH = 480;
/** Width of a folded panel (the strip with its toggle). */
export const COMPOSER_SIDEBAR_STRIP = 37;
/** Below this window width the panels are not shown at all (the `md` breakpoint). */
export const COMPOSER_SIDEBAR_MIN_WINDOW = 768;
/**
* Narrowest the composer itself may get next to open panels: below this its
* bottom bar (Attach, Templates, Options, Discard, Send) runs into itself.
@@ -33,6 +40,8 @@ interface SideState {
collapsed: boolean;
/** Opened by the user while there was no room for it. */
overlayOpen?: boolean;
/** The user's width for it; the default when not dragged yet. */
width?: number;
}
/**
@@ -50,7 +59,7 @@ export function composerSidebarModes(
const place = (side: SideState): ComposerSidebarMode | null => {
if (!side.present) return null;
if (side.collapsed) return "folded";
const extra = COMPOSER_SIDEBAR_WIDTH - COMPOSER_SIDEBAR_STRIP;
const extra = (side.width ?? COMPOSER_SIDEBAR_WIDTH) - COMPOSER_SIDEBAR_STRIP;
if (extra <= room) {
room -= extra;
return "docked";
@@ -62,22 +71,52 @@ export function composerSidebarModes(
}
const storageKey = (side: ComposerSidebarSide) => `composerPluginSidebarCollapsed:${side}`;
const widthKey = (side: ComposerSidebarSide) => `composerPluginSidebarWidth:${side}`;
function readCollapsed(side: ComposerSidebarSide): boolean {
if (typeof window === "undefined") return false;
try { return localStorage.getItem(storageKey(side)) === "1"; } catch { return false; }
/** The user's choice for a side; null until they fold or open it once. */
function readCollapsed(side: ComposerSidebarSide): boolean | null {
if (typeof window === "undefined") return null;
try {
const stored = localStorage.getItem(storageKey(side));
return stored === "1" ? true : stored === "0" ? false : null;
} catch { return null; }
}
/** Layout state of both panels for a composer whose root element is `root`. */
export function useComposerSidebars(root: HTMLElement | null) {
const hasLeft = usePluginSlotOffers("composer-sidebar").length > 0;
const hasRight = usePluginSlotOffers("composer-sidebar-right").length > 0;
export function clampSidebarWidth(width: number): number {
return Math.max(MIN_COMPOSER_SIDEBAR_WIDTH, Math.min(MAX_COMPOSER_SIDEBAR_WIDTH, Math.round(width)));
}
function readWidth(side: ComposerSidebarSide): number {
if (typeof window === "undefined") return COMPOSER_SIDEBAR_WIDTH;
try {
const n = parseInt(localStorage.getItem(widthKey(side)) ?? "", 10);
return Number.isFinite(n) ? clampSidebarWidth(n) : COMPOSER_SIDEBAR_WIDTH;
} catch { return COMPOSER_SIDEBAR_WIDTH; }
}
/**
* Layout state of both panels for a composer whose root element is `root`.
* `composerId` is the id plugins know this composer by; their
* api.ui.openPanel requests for it are answered here.
*/
export function useComposerSidebars(root: HTMLElement | null, composerId: string | null = null) {
const leftOffers = usePluginSlotOffers("composer-sidebar");
const rightOffers = usePluginSlotOffers("composer-sidebar-right");
const hasLeft = leftOffers.length > 0;
const hasRight = rightOffers.length > 0;
const [width, setWidth] = useState(0);
const [collapsed, setCollapsed] = useState<Record<ComposerSidebarSide, boolean>>(() => ({
// The user's choice per side; until they make one, a panel whose plugin
// asked to start folded (a chat opened on demand) is folded, the rest open.
const [chosen, setChosen] = useState<Record<ComposerSidebarSide, boolean | null>>(() => ({
left: readCollapsed("left"),
right: readCollapsed("right"),
}));
const collapsed: Record<ComposerSidebarSide, boolean> = {
left: chosen.left ?? leftOffers.some((o) => o.startFolded),
right: chosen.right ?? rightOffers.some((o) => o.startFolded),
};
const [overlayOpen, setOverlayOpen] = useState<Record<ComposerSidebarSide, boolean>>({ left: false, right: false });
const [widths, setWidths] = useState<Record<ComposerSidebarSide, number>>(() => ({ left: readWidth("left"), right: readWidth("right") }));
useEffect(() => {
if (!root || (!hasLeft && !hasRight) || typeof ResizeObserver === "undefined") return;
@@ -90,12 +129,19 @@ export function useComposerSidebars(root: HTMLElement | null) {
const modes = composerSidebarModes(
width,
{ present: hasLeft, collapsed: collapsed.left, overlayOpen: overlayOpen.left },
{ present: hasRight, collapsed: collapsed.right, overlayOpen: overlayOpen.right },
{ present: hasLeft, collapsed: collapsed.left, overlayOpen: overlayOpen.left, width: widths.left },
{ present: hasRight, collapsed: collapsed.right, overlayOpen: overlayOpen.right, width: widths.right },
);
/** The user dragged a panel's edge. */
const setSideWidth = (side: ComposerSidebarSide, value: number) => {
const next = clampSidebarWidth(value);
setWidths((prev) => (prev[side] === next ? prev : { ...prev, [side]: next }));
try { localStorage.setItem(widthKey(side), String(next)); } catch { /* storage blocked */ }
};
const setSideCollapsed = (side: ComposerSidebarSide, value: boolean) => {
setCollapsed((prev) => ({ ...prev, [side]: value }));
setChosen((prev) => ({ ...prev, [side]: value }));
try { localStorage.setItem(storageKey(side), value ? "1" : "0"); } catch { /* storage blocked */ }
};
@@ -104,8 +150,8 @@ export function useComposerSidebars(root: HTMLElement | null) {
const next = { ...collapsed, [side]: false };
const fits = composerSidebarModes(
width,
{ present: hasLeft, collapsed: next.left },
{ present: hasRight, collapsed: next.right },
{ present: hasLeft, collapsed: next.left, width: widths.left },
{ present: hasRight, collapsed: next.right, width: widths.right },
)[side] === "docked";
setSideCollapsed(side, false);
setOverlayOpen((prev) => ({ ...prev, [side]: !fits }));
@@ -115,7 +161,32 @@ export function useComposerSidebars(root: HTMLElement | null) {
setOverlayOpen((prev) => ({ ...prev, [side]: false }));
};
return { modes, open, close };
// Plugins opening their panel (api.ui.openPanel): counts up per side when
// it opens, and reaches the slot as `focusRequest`.
const [focusRequests, setFocusRequests] = useState<Record<ComposerSidebarSide, number>>({ left: 0, right: 0 });
const latest = useRef({ modes, open, close, composerId, hasLeft, hasRight });
latest.current = { modes, open, close, composerId, hasLeft, hasRight };
useEffect(() => onPanelRequest((request: PanelRequest) => {
const side: ComposerSidebarSide | null = request.slot === "composer-sidebar" ? "left" : request.slot === "composer-sidebar-right" ? "right" : null;
if (!side) return null;
const state = latest.current;
if (request.composerId && request.composerId !== state.composerId) return null;
if (!(side === "left" ? state.hasLeft : state.hasRight)) return null;
// On a phone the panels are not on screen (hidden below md): the plugin
// then falls back to a dialog.
if (typeof window !== "undefined" && window.innerWidth < COMPOSER_SIDEBAR_MIN_WINDOW) return null;
const isOpen = state.modes[side] === "docked" || state.modes[side] === "overlay";
const wantOpen = request.mode === "open" ? true : request.mode === "close" ? false : !isOpen;
if (wantOpen) {
if (!isOpen) state.open(side);
setFocusRequests((prev) => ({ ...prev, [side]: prev[side] + 1 }));
} else if (isOpen) {
state.close(side);
}
return wantOpen;
}), []);
return { modes, open, close, focusRequests, widths, setSideWidth };
}
/** One plugin panel of the composer, in the mode useComposerSidebars picked. */
@@ -124,13 +195,22 @@ export function ComposerPluginSidebar({
mode,
onOpen,
onClose,
extraProps,
width = COMPOSER_SIDEBAR_WIDTH,
onWidthChange,
}: {
side: ComposerSidebarSide;
mode: ComposerSidebarMode | null;
onOpen: () => void;
onClose: () => void;
/** What the panel's plugins get: the composer id, the draft and the selection (see the slot catalogue). */
extraProps?: Record<string, unknown>;
width?: number;
/** The user drags the panel's edge; without it the panel has no handle. */
onWidthChange?: (width: number) => void;
}) {
const t = useTranslations("email_viewer");
const dragStart = useRef(width);
if (!mode) return null;
const border = side === "left" ? "border-e" : "border-s";
const OpenIcon = side === "left" ? PanelLeftOpen : PanelRightOpen;
@@ -157,14 +237,28 @@ export function ComposerPluginSidebar({
);
}
// Docked beside the composer: a handle on the panel's inner edge. Dragging
// towards the composer widens the panel (the delta flips for the right one).
const handle = mode === "docked" && onWidthChange ? (
<ResizeHandle
testId={`composer-sidebar-resize-${side}`}
className="hidden md:block"
onResizeStart={() => { dragStart.current = width; }}
onResize={(delta) => onWidthChange(dragStart.current + (side === "left" ? delta : -delta))}
onResizeEnd={() => { dragStart.current = width; }}
onDoubleClick={() => onWidthChange(COMPOSER_SIDEBAR_WIDTH)}
/>
) : null;
return (
<>
{side === "right" && handle}
<div
className={cn(
"hidden md:flex flex-col shrink-0 h-full border-border bg-background overflow-hidden",
border,
mode === "overlay" && cn("absolute inset-y-0 z-30 shadow-xl", side === "left" ? "start-0" : "end-0"),
)}
style={{ width: COMPOSER_SIDEBAR_WIDTH }}
style={{ width }}
data-composer-sidebar={side}
data-composer-sidebar-mode={mode}
>
@@ -181,9 +275,11 @@ export function ComposerPluginSidebar({
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{side === "left"
? <PluginSlot name="composer-sidebar" className="flex flex-col" />
: <PluginSlot name="composer-sidebar-right" className="flex flex-col" />}
? <PluginSlot name="composer-sidebar" className="flex flex-col" extraProps={extraProps} />
: <PluginSlot name="composer-sidebar-right" className="flex flex-col" extraProps={extraProps} />}
</div>
</div>
{side === "left" && handle}
</>
);
}
@@ -0,0 +1,90 @@
"use client";
// The right-click menu on selected text in the composer. It only appears
// when a plugin declared entries for it (the composer-selection-menu
// surface, api.ui.setActions); otherwise the browser's own menu stays, with
// its spelling suggestions. Cut and Copy are kept, since the menu replaces
// the one that had them. A click on a plugin entry carries the composer id
// and the selected text.
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { useContextMenu } from "@/hooks/use-context-menu";
import { Copy, Scissors } from "@/components/icons";
export const COMPOSER_SELECTION_SURFACE = "composer-selection-menu";
export interface SelectionMenuTarget {
/** The selected text, as the plugin gets it. */
text: string;
}
/**
* Where the menu opens for a contextmenu event. The keyboard (Shift+F10,
* the Menu key) fires it at 0,0: then the menu opens under the selection.
*/
export function selectionMenuPoint(
e: { clientX: number; clientY: number; currentTarget?: EventTarget | null },
selection: Selection | null = typeof window === "undefined" ? null : window.getSelection(),
): { x: number; y: number } {
if (e.clientX || e.clientY) return { x: e.clientX, y: e.clientY };
if (selection && selection.rangeCount > 0) {
const rect = selection.getRangeAt(0).getBoundingClientRect();
if (rect.width || rect.height) return { x: rect.left, y: rect.bottom };
}
const el = e.currentTarget instanceof Element ? e.currentTarget : null;
const rect = el?.getBoundingClientRect();
return rect ? { x: rect.left + 16, y: rect.top + 16 } : { x: 0, y: 0 };
}
/** The menu's state and the contextmenu handler for the message text. */
export function useComposerSelectionMenu() {
const actions = usePluginActions(COMPOSER_SELECTION_SURFACE);
const menu = useContextMenu<SelectionMenuTarget>();
const { openContextMenu } = menu;
const enabled = actions.length > 0;
const onContextMenu = useCallback((e: React.MouseEvent, read: () => string) => {
if (!enabled) return;
const text = read();
if (!text.trim()) return;
const point = selectionMenuPoint(e);
openContextMenu({
preventDefault: () => e.preventDefault(),
stopPropagation: () => e.stopPropagation(),
clientX: point.x,
clientY: point.y,
} as React.MouseEvent, { text });
}, [enabled, openContextMenu]);
return { ...menu, onContextMenu, enabled };
}
export function ComposerSelectionMenu({
menu,
composerId,
onClipboard,
}: {
menu: ReturnType<typeof useComposerSelectionMenu>;
composerId: string | null;
/** Cut or copy the selection the menu was opened on. */
onClipboard: (command: "cut" | "copy") => void;
}) {
const t = useTranslations("email_composer");
const { contextMenu, closeContextMenu, menuRef } = menu;
if (!contextMenu.isOpen) return null;
const text = contextMenu.data?.text ?? "";
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem icon={Scissors} label={t("cut")} testId="composer-selection-cut" onClick={() => { closeContextMenu(); onClipboard("cut"); }} />
<ContextMenuItem icon={Copy} label={t("copy")} testId="composer-selection-copy" onClick={() => { closeContextMenu(); onClipboard("copy"); }} />
<ContextMenuSeparator />
<PluginActions
surface="composer-selection-menu"
variant="menu"
context={{ composerId, selection: { text } }}
onRan={closeContextMenu}
/>
</ContextMenu>
);
}
+106 -8
View File
@@ -71,6 +71,7 @@ import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@
import { useAccountStore } from "@/stores/account-store";
import { useSettingsStore } from "@/stores/settings-store";
import { ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars";
import { ComposerSelectionMenu, useComposerSelectionMenu } from "./composer-selection-menu";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { usePluginActions } from "@/components/plugins/plugin-actions";
@@ -390,6 +391,9 @@ const DRAFT_HOOK_TIMEOUT_MS = 5000;
/** Longest text a plugin may insert into the message at once. */
const MAX_PLUGIN_INSERT_LENGTH = 100_000;
/** How long after the last caret move the selection goes to the plugin frames. */
const PLUGIN_SELECTION_DELAY_MS = 150;
const EMPTY_SELECTION = { text: '', empty: true } as const;
export function EmailComposer({
onSend,
@@ -2275,10 +2279,15 @@ export function EmailComposer({
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
// The plugin side panels fold when the composer gets too narrow for them.
const [composerRootEl, setComposerRootEl] = useState<HTMLDivElement | null>(null);
const pluginSidebars = useComposerSidebars(composerRootEl);
// The id plugins know this composer by (registered below, with the handle).
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId);
const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source');
const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0;
const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0;
const hasLeftSidebarOffers = usePluginSlotOffers('composer-sidebar').length > 0;
const hasRightSidebarOffers = usePluginSlotOffers('composer-sidebar-right').length > 0;
const hasSidebarOffers = hasLeftSidebarOffers || hasRightSidebarOffers;
// Plugin entries make the Send and Attach buttons menus even where the app
// itself has nothing else to put there.
const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
@@ -2619,7 +2628,7 @@ export function EmailComposer({
// composer-above-body / composer-below-body get the draft when they appear
// and then 2 seconds after the last change, like onDraftChange: a new prop
// object on every keystroke would post the whole body into their iframes.
const wantsPluginDraft = hasAboveBodyOffers || hasBelowBodyOffers;
const wantsPluginDraft = hasAboveBodyOffers || hasBelowBodyOffers || hasSidebarOffers;
const [pluginDraft, setPluginDraft] = useState<ReturnType<typeof draftSnapshot> | null>(null);
const pluginDraftShownRef = useRef(false);
useEffect(() => {
@@ -2685,7 +2694,6 @@ export function EmailComposer({
};
const pluginAttachRef = useRef(handlePluginAttachmentAdd);
pluginAttachRef.current = handlePluginAttachmentAdd;
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
useEffect(() => {
const plainRange = (value: string) => plainOwnRange(value, pluginEditOptsRef.current);
const { id, unregister } = registerComposer({
@@ -2737,10 +2745,97 @@ export function EmailComposer({
return unregister;
}, []);
// The selected text of the message, for the plugin frames around it and
// the panels beside it (an assistant works on what the user highlights).
// Read from the editor's own selection state, which stays put while the
// user clicks into a panel, and sent only when it changed, a moment after
// the last change: a prop object per caret move would flood the frames.
const wantsPluginSelection = wantsPluginDraft;
const [pluginSelection, setPluginSelection] = useState<{ text: string; empty: boolean }>(EMPTY_SELECTION);
const [pluginEditor, setPluginEditor] = useState<Editor | null>(null);
useEffect(() => {
if (!wantsPluginSelection) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const publish = () => {
timer = null;
let next: { text: string; empty: boolean } = EMPTY_SELECTION;
if (plainTextModeRef.current) {
const textarea = bodyRef.current;
if (textarea) {
const text = textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0);
next = text ? { text, empty: false } : EMPTY_SELECTION;
}
} else if (pluginEditor) {
const { text, empty } = readEditorSelection(pluginEditor);
next = empty ? EMPTY_SELECTION : { text, empty: false };
}
setPluginSelection((prev) => (prev.empty === next.empty && prev.text === next.text ? prev : next));
};
const schedule = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(publish, PLUGIN_SELECTION_DELAY_MS);
};
// The textarea has no selection event of its own that covers the
// keyboard; the document's selectionchange fires for it.
const onDocumentSelection = () => {
if (plainTextModeRef.current && document.activeElement === bodyRef.current) schedule();
};
document.addEventListener('selectionchange', onDocumentSelection);
pluginEditor?.on('selectionUpdate', schedule);
publish();
return () => {
if (timer) clearTimeout(timer);
document.removeEventListener('selectionchange', onDocumentSelection);
pluginEditor?.off('selectionUpdate', schedule);
};
}, [wantsPluginSelection, pluginEditor, plainTextMode]);
const pluginDraftProps = useMemo(
() => ({ draft: pluginDraft, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
[pluginDraft, pluginInsertText, pluginSelectedText, pluginComposerId],
() => ({ draft: pluginDraft, selection: pluginSelection, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
[pluginDraft, pluginSelection, pluginInsertText, pluginSelectedText, pluginComposerId],
);
const pluginSidebarLeftProps = useMemo(
() => ({ draft: pluginDraft, selection: pluginSelection, composerId: pluginComposerId, focusRequest: pluginSidebars.focusRequests.left }),
[pluginDraft, pluginSelection, pluginComposerId, pluginSidebars.focusRequests.left],
);
const pluginSidebarRightProps = useMemo(
() => ({ draft: pluginDraft, selection: pluginSelection, composerId: pluginComposerId, focusRequest: pluginSidebars.focusRequests.right }),
[pluginDraft, pluginSelection, pluginComposerId, pluginSidebars.focusRequests.right],
);
// The right-click menu on selected text, when a plugin put entries in it.
const selectionMenu = useComposerSelectionMenu();
const readSelectedText = useCallback((): string => {
if (plainTextModeRef.current) {
const textarea = bodyRef.current;
return textarea ? textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0) : '';
}
const editor = editorRef.current;
return editor ? readEditorSelection(editor).text : '';
}, []);
const onBodyContextMenu = useCallback((e: React.MouseEvent) => {
selectionMenu.onContextMenu(e, readSelectedText);
}, [selectionMenu, readSelectedText]);
// Cut and Copy from that menu: the browser's own command, run with the
// selection active again, keeps formatting on the clipboard; when the
// command is not available the plain text goes there.
const clipboardFromMenu = useCallback((command: 'cut' | 'copy') => {
const text = readSelectedText();
const plain = plainTextModeRef.current;
const textarea = bodyRef.current;
const editor = editorRef.current;
if (plain) textarea?.focus(); else editor?.commands.focus();
let done = false;
try { done = typeof document.execCommand === 'function' && document.execCommand(command); } catch { done = false; }
if (done) return;
void navigator.clipboard?.writeText(text).catch(() => {});
if (command !== 'cut') return;
if (plain) {
if (textarea) replaceTextareaRange(textarea, textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0, '');
} else {
editor?.commands.deleteSelection();
}
}, [readSelectedText]);
const pluginToolbarProps = useMemo(
() => ({ insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
[pluginInsertText, pluginSelectedText, pluginComposerId],
@@ -3519,7 +3614,7 @@ export function EmailComposer({
className={cn("flex h-full bg-background relative", className)}
onKeyDownCapture={handleComposerKeyDown}
>
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} />
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} extraProps={pluginSidebarLeftProps} width={pluginSidebars.widths.left} onWidthChange={(w) => pluginSidebars.setSideWidth('left', w)} />
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
@@ -4048,9 +4143,10 @@ export function EmailComposer({
)}
style={{ height: 'calc(100vh - 350px)' }}
aria-invalid={validationErrors.body || undefined}
onContextMenu={onBodyContextMenu}
/>
) : (
<div ref={editorContainerRef}>
<div ref={editorContainerRef} onContextMenu={onBodyContextMenu}>
<RichTextEditor
content={body}
onChange={(html) => {
@@ -4064,6 +4160,7 @@ export function EmailComposer({
toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'}
onEditorReady={(ed) => {
editorRef.current = ed;
setPluginEditor(ed);
if (!replyFocusedRef.current && (mode === 'reply' || mode === 'replyAll')) {
replyFocusedRef.current = true;
ed.commands.focus('start');
@@ -4670,7 +4767,8 @@ export function EmailComposer({
/>
)}
</div>
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} />
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} extraProps={pluginSidebarRightProps} width={pluginSidebars.widths.right} onWidthChange={(w) => pluginSidebars.setSideWidth('right', w)} />
<ComposerSelectionMenu menu={selectionMenu} composerId={pluginComposerId} onClipboard={clipboardFromMenu} />
</div>
);
}
+249 -104
View File
@@ -18,6 +18,8 @@ import { useCopyLink } from "@/hooks/use-copy-link";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { SubMenuPanel } from "@/components/ui/context-menu";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide } from "@/lib/toolbar-overflow";
@@ -994,6 +996,7 @@ function DetailsCategorySlot({ email, category }: { email: EmailReadView | null;
/** Narrowest the message may get next to a plugin panel before the panel collapses. */
const MIN_READER_WIDTH = 480;
const NO_BODY_SELECTION = { text: '' };
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
return (
@@ -1154,6 +1157,9 @@ export function EmailViewer({
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
// The top toolbar leaves the message column on desktops and tablets, so
// it runs over the side panels to the right edge.
const liftToolbar = !isMobile;
// Mountain View's conversation view: an icon-only toolbar at the top, and Reply /
// Reply all / Forward as buttons under the message rather than in the bar.
const mvViewer = interfaceLayout === 'mountain-view' && !isMobile;
@@ -1167,8 +1173,8 @@ export function EmailViewer({
const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : [];
const mvConversationIds = mvOpenIndex >= 0 ? mvConversation!.map((e) => e.id).join(',') : '';
const threadSummaryProps = useMemo(
() => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [] }),
[email?.threadId, mvConversationIds],
() => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [], deviceType: isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop' }),
[email?.threadId, mvConversationIds, isMobile, isTablet],
);
// Mountain View: which other messages of the conversation are unfolded. A
// conversation opens with its unread messages unfolded, as Gmail does, so
@@ -1509,13 +1515,38 @@ export function EmailViewer({
() => (email && wantsPluginEmailView ? emailToReadView(email) : null),
[email, wantsPluginEmailView],
);
const pluginEmailProps = useMemo(() => ({ email: pluginEmailView }), [pluginEmailView]);
// Reader frames learn the layout: the bar a plugin shows on phones has no
// place next to a side panel on a desktop.
const pluginDeviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
const pluginEmailProps = useMemo(() => ({ email: pluginEmailView, deviceType: pluginDeviceType }), [pluginEmailView, pluginDeviceType]);
// The text selected in the body, for the side panel (a plugin working on
// what the user highlights) and the right-click menu on it. Reset per
// message; the body documents report changes a moment after they happen.
const [bodySelection, setBodySelection] = useState<{ text: string }>(NO_BODY_SELECTION);
useEffect(() => { setBodySelection(NO_BODY_SELECTION); }, [email?.id]);
const onBodySelection = useCallback((text: string) => {
setBodySelection((prev) => (prev.text === text ? prev : (text ? { text } : NO_BODY_SELECTION)));
}, []);
const [detailFocusRequest, setDetailFocusRequest] = useState(0);
const pluginDetailProps = useMemo(
() => ({ email: pluginEmailView, selection: bodySelection, focusRequest: detailFocusRequest }),
[pluginEmailView, bodySelection, detailFocusRequest],
);
const selectionMenu = useReaderSelectionMenu();
const emailIdRef = useRef(email?.id);
emailIdRef.current = email?.id;
const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
// The user's choice; until they make one, a panel whose plugin asked to
// start folded (a chat opened on demand) is folded, the rest open.
const [detailSidebarChoice, setDetailSidebarCollapsed] = useState<boolean | null>(() => {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem('emailDetailSidebarCollapsed');
return stored === '1' ? true : stored === '0' ? false : null;
} catch { return null; }
});
const detailSidebarCollapsed = detailSidebarChoice ?? detailSlots.some((o) => o.startFolded);
const [detailSidebarWidth, setDetailSidebarWidth] = useState<number>(() => {
if (typeof window === 'undefined') return 280;
try {
@@ -1547,10 +1578,42 @@ export function EmailViewer({
// Opened by hand while the message is too narrow for it: the panel lies over
// the message instead of squeezing it.
const sidebarOverlay = !sidebarCollapsed && readerTooNarrow;
// Plugins opening their panel (api.ui.openPanel): the same as the user's
// click on the toggle, plus a new focusRequest for the slot.
const detailPanelRef = useRef({ hasDetailSidebar, sidebarCollapsed, readerTooNarrow, isMobile });
detailPanelRef.current = { hasDetailSidebar, sidebarCollapsed, readerTooNarrow, isMobile };
// The toolbar's panel button, like a plugin's request: opening focuses the panel.
const toggleDetailSidebar = () => {
if (sidebarCollapsed) {
setDetailSidebarCollapsed(false);
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
setDetailFocusRequest((n) => n + 1);
} else {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}
};
useEffect(() => onPanelRequest((request) => {
if (request.slot !== 'email-detail-sidebar') return null;
const state = detailPanelRef.current;
if (!state.hasDetailSidebar || state.isMobile) return null;
const isOpen = !state.sidebarCollapsed;
const wantOpen = request.mode === 'open' ? true : request.mode === 'close' ? false : !isOpen;
if (wantOpen) {
setDetailSidebarCollapsed(false);
if (state.readerTooNarrow) setDetailSidebarForcedOpen(true);
setDetailFocusRequest((n) => n + 1);
} else {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}
return wantOpen;
}), []);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
}, [detailSidebarCollapsed]);
if (detailSidebarChoice === null) return;
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarChoice ? '1' : '0'); } catch { /* ignore */ }
}, [detailSidebarChoice]);
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ }
}, [detailSidebarWidth]);
@@ -1596,8 +1659,20 @@ export function EmailViewer({
setMoveMenuOpen(false);
}
}
// A click into a frame (the message body, a plugin's side panel) never
// reaches this document; the window losing focus to it is the click away.
function handleWindowBlur() {
setMoreMenuOpen(false);
setMoreMenuSub(null);
setTagMenuOpen(false);
setMoveMenuOpen(false);
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
window.addEventListener('blur', handleWindowBlur);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('blur', handleWindowBlur);
};
}, [moreMenuOpen, tagMenuOpen, moveMenuOpen, mobileMoreRef]);
// Close dropdowns when email changes
@@ -1725,6 +1800,7 @@ export function EmailViewer({
};
}, [
toolbarPosition,
liftToolbar,
email?.id,
showToolbarLabels,
isLoading,
@@ -2947,6 +3023,11 @@ export function EmailViewer({
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
// A plain-text body sits in the page: watch the page's selection for it.
useEffect(() => {
if (!plainBodyEl || !hasDetailSidebar) return;
return watchElementSelection(plainBodyEl, { emailId: email?.id, onSelection: onBodySelection });
}, [plainBodyEl, hasDetailSidebar, email?.id, onBodySelection]);
// One object per body: React writes innerHTML again whenever this object
// changes, which would drop what was mounted in the body and fold the quote
// back up on every render.
@@ -3194,12 +3275,19 @@ export function EmailViewer({
// A press in the body closes a mention's card. The page never sees
// it: the focus is already inside the iframe, so there is no blur.
doc.addEventListener('mousedown', () => setBodyMention(null), true);
// Selected text, for the side panel, the onTextSelectionChange hook
// and the right-click menu plugins may add entries to.
watchBodySelection(doc, iframe, {
emailId: email?.id,
onSelection: (text) => { if (iframe.contentDocument === doc) onBodySelection(text); },
onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: emailIdRef.current, text }),
});
}
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, [isDark, effectiveEmailContent.externalBlocked, email?.id, t, openCidPartLink]);
}, [isDark, effectiveEmailContent.externalBlocked, email?.id, t, openCidPartLink, onBodySelection, selectionMenu.openRef]);
// Wire up the iframe as soon as its sandboxed document has parsed, rather than
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
@@ -3469,10 +3557,98 @@ export function EmailViewer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]);
// The plugin side panel. One element in the same place whatever the viewer
// shows (a message, the next one loading, none), so its frame lives on while
// the user reads on instead of starting over with every message.
const panelToggleInToolbar = !!email;
const sidePanel = (
<>
{hasDetailSidebar && !isMobile && (
<div className="contents" data-reader-side-panel="">
{/* With a message open the toggle sits in its toolbar; without one, here. */}
{sidebarCollapsed && !panelToggleInToolbar && (
<div className="flex flex-col items-center border-s border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(false);
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
}}
className="h-8 w-8 m-1"
aria-label={t('plugin_panel_show')}
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!sidebarCollapsed && (
<>
{!sidebarOverlay && (
<ResizeHandle
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onResize={(delta) => {
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
setDetailSidebarWidth(newWidth);
}}
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onDoubleClick={() => setDetailSidebarWidth(280)}
/>
)}
<div
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
className={cn(
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
sidebarOverlay && "absolute inset-y-0 end-0 z-30 shadow-xl",
)}
style={sidebarOverlay
? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) }
: { width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
{!panelToggleInToolbar && (
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}}
className="h-7 w-7"
aria-label={t('plugin_panel_hide')}
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={pluginDetailProps} />
</div>
</div>
</>
)}
</div>
)}
</>
);
const shell = (content: React.ReactNode) => (
<div
ref={setViewerRootEl}
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
>
{null}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
{content}
{sidePanel}
{null}
</div>
</div>
);
// Show loading skeleton while email is being fetched
if (isLoading && !email) {
return (
<div className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-200", className)}>
return shell(
<div className="flex-1 min-w-0 flex flex-col h-full bg-background overflow-hidden animate-in fade-in duration-200">
{/* Loading Header Skeleton - gentler animation */}
<div className="bg-background border-b border-border">
<div className="px-4 lg:px-6 py-3 lg:py-4">
@@ -3524,8 +3700,8 @@ export function EmailViewer({
const logoSrc = withBasePath(resolvedTheme === 'dark'
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
return shell(
<div className="flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
<div className="text-center p-8 max-w-md">
<img
src={logoSrc}
@@ -3569,8 +3745,8 @@ export function EmailViewer({
// Rendered in Pro (embedded) too: a silent void here read as "broken"
// in a split pane - the empty reading pane should always say what it is
// and offer a way forward.
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
return shell(
<div className="flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
<div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
@@ -4294,17 +4470,63 @@ export function EmailViewer({
</div>
)}
</div>
{/* The plugin side panel: shown or hidden for every message alike. */}
{hasDetailSidebar && !isMobile && (
<Button
variant="ghost"
size="icon"
onClick={toggleDetailSidebar}
className={cn("h-8 w-8", mvViewer && "order-10")}
title={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-label={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-pressed={!sidebarCollapsed}
data-plugin-panel-toggle=""
>
{sidebarCollapsed ? <PanelRightOpen className="w-4 h-4 text-muted-foreground" /> : <PanelRightClose className="w-4 h-4 text-muted-foreground" />}
</Button>
)}
</div>
)}
</>
);
// The top toolbar. On a desktop or tablet it spans the whole reader, over
// the plugin and contact side panels too; on a phone it stays in the
// message column.
const topToolbar = (
<div
data-viewer-top-toolbar={liftToolbar ? "full-width" : "column"}
className={cn(
"bg-background flex-shrink-0",
!mvViewer && "border-b border-border",
"max-lg:sticky max-lg:top-0 max-lg:z-10"
)}
>
<div className={cn("px-2 sm:px-4 lg:px-6 flex items-center", mvViewer ? "h-12 lg:ps-4" : "h-14")}>
<div
ref={toolbarRef}
data-mv-viewer-toolbar={mvViewer ? "" : undefined}
className={cn("flex items-center gap-0.5 sm:gap-2 w-full", mvViewer ? "justify-start" : "justify-between")}
>
{renderToolbarItems(true)}
{mvViewer && mvPosition && (
<MvPager position={mvPosition} onPrev={onNavigatePrev} onNext={onNavigateNext} />
)}
</div>
</div>
</div>
);
return (
<div
ref={setViewerRootEl}
data-tour="email-viewer"
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden relative", className)}
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
>
{toolbarPosition === 'top' && liftToolbar && topToolbar}
{/* The message and the side panels, below the toolbar. Overlays anchor here. */}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
<>
{/* Mobile More menu sidebar overlay. Inside a Pro pane both pieces
anchor to the viewer's relative root with absolute positioning: the
viewport is desktop-sized there even though the pane is phone-sized,
@@ -4572,26 +4794,7 @@ export function EmailViewer({
{/* Main email content */}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
{/* === TOOLBAR (top position) === */}
{toolbarPosition === 'top' && (
<div className={cn(
"bg-background",
!mvViewer && "border-b border-border",
"max-lg:sticky max-lg:top-0 max-lg:z-10"
)}>
<div className={cn("px-2 sm:px-4 lg:px-6 flex items-center", mvViewer ? "h-12 lg:ps-4" : "h-14")}>
<div
ref={toolbarRef}
data-mv-viewer-toolbar={mvViewer ? "" : undefined}
className={cn("flex items-center gap-0.5 sm:gap-2 w-full", mvViewer ? "justify-start" : "justify-between")}
>
{renderToolbarItems(true)}
{mvViewer && mvPosition && (
<MvPager position={mvPosition} onPrev={onNavigatePrev} onNext={onNavigateNext} />
)}
</div>
</div>
</div>
)}
{toolbarPosition === 'top' && !liftToolbar && topToolbar}
<PluginSlot name="reader-header" className="w-full bg-background" extraProps={pluginEmailProps} />
@@ -6074,6 +6277,9 @@ export function EmailViewer({
ref={setPlainBodyEl}
className="email-content-text text-foreground"
dir="auto"
data-plugin-source="email-body"
data-email-id={email?.id}
onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email?.id)}
dangerouslySetInnerHTML={plainBodyHtml}
style={{
...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }),
@@ -6349,72 +6555,10 @@ export function EmailViewer({
</nav>
)}
{/* Plugin Detail Sidebar - resizable, collapsible */}
{hasDetailSidebar && !isMobile && (
<>
{/* Collapse toggle when sidebar is collapsed */}
{sidebarCollapsed && (
<div className="flex flex-col items-center border-s border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(false);
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
}}
className="h-8 w-8 m-1"
aria-label={t('plugin_panel_show')}
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!sidebarCollapsed && (
<>
{!sidebarOverlay && (
<ResizeHandle
onResizeStart={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onResize={(delta) => {
const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
setDetailSidebarWidth(newWidth);
}}
onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
onDoubleClick={() => setDetailSidebarWidth(280)}
/>
)}
<div
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
className={cn(
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
sidebarOverlay && "absolute inset-y-0 end-0 z-30 shadow-xl",
)}
style={sidebarOverlay
? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) }
: { width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}}
className="h-7 w-7"
aria-label={t('plugin_panel_hide')}
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={pluginEmailProps} />
</div>
</div>
</>
)}
</>
)}
<ReaderSelectionMenu menu={selectionMenu} />
</>
{sidePanel}
<>
{bodyMention && (
<RecipientCardPopover
anchor={bodyMention.anchor}
@@ -6471,7 +6615,8 @@ export function EmailViewer({
getFileContent={getInAppPreviewContent}
/>
)}
</>
</div>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
"use client";
// Selected text in a message body, for plugins: the right-click menu on it
// (the reader-selection-menu surface, api.ui.setActions), the selection the
// reader's side panel gets as a prop, and the onTextSelectionChange hook.
// An HTML body lives in a sandboxed iframe whose selection the page never
// sees, so the body's own document is watched; a plain-text body sits in the
// page and is watched through the document's selection.
//
// The menu only replaces the browser's own when a plugin put entries in it.
// Copy is kept, since the menu replaces the one that had it.
import { useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import { useContextMenu } from "@/hooks/use-context-menu";
import { Copy } from "@/components/icons";
import { uiHooks } from "@/lib/plugin-hooks";
export const READER_SELECTION_SURFACE = "reader-selection-menu";
/** How long after the last change the selection goes to plugins. */
export const BODY_SELECTION_DELAY_MS = 150;
export interface ReaderSelectionTarget {
emailId: string | undefined;
text: string;
}
export interface Point { x: number; y: number }
/**
* Where a contextmenu event inside a body iframe is on the page: the frame's
* offset plus the event's position, scaled when the body was shrunk to fit a
* narrow screen. The keyboard (Shift+F10, the Menu key) fires the event at
* 0,0: then the point is under the selection.
*/
export function bodyMenuPoint(
e: { clientX: number; clientY: number },
doc: Document,
frame: HTMLIFrameElement | null,
): Point {
let x = e.clientX;
let y = e.clientY;
if (!x && !y) {
const sel = doc.getSelection();
const rect = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).getBoundingClientRect() : null;
if (rect && (rect.width || rect.height)) {
x = rect.left;
y = rect.bottom;
}
}
if (!frame) return { x, y };
const frameRect = frame.getBoundingClientRect();
const laidOut = doc.documentElement.clientWidth;
const scale = laidOut > 0 && frameRect.width > 0 ? frameRect.width / laidOut : 1;
return { x: frameRect.left + x * scale, y: frameRect.top + y * scale };
}
export interface BodySelectionWatch {
emailId: string | undefined;
/** The selected text as it changes; '' when the selection is gone. */
onSelection?: (text: string) => void;
/** A right-click on selected text. Return true when a menu was opened (the browser's is then suppressed). */
onContextMenu?: (point: Point, text: string) => boolean;
delayMs?: number;
}
function announce(text: string, emailId: string | undefined): void {
if (!text || uiHooks.onTextSelectionChange.size === 0) return;
void uiHooks.onTextSelectionChange.emit({ text, source: "email-body", emailId });
}
/**
* Watch the document of a body iframe. Returns a function that stops
* watching; the listeners also die with the document.
*/
export function watchBodySelection(doc: Document, frame: HTMLIFrameElement | null, opts: BodySelectionWatch): () => void {
const delay = opts.delayMs ?? BODY_SELECTION_DELAY_MS;
let timer: ReturnType<typeof setTimeout> | null = null;
let last = "";
const read = () => doc.getSelection()?.toString() ?? "";
const publish = () => {
timer = null;
const text = read();
if (text === last) return;
last = text;
opts.onSelection?.(text);
announce(text, opts.emailId);
};
const onSelectionChange = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(publish, delay);
};
const onContextMenu = (e: MouseEvent) => {
const text = read();
if (!text.trim() || !opts.onContextMenu) return;
if (opts.onContextMenu(bodyMenuPoint(e, doc, frame), text)) e.preventDefault();
};
doc.addEventListener("selectionchange", onSelectionChange);
doc.addEventListener("contextmenu", onContextMenu);
return () => {
if (timer) clearTimeout(timer);
doc.removeEventListener("selectionchange", onSelectionChange);
doc.removeEventListener("contextmenu", onContextMenu);
};
}
/** The text selected inside `el`, from the page's selection; '' when the selection is elsewhere. */
export function selectedTextIn(el: Element): string {
const sel = el.ownerDocument.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return "";
const anchor = sel.anchorNode;
const focus = sel.focusNode;
if (!anchor || !focus || !el.contains(anchor) || !el.contains(focus)) return "";
return sel.toString();
}
/** Watch a plain-text body that sits in the page itself (no iframe). */
export function watchElementSelection(el: HTMLElement, opts: Pick<BodySelectionWatch, "emailId" | "onSelection" | "delayMs">): () => void {
const delay = opts.delayMs ?? BODY_SELECTION_DELAY_MS;
const doc = el.ownerDocument;
let timer: ReturnType<typeof setTimeout> | null = null;
let last = "";
const publish = () => {
timer = null;
const text = selectedTextIn(el);
if (text === last) return;
last = text;
opts.onSelection?.(text);
// The page-wide listener (PluginAppEvents) tells plugins about this one.
};
const onSelectionChange = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(publish, delay);
};
doc.addEventListener("selectionchange", onSelectionChange);
return () => {
if (timer) clearTimeout(timer);
doc.removeEventListener("selectionchange", onSelectionChange);
};
}
/** The menu's state, and `open` for a right-click on selected text. */
export function useReaderSelectionMenu() {
const actions = usePluginActions(READER_SELECTION_SURFACE);
const menu = useContextMenu<ReaderSelectionTarget>();
const { openContextMenu } = menu;
const enabled = actions.length > 0;
// Body documents are wired once, when they load; they read the latest
// state through this ref.
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const open = useCallback((point: Point, target: ReaderSelectionTarget, e?: React.MouseEvent): boolean => {
if (!enabledRef.current || !target.text.trim()) return false;
openContextMenu({
preventDefault: () => e?.preventDefault(),
stopPropagation: () => e?.stopPropagation(),
clientX: point.x,
clientY: point.y,
} as React.MouseEvent, target);
return true;
}, [openContextMenu]);
const openRef = useRef(open);
openRef.current = open;
/** For a plain-text body in the page: the React contextmenu handler. */
const onElementContextMenu = useCallback((e: React.MouseEvent, emailId: string | undefined) => {
const el = e.currentTarget as HTMLElement;
const text = selectedTextIn(el);
if (!text.trim()) return;
const point = e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : bodyMenuPoint(e, el.ownerDocument, null);
openRef.current(point, { emailId, text }, e);
}, []);
return { ...menu, open, openRef, onElementContextMenu, enabled };
}
export function ReaderSelectionMenu({ menu }: { menu: ReturnType<typeof useReaderSelectionMenu> }) {
const t = useTranslations("email_viewer");
const { contextMenu, closeContextMenu, menuRef } = menu;
if (!contextMenu.isOpen) return null;
const target = contextMenu.data;
const copy = () => {
closeContextMenu();
void navigator.clipboard?.writeText(target?.text ?? "").catch(() => {});
};
return (
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem icon={Copy} label={t("copy")} testId="reader-selection-copy" onClick={copy} />
<ContextMenuSeparator />
<PluginActions
surface="reader-selection-menu"
variant="menu"
context={{ emailId: target?.emailId, selection: { text: target?.text ?? "" } }}
onRan={closeContextMenu}
/>
</ContextMenu>
);
}
+20 -4
View File
@@ -46,8 +46,10 @@ import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection } from "./reader-selection-menu";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
import { emailToReadView } from "@/lib/plugin-projection";
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
@@ -190,9 +192,10 @@ export function ThreadConversationView({
const t = useTranslations();
const externalContent = useConversationExternalContent();
const emailIdList = emails.map((email) => email.id).join(',');
const { isMobile: summaryOnPhone, isTablet: summaryOnTablet } = useDeviceDetection();
const threadSummaryProps = useMemo(
() => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [] }),
[thread.latestEmail.threadId, emailIdList],
() => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [], deviceType: summaryOnPhone ? 'mobile' : summaryOnTablet ? 'tablet' : 'desktop' }),
[thread.latestEmail.threadId, emailIdList, summaryOnPhone, summaryOnTablet],
);
// Track which emails are expanded (most recent by default)
@@ -324,7 +327,9 @@ function useConversationPluginProps(email: Email) {
const belowSubjectOffers = usePluginSlotOffers('reader-below-subject').length;
const bannerOffers = usePluginSlotOffers('email-banner').length;
const wantsView = headerOffers + belowSubjectOffers + bannerOffers > 0;
const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null }), [email, wantsView]);
const { isMobile, isTablet } = useDeviceDetection();
const deviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null, deviceType }), [email, wantsView, deviceType]);
const idProps = useMemo(() => ({ emailId: email.id }), [email.id]);
return { emailProps, idProps };
}
@@ -548,6 +553,7 @@ export function ConversationEmailBody({
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
const selectionMenu = useReaderSelectionMenu();
const wantsQuoteSlot = usePluginSlotOffers('reader-above-quote').length > 0;
const quoteSlotProps = useMemo(
() => ({ email: wantsQuoteSlot ? emailToReadView(email) : null }),
@@ -796,11 +802,17 @@ export function ConversationEmailBody({
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
// Selected text: the onTextSelectionChange hook and the right-click
// menu plugins may add entries to.
watchBodySelection(doc, iframe, {
emailId: email.id,
onContextMenu: (point, text) => selectionMenu.openRef.current(point, { emailId: email.id, text }),
});
setHtmlBodyEl(doc.body);
} catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore.
}
}, [t, isDark, allowExternal]);
}, [t, isDark, allowExternal, email.id, selectionMenu.openRef]);
return (
<div className={className}>
@@ -868,9 +880,13 @@ export function ConversationEmailBody({
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: '13px' }}
data-plugin-source="email-body"
data-email-id={email.id}
onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email.id)}
dangerouslySetInnerHTML={plainBodyHtml}
/>
)}
<ReaderSelectionMenu menu={selectionMenu} />
<ReaderAboveQuoteSlot
root={emailContent.isHtml ? htmlBodyEl : plainBodyEl}
frame={emailContent.isHtml ? iframeRef.current : null}
+10 -13
View File
@@ -59,8 +59,11 @@ function isMousePressOnRow(e: React.PointerEvent): boolean {
* Whether a row has been pointed at yet. The hover actions stay display:none
* until the pointer is over the row, so mounting them for every row up front
* only added a handful of icon buttons per row to every list render. They
* mount on the first pointer-enter and stay from then on, so the hover
* behaviour itself is unchanged.
* mount on the first pointer-over and stay from then on, so the hover
* behaviour itself is unchanged. Pointer-over rather than pointer-enter: when
* a row slides under a resting pointer (the row above was just deleted from
* its hover actions), the browser's pointerover comes from the list around
* it, and React builds no onPointerEnter from that.
*/
/**
* Role of the folder the list shows (sender vs recipient, junk actions). In
@@ -77,7 +80,7 @@ export function listMailboxRole(state: {
?? (state.isUnifiedView ? (state.unifiedRole ?? undefined) : undefined);
}
function useArmOnPointerEnter(): [boolean, () => void] {
function useArmOnPointerOver(): [boolean, () => void] {
const [armed, setArmed] = React.useState(false);
const arm = React.useCallback(() => setArmed(true), []);
return [armed, arm];
@@ -429,7 +432,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
sourceMailboxId: selectedMailbox,
});
const [hoverActionsArmed, armHoverActions] = useArmOnPointerEnter();
const [hoverActionsArmed, armHoverActions] = useArmOnPointerOver();
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
@@ -526,10 +529,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{...dragHandlers}
{...touchHandlers}
data-testid="email-list-item"
onPointerEnter={() => {
armHoverActions();
dragHandlers.onPointerEnter?.();
}}
onPointerOver={armHoverActions}
onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(); }}
data-email-id={email.id}
data-subject={email.subject || ''}
@@ -983,7 +983,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
threadEmails: thread.emails,
});
const [hoverActionsArmed, armHoverActions] = useArmOnPointerEnter();
const [hoverActionsArmed, armHoverActions] = useArmOnPointerOver();
const { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel, isPressed: isThreadPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
@@ -1129,10 +1129,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<div
{...dragHandlers}
{...threadLongPressHandlers}
onPointerEnter={() => {
armHoverActions();
dragHandlers.onPointerEnter?.();
}}
onPointerOver={armHoverActions}
onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }}
aria-current={isSelected ? 'true' : undefined}
data-unread={hasUnread ? 'true' : 'false'}
@@ -71,35 +71,106 @@ describe('ResizeHandle', () => {
});
});
describe('mouse drag', () => {
it('should call onResize during mousemove after mousedown', () => {
const onResize = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={onResize} />
);
const handle = getByRole('separator');
describe('pointer drag', () => {
const down = (el: Element, pos: number) =>
fireEvent.pointerDown(el, { pointerId: 1, button: 0, buttons: 1, pointerType: 'mouse', clientX: pos, clientY: pos });
const move = (pos: number, buttons = 1) =>
fireEvent.pointerMove(document, { pointerId: 1, buttons, pointerType: 'mouse', clientX: pos, clientY: pos });
const up = () =>
fireEvent.pointerUp(document, { pointerId: 1, button: 0, buttons: 0, pointerType: 'mouse' });
fireEvent.mouseDown(handle, { clientX: 100 });
fireEvent.mouseMove(document, { clientX: 115 });
it('should call onResize during pointermove after pointerdown', () => {
const onResize = vi.fn();
const { getByRole } = render(<ResizeHandle onResize={onResize} />);
down(getByRole('separator'), 100);
move(115);
expect(onResize).toHaveBeenCalledWith(15);
});
it('should call onResizeEnd on mouseup', () => {
const onResizeEnd = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={vi.fn()} onResizeEnd={onResizeEnd} />
);
it('should measure vertically for a horizontal handle', () => {
const onResize = vi.fn();
const { getByRole } = render(<ResizeHandle orientation="horizontal" onResize={onResize} />);
const handle = getByRole('separator');
fireEvent.mouseDown(handle, { clientX: 100 });
fireEvent.mouseUp(document);
expect(onResizeEnd).toHaveBeenCalledOnce();
fireEvent.pointerDown(handle, { pointerId: 1, button: 0, buttons: 1, pointerType: 'mouse', clientX: 0, clientY: 50 });
fireEvent.pointerMove(document, { pointerId: 1, buttons: 1, pointerType: 'mouse', clientX: 300, clientY: 30 });
expect(onResize).toHaveBeenCalledWith(-20);
});
it('should not call onResize on mousemove without mousedown', () => {
it('should call onResizeEnd once on pointerup and stop following the pointer', () => {
const onResize = vi.fn();
const onResizeEnd = vi.fn();
const { getByRole } = render(<ResizeHandle onResize={onResize} onResizeEnd={onResizeEnd} />);
down(getByRole('separator'), 100);
up();
expect(onResizeEnd).toHaveBeenCalledOnce();
move(140);
expect(onResize).not.toHaveBeenCalled();
expect(document.body.style.cursor).toBe('');
});
it('should stop when a move arrives with no button held (release missed over an iframe)', () => {
const onResize = vi.fn();
const onResizeEnd = vi.fn();
const { getByRole } = render(<ResizeHandle onResize={onResize} onResizeEnd={onResizeEnd} />);
down(getByRole('separator'), 100);
move(110);
expect(onResize).toHaveBeenCalledTimes(1);
// The mouse came back from the message iframe already released: the
// document never saw a pointerup.
move(150, 0);
expect(onResizeEnd).toHaveBeenCalledOnce();
move(200);
expect(onResize).toHaveBeenCalledTimes(1);
});
it('should stop when the window loses focus mid-drag', () => {
const onResize = vi.fn();
const onResizeEnd = vi.fn();
const { getByRole } = render(<ResizeHandle onResize={onResize} onResizeEnd={onResizeEnd} />);
down(getByRole('separator'), 100);
fireEvent.blur(window);
expect(onResizeEnd).toHaveBeenCalledOnce();
move(200);
expect(onResize).not.toHaveBeenCalled();
});
it('should ignore a second pointer while one is dragging', () => {
const onResize = vi.fn();
const onResizeEnd = vi.fn();
const { getByRole } = render(<ResizeHandle onResize={onResize} onResizeEnd={onResizeEnd} />);
const handle = getByRole('separator');
down(handle, 100);
fireEvent.pointerDown(handle, { pointerId: 2, button: 0, buttons: 1, pointerType: 'touch', clientX: 500, clientY: 500 });
fireEvent.pointerMove(document, { pointerId: 2, buttons: 1, pointerType: 'touch', clientX: 520, clientY: 520 });
fireEvent.pointerUp(document, { pointerId: 2, button: 0, buttons: 0, pointerType: 'touch' });
expect(onResize).not.toHaveBeenCalled();
expect(onResizeEnd).not.toHaveBeenCalled();
move(130);
expect(onResize).toHaveBeenCalledWith(30);
});
it('should ignore a right-button press', () => {
const onResizeStart = vi.fn();
const { getByRole } = render(<ResizeHandle onResize={vi.fn()} onResizeStart={onResizeStart} />);
fireEvent.pointerDown(getByRole('separator'), { pointerId: 1, button: 2, buttons: 2, pointerType: 'mouse', clientX: 100, clientY: 0 });
expect(onResizeStart).not.toHaveBeenCalled();
});
it('should keep using the latest onResize without re-arming the drag', () => {
const first = vi.fn();
const second = vi.fn();
const { getByRole, rerender } = render(<ResizeHandle onResize={first} />);
down(getByRole('separator'), 100);
rerender(<ResizeHandle onResize={second} />);
move(120);
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledWith(20);
});
it('should not call onResize on pointermove without pointerdown', () => {
const onResize = vi.fn();
render(<ResizeHandle onResize={onResize} />);
fireEvent.mouseMove(document, { clientX: 200 });
move(200);
expect(onResize).not.toHaveBeenCalled();
});
});
+18 -4
View File
@@ -66,6 +66,11 @@ interface NavigationRailProps {
* active app).
*/
activeItemId?: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings' | null;
/**
* The admin panel's own rail: shows the Admin entry as the current one,
* for password admins too, who are not Stalwart admins.
*/
adminActive?: boolean;
/**
* Pro shell only: renders a Search entry that opens the global search
* palette (#641) instead of navigating anywhere.
@@ -204,6 +209,7 @@ export function NavigationRail({
onNavigate,
activeItemId,
onOpenSearch,
adminActive = false,
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const tGlobalSearch = useTranslations("global_search");
@@ -459,17 +465,21 @@ export function NavigationRail({
})}
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
{isStalwartAdmin && (
{(isStalwartAdmin || adminActive) && (
<a
href={`${getPathPrefix()}/admin`}
className={cn(
"flex flex-col items-center justify-center gap-1 py-2 px-1 min-h-[44px] grow shrink-0 basis-[64px]",
"transition-colors duration-150",
"text-muted-foreground hover:text-foreground"
adminActive ? "text-primary" : "text-muted-foreground hover:text-foreground"
)}
aria-current={adminActive ? "page" : undefined}
>
<span className="relative">
<Shield className="w-5 h-5" />
{adminActive && !mountainView && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-4 h-0.5 rounded-full bg-primary" />
)}
{hasUpdate && (
<span
className={cn(
@@ -695,11 +705,15 @@ export function NavigationRail({
{/* Footer: Admin + Settings + Help + Storage Quota + Sign Out + Push Status */}
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1">
{isStalwartAdmin && (
{(isStalwartAdmin || adminActive) && (
<a
href={`${getPathPrefix()}/admin`}
className="flex items-center justify-center w-10 h-10 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-muted relative"
className={cn(
"flex items-center justify-center w-10 h-10 rounded-md transition-colors relative",
adminActive ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
title={t("admin") || "Admin"}
aria-current={adminActive ? "page" : undefined}
>
<Shield className="w-[18px] h-[18px]" />
{hasUpdate && (
+75 -21
View File
@@ -10,23 +10,57 @@ interface ResizeHandleProps {
onDoubleClick?: () => void;
orientation?: "vertical" | "horizontal";
className?: string;
/** Stable hook for tests (not user-visible). */
testId?: string;
}
const KEYBOARD_STEP = 10;
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, orientation = "vertical", className }: ResizeHandleProps) {
const isDragging = useRef(false);
export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleClick, orientation = "vertical", className, testId }: ResizeHandleProps) {
const handleRef = useRef<HTMLDivElement>(null);
// Id of the pointer that started the drag, or null when idle. Only that
// pointer may move or end the drag, so a second finger or button can't
// hijack it.
const activePointer = useRef<number | null>(null);
const startPos = useRef(0);
const isHorizontal = orientation === "horizontal";
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Latest callbacks, so the document listeners below never go stale and
// never need re-subscribing while a drag is in progress.
const callbacks = useRef({ onResizeStart, onResize, onResizeEnd });
callbacks.current = { onResizeStart, onResize, onResizeEnd };
const endDrag = useCallback(() => {
if (activePointer.current === null) return;
const pointerId = activePointer.current;
activePointer.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
const el = handleRef.current;
if (el && typeof el.hasPointerCapture === "function" && el.hasPointerCapture(pointerId)) {
el.releasePointerCapture(pointerId);
}
callbacks.current.onResizeEnd?.();
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
// Primary button only; ignore a second pointer while one is dragging.
if (e.button !== 0 || activePointer.current !== null) return;
e.preventDefault();
isDragging.current = true;
activePointer.current = e.pointerId;
startPos.current = isHorizontal ? e.clientY : e.clientX;
document.body.style.cursor = isHorizontal ? "row-resize" : "col-resize";
document.body.style.userSelect = "none";
onResizeStart?.();
}, [onResizeStart, isHorizontal]);
// Capture routes every later pointer event for this drag to the handle,
// even when the pointer is over an iframe (message body, plugin panel)
// or outside the window. Without it the release over an iframe never
// reaches this document and the handle keeps following the mouse.
const el = e.currentTarget;
if (typeof el.setPointerCapture === "function") {
try { el.setPointerCapture(e.pointerId); } catch { /* unsupported pointer */ }
}
callbacks.current.onResizeStart?.();
}, [isHorizontal]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
let delta = 0;
@@ -45,35 +79,52 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
}, [onResize, onResizeEnd, isHorizontal]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
const handlePointerMove = (e: PointerEvent) => {
if (activePointer.current === null || e.pointerId !== activePointer.current) return;
// A move with no button held means the release happened somewhere we
// never heard about (another frame, outside the window). Stop instead
// of dragging the divider around with a released mouse.
if (e.pointerType === "mouse" && e.buttons === 0) {
endDrag();
return;
}
const delta = (isHorizontal ? e.clientY : e.clientX) - startPos.current;
onResize(delta);
callbacks.current.onResize(delta);
};
const handleMouseUp = () => {
if (!isDragging.current) return;
isDragging.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
onResizeEnd?.();
const handlePointerEnd = (e: PointerEvent) => {
if (activePointer.current === null || e.pointerId !== activePointer.current) return;
endDrag();
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
// Listen on the document rather than the handle so the fallback without
// pointer capture still sees moves anywhere in the page.
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerEnd);
document.addEventListener("pointercancel", handlePointerEnd);
window.addEventListener("blur", endDrag);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerEnd);
document.removeEventListener("pointercancel", handlePointerEnd);
window.removeEventListener("blur", endDrag);
};
}, [onResize, onResizeEnd, isHorizontal]);
}, [endDrag, isHorizontal]);
// Unmounting mid-drag (the pane layout switched) must not leave the body
// cursor and selection lock behind.
useEffect(() => endDrag, [endDrag]);
return (
<div
ref={handleRef}
role="separator"
aria-orientation={isHorizontal ? "horizontal" : "vertical"}
aria-label="Resize"
data-testid={testId}
tabIndex={0}
onMouseDown={handleMouseDown}
onPointerDown={handlePointerDown}
onLostPointerCapture={endDrag}
onKeyDown={handleKeyDown}
onDoubleClick={onDoubleClick}
className={cn(
@@ -81,6 +132,9 @@ export function ResizeHandle({ onResizeStart, onResize, onResizeEnd, onDoubleCli
// net layout space (panes sit flush) while staying draggable.
"flex-shrink-0 hover:bg-primary/30 active:bg-primary/50 transition-colors relative group z-10",
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
// touch-none keeps the browser from turning a touch drag into a
// scroll and cancelling the pointer.
"touch-none",
isHorizontal ? "h-1 -my-0.5 cursor-row-resize bg-border" : "w-1 -mx-0.5 cursor-col-resize",
className
)}
@@ -75,6 +75,8 @@ vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: ({ o
const { MailApp } = await import('../mail-app');
const { ConversationEmailBody } = await import('@/components/email/thread-conversation-view');
const { EmailViewer } = await import('@/components/email/email-viewer');
const { ProEmailTabBody } = await import('@/components/pro/pro-email-tab-body');
const { ProFolderTabBody } = await import('@/components/pro/pro-folder-tab-body');
const sharedMailbox = { id: 'nrichmond:inbox', originalId: 'inbox', name: 'Shared', isShared: true, accountId: 'nrichmond' } as Mailbox;
const primaryMailbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
@@ -602,3 +604,132 @@ describe('conversation blob routing', () => {
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owner-a');
});
});
// Pro hosts must carry the same message-scoped context as MailApp, including
// when the global mail selection is absent or belongs to another message.
type ProBlobAction = 'download' | 'preview click' | 'CID' | 'export';
const proBlobActions: ProBlobAction[] = ['download', 'preview click', 'CID', 'export'];
const proRoutes = [
{ label: 'direct shared folder', secondary: false, owner: 'nrichmond', selection: 'null', unified: false },
{ label: 'secondary shared folder', secondary: true, owner: 'nrichmond', selection: 'unrelated', unified: false },
{ label: 'primary folder', secondary: false, owner: 'postal12345', selection: 'null', unified: false },
{ label: 'secondary primary folder', secondary: true, owner: 'secondary-primary', selection: 'unrelated', unified: false },
{ label: 'Unified tab, null selection', secondary: true, owner: 'nrichmond', selection: 'null', unified: true },
{ label: 'Unified tab, unrelated selection', secondary: true, owner: 'nrichmond', selection: 'unrelated', unified: true },
{ label: 'explicit secondary email tab', secondary: true, owner: 'nrichmond', selection: 'null', unified: false, emailTab: true },
];
async function openProMessage(route: typeof proRoutes[number], action: ProBlobAction) {
const { a, b, fetchMock } = await conversationClients();
const origin = route.secondary ? b : a;
const full = message({
subject: 'Pro message',
attachments: [{ ...cardAttachment, name: 'photo.png', ...(action === 'CID' ? { cid: 'test-image' } : {}) }],
...(action === 'CID' ? {
textBody: [], htmlBody: [{ partId: '1', blobId: 'html-blob', type: 'text/html', size: 30 }],
bodyValues: { '1': { value: '<img src="cid:test-image">', isTruncated: false, isEncodingProblem: false } },
} : {}),
});
const stub = { ...full, sourceClientAccountId: 'login-other', sourceAccountId: route.owner };
vi.spyOn(a, 'getEmail').mockResolvedValue(full);
vi.spyOn(b, 'getEmail').mockResolvedValue(full);
useSettingsStore.setState({ emailDownloadTemplate: '{subject}', mailAttachmentAction: action === 'preview click' ? 'preview' : 'download' });
useEmailStore.setState({
emails: route.unified ? [stub] : [],
selectedEmail: route.selection === 'null' ? null : message({ id: 'unrelated', sourceClientAccountId: 'login-primary', sourceAccountId: 'unrelated-owner' }),
isUnifiedView: route.unified, selectedMailbox: route.unified ? '__unified_inbox__' : sharedMailbox.id,
accountMailboxes: { 'login-primary': [primaryMailbox, sharedMailbox], 'login-other': [primaryMailbox, sharedMailbox] },
});
if (route.unified || route.emailTab) {
render(<ProEmailTabBody tabId="reader" data={{ accountId: route.owner, clientAccountId: route.emailTab ? 'login-other' : undefined, emailId: full.id, mailboxId: '__unified_inbox__', title: full.subject! }} />);
} else {
const mailbox = route.owner === 'nrichmond' ? sharedMailbox : primaryMailbox;
vi.spyOn(origin, 'getEmails').mockResolvedValue({ emails: [full], total: 1, hasMore: false });
render(<ProFolderTabBody tabId="folder" data={{ accountId: route.secondary ? 'login-other' : 'login-primary', mailboxId: mailbox.id, title: mailbox.name }} />);
fireEvent.click(await screen.findByText('Pro message'));
}
if (action === 'CID') await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
else await screen.findAllByText('photo.png');
return { a, b, origin, fetchMock, full };
}
async function actOnProBlob(action: ProBlobAction) {
if (action === 'download') await downloadAttachment();
if (action === 'preview click') {
fireEvent.click((await screen.findAllByText('photo.png'))[0]);
// Preserve Pro's existing action: Preview clicks download, with no modal.
expect(screen.queryByText('Preview fetch')).not.toBeInTheDocument();
}
if (action === 'export') await exportEmail();
}
function expectProBlob(route: typeof proRoutes[number], action: ProBlobAction, index = 0) {
const host = route.secondary ? 'secondary' : 'primary';
const blobPath = action === 'export' ? 'raw-blob/Pro%20message.eml?accept=message%2Frfc822' : 'same-blob/photo.png?accept=image%2Fpng';
expectBlobRequest(`https://${host}.example.com/download/${route.owner}/${blobPath}`, `${host}-login`, index);
}
describe('Pro blob routing', () => {
it.each(proRoutes.flatMap(route => proBlobActions.map(action => ({ ...route, action }))))(
'$label: $action uses the message owner and authenticated client', async route => {
const { origin, fetchMock } = await openProMessage(route, route.action);
await actOnProBlob(route.action);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expectProBlob(route, route.action);
if (route.owner === 'nrichmond') expect(origin.getEmail).toHaveBeenCalledWith('email', route.owner);
},
);
it.each([proRoutes[0], proRoutes[1], proRoutes[4]].flatMap(route => proBlobActions.map(action => ({ ...route, action }))))(
'$label: $action retains its source after account and selection changes', async route => {
const { a, b, fetchMock } = await openProMessage(route, route.action);
if (route.action === 'CID') await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
// Leave the loaded reader mounted while a different Mail pane navigates.
act(() => {
useEmailStore.setState({ emails: [], selectedEmail: message({ id: 'other', sourceClientAccountId: 'login-primary', sourceAccountId: 'unrelated-owner' }), selectedMailbox: 'inbox', isUnifiedView: false });
useAuthStore.setState({ client: route.secondary ? b : a, activeAccountId: route.secondary ? 'login-other' : 'login-primary' });
});
// Switch through null, as reconnecting account switches can do.
act(() => useAuthStore.setState({ client: null }));
act(() => useAuthStore.setState({ client: route.secondary ? a : b, activeAccountId: route.secondary ? 'login-primary' : 'login-other' }));
await act(async () => {});
await actOnProBlob(route.action);
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
for (let index = 0; index < fetchMock.mock.calls.length; index++) expectProBlob(route, route.action, index);
},
);
it('retains an in-flight Unified load across account and selection changes', async () => {
const { a, b, fetchMock } = await conversationClients();
let finish!: (email: Email) => void;
vi.spyOn(b, 'getEmail').mockImplementation(() => new Promise(resolve => { finish = resolve; }));
const wrongClient = vi.spyOn(a, 'getEmail');
useEmailStore.setState({ emails: [message({ sourceClientAccountId: 'login-other', sourceAccountId: 'nrichmond' })], selectedEmail: null, isUnifiedView: true });
render(<ProEmailTabBody tabId="reader" data={{ accountId: 'nrichmond', emailId: 'email', mailboxId: null, title: 'Loading' }} />);
await waitFor(() => expect(b.getEmail).toHaveBeenCalledWith('email', 'nrichmond'));
act(() => {
useEmailStore.setState({ emails: [], selectedEmail: message({ id: 'unrelated' }), isUnifiedView: false, selectedMailbox: 'inbox' });
useAuthStore.setState({ client: b, activeAccountId: 'login-other' });
});
await act(async () => finish(message({ attachments: [{ ...cardAttachment, name: 'photo.png' }] })));
await downloadAttachment();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expectProBlob(proRoutes[4], 'download');
expect(wrongClient).not.toHaveBeenCalled();
expect(b.getEmail).toHaveBeenCalledTimes(1);
});
it('changes CID routing when a reused reader opens identical ids on another client/owner', async () => {
const { a, b, fetchMock } = await conversationClients();
const full = inlineMessage({ attachments: [{ ...cardAttachment, name: 'photo.png', cid: 'test-image' }] });
vi.spyOn(a, 'getEmail').mockResolvedValue(full);
vi.spyOn(b, 'getEmail').mockResolvedValue({ ...full });
const reader = (login: string, owner: string) => <ProEmailTabBody tabId="reader" data={{ accountId: owner, clientAccountId: login, emailId: full.id, mailboxId: null, title: full.subject! }} />;
const { rerender } = render(reader('login-primary', 'owner-a'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expectBlobRequest('https://primary.example.com/download/owner-a/same-blob/photo.png?accept=image%2Fpng', 'primary-login');
rerender(reader('login-other', 'owner-b'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
expectBlobRequest('https://secondary.example.com/download/owner-b/same-blob/photo.png?accept=image%2Fpng', 'secondary-login', 1);
});
});
@@ -15,14 +15,14 @@ export function lastSlotProps(slot: string): Record<string, unknown> {
return lastProps.get(slot) ?? {};
}
/** Registers a fake plugin that offers the given slots. */
export function offerSlots(pluginId: string, slots: string[]): void {
/** Registers a fake plugin that offers the given slots; `fill` names the panel slots whose frame fills the panel, `startFolded` those that start folded. */
export function offerSlots(pluginId: string, slots: string[], { fill = [], startFolded = [] }: { fill?: string[]; startFolded?: string[] } = {}): void {
register({
plugin: { id: pluginId, name: pluginId },
code: '',
tier: 'untrusted',
background: {},
slotOffers: slots.map((name) => ({ name: name as SlotName, order: 100, hasShouldShow: false })),
slotOffers: slots.map((name) => ({ name: name as SlotName, order: 100, hasShouldShow: false, ...(fill.includes(name) ? { fill: true } : {}), ...(startFolded.includes(name) ? { startFolded: true } : {}) })),
hookDisposables: [],
} as unknown as ActivePlugin);
offered.add(pluginId);
@@ -35,12 +35,13 @@ export function clearOffers(): void {
}
/** Stand-in for PluginIframeSlot: a div carrying the slot name and its props as JSON. */
export function FakeIframeSlot({ pluginId, slot, extraProps }: { pluginId: string; slot: string; extraProps?: Record<string, unknown> }) {
export function FakeIframeSlot({ pluginId, slot, extraProps, fill }: { pluginId: string; slot: string; extraProps?: Record<string, unknown>; fill?: boolean }) {
lastProps.set(slot, extraProps ?? {});
return React.createElement('div', {
'data-testid': `slot-${slot}`,
'data-plugin': pluginId,
'data-props': JSON.stringify(extraProps ?? {}),
'data-fill': fill ? 'true' : undefined,
});
}
@@ -12,7 +12,7 @@ vi.mock('@/lib/plugin-sandbox/registry', () => ({
code: '',
tier: 'untrusted',
background: { evaluateShouldShow },
slotOffers: [{ name: 'email-banner', hasShouldShow, order: 100 }],
slotOffers: [{ name: 'email-banner', hasShouldShow, order: 100 }, { name: 'composer-sidebar-right', hasShouldShow: false, order: 100, fill: true }],
}),
}));
@@ -40,6 +40,18 @@ beforeEach(() => {
});
describe('PluginIframeSlot', () => {
it('lets a panel frame fill its panel instead of growing with its content', async () => {
activePlugin = plugin(['ui:composer-sidebar']);
const { container } = render(<PluginIframeSlot pluginId="p1" slot="composer-sidebar-right" extraProps={{}} fill />);
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
const opts = (createSlotInstance.mock.calls[0] as unknown[])[0] as { fill?: boolean };
expect(opts.fill).toBe(true);
const wrapper = container.querySelector('[data-plugin-iframe-slot]') as HTMLElement;
expect(wrapper.dataset.pluginIframeFill).toBe('true');
expect(wrapper.style.flex).toBe('1 1 0%');
});
it('mounts a banner-only plugin without the opened message', async () => {
activePlugin = plugin(['ui:email-banner']);
render(<PluginIframeSlot pluginId="p1" slot="email-banner" extraProps={{ email: EMAIL }} />);
+7 -3
View File
@@ -23,6 +23,8 @@ interface Props {
pluginId: string;
slot: SlotName;
extraProps?: Record<string, unknown>;
/** The frame fills its panel and scrolls inside, instead of growing with its content. */
fill?: boolean;
}
interface FloatingDropdownState {
@@ -36,7 +38,7 @@ interface FloatingDropdownState {
ariaLabel?: string;
}
export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
export function PluginIframeSlot({ pluginId, slot, extraProps, fill = false }: Props) {
const wrapperRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<SandboxInstance | null>(null);
const [height, setHeight] = useState<number>(0);
@@ -82,6 +84,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
locale,
tier: active.tier,
extraProps: props,
fill,
hostContainer: wrapperRef.current,
onResize: (h, w) => {
setHeight(h);
@@ -126,7 +129,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
// We intentionally don't depend on props here - propagating prop
// changes happens via postMessage below to avoid iframe churn.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show, pluginId, slot]);
}, [show, pluginId, slot, fill]);
// Push prop updates without remount.
useEffect(() => {
@@ -154,8 +157,9 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
ref={wrapperRef}
style={inline
? { display: 'inline-block', verticalAlign: 'middle', height, width: width ?? 0, maxWidth: '100%', overflow: 'hidden' }
: { height, minHeight: height }}
: fill ? { flex: '1 1 0%', minHeight: 0 } : { height, minHeight: height }}
data-plugin-iframe-slot={`${pluginId}:${slot}`}
data-plugin-iframe-fill={fill || undefined}
/>
{floatingDropdown && (
<FloatingDropdownPortal
+8 -2
View File
@@ -19,10 +19,15 @@ export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
if (offers.length === 0) return null;
// Inline slots line up next to each other in a toolbar row.
// Inline slots line up next to each other in a toolbar row. A panel slot
// with a frame that fills the panel takes the panel's height and shares
// it between such frames.
const fills = offers.some((offer) => offer.fill);
return (
<div
className={isInlineSlot(name) ? cn('inline-flex flex-wrap items-center gap-1', className) : className}
className={isInlineSlot(name)
? cn('inline-flex flex-wrap items-center gap-1', className)
: cn(className, fills && 'h-full flex flex-col min-h-0')}
data-plugin-slot={name}
>
{offers.map((offer) => (
@@ -31,6 +36,7 @@ export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
pluginId={offer.pluginId}
slot={name}
extraProps={extraProps}
fill={offer.fill}
/>
))}
</div>
+43 -7
View File
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { EmailViewer } from "@/components/email/email-viewer";
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext } from "@/stores/email-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { findDraftIdentityId } from "@/lib/reply-identity";
@@ -100,6 +100,17 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
const openComposeTabNow = useProTabStore((s) => s.openComposeTab);
const [email, setEmail] = useState<Email | null>(null);
const [blobSource, setBlobSource] = useState<EmailBlobContext>({ client: null });
// A reader owns its load, including an in-flight one across account switches.
// Only a different message or explicit tab context starts a new load.
const loadRef = useRef<{
emailId: string;
clientOverride: typeof clientOverride;
accountId: typeof accountId;
blobSource: EmailBlobContext;
promise: Promise<Email | null>;
applied: boolean;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const composerSessionIdRef = useRef(0);
@@ -137,11 +148,34 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
useEffect(() => {
let cancelled = false;
if (!client) return;
let load = loadRef.current;
if (!load || load.emailId !== emailId || load.clientOverride !== clientOverride || load.accountId !== accountId) {
const state = useEmailStore.getState();
const ref = state.selectedEmail?.id === emailId
? state.selectedEmail
: state.emails.find((e) => e.id === emailId);
// Folder/search panes supply their own context. A primary folder must
// not inherit an unrelated globally selected shared folder's owner.
const source = accountId || clientOverride
? { client, accountId: accountId ?? client.getAccountId() }
: resolveEmailBlobContext(ref ?? null, client);
load = {
emailId, clientOverride, accountId, blobSource: source, applied: false,
promise: accountId || clientOverride
? client.getEmail(emailId, source.accountId)
: fetchEmailContent(client, emailId),
};
loadRef.current = load;
}
const pending = load;
if (pending.applied) return;
setIsLoading(true);
(accountId ? client.getEmail(emailId, accountId) : fetchEmailContent(client, emailId))
pending.promise
.then((loaded) => {
if (cancelled) return;
pending.applied = true;
setEmail(loaded);
setBlobSource(pending.blobSource);
if (loaded) onLoadedRef.current?.(loaded);
})
.catch((err) => {
@@ -153,7 +187,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
return () => {
cancelled = true;
};
}, [client, emailId, accountId, fetchEmailContent]);
}, [client, clientOverride, emailId, accountId, fetchEmailContent]);
const currentMailboxRole = useMemo(() => {
if (!email) return undefined;
@@ -326,14 +360,15 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
}
}, [client, email, moveToMailbox, onClose]);
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string) => {
if (!client) return;
const handleDownloadAttachment = useCallback(async (blobId: string, name: string, type?: string, _forceDownload?: boolean, source?: EmailBlobContext) => {
const context = source ?? blobSource;
if (!context.client) return;
try {
await client.downloadBlob(blobId, name, type);
await context.client.downloadBlob(blobId, name, type, context.accountId);
} catch (err) {
console.error('Download failed:', err);
}
}, [client]);
}, [blobSource]);
const handleQuickReply = useCallback(async (body: string) => {
handleReply(body);
@@ -394,6 +429,7 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
email={email}
blobSource={blobSource}
isLoading={isLoading}
onReply={handleReply}
onReplyAll={handleReplyAll}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { useMemo } from 'react';
import { usePathname } from 'next/navigation';
import { withBasePath } from '@/lib/browser-navigation';
import { parseAdminPath, type AdminLocation } from '@/lib/admin/admin-nav';
/**
* Moves the admin panel to another of its pages without a route transition.
*
* The admin tree is one Next route, so a page switch is a history entry and
* nothing else: Next patches `history.pushState`, copies its own bookkeeping
* into the entry when the state passed is null, and updates `usePathname()`.
* Passing the current `history.state` instead would skip that sync, so the
* state here is always null. The back button lands on the previous entry
* the same way (Next's popstate handler restores the URL from the entry).
*/
export function navigateAdmin(path: string, options: { replace?: boolean } = {}): void {
if (typeof window === 'undefined') return;
const url = withBasePath(path);
if (`${window.location.pathname}${window.location.search}` === url && !options.replace) return;
if (options.replace) window.history.replaceState(null, '', url);
else window.history.pushState(null, '', url);
}
const INDEX: AdminLocation = { page: null, detail: null };
/** The page (and detail) the address bar names right now. */
export function useAdminLocation(): AdminLocation {
const pathname = usePathname();
return useMemo(() => parseAdminPath(pathname ?? '') ?? INDEX, [pathname]);
}
+6 -5
View File
@@ -66,15 +66,15 @@ describe('resolving to app paths', () => {
expect(path('files')).toBe('/files');
});
it('marks the admin targets, and carries the tab the shell keeps in a store', () => {
it('marks the admin targets and resolves a tab to that page\'s URL', () => {
const admin = resolve('admin', { tab: 'policy' });
expect(admin.kind).toBe('ok');
if (admin.kind === 'ok') {
expect(admin.resolution).toEqual({ path: '/admin', adminTab: 'policy', admin: true });
expect(admin.resolution).toEqual({ path: '/admin/policy', admin: true });
}
const auth = resolve('admin_auth');
if (auth.kind === 'ok') expect(auth.resolution.adminTab).toBe('auth');
if (auth.kind === 'ok') expect(auth.resolution).toEqual({ path: '/admin/auth', admin: true });
const ext = resolve('admin_extension', { slug: 'quick-reply' });
if (ext.kind === 'ok') {
@@ -100,9 +100,10 @@ describe('resolving to app paths', () => {
expect(safeAdminNext(encodeURIComponent(versioned))).toBe(versioned);
});
it('defaults the admin tab when the link named none', () => {
it('opens the panel front page when the link named no tab', () => {
const admin = resolve('admin');
if (admin.kind === 'ok') expect(admin.resolution.adminTab).toBe('dashboard');
expect(admin.kind).toBe('ok');
if (admin.kind === 'ok') expect(admin.resolution).toEqual({ path: '/admin', admin: true });
});
it('leaves the user targets unmarked, so they take the normal login path', () => {
@@ -16,6 +16,7 @@ import { dispatchApiCall } from '../plugin-sandbox/host-api';
import { actionsFor, resetActions, runAction, checkActions } from '../plugin-sandbox/actions';
import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels';
import { allPages, resetPages } from '../plugin-sandbox/pages';
import { onPanelRequest, resetPanelRequests } from '../plugin-sandbox/panel-requests';
import type { StreamSink } from '../plugin-sandbox/proxy-client';
import '../plugin-sandbox/loader';
@@ -35,6 +36,36 @@ beforeEach(() => {
registryEntries.length = 0;
});
describe('ui.setPanel (openPanel, closePanel, togglePanel)', () => {
beforeEach(() => { resetPanelRequests(); });
it('needs the permission of the panel\'s slot', async () => {
await expect(dispatchApiCall(plugin({ permissions: [] }), 'ui.setPanel', ['composer-sidebar-right', 'open', { composerId: 'c1' }]))
.rejects.toThrow(/may not open the panel/);
await expect(dispatchApiCall(plugin({ permissions: ['ui:composer-sidebar'] }), 'ui.setPanel', ['email-detail-sidebar', 'open', undefined]))
.rejects.toThrow(/may not open the panel/);
await expect(dispatchApiCall(plugin({ permissions: ['ui:email-details'] }), 'ui.setPanel', ['sidebar-widget', 'open', undefined]))
.rejects.toThrow(/may not open the panel/);
});
it('delivers the request to the panel on screen and answers with its state', async () => {
const seen: unknown[] = [];
onPanelRequest((request) => { seen.push(request); return request.mode !== 'close'; });
await expect(dispatchApiCall(plugin({ permissions: ['ui:composer-sidebar'] }), 'ui.setPanel', ['composer-sidebar-right', 'toggle', { composerId: 'c1' }])).resolves.toBe(true);
await expect(dispatchApiCall(plugin({ permissions: ['ui:email-details'] }), 'ui.setPanel', ['email-detail-sidebar', 'close', undefined])).resolves.toBe(false);
expect(seen).toEqual([
{ pluginId: 'p1', slot: 'composer-sidebar-right', mode: 'toggle', composerId: 'c1' },
{ pluginId: 'p1', slot: 'email-detail-sidebar', mode: 'close' },
]);
});
it('answers null when no panel is on screen, and refuses odd arguments', async () => {
await expect(dispatchApiCall(plugin({ permissions: ['ui:email-details'] }), 'ui.setPanel', ['email-detail-sidebar', 'open', undefined])).resolves.toBeNull();
await expect(dispatchApiCall(plugin({ permissions: ['ui:composer-sidebar'] }), 'ui.setPanel', ['composer-sidebar', 'open', { composerId: 5 }])).rejects.toThrow(/composerId/);
await expect(dispatchApiCall(plugin({ permissions: ['ui:composer-sidebar'] }), 'ui.setPanel', ['composer-sidebar', 'sideways', undefined])).rejects.toThrow(/mode/);
});
});
describe('ui.setActions', () => {
it('puts a plugin\'s buttons on a surface it may use', async () => {
await dispatchApiCall(plugin({ permissions: ['ui:message-list'] }), 'ui.setActions', ['message-row-actions', [
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
// api.app.getInfo: what a plugin may know about the app it runs in (an AI
// assistant answering "what webmail is this?"), with no permission.
const config = { appName: 'Rath Mail', stalwartFeaturesEnabled: false, demoMode: false };
const fetchConfig = vi.fn(async () => config);
vi.mock('@/hooks/use-config', () => ({ fetchConfig: () => fetchConfig() }));
const { dispatchApiCall } = await import('../plugin-sandbox/host-api');
const plugin = (permissions: string[] = []) => ({
id: 'p', name: 'P', version: '1.0.0', author: 'a', description: 'd', type: 'hook',
permissions, managed: permissions.length > 0, entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, httpPostAllowedOrigins: [],
} as unknown as InstalledPlugin);
afterEach(() => {
vi.unstubAllEnvs();
fetchConfig.mockClear();
});
describe('api.app.getInfo', () => {
it('gives the product, the administrator\'s name for it, the version and the edition, without a permission', async () => {
vi.stubEnv('NEXT_PUBLIC_APP_VERSION', '1.17.0');
expect(await dispatchApiCall(plugin(), 'app.getInfo', [])).toEqual({
product: 'Bulwark Mail',
name: 'Rath Mail',
version: '1.17.0',
edition: 'full',
stalwartFeatures: false,
demoMode: false,
});
});
it('still answers when the config can not be read', async () => {
fetchConfig.mockRejectedValueOnce(new Error('offline'));
const info = await dispatchApiCall(plugin(), 'app.getInfo', []) as { name: string; product: string };
expect(info.product).toBe('Bulwark Mail');
expect(info.name).toBe('Webmail');
});
});
describe('api.app.searchSettings', () => {
const setUp = async ({ locale = 'en', vacation = true } = {}) => {
const { useLocaleStore } = await import('@/stores/locale-store');
const { useAuthStore } = await import('@/stores/auth-store');
useLocaleStore.setState({ locale } as never);
useAuthStore.setState({ client: {
supportsSieve: () => true, supportsVacationResponse: () => vacation, supportsCalendars: () => true, supportsFiles: () => false,
} } as never);
};
it('finds a setting from an English query, named in the user\'s language', async () => {
await setUp({ locale: 'de' });
const results = await dispatchApiCall(plugin(), 'app.searchSettings', ['undo send', { limit: 3 }]) as { pageId: string; page: string; label: string }[];
expect(results.length).toBeGreaterThan(0);
expect(results[0].pageId).toBe('composing');
expect(results[0].page).toBe('Verfassen');
const de = (await import('@/locales/de/common.json')).default as unknown as { settings: { email_behavior: { send_delay: { label: string } } } };
expect(results.map((r) => r.label)).toContain(de.settings.email_behavior.send_delay.label);
});
it('leaves out pages the server does not offer', async () => {
await setUp({ vacation: false });
const results = await dispatchApiCall(plugin(), 'app.searchSettings', ['out of office automatic reply']) as { pageId: string }[];
expect(results.some((r) => r.pageId === 'vacation')).toBe(false);
await setUp({ vacation: true });
const again = await dispatchApiCall(plugin(), 'app.searchSettings', ['out of office automatic reply']) as { pageId: string }[];
expect(again.some((r) => r.pageId === 'vacation')).toBe(true);
});
it('wants a query', async () => {
await expect(dispatchApiCall(plugin(), 'app.searchSettings', [''])).rejects.toThrow(/non-empty string/);
});
});
describe('api.app.getSettings', () => {
it('needs settings:read, and shares plain preferences but nothing about other people', async () => {
const { useSettingsStore } = await import('@/stores/settings-store');
useSettingsStore.setState({ sendDelaySeconds: 10, markAsReadDelay: 2000, mailLayout: 'focus', trustedSenders: ['boss@example.org'], pushRelayUrl: 'https://relay.example' } as never);
await expect(dispatchApiCall(plugin(), 'app.getSettings', [])).rejects.toThrow();
const settings = await dispatchApiCall(plugin(['settings:read']), 'app.getSettings', []) as Record<string, unknown>;
expect(settings).toMatchObject({ sendDelaySeconds: 10, markAsReadDelay: 2000, mailLayout: 'focus' });
expect(settings).not.toHaveProperty('trustedSenders');
expect(settings).not.toHaveProperty('pushRelayUrl');
expect(JSON.stringify(settings)).not.toContain('boss@example.org');
});
});
@@ -102,6 +102,30 @@ describe('api.email.get', () => {
});
});
describe('api.email.search', () => {
it('runs a JMAP filter across all mail, newest first, as summaries', async () => {
const list = await call(READER, 'email.search', { text: 'invoice', from: 'carol', after: '2026-10-01' }) as { id: string }[];
expect(list.map((e) => e.id)).toEqual(['a', 'b']);
expect(list[0]).not.toHaveProperty('text');
expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 20, 0, undefined, false, { text: 'invoice', from: 'carol', after: '2026-10-01T00:00:00.000Z' });
});
it('searches one folder, a shared one through its owner, with a subject and attachments', async () => {
await call(READER, 'email.search', { subject: 'offer', hasAttachment: true, mailboxId: 'acc2:box', limit: 5 });
expect(client.getEmails).toHaveBeenCalledWith('box', 'acc2', 5, 0, undefined, false, { subject: 'offer', hasAttachment: true });
});
it('wants a condition, a real date, at most 50 results, and email:read', async () => {
await expect(call(READER, 'email.search', {})).rejects.toThrow(/at least one of/);
await expect(call(READER, 'email.search', { text: ' ' })).rejects.toThrow(/at least one of/);
await expect(call(READER, 'email.search', { text: 'x', before: 'yesterday' })).rejects.toThrow(/before must be a date/);
await expect(call(READER, 'email.search', { text: 'x', limit: 51 })).rejects.toThrow(/limit must be a whole number from 1 to 50/);
await expect(call(READER, 'email.search', { body: 'x' })).rejects.toThrow(/unknown property "body"/);
await expect(call(plugin([]), 'email.search', { text: 'x' })).rejects.toThrow(/lacks permission "email:read"/);
expect(client.getEmails).not.toHaveBeenCalled();
});
});
describe('api.email.list', () => {
it('returns summaries of a folder page', async () => {
const list = await call(READER, 'email.list', { mailboxId: 'inbox', limit: 2, position: 10 }) as { id: string }[];
+14
View File
@@ -31,6 +31,20 @@ describe('writePushPreferences', () => {
await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined();
expect(put).not.toHaveBeenCalled();
});
it('does nothing where reading the Cache store throws', async () => {
// A plugin sandbox frame has an opaque origin: reading `caches` there,
// typeof included, throws a SecurityError.
Object.defineProperty(globalThis, 'caches', {
configurable: true,
get() {
throw new DOMException('Cache storage is disabled because the context is sandboxed', 'SecurityError');
},
});
const { writePushPreferences } = await import('../push-preferences');
await expect(writePushPreferences({ sound: true })).resolves.toBeUndefined();
expect(put).not.toHaveBeenCalled();
});
});
describe('settings store push preference mirror', () => {
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { ADMIN_PAGES, ADMIN_PAGE_IDS, adminPath, isAdminPageId, parseAdminPath } from '../admin-nav';
describe('admin page URLs', () => {
it('reads the page and the detail from a pathname', () => {
expect(parseAdminPath('/admin')).toEqual({ page: null, detail: null });
expect(parseAdminPath('/admin/')).toEqual({ page: null, detail: null });
expect(parseAdminPath('/admin/settings')).toEqual({ page: 'settings', detail: null });
expect(parseAdminPath('/admin/plugins/jitsi-meet')).toEqual({ page: 'plugins', detail: 'jitsi-meet' });
expect(parseAdminPath('/admin/marketplace/quick-reply')).toEqual({ page: 'marketplace', detail: 'quick-reply' });
});
it('decodes a detail id and keeps slashes inside it', () => {
expect(parseAdminPath('/admin/plugins/acme%2Fwidget')).toEqual({ page: 'plugins', detail: 'acme/widget' });
expect(parseAdminPath('/admin/plugins/acme/widget')).toEqual({ page: 'plugins', detail: 'acme/widget' });
});
it('ignores a detail on pages that have none, and an unknown page', () => {
expect(parseAdminPath('/admin/settings/anything')).toEqual({ page: 'settings', detail: null });
expect(parseAdminPath('/admin/nope')).toEqual({ page: null, detail: null });
});
it('is null outside the admin tree', () => {
expect(parseAdminPath('/mail')).toBeNull();
expect(parseAdminPath('/administration')).toBeNull();
expect(parseAdminPath('/en/admin')).toBeNull();
});
it('builds the paths it parses', () => {
expect(adminPath()).toBe('/admin');
expect(adminPath(null)).toBe('/admin');
expect(adminPath('policy')).toBe('/admin/policy');
expect(adminPath('themes', 'nord')).toBe('/admin/themes/nord');
expect(adminPath('plugins', 'acme/widget')).toBe('/admin/plugins/acme%2Fwidget');
for (const id of ADMIN_PAGE_IDS) {
expect(parseAdminPath(adminPath(id))).toEqual({ page: id, detail: null });
}
expect(parseAdminPath(adminPath('plugins', 'acme/widget'))).toEqual({ page: 'plugins', detail: 'acme/widget' });
});
it('knows its page ids', () => {
expect(isAdminPageId('plugins')).toBe(true);
expect(isAdminPageId('login')).toBe(false);
expect(isAdminPageId(null)).toBe(false);
});
it('registers every page once, with the detail flag only where a detail page exists', () => {
expect(ADMIN_PAGES.map((p) => p.id)).toEqual([...ADMIN_PAGE_IDS]);
expect(ADMIN_PAGES.filter((p) => p.hasDetail).map((p) => p.id)).toEqual(['plugins', 'themes', 'marketplace']);
for (const page of ADMIN_PAGES) {
const ids = page.sections.map((s) => s.id);
expect(new Set(ids).size).toBe(ids.length);
}
});
});
+234
View File
@@ -0,0 +1,234 @@
/**
* The admin panel's pages and their URLs.
*
* Every page lives at `/admin/<id>`; plugins, themes and the marketplace
* have a detail page below it (`/admin/plugins/<pluginId>`). The whole tree
* is one Next route (app/(main)/admin/[[...segments]]) and switching pages
* goes through history.pushState, so the sidebar never starts a route
* transition - the same way the user settings keep their permalinks (#733).
*
* This module is plain data: the connector registry imports it on the
* server, so no React in here. Icons live in the shell.
*/
export const ADMIN_PAGE_IDS = [
'dashboard',
'settings',
'auth',
'branding',
'policy',
'plugins',
'themes',
'marketplace',
'version',
'telemetry',
'logs',
] as const;
export type AdminPageId = typeof ADMIN_PAGE_IDS[number];
export function isAdminPageId(value: string | null | undefined): value is AdminPageId {
return typeof value === 'string' && (ADMIN_PAGE_IDS as readonly string[]).includes(value);
}
/** `top` has no heading in the sidebar; the other two do. */
export type AdminPageGroup = 'top' | 'extensions' | 'system';
export interface AdminSectionDef {
/** The `id` the page gives its SettingsSection, for jump links and search. */
id: string;
title: string;
/** Extra words the sidebar search matches, besides the title. */
keywords?: string;
}
export interface AdminPageDef {
id: AdminPageId;
label: string;
/** One line under the page title. */
description: string;
group: AdminPageGroup;
/** Words the sidebar search matches, besides the label and the sections. */
keywords: string;
sections: readonly AdminSectionDef[];
/** Whether `/admin/<id>/<detail>` opens a detail page. */
hasDetail?: boolean;
}
export const ADMIN_PAGES: readonly AdminPageDef[] = [
{
id: 'dashboard',
label: 'Overview',
description: 'What this Bulwark is running and what needs attention',
group: 'top',
keywords: 'dashboard home status warnings accounts activity recent',
sections: [],
},
{
id: 'settings',
label: 'Server',
description: 'How this Bulwark connects and what the login page offers',
group: 'top',
keywords: 'settings application name jmap server url demo mode stalwart features search engine indexing',
sections: [
{ id: 'general', title: 'General', keywords: 'app name jmap url custom endpoint demo mode stalwart indexing' },
{ id: 'login', title: 'Login page', keywords: 'version 2fa totp code access token remember me heading subtitle' },
{ id: 'servers', title: 'JMAP servers', keywords: 'multi server domain auto pick list' },
{ id: 'wopi', title: 'Document editing', keywords: 'wopi collabora onlyoffice office files host url' },
{ id: 'logging', title: 'Logging', keywords: 'log level format json text debug' },
{ id: 'sync', title: 'Settings sync', keywords: 'session secret devices' },
],
},
{
id: 'auth',
label: 'Authentication',
description: 'OAuth, single sign-on, admin access and session security',
group: 'top',
keywords: 'login password oidc openid',
sections: [
{ id: 'oauth', title: 'OAuth / OpenID Connect', keywords: 'client id secret issuer scopes private endpoints auto configure stalwart' },
{ id: 'sso', title: 'Single sign-on', keywords: 'auto sso redirect end session logout provider post-logout' },
{ id: 'admin-access', title: 'Admin access', keywords: 'stalwart admin dashboard password automatic' },
{ id: 'session', title: 'Session and security', keywords: 'cookie samesite frame ancestors parent origin embed' },
],
},
{
id: 'branding',
label: 'Branding',
description: 'Logos, colours and company details, per domain if needed',
group: 'top',
keywords: 'logo favicon icon company imprint privacy website domain',
sections: [
{ id: 'scope', title: 'Scope', keywords: 'domain host wildcard default' },
{ id: 'images', title: 'Images and logos', keywords: 'favicon app logo login logo light dark upload' },
{ id: 'pwa', title: 'Progressive web app', keywords: 'pwa icon screenshot short name description theme colour background colour install' },
{ id: 'company', title: 'Company information', keywords: 'company name imprint privacy policy website url' },
],
},
{
id: 'policy',
label: 'User policy',
description: 'Which features users get and which settings they may change',
group: 'top',
keywords: 'features gates restrictions lock hide users',
sections: [
{ id: 'features', title: 'Features', keywords: 'calendar contacts files smime templates keywords sidebar apps unified mailbox archive custom from debug export' },
{ id: 'push-relays', title: 'Push relays', keywords: 'notifications relay url default lock' },
{ id: 'sidebar-apps', title: 'Default sidebar apps', keywords: 'apps rail intranet inline tab icon' },
{ id: 'restrictions', title: 'Locked settings', keywords: 'restrict hide lock font size density layout delete action reply mode session timeout notifications debug' },
],
},
{
id: 'plugins',
label: 'Plugins',
description: 'Plugins installed for every user, and what users may install themselves',
group: 'extensions',
keywords: 'extensions addons upload zip approve permissions configure force enable',
sections: [
{ id: 'approvals', title: 'Approval requests', keywords: 'approve deny user uploaded code review' },
{ id: 'installed', title: 'Installed plugins', keywords: 'enable disable remove roll back configure' },
{ id: 'policy', title: 'Plugin policy', keywords: 'plugins enabled user uploads require approval' },
],
hasDetail: true,
},
{
id: 'themes',
label: 'Themes',
description: 'Themes installed for every user, defaults and what users may pick',
group: 'extensions',
keywords: 'colour color mode dark light default upload zip',
sections: [
{ id: 'installed', title: 'Installed themes', keywords: 'enable disable remove roll back' },
{ id: 'policy', title: 'Theme policy', keywords: 'themes enabled user uploads default theme colour mode lock' },
{ id: 'offered', title: 'Offered to users', keywords: 'built-in themes hide' },
],
hasDetail: true,
},
{
id: 'marketplace',
label: 'Marketplace',
description: 'Plugins and themes from the extension directory',
group: 'extensions',
keywords: 'directory install update browse extensions advisories',
sections: [],
hasDetail: true,
},
{
id: 'version',
label: 'Updates',
description: 'The running version and the hourly update check',
group: 'system',
keywords: 'version release upgrade check security advisory',
sections: [
{ id: 'status', title: 'Status', keywords: 'running latest release severity advisory' },
{ id: 'schedule', title: 'Schedule', keywords: 'last checked next' },
{ id: 'source', title: 'Source', keywords: 'endpoint disabled env' },
],
},
{
id: 'telemetry',
label: 'Usage stats',
description: 'The anonymous daily heartbeat, off unless you turn it on',
group: 'system',
keywords: 'telemetry heartbeat anonymous consent privacy',
sections: [
{ id: 'status', title: 'Status', keywords: 'enable disable consent' },
{ id: 'accounts', title: 'Account activity', keywords: 'active total logins' },
{ id: 'endpoint', title: 'Endpoint', keywords: 'collector url' },
{ id: 'payload', title: 'Payload preview', keywords: 'send now json' },
],
},
{
id: 'logs',
label: 'Audit log',
description: 'Who changed what in this panel, and when',
group: 'system',
keywords: 'audit history actions login config policy ip',
sections: [],
},
];
const PAGE_BY_ID = new Map(ADMIN_PAGES.map((page) => [page.id, page]));
export function adminPageDef(id: AdminPageId): AdminPageDef {
return PAGE_BY_ID.get(id)!;
}
export const ADMIN_ROOT = '/admin';
/** Where in the admin panel a pathname points. `page` null is the index. */
export interface AdminLocation {
page: AdminPageId | null;
/** The id or slug of a detail page, for pages that have them. */
detail: string | null;
}
/**
* Reads the admin location from a pathname (without the base path, as
* `usePathname()` gives it). Null when the path is not inside /admin.
* An unknown page name counts as the index rather than a 404: the login and
* password routes are separate pages and never get here.
*/
export function parseAdminPath(pathname: string): AdminLocation | null {
if (pathname !== ADMIN_ROOT && !pathname.startsWith(`${ADMIN_ROOT}/`)) return null;
const segments = pathname
.slice(ADMIN_ROOT.length)
.split('/')
.filter(Boolean)
.map((segment) => {
try { return decodeURIComponent(segment); } catch { return segment; }
});
if (segments.length === 0) return { page: null, detail: null };
const [first, ...rest] = segments;
if (!isAdminPageId(first)) return { page: null, detail: null };
const def = adminPageDef(first);
const detail = def.hasDetail && rest.length > 0 ? rest.join('/') : null;
return { page: first, detail };
}
/** The app-relative path of a page, or of one of its detail pages. */
export function adminPath(page?: AdminPageId | null, detail?: string | null): string {
if (!page) return ADMIN_ROOT;
const base = `${ADMIN_ROOT}/${page}`;
return detail ? `${base}/${encodeURIComponent(detail)}` : base;
}
+7 -18
View File
@@ -27,7 +27,7 @@
* is deliberately no parameter that carries a path, a URL, or a redirect.
*/
import { ADMIN_TABS, type AdminTabId } from '@/stores/admin-tab-store';
import { ADMIN_PAGE_IDS, adminPath, isAdminPageId } from '@/lib/admin/admin-nav';
import type { SettingsSearchTab } from '@/lib/settings-search';
import { buildSettingsPath, buildFilesPath } from '@/lib/deep-links';
import { RELEASE_VERSION } from '@/lib/release-version';
@@ -63,19 +63,10 @@ export interface ParamSpec {
readonly required?: boolean;
}
/**
* What a resolved target asks the client to do.
*
* Most targets are just a path. The admin ones carry a `tab` as well, because
* the admin shell keeps its active tab in a zustand store rather than in the
* URL (see app/(main)/admin/layout.tsx) - so reaching a specific panel means
* setting that store before navigating to /admin. If admin ever gains real
* `/admin/<tab>` routes, this collapses back into a plain path.
*/
/** What a resolved target asks the client to do: a path, and whether it needs the admin session. */
export interface Resolution {
/** App-relative path; feed through `appPath()` before navigating. */
readonly path: string;
readonly adminTab?: AdminTabId;
/** True when the destination requires an admin session, not a user one. */
readonly admin?: boolean;
}
@@ -134,12 +125,10 @@ export const TARGETS: readonly Target[] = [
{
name: 'admin',
label: 'the admin area',
params: { tab: { type: 'enum', values: ADMIN_TABS } },
resolve: (p) => ({
path: '/admin',
adminTab: (p.tab as AdminTabId | undefined) ?? 'dashboard',
admin: true,
}),
params: { tab: { type: 'enum', values: ADMIN_PAGE_IDS } },
// Every admin page has its own URL (lib/admin/admin-nav.ts); a link that
// names no tab opens the panel's front page.
resolve: (p) => ({ path: isAdminPageId(p.tab) ? adminPath(p.tab) : '/admin', admin: true }),
},
{
name: 'admin_extension',
@@ -165,7 +154,7 @@ export const TARGETS: readonly Target[] = [
{
name: 'admin_auth',
label: 'authentication settings',
resolve: () => ({ path: '/admin', adminTab: 'auth', admin: true }),
resolve: () => ({ path: adminPath('auth'), admin: true }),
},
{
name: 'setup',
+149
View File
@@ -0,0 +1,149 @@
// What a plugin may learn about the app itself: where a setting is (the same
// index the settings page searches, in the user's language) and the user's
// preferences. Used by the AI assistant to answer "how do I ...?" and "why
// does the app ...?" with the app's own names and the user's own values.
import enMessages from '@/locales/en/common.json';
import { LOCALE_LOADERS } from '@/components/providers/intl-provider';
import { mergeMessages } from '@/i18n/merge-messages';
import { detectBrowserLocale } from '@/i18n/detect-locale';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { fetchConfig } from '@/hooks/use-config';
import {
collectSubResults, getByPath, tabKeywords, tabSearchPaths, type SettingsSearchTab, type SubResult,
} from '@/lib/settings-search';
import type { AppSettingsView, SettingsSearchResult } from '../plugin-types';
type Messages = Record<string, unknown>;
const MAX_QUERY = 200;
const MAX_RESULTS = 20;
async function messagesFor(locale: string): Promise<Messages> {
const loader = LOCALE_LOADERS[locale];
if (locale === 'en' || !loader) return enMessages as Messages;
try {
return mergeMessages(enMessages as never, (await loader()) as never) as Messages;
} catch {
return enMessages as Messages;
}
}
/** The settings pages this user has: the server's support and the admin's gates, as on the settings page. */
async function availablePages(): Promise<Set<SettingsSearchTab>> {
const client = useAuthStore.getState().client;
const policy = usePolicyStore.getState();
const gate = (feature: Parameters<typeof policy.isFeatureEnabled>[0]) => {
try { return policy.isFeatureEnabled(feature); } catch { return true; }
};
let stalwart = true;
try { stalwart = (await fetchConfig()).stalwartFeaturesEnabled ?? true; } catch { /* keep the default */ }
const pages = new Set<SettingsSearchTab>(['account', 'notifications', 'general', 'appearance', 'message_list', 'reading', 'composing', 'organize', 'backup']);
if (stalwart) pages.add('security');
if (client?.supportsSieve?.()) pages.add('filters');
if (client?.supportsVacationResponse?.()) pages.add('vacation');
if (client?.supportsCalendars?.() && gate('calendarEnabled')) pages.add('calendar');
if (gate('contactsEnabled')) pages.add('contacts');
if (client?.supportsFiles?.() && gate('filesEnabled')) pages.add('files');
if (gate('sidebarAppsEnabled') || gate('pluginsEnabled')) pages.add('addons');
return pages;
}
function pageName(messages: Messages, tab: SettingsSearchTab): string {
const name = getByPath(messages, `settings.tabs.${tab}`);
return typeof name === 'string' ? name : tab;
}
/**
* Settings matching a query, in the user's language: the page, the
* setting's label and its description. The query may be in the user's
* language or in English (a model often asks in English); every word counts.
*/
export async function doAppSearchSettings(query: unknown, options?: unknown): Promise<SettingsSearchResult[]> {
if (typeof query !== 'string' || !query.trim()) throw new Error('app.searchSettings: query must be a non-empty string');
const words = query.toLowerCase().slice(0, MAX_QUERY).split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 1);
if (!words.length) return [];
const rawLimit = (options as { limit?: unknown } | undefined)?.limit;
const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit) ? Math.min(MAX_RESULTS, Math.max(1, Math.floor(rawLimit))) : 8;
const locale = useLocaleStore.getState().locale || detectBrowserLocale('en');
const local = await messagesFor(locale);
const english = enMessages as Messages;
const pages = await availablePages();
const scored: { score: number; order: number; result: SettingsSearchResult }[] = [];
let order = 0;
for (const tab of Object.keys(tabSearchPaths) as SettingsSearchTab[]) {
if (!pages.has(tab)) continue;
const page = pageName(local, tab);
const pageHaystack = `${page} ${pageName(english, tab)} ${tab.replace(/_/g, ' ')} ${tabKeywords[tab] ?? ''}`.toLowerCase();
for (const path of tabSearchPaths[tab]) {
// The merged catalogue has the English one's shape, so both walks line up.
const mine: SubResult[] = [];
const theirs: SubResult[] = [];
collectSubResults(getByPath(local, path), mine);
collectSubResults(getByPath(english, path), theirs);
mine.forEach((entry, i) => {
const en = theirs[i];
const text = `${entry.label} ${entry.description ?? ''} ${en?.label ?? ''} ${en?.description ?? ''}`.toLowerCase();
let score = 0;
for (const word of words) {
if (text.includes(word)) score += 2;
else if (pageHaystack.includes(word)) score += 1;
}
if (score > 0) {
scored.push({ score, order: order++, result: { pageId: tab, page, label: entry.label, ...(entry.description ? { description: entry.description } : {}) } });
}
});
}
}
scored.sort((a, b) => b.score - a.score || a.order - b.order);
const seen = new Set<string>();
const out: SettingsSearchResult[] = [];
for (const { result } of scored) {
const key = `${result.pageId}|${result.label}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(result);
if (out.length === limit) break;
}
return out;
}
/**
* The user's preferences that say how the app behaves for them: layout,
* theme, language and formats, reading and sending behaviour, notifications.
* Plain values only; nothing about other people (trusted senders, mutes)
* and nothing that addresses a server (the push relay).
*/
export const SHARED_SETTINGS = [
'fontSize', 'density', 'interfaceLayout', 'mailLayout', 'toolbarPosition', 'showToolbarLabels', 'animationsEnabled', 'proInterface',
'dateFormat', 'timeFormat', 'firstDayOfWeek', 'timeZone',
'disableThreading', 'showPreview', 'listColumns', 'emailsPerPage', 'showVerificationCodes',
'markAsReadDelay', 'externalContentPolicy', 'attachmentPosition', 'mailAttachmentAction', 'emailAlwaysLightMode', 'returnToListAfterAction', 'plainTextFont',
'deleteAction', 'archiveMode', 'permanentlyDeleteJunk', 'confirmBulkDelete',
'sendDelaySeconds', 'sendConfirmation', 'defaultReplyMode', 'plainTextMode', 'signaturePosition', 'signatureSeparatorEnabled',
'requestReadReceiptDefault', 'readReceiptResponse', 'autoSaveDraftInterval', 'autoSelectReplyIdentity',
'showWeekNumbers', 'calendarWorkingDays', 'enableCalendarTasks', 'showBirthdayCalendar',
'emailNotificationsEnabled', 'emailNotificationSound', 'calendarNotificationsEnabled',
'enableUnifiedMailbox', 'sessionTimeout',
] as const;
export function doAppGetSettings(): AppSettingsView {
const state = useSettingsStore.getState() as unknown as Record<string, unknown>;
const out: AppSettingsView = {};
for (const key of SHARED_SETTINGS) {
const value = state[key];
if (typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) out[key] = value;
else if (Array.isArray(value) && value.every((v) => typeof v === 'number' || typeof v === 'string')) out[key] = value.slice(0, 20) as number[] | string[];
}
const theme = (useThemeStore.getState() as unknown as { theme?: unknown }).theme;
if (typeof theme === 'string') out.theme = theme;
const locale = useLocaleStore.getState().locale;
if (locale) out.language = locale;
return out;
}
+45 -3
View File
@@ -13,9 +13,7 @@ import type { Email, Mailbox } from '../jmap/types';
import type { IJMAPClient } from '../jmap/client-interface';
import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store';
import { emailSummary, emailToReadView } from '../plugin-projection';
import {
MAX_PLUGIN_FETCH_BYTES, readId, readInteger, readObject, sessionClient, withinRate,
} from './host-api-input';
import { MAX_PLUGIN_FETCH_BYTES, readBoolean, readId, readInteger, readObject, readString, sessionClient, withinRate } from './host-api-input';
/** Most messages one api.email.list call returns. */
export const MAX_PLUGIN_EMAIL_PAGE = 100;
@@ -92,6 +90,50 @@ export async function doEmailList(options: unknown): Promise<EmailSummary[]> {
return emails.map(emailSummary);
}
/** Most messages one search returns. */
export const MAX_PLUGIN_SEARCH_RESULTS = 50;
const SEARCH_TEXT_FIELDS = ['text', 'from', 'to', 'subject'] as const;
const SEARCH_DATE_FIELDS = ['after', 'before'] as const;
/**
* Messages matching a JMAP filter, newest first: `text` (sender, recipients,
* subject and body, whole words), `from`, `to`, `subject`, `after` and
* `before` (dates), `hasAttachment`, in one folder with `mailboxId`. At
* least one condition; summaries without the body (api.email.get reads one).
*/
export async function doEmailSearch(options: unknown): Promise<EmailSummary[]> {
const method = 'email.search';
const opts = readObject(options, method, 'query', [...SEARCH_TEXT_FIELDS, ...SEARCH_DATE_FIELDS, 'hasAttachment', 'mailboxId', 'limit']);
const limit = opts.limit === undefined ? 20 : readInteger(opts.limit, method, 'limit', 1, MAX_PLUGIN_SEARCH_RESULTS);
const filter: Record<string, unknown> = {};
for (const key of SEARCH_TEXT_FIELDS) {
if (opts[key] === undefined) continue;
const value = readString(opts[key], method, key, 500).trim();
if (value) filter[key] = value;
}
for (const key of SEARCH_DATE_FIELDS) {
if (opts[key] === undefined) continue;
const value = readString(opts[key], method, key, 40).trim();
const date = new Date(value);
if (!value || Number.isNaN(date.getTime())) throw new Error(`${method}: ${key} must be a date like 2026-05-01`);
filter[key] = date.toISOString();
}
if (opts.hasAttachment !== undefined) filter.hasAttachment = readBoolean(opts.hasAttachment, method, 'hasAttachment');
if (Object.keys(filter).length === 0) throw new Error(`${method}: give at least one of text, from, to, subject, after, before or hasAttachment`);
const client = sessionClient(method);
let jmapMailboxId: string | undefined;
let accountId: string | undefined;
if (opts.mailboxId !== undefined) {
const id = readId(opts.mailboxId, method, 'mailboxId');
const mailbox = (await knownMailboxes(client)).find((m) => m.id === id);
if (!mailbox) throw new Error(`${method}: no mailbox with id "${id}"`);
jmapMailboxId = mailbox.originalId || mailbox.id;
accountId = mailbox.isShared ? mailbox.accountId : undefined;
}
const { emails } = await client.getEmails(jmapMailboxId, accountId, limit, 0, undefined, false, filter);
return emails.map(emailSummary);
}
// ─── Bytes ────────────────────────────────────────────────────
/** Every attachment part of a message, nested ones included. */
+39 -2
View File
@@ -2,7 +2,10 @@
// on `plugin.permissions` BEFORE doing the underlying work, and only returns
// structured-cloneable data back to the iframe.
import type { InstalledPlugin, Permission } from '../plugin-types';
import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types';
import { fetchConfig } from '@/hooks/use-config';
import { IS_LITE } from '../lite';
import { doAppGetSettings, doAppSearchSettings } from './host-api-app';
import { mayOfferSlot, pluginHasPermission } from './permissions';
import { proxyAccounts, proxyCancel, proxyConnect, proxyDisconnect, proxyFetch, type StreamSink } from './proxy-client';
import { checkActions, isActionSurface, setPluginActions } from './actions';
@@ -47,12 +50,13 @@ import {
doTemplatesUpdate, doVacationGet, doVacationSet,
} from './host-api-settings';
import {
doEmailArchive, doEmailGet, doEmailGetAttachment, doEmailGetRaw, doEmailList, doEmailMove, doMailboxesList,
doEmailArchive, doEmailGet, doEmailGetAttachment, doEmailGetRaw, doEmailList, doEmailMove, doEmailSearch, doMailboxesList,
} from './host-api-mail';
import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input';
import { doComposeOpen, doComposeReply, doDraftsCreate } from './host-api-compose';
import { doNotificationsShow } from './host-api-notify';
import { setSectionTitle } from './section-titles';
import { requestPanel } from './panel-requests';
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
/**
@@ -149,6 +153,12 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
'user.getIdentities': 'identity:read',
// The IANA zone the app shows times in (Settings or the browser's).
'user.getTimeZone': null,
// The app's name and version: nothing about the user.
'app.getInfo': null,
// Where a setting is: the app's own texts, nothing about the user.
'app.searchSettings': null,
// The user's preferences (layout, formats, reading and sending behaviour).
'app.getSettings': 'settings:read',
'user.logout': 'auth:emit',
// admin
'admin.getConfig': 'admin:config',
@@ -210,6 +220,9 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
'ui.setDayLabels': 'ui:calendar-action',
'ui.setPages': 'ui:app-page',
'ui.setSectionTitle': 'ui:sidebar-widget',
// Open, close or toggle the panel the plugin's slot sits in; the panel's
// own permission is checked per call (see the slot catalogue).
'ui.setPanel': null,
// sieve (delivery-time classification)
'sieve.isSupported': 'filters:read',
'sieve.getActiveScript': 'filters:read',
@@ -244,6 +257,7 @@ export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
// reading mail: one message with its text, a page of a folder, the folders
'email.get': 'email:read',
'email.list': 'email:read',
'email.search': 'email:read',
'mailboxes.list': 'email:read',
// The bytes of one attachment of a given message, or of the message
// itself. Unlike jmap.fetchBlob these only reach blobs of a message the
@@ -1639,6 +1653,20 @@ async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
if (!res.ok) return {};
return res.json();
}
/** The app a plugin runs in: product, the administrator's name for it, version, edition. */
async function doAppGetInfo(): Promise<AppInfoView> {
let config: Awaited<ReturnType<typeof fetchConfig>> | null = null;
try { config = await fetchConfig(); } catch { config = null; }
return {
product: 'Bulwark Mail',
name: config?.appName || 'Webmail',
version: process.env.NEXT_PUBLIC_APP_VERSION || '',
edition: IS_LITE ? 'lite' : 'full',
stalwartFeatures: config?.stalwartFeaturesEnabled ?? true,
demoMode: config?.demoMode ?? false,
};
}
/** The zone the app shows times in: the user's choice, else the browser's. */
function userTimeZone(): string {
const setting = useSettingsStore.getState().timeZone;
@@ -1749,6 +1777,11 @@ export async function dispatchApiCall(
case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined;
case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined;
case 'ui.setSectionTitle': setSectionTitle(plugin.id, args[0]); return undefined;
case 'ui.setPanel': {
const slot = args[0];
if (typeof slot !== 'string' || !mayOfferSlot(plugin, slot)) throw new Error(`Plugin "${plugin.id}" may not open the panel "${String(slot)}"`);
return requestPanel(plugin.id, slot, args[1], args[2]);
}
case 'storage.get': return storageGet(plugin.id, args[0] as string);
case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined;
@@ -1830,6 +1863,9 @@ export async function dispatchApiCall(
case 'user.getAccounts': return doUserGetAccounts();
case 'user.getIdentities': return doUserGetIdentities();
case 'user.getTimeZone': return userTimeZone();
case 'app.getInfo': return doAppGetInfo();
case 'app.searchSettings': return doAppSearchSettings(args[0], args[1]);
case 'app.getSettings': return doAppGetSettings();
case 'user.logout' : return doUserLogout();
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
@@ -2022,6 +2058,7 @@ export async function dispatchApiCall(
case 'email.get': return doEmailGet(args[0]);
case 'email.list': return doEmailList(args[0]);
case 'email.search': return doEmailSearch(args[0]);
case 'mailboxes.list': return doMailboxesList();
case 'email.getAttachment': return doEmailGetAttachment(args[0], args[1]);
case 'email.getRaw': return doEmailGetRaw(args[0]);
+8 -1
View File
@@ -160,6 +160,8 @@ export interface SlotOptions {
/** Resolved execution tier (from `resolvePluginTier`). */
tier: PluginTier;
extraProps: Record<string, unknown>;
/** The frame fills its panel instead of growing with its content (SlotInit.fill). */
fill?: boolean;
/** Container element the visible slot iframe is mounted into. */
hostContainer: HTMLElement;
/** Called whenever the sandbox reports a new content size (width only for inline slots). */
@@ -170,7 +172,7 @@ export interface SlotOptions {
export interface InitDoneInfo {
hooks: string[];
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }>;
shortcuts: Array<{ id: string; keys: string; label: string; category?: string }>;
}
@@ -325,6 +327,10 @@ export class SandboxInstance {
// Starts at the widest it may get; the first resize shrinks it.
this.iframe.style.width = `${MAX_INLINE_SLOT_WIDTH}px`;
this.iframe.style.height = '0px';
} else if (initPayload.mode === 'slot' && initPayload.fill) {
// As tall as its panel; the plugin scrolls inside.
this.iframe.style.width = '100%';
this.iframe.style.height = '100%';
} else {
this.iframe.style.width = '100%';
this.iframe.style.height = '0px';
@@ -661,6 +667,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
httpOrigins: opts.plugin.httpOrigins,
},
extraProps: opts.extraProps,
...(opts.fill ? { fill: true } : {}),
locale: opts.locale,
theme: snapshotHostTheme(),
};
+67
View File
@@ -0,0 +1,67 @@
// Plugins opening and closing the panel their slot sits in
// (api.ui.openPanel / closePanel / togglePanel): the composer's side panels
// and the reader's detail panel. A toolbar button, a shortcut or a
// right-click entry can then bring the plugin's panel up instead of a
// dialog. The panels subscribe here while they are on screen; a request
// nobody answers (no composer open, no message open, a phone) resolves to
// null, so the plugin can fall back to a dialog.
//
// When a panel opens on request, its slot gets a new `focusRequest` number
// in its props, so the plugin can put the focus where it belongs.
export const PANEL_SLOTS = ['composer-sidebar', 'composer-sidebar-right', 'email-detail-sidebar'] as const;
export type PanelSlot = typeof PANEL_SLOTS[number];
export type PanelMode = 'open' | 'close' | 'toggle';
export interface PanelRequest {
pluginId: string;
slot: PanelSlot;
mode: PanelMode;
/** For a composer panel: the composer it belongs to (from the plugin's click context or slot props). */
composerId?: string;
}
/** Answers with the panel's state after the request (true = open), or null when the request is not for it. */
export type PanelRequestHandler = (request: PanelRequest) => boolean | null;
const handlers = new Set<PanelRequestHandler>();
export function onPanelRequest(handler: PanelRequestHandler): () => void {
handlers.add(handler);
return () => { handlers.delete(handler); };
}
export function isPanelSlot(value: unknown): value is PanelSlot {
return typeof value === 'string' && (PANEL_SLOTS as readonly string[]).includes(value);
}
/**
* Check a plugin's request and deliver it: the panel's state afterwards, or
* null when no such panel is on screen. Throws with a message the plugin sees.
*/
export function requestPanel(pluginId: string, slot: unknown, mode: unknown, opts: unknown): boolean | null {
const method = `ui.${mode === 'open' ? 'openPanel' : mode === 'close' ? 'closePanel' : 'togglePanel'}`;
if (!isPanelSlot(slot)) throw new Error(`${method}: "${String(slot)}" is not a panel (${PANEL_SLOTS.join(', ')})`);
if (mode !== 'open' && mode !== 'close' && mode !== 'toggle') throw new Error(`${method}: unknown mode`);
let composerId: string | undefined;
if (opts !== undefined && opts !== null) {
if (typeof opts !== 'object') throw new Error(`${method}: options must be an object`);
const value = (opts as { composerId?: unknown }).composerId;
if (value !== undefined) {
if (typeof value !== 'string' || !value) throw new Error(`${method}: options.composerId must be a string`);
composerId = value;
}
}
const request: PanelRequest = { pluginId, slot, mode, ...(composerId ? { composerId } : {}) };
for (const handler of handlers) {
let answer: boolean | null = null;
try { answer = handler(request); } catch { answer = null; }
if (answer !== null) return answer;
}
return null;
}
/** Test helper. */
export function resetPanelRequests(): void {
handlers.clear();
}
+8 -2
View File
@@ -44,6 +44,12 @@ export interface SlotInit {
tier: PluginTier;
/** Slot name the iframe should render a component for. */
slot: SlotName;
/**
* The frame fills its panel instead of growing with its content: the
* plugin's root is as tall as the panel and scrolls inside, so it can pin
* controls at the bottom (a chat input). Only for panel slots.
*/
fill?: boolean;
/** Same bundle code as the background instance. */
code: string;
/**
@@ -85,8 +91,8 @@ export interface InitDoneMsg {
type: 'init-done';
/** Hook names the plugin registered. The host installs proxy handlers. */
hooks: string[];
/** Slots the plugin claims. Used by the host to know when a slot is offered. */
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number }>;
/** Slots the plugin claims. Used by the host to know when a slot is offered. `fill`: a panel slot that fills its panel (see slot-layout.ts). */
slots: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }>;
/**
* Keyboard shortcuts the plugin declares. The host installs a global
* keydown listener that dispatches to the `shortcut:<id>` hook when the
+7 -1
View File
@@ -13,6 +13,10 @@ export interface SlotOffer {
name: SlotName;
order: number;
hasShouldShow: boolean;
/** The frame fills its panel (panel slots only; see slot-layout.ts). */
fill?: boolean;
/** The panel starts folded until the user or the plugin opens it (panel slots only). */
startFolded?: boolean;
}
export interface ActivePlugin {
@@ -31,6 +35,8 @@ export interface ResolvedSlotOffer {
pluginId: string;
order: number;
hasShouldShow: boolean;
fill?: boolean;
startFolded?: boolean;
}
const active = new Map<string, ActivePlugin>();
@@ -80,7 +86,7 @@ export function offersForSlot(slot: SlotName): readonly ResolvedSlotOffer[] {
for (const entry of active.values()) {
for (const offer of entry.slotOffers) {
if (offer.name === slot) {
out.push({ pluginId: entry.plugin.id, order: offer.order, hasShouldShow: offer.hasShouldShow });
out.push({ pluginId: entry.plugin.id, order: offer.order, hasShouldShow: offer.hasShouldShow, ...(offer.fill ? { fill: true } : {}), ...(offer.startFolded ? { startFolded: true } : {}) });
}
}
}
+49 -6
View File
@@ -31,9 +31,9 @@ import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import { pluginUiKit, SlotPropsContext } from './ui-kit';
import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui-kit/floating-dropdown-bridge';
import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n';
import { isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import { isFillableSlot, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import type {
CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView,
AppInfoView, AppSettingsView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView,
EmailSummary, IdentityPatch, IdentityView, MailboxView, NotificationInput, SlotName, TaskInput,
TaskView, TemplateInput, TemplateView, VacationInput, VacationView,
} from '../plugin-types';
@@ -43,7 +43,7 @@ import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-securit
// ─── Module-scope state ──────────────────────────────────────
interface PluginExports {
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number }>;
slots?: Record<string, { component: React.ComponentType<Record<string, unknown>>; shouldShow?: (ctx: unknown) => boolean; order?: number; fill?: boolean; startFolded?: boolean }>;
hooks?: Record<string, (...args: unknown[]) => unknown>;
/**
* Keyboard shortcut bindings. Each entry's `handler` is registered as a
@@ -321,6 +321,15 @@ function buildPluginApi(manifest: PluginManifest) {
return () => { settingsListeners.delete(listener); };
},
},
/** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */
app: {
getInfo: () => callApi('app.getInfo', []) as Promise<AppInfoView>,
/** Settings matching a query (the user's language or English): page, label, description. No permission needed. */
searchSettings: (query: string, options?: { limit?: number }) =>
callApi('app.searchSettings', [query, options]) as Promise<SettingsSearchResult[]>,
/** The user's preferences: layout, formats, reading and sending behaviour. Needs settings:read. */
getSettings: () => callApi('app.getSettings', []) as Promise<AppSettingsView>,
},
crypto: {
getPublicKeys: () => callApi('crypto.getPublicKeys', []),
createPublicKey: (input: PublicKeyInput) => callApi('crypto.createPublicKey', [input]),
@@ -545,6 +554,18 @@ function buildPluginApi(manifest: PluginManifest) {
* back to the plugin's name. At most 40 characters.
*/
setSectionTitle: (title: string | null) => callApi('ui.setSectionTitle', [title]) as Promise<void>,
/**
* Open, close or toggle the panel this plugin's slot sits in:
* 'composer-sidebar', 'composer-sidebar-right' (pass the composerId
* from the click context or the slot's props) or 'email-detail-sidebar'.
* Resolve to whether the panel is open afterwards, or null when no such
* panel is on screen (no composer or message open, or a phone), so the
* plugin can fall back to a dialog. A panel opened this way gets a new
* `focusRequest` prop, so the slot can focus its input.
*/
openPanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'open', opts]) as Promise<boolean | null>,
closePanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'close', opts]) as Promise<boolean | null>,
togglePanel: (slot: string, opts?: { composerId?: string }) => callApi('ui.setPanel', [slot, 'toggle', opts]) as Promise<boolean | null>,
},
// Requests with an account or key the manifest declares under
// `credentials`; the server adds the secret (permission http:proxy).
@@ -584,6 +605,15 @@ function buildPluginApi(manifest: PluginManifest) {
*/
list: (options?: { mailboxId?: string; limit?: number; position?: number }) =>
callApi('email.list', [options]) as Promise<EmailSummary[]>,
/**
* Messages matching a search, newest first, as summaries without the
* body: `text` (sender, recipients, subject and body, whole words),
* `from`, `to`, `subject`, `after`/`before` (dates), `hasAttachment`,
* in one folder with `mailboxId`. At least one condition; limit 1-50
* (default 20).
*/
search: (query: { text?: string; from?: string; to?: string; subject?: string; after?: string; before?: string; hasAttachment?: boolean; mailboxId?: string; limit?: number }) =>
callApi('email.search', [query]) as Promise<EmailSummary[]>,
/**
* The bytes of one attachment of a message (email:blob-read, at most
* 25 MB). `blobId` must be one of that message's attachments, e.g.
@@ -901,7 +931,7 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
}
// Enumerate slot offers.
const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number }> = [];
const slotInfo: Array<{ name: SlotName; hasShouldShow: boolean; order: number; fill?: boolean; startFolded?: boolean }> = [];
const slots = exports.slots ?? {};
for (const [name, def] of Object.entries(slots)) {
if (def && typeof def.component === 'function') {
@@ -909,6 +939,10 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
name: name as SlotName,
hasShouldShow: typeof def.shouldShow === 'function',
order: typeof def.order === 'number' ? def.order : 100,
// A panel that fills its panel instead of growing with its content.
...(def.fill === true && isFillableSlot(name) ? { fill: true } : {}),
// A panel the user opens when they want it (a chat), not on every message.
...(def.startFolded === true && isFillableSlot(name) ? { startFolded: true } : {}),
});
}
}
@@ -958,6 +992,15 @@ function bootSlot(payload: SlotInit): void {
let currentProps = decodeCallbacks(payload.extraProps) as Record<string, unknown>;
const Component = slotDef.component;
const inline = isInlineSlot(payload.slot);
// Filling its panel: the document is as tall as the frame and the slot's
// root takes all of it; the height is the panel's, so nothing is measured.
const fill = payload.fill === true && isFillableSlot(payload.slot);
if (fill) {
document.documentElement.style.height = '100%';
document.body.style.height = '100%';
document.body.style.overflow = 'hidden';
rootEl.style.height = '100%';
}
// A trivial pub/sub so host-pushed `props-update` messages re-render the
// slot tree without tearing down the iframe.
@@ -992,7 +1035,7 @@ function bootSlot(payload: SlotInit): void {
};
}, []);
React.useEffect(() => {
if (!wrapRef.current) return;
if (!wrapRef.current || fill) return;
const el = wrapRef.current;
let lastHeight = -1;
let lastWidth = -1;
@@ -1014,7 +1057,7 @@ function bootSlot(payload: SlotInit): void {
// that wraps to fit it reports a narrow width back, so it never grew.
const style = inline
? { display: 'inline-block', width: 'max-content', maxWidth: MAX_INLINE_SLOT_WIDTH }
: undefined;
: fill ? { height: '100%' } : undefined;
return React.createElement('div', { ref: wrapRef, style },
React.createElement(SlotPropsContext.Provider, { value: props }, React.createElement(Component, props)));
};

Some files were not shown because too many files have changed in this diff Show More