From 41a458d8723bd2db777e0c31a313cc462e60cb1f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 4 May 2026 23:56:55 +0200 Subject: [PATCH 01/49] fix: add Grafana badge to README for dashboard access --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0694d9a8..eb830e07 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) [![Version](https://img.shields.io/badge/version-1.6.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) +[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) From 853b0eb8553b317dfe5f088b847dcd5d5c9e4a2d Mon Sep 17 00:00:00 2001 From: Luis Felipe Marzagao Date: Tue, 5 May 2026 01:08:10 +0000 Subject: [PATCH 02/49] fix: add missing Czech flag icon --- components/ui/flag-icons.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/components/ui/flag-icons.tsx b/components/ui/flag-icons.tsx index 93b1e329..e7127ec1 100644 --- a/components/ui/flag-icons.tsx +++ b/components/ui/flag-icons.tsx @@ -190,6 +190,17 @@ export function FlagCN(props: FlagProps) { ); } +/** Czech Republic - White and red horizontal bands with a blue triangle */ +export function FlagCS(props: FlagProps) { + return ( + + + + + + ); +} + /** Map locale codes to flag components */ export const flagComponents: Record ReactElement> = { en: FlagGB, @@ -207,4 +218,5 @@ export const flagComponents: Record ReactElement> tr: FlagTR, uk: FlagUA, zh: FlagCN, + cs: FlagCS, }; From 7b058ed0acfe3406fb90f9952ffce6ad189f747e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 17:17:01 +0200 Subject: [PATCH 03/49] fix: calendar invitation picker clipping #250 --- .../email/calendar-invitation-banner.tsx | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 5e35705d..735f7375 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { ArrowRight, Calendar, @@ -371,6 +372,8 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const [actionError, setActionError] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [showCalendarPicker, setShowCalendarPicker] = useState(false); + const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null); + const pickerTriggerRef = useRef(null); const [selectedCalendarId, setSelectedCalendarId] = useState(''); const [rawIcsMethod, setRawIcsMethod] = useState('unknown'); const [isCollapsed, setIsCollapsed] = useState(true); @@ -437,6 +440,17 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } }, [calendars, selectedCalendarId]); + useEffect(() => { + if (!showCalendarPicker) return; + const close = () => setShowCalendarPicker(false); + window.addEventListener('scroll', close, true); + window.addEventListener('resize', close); + return () => { + window.removeEventListener('scroll', close, true); + window.removeEventListener('resize', close); + }; + }, [showCalendarPicker]); + if (!attachment || !calendarInvitationParsingEnabled) return null; const detectedMethod = parsedEvent ? getInvitationMethod(parsedEvent, { email, attachment }) : 'unknown'; @@ -966,14 +980,23 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp )} {supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && ( -
+ <> - {showCalendarPicker && calendars.length > 1 && ( -
+ {showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal( +
{t('select_calendar')}
@@ -1004,9 +1030,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {cal.name} ))} -
+
, + document.body, )} -
+ )} {canApplyProposal && ( From 94f55afd1f613978745993678abdb0f2152375d2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 17:21:46 +0200 Subject: [PATCH 04/49] fix: remove fly-in animation from context menu submenus --- components/ui/context-menu.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index bc0d942c..23cb613e 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -238,10 +238,7 @@ export function ContextMenuSubMenu({ {isOpen && (
Date: Tue, 5 May 2026 18:05:17 +0200 Subject: [PATCH 05/49] feat: plugin hot-reload + dev-folder loading --- app/api/admin/plugins/[id]/bundle/route.ts | 42 ++++-- app/api/plugins/route.ts | 45 +++--- lib/admin/plugin-dev.ts | 156 +++++++++++++++++++++ lib/admin/plugin-registry.ts | 24 +++- lib/plugin-types.ts | 5 + stores/plugin-store.ts | 28 +++- 6 files changed, 267 insertions(+), 33 deletions(-) create mode 100644 lib/admin/plugin-dev.ts diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index 44923d20..d4fa1610 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import { readFile } from 'node:fs/promises'; import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; +import { getDevPlugin } from '@/lib/admin/plugin-dev'; /** * GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle @@ -8,7 +10,7 @@ import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; * Only serves plugins that exist in the registry and are enabled. */ export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { try { @@ -19,6 +21,21 @@ export async function GET( return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); } + // Dev plugins are read straight from disk and served with no caching so + // every refresh picks up the latest build. + const devEntry = await getDevPlugin(id); + if (devEntry) { + const code = await readFile(devEntry.bundlePath, 'utf-8'); + return new NextResponse(code, { + headers: { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Cache-Control': 'no-store', + 'ETag': `"${devEntry.plugin.bundleHash}"`, + 'Content-Length': String(Buffer.byteLength(code, 'utf-8')), + }, + }); + } + const plugin = await getPlugin(id); if (!plugin) { return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); @@ -33,13 +50,22 @@ export async function GET( return NextResponse.json({ error: 'Bundle not found' }, { status: 404 }); } - return new NextResponse(code, { - headers: { - 'Content-Type': 'application/javascript; charset=utf-8', - 'Cache-Control': 'public, max-age=3600, must-revalidate', - 'Content-Length': String(Buffer.byteLength(code, 'utf-8')), - }, - }); + // Use the registry's bundleHash as the ETag so the browser can revalidate + // cheaply. Cache-Control: no-cache forces revalidation on every request, + // but a matching If-None-Match returns 304 with no body. + const etag = plugin.bundleHash ? `"${plugin.bundleHash}"` : undefined; + const headers: Record = { + 'Content-Type': 'application/javascript; charset=utf-8', + 'Cache-Control': 'private, no-cache, must-revalidate', + }; + if (etag) headers['ETag'] = etag; + + if (etag && request.headers.get('if-none-match') === etag) { + return new NextResponse(null, { status: 304, headers }); + } + + headers['Content-Length'] = String(Buffer.byteLength(code, 'utf-8')); + return new NextResponse(code, { headers }); } catch { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index f0c96d9b..80498639 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry'; +import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { logger } from '@/lib/logger'; /** @@ -10,26 +11,38 @@ import { logger } from '@/lib/logger'; */ export async function GET() { try { - const [pluginRegistry, themeRegistry] = await Promise.all([ + const [pluginRegistry, themeRegistry, devEntries] = await Promise.all([ getPluginRegistry(), getThemeRegistry(), + listDevPlugins(), ]); - // Only serve enabled plugins - const plugins = pluginRegistry.plugins - .filter(p => p.enabled) - .map(p => ({ - id: p.id, - name: p.name, - version: p.version, - author: p.author, - description: p.description, - type: p.type, - permissions: p.permissions, - entrypoint: p.entrypoint, - forceEnabled: p.forceEnabled || false, - settingsSchema: undefined, // Will be read from the bundle's manifest - })); + // Dev plugins win on id collision so a developer can shadow an installed + // plugin without uninstalling it first. + const devIds = new Set(devEntries.map(e => e.plugin.id)); + const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id)); + + const plugins = [ + ...devEntries.map(e => ({ ...e.plugin, dev: true })), + ...installedEnabled.map(p => ({ ...p, dev: false })), + ].map(p => ({ + id: p.id, + name: p.name, + version: p.version, + author: p.author, + description: p.description, + type: p.type, + permissions: p.permissions, + entrypoint: p.entrypoint, + forceEnabled: p.forceEnabled || false, + // Content hash + updatedAt let clients detect re-uploads even when + // the manifest version is unchanged. + bundleHash: p.bundleHash, + updatedAt: p.updatedAt, + // Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge. + dev: p.dev, + settingsSchema: undefined, // Will be read from the bundle's manifest + })); // Only serve enabled themes const themes = themeRegistry.themes diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts new file mode 100644 index 00000000..5e3f7da8 --- /dev/null +++ b/lib/admin/plugin-dev.ts @@ -0,0 +1,156 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { logger } from '@/lib/logger'; +import type { ServerPlugin } from './plugin-registry'; + +/** + * Dev-mode plugin loading. + * + * When the `PLUGIN_DEV_DIR` env var points at a directory, every immediate + * subfolder is treated as a candidate plugin and merged into the registry + * served to clients. + * + * PLUGIN_DEV_DIR=/path/to/repos/plugins + * + * Each subfolder must contain `manifest.json` and the entrypoint file. If a + * `dist/` subdirectory exists with its own `manifest.json` (typical for + * plugins built via esbuild) we use that instead — so no extra copy step is + * needed during development. + * + * Dev plugins always win on id collision with admin-installed plugins, the + * bundle is served with `Cache-Control: no-store`, and the bundle hash is + * recomputed on every request so that any save propagates to all connected + * clients on their next page refresh. + */ + +export interface DevPluginEntry { + plugin: ServerPlugin; + bundlePath: string; + manifestPath: string; +} + +const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; + +export function getPluginDevDir(): string | null { + const dir = process.env.PLUGIN_DEV_DIR; + if (!dir) return null; + const resolved = path.resolve(dir); + if (!existsSync(resolved)) { + logger.warn(`PLUGIN_DEV_DIR is set but does not exist: ${resolved}`); + return null; + } + return resolved; +} + +function asString(v: unknown, fallback = ''): string { + return typeof v === 'string' ? v : fallback; +} + +async function readManifest(manifestPath: string): Promise | null> { + try { + const raw = await readFile(manifestPath, 'utf-8'); + const parsed = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null ? parsed : null; + } catch { + return null; + } +} + +async function loadDevPlugin(pluginDir: string): Promise { + // Prefer dist/ when present (bundled output) so devs don't have to copy + // manifest.json around. + const distDir = path.join(pluginDir, 'dist'); + let manifestPath = path.join(distDir, 'manifest.json'); + let baseDir = distDir; + if (!existsSync(manifestPath)) { + manifestPath = path.join(pluginDir, 'manifest.json'); + baseDir = pluginDir; + } + if (!existsSync(manifestPath)) return null; + + const manifest = await readManifest(manifestPath); + if (!manifest) return null; + const id = asString(manifest.id); + if (!PLUGIN_ID_RE.test(id)) return null; + + const entrypoint = asString(manifest.entrypoint, 'index.js'); + const bundlePath = path.join(baseDir, entrypoint); + if (!existsSync(bundlePath)) return null; + + let bundleHash: string; + try { + const code = await readFile(bundlePath); + bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16); + } catch { + return null; + } + + let installedAt = new Date().toISOString(); + try { + const stats = await stat(bundlePath); + installedAt = stats.mtime.toISOString(); + } catch { + /* ignore */ + } + + const permissions = Array.isArray(manifest.permissions) + ? manifest.permissions.filter((p): p is string => typeof p === 'string') + : []; + + const plugin: ServerPlugin = { + id, + name: asString(manifest.name, id), + version: asString(manifest.version, '0.0.0-dev'), + author: asString(manifest.author), + description: asString(manifest.description), + type: asString(manifest.type, 'hook'), + permissions, + entrypoint, + enabled: true, + forceEnabled: false, + ...(manifest.configSchema && typeof manifest.configSchema === 'object' + ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } + : {}), + installedAt, + updatedAt: new Date().toISOString(), + bundleHash, + }; + return { plugin, bundlePath, manifestPath }; +} + +export async function listDevPlugins(): Promise { + const dir = getPluginDevDir(); + if (!dir) return []; + + let entries: string[]; + try { + entries = await readdir(dir); + } catch (error) { + logger.warn('Failed to read PLUGIN_DEV_DIR', { + dir, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + + const out: DevPluginEntry[] = []; + for (const name of entries) { + if (name.startsWith('.') || name === 'node_modules') continue; + const fullPath = path.join(dir, name); + let isDir = false; + try { isDir = (await stat(fullPath)).isDirectory(); } catch { continue; } + if (!isDir) continue; + + const entry = await loadDevPlugin(fullPath); + if (entry) out.push(entry); + } + return out; +} + +export async function getDevPlugin(id: string): Promise { + if (!PLUGIN_ID_RE.test(id)) return null; + const list = await listDevPlugins(); + return list.find(e => e.plugin.id === id) ?? null; +} diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 788aa732..13fab06c 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -1,5 +1,6 @@ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import path from 'node:path'; import { logger } from '@/lib/logger'; @@ -41,6 +42,12 @@ export interface ServerPlugin { configSchema?: Record; installedAt: string; updatedAt: string; + /** + * SHA-256 hex of the bundle code (first 16 chars). Refreshed every save so + * the same version re-uploaded with new code still appears as a change to + * the client. Also doubles as the HTTP ETag for the bundle endpoint. + */ + bundleHash?: string; /** * Validated CSP origins (https-only, single-origin form) the plugin may * embed. Merged into the host frame-src by the proxy. @@ -120,13 +127,24 @@ export async function savePlugin( const bundlePath = path.join(dir, `${plugin.id}.js`); await writeFile(bundlePath, code, 'utf-8'); - // Update registry + // Stamp content hash + updatedAt so clients can detect re-uploads even + // when the manifest version hasn't changed. Preserve the original + // installedAt across re-uploads. + const bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16); + const now = new Date().toISOString(); + const registry = await getPluginRegistry(); const idx = registry.plugins.findIndex(p => p.id === plugin.id); + const next: ServerPlugin = { + ...plugin, + bundleHash, + updatedAt: now, + installedAt: idx >= 0 ? registry.plugins[idx].installedAt : plugin.installedAt, + }; if (idx >= 0) { - registry.plugins[idx] = plugin; + registry.plugins[idx] = next; } else { - registry.plugins.push(plugin); + registry.plugins.push(next); } await writeJsonFile(pluginRegistryPath(), registry); } diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 8f58778d..365778c2 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -168,6 +168,11 @@ export interface InstalledPlugin { settings: Record; /** Bundled translations, carried over from the manifest on install. */ locales?: Record>; + /** + * Content hash of the installed bundle, mirrored from the server. Used to + * detect re-uploads of the same version so clients re-download the JS. + */ + bundleHash?: string; } // ─── UI Slots ──────────────────────────────────────────────── diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 55499fcd..6b455826 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -303,6 +303,11 @@ interface ServerPluginInfo { permissions: string[]; entrypoint: string; forceEnabled: boolean; + /** Content hash of the bundle - changes whenever code changes, even if the version doesn't */ + bundleHash?: string; + updatedAt?: string; + /** True when the plugin was loaded from the server's PLUGIN_DEV_DIR */ + dev?: boolean; } const SERVER_MANAGED_KEY = 'server-managed-plugin-ids'; @@ -382,7 +387,7 @@ async function syncServerPlugins( if (!local) { // New server plugin - download and install - const code = await downloadPluginBundle(sp.id); + const code = await downloadPluginBundle(sp.id, sp.bundleHash); if (!code) continue; await pluginStorage.saveCode(sp.id, code); @@ -402,6 +407,7 @@ async function syncServerPlugins( forceEnabled: sp.forceEnabled, adminApproved: true, // Server-managed plugins are always approved settings: {}, + bundleHash: sp.bundleHash, }; set(state => { @@ -410,9 +416,15 @@ async function syncServerPlugins( } return { plugins: [...state.plugins, plugin] }; }); - } else if (local.version !== sp.version) { - // Version changed - re-download bundle - const code = await downloadPluginBundle(sp.id); + } else if ( + local.version !== sp.version || + // bundleHash mismatch covers re-uploads of the same version with new + // code. Falsy local hash (older installs that never carried one) also + // forces a refresh so we capture the hash on the next sync. + (sp.bundleHash && local.bundleHash !== sp.bundleHash) + ) { + // Version or content changed - re-download bundle + const code = await downloadPluginBundle(sp.id, sp.bundleHash); if (!code) continue; await pluginStorage.saveCode(sp.id, code); @@ -430,6 +442,7 @@ async function syncServerPlugins( entrypoint: sp.entrypoint, managed: true, forceEnabled: sp.forceEnabled, + bundleHash: sp.bundleHash, } : p ), @@ -483,9 +496,12 @@ async function syncServerPlugins( } } -async function downloadPluginBundle(pluginId: string): Promise { +async function downloadPluginBundle(pluginId: string, bundleHash?: string): Promise { try { - const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle`); + // Append the hash as a query string so any intermediary HTTP cache + // (browser, service worker, CDN) treats each version as a distinct URL. + const suffix = bundleHash ? `?v=${encodeURIComponent(bundleHash)}` : ''; + const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle${suffix}`); if (!res.ok) return null; return await res.text(); } catch { From 1b0ca8967ecac0d7d2abcdad0be61945704765b6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 18:35:22 +0200 Subject: [PATCH 06/49] feat: bundle plugin src/ on demand via esbuild --- app/admin/_tabs/auth.tsx | 378 +++++++++++++++++ app/admin/_tabs/branding.tsx | 297 ++++++++++++++ app/admin/_tabs/dashboard.tsx | 224 ++++++++++ app/admin/_tabs/plugins.tsx | 453 +++++++++++++++++++++ app/admin/_tabs/policy.tsx | 217 ++++++++++ app/admin/_tabs/settings.tsx | 245 +++++++++++ app/admin/plugins/page.tsx | 22 +- app/api/admin/plugins/[id]/bundle/route.ts | 9 +- app/api/admin/plugins/route.ts | 17 +- lib/admin/plugin-dev.ts | 103 +++-- next.config.ts | 4 + package-lock.json | 243 +++++------ package.json | 1 + 13 files changed, 2040 insertions(+), 173 deletions(-) create mode 100644 app/admin/_tabs/auth.tsx create mode 100644 app/admin/_tabs/branding.tsx create mode 100644 app/admin/_tabs/dashboard.tsx create mode 100644 app/admin/_tabs/plugins.tsx create mode 100644 app/admin/_tabs/policy.tsx create mode 100644 app/admin/_tabs/settings.tsx diff --git a/app/admin/_tabs/auth.tsx b/app/admin/_tabs/auth.tsx new file mode 100644 index 00000000..274d3925 --- /dev/null +++ b/app/admin/_tabs/auth.tsx @@ -0,0 +1,378 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface ConfigEntry { + value: unknown; + source: 'admin' | 'env' | 'default'; +} + +export function AuthTab() { + const [config, setConfig] = useState>({}); + const [edits, setEdits] = useState>({}); + 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: '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; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+
+

Authentication

+

OAuth, SSO, and session configuration

+
+ {hasEdits && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+
+
+ +

Auto-configure OAuth (Stalwart)

+
+

+ 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. +

+
+ +
+
+ + {setupOpen && ( +
{ if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }} + > +
+
+

Auto-configure OAuth

+

+ Verify the URLs below before continuing. The webmail and Stalwart can live on different domains. +

+
+
+
+ + 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" + /> +

+ Used to register redirect URIs (one per locale: {setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback) on Stalwart. +

+ {!setupOriginValid && setupOrigin.length > 0 && ( +

Must be like https://host with no path.

+ )} +
+
+ + 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" + /> +

+ Where Stalwart serves /.well-known/oauth-authorization-server. Saved as OAUTH_ISSUER_URL. Pre-filled from your JMAP server URL. +

+ {!setupIssuerValid && setupIssuer.length > 0 && ( +

Must be like https://host with no path.

+ )} +
+ +
+
+ + +
+
+
+ )} + +
+ + + + + +
+ +
+ +
+ +
+ 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' && ( + + )} +
+
+ ); +} + +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 ( +
+
+
+ {label} + +
+ {description &&

{description}

} +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} + +function Select({ label, configKey, value, source, options, onChange, onRevert }: { + label: string; configKey: string; value: string; source?: string; options: string[]; + onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; +}) { + return ( +
+
+ {label} + +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} diff --git a/app/admin/_tabs/branding.tsx b/app/admin/_tabs/branding.tsx new file mode 100644 index 00000000..e5e3f1ca --- /dev/null +++ b/app/admin/_tabs/branding.tsx @@ -0,0 +1,297 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface ConfigEntry { + value: unknown; + source: 'admin' | 'env' | 'default'; +} + +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' }, +]; + +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' }, +]; + +export function BrandingTab() { + const [config, setConfig] = useState>({}); + const [edits, setEdits] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(null); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const fileInputRefs = useRef>({}); + + 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: string) { + setEdits(prev => ({ ...prev, [key]: value })); + setMessage(null); + } + + function currentValue(key: string): string { + if (key in edits) return edits[key] as string; + return (config[key]?.value as string) ?? ''; + } + + 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: '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) { + setUploading(slot); + setMessage(null); + + const formData = new FormData(); + formData.append('file', file); + formData.append('slot', slot); + + 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; + }); + setConfig(prev => ({ + ...prev, + [slot]: { value: data.url, source: 'admin' }, + })); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Upload failed' }); + } + setUploading(null); + } + + async function handleDeleteUpload(slot: string) { + setMessage(null); + + const res = await apiFetch('/api/admin/branding', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slot }), + }); + + 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) { + 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 isUploadedFile = (key: string): boolean => { + const val = currentValue(key); + return val.startsWith('/api/admin/branding/'); + }; + + const hasEdits = Object.keys(edits).length > 0; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+
+

Branding

+

Customize logos, favicon, and company information

+
+ {hasEdits && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+

Images & Logos

+

Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)

+
+
+ {IMAGE_FIELDS.map(field => ( +
+
+
+ + {config[field.key]?.source === 'admin' && ( + + {isUploadedFile(field.key) ? 'uploaded' : 'admin'} + + )} +
+
+ handleChange(field.key, e.target.value)} + placeholder="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" + /> + { 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 = ''; + }} + /> + + {isUploadedFile(field.key) && ( + + )} + {config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && ( + + )} +
+
+ {currentValue(field.key) && ( +
+ +
+ {field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+
+ )} +
+ ))} +
+
+ +
+
+

Company Information

+
+
+ {TEXT_FIELDS.map(field => ( +
+
+ + {config[field.key]?.source === 'admin' && ( + admin + )} +
+
+ 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" + /> + {config[field.key]?.source === 'admin' && ( + + )} +
+
+ ))} +
+
+
+ ); +} diff --git a/app/admin/_tabs/dashboard.tsx b/app/admin/_tabs/dashboard.tsx new file mode 100644 index 00000000..558d8414 --- /dev/null +++ b/app/admin/_tabs/dashboard.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; +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(null); + const [recentActivity, setRecentActivity] = useState([]); + const [config, setConfig] = useState(null); + const [, setConfigSources] = useState | null>(null); + const [warnings, setWarnings] = useState([]); + 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); + const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown'); + + 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); + } + } + + if (configData?.jmapServerUrl) { + try { + const jmapRes = await apiFetch('/api/config'); + setJmapHealth(jmapRes.ok ? 'ok' : 'error'); + } catch { + setJmapHealth('error'); + } + } + + const w: string[] = []; + if (adminConfigRes.ok) { + const sources = await adminConfigRes.json(); + setConfigSources(sources); + const sessionSecret = sources?.sessionSecret; + if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') { + 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 ( +
+ {warnings.map((msg, i) => ( +
+ +

{msg}

+
+ ))} + + {status && !status.lastLogin && ( +
+ +
+

First login detected

+

+ Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely. +

+
+
+ )} + + + + {config?.appName || '-'} + + + {jmapHostname} + + + + + {jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'} + + + + + {status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'} + + + + + + + {}} disabled /> + + + {}} disabled /> + + + {}} disabled /> + + + {}} disabled /> + + + + + + {accountCounts?.total ?? '-'} + + + {accountCounts?.active7d ?? '-'} + + + + + + {pluginCount} + + + {themeCount} + + + {policyRuleCount} + + + + + {recentActivity.length === 0 ? ( +
+ No activity recorded yet +
+ ) : ( + recentActivity.map((entry, i) => ( + +
+ {entry.ip} + {new Date(entry.ts).toLocaleString()} +
+
+ )) + )} +
+
+ ); +} + +function formatDetail(detail: Record): 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); +} diff --git a/app/admin/_tabs/plugins.tsx b/app/admin/_tabs/plugins.tsx new file mode 100644 index 00000000..dcd7cc3e --- /dev/null +++ b/app/admin/_tabs/plugins.tsx @@ -0,0 +1,453 @@ +'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 } from 'lucide-react'; +import type { SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface PluginEntry { + id: string; + name: string; + version: string; + author: string; + description: string; + type: string; + enabled: boolean; + forceEnabled?: boolean; + permissions: string[]; + installedAt: string; + updatedAt: string; +} + +export function PluginsTab() { + const [plugins, setPlugins] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const fileInputRef = useRef(null); + const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); + const [policyDirty, setPolicyDirty] = useState(false); + const [savingPolicy, setSavingPolicy] = useState(false); + + 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) { + 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 = { 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 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' }); + } + } + + if (loading) { + return
Loading...
; + } + + const pluginsEnabled = policy.features.pluginsEnabled ?? true; + const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true; + const requirePluginApproval = policy.features.requirePluginApproval ?? true; + + return ( +
+
+
+

Plugins

+

Manage plugins and plugin policy for all users

+
+
+ {policyDirty && ( + + )} + +
+
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+
+ +

Plugin Policy

+
+

Control plugin availability for users

+
+
+
+
+ Plugins Enabled +

Allow the plugin system to load and run plugins for users

+
+ +
+ +
+
+ User Plugin Uploads +

Allow users to upload plugin ZIP files in Settings

+
+ +
+ +
+
+ Require Admin Approval +

User-uploaded plugins must be approved by an admin before they can be enabled

+
+ +
+ + {plugins.length > 0 && ( +
+
+ Force Enable / Disable All +

Bulk toggle all deployed plugins at once

+
+
+ + +
+
+ )} +
+
+ +
+
+
+ +

Deployed Plugins

+
+

Admin-uploaded plugins for all users

+
+ {plugins.length === 0 ? ( +
+ +

No plugins installed

+

Upload a plugin ZIP file to get started

+
+ ) : ( +
+ {plugins.map(plugin => ( +
+
+
+ {plugin.name} + v{plugin.version} + + {plugin.enabled ? 'Enabled' : 'Disabled'} + + {plugin.forceEnabled && ( + + Forced + + )} +
+ {plugin.description && ( +

{plugin.description}

+ )} +
+ by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()} +
+ {plugin.permissions.length > 0 && ( +
+ + + Permissions: {plugin.permissions.join(', ')} + +
+ )} +
+ +
+ + + + + + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/admin/_tabs/policy.tsx b/app/admin/_tabs/policy.tsx new file mode 100644 index 00000000..5295265d --- /dev/null +++ b/app/admin/_tabs/policy.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Save, Loader2, Lock } from 'lucide-react'; +import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types'; +import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; +import { apiFetch } from '@/lib/browser-navigation'; + +const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled']; + +const FEATURE_GATE_LABELS: Partial> = { + 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' }, + 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' }, + filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' }, +}; + +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: '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', 'permanent'] }, + { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, + { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] }, + { 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: '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: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' }, +]; + +export function PolicyTab() { + const [policy, setPolicy] = useState({ ...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 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); + } + + if (loading) { + return
Loading...
; + } + + const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))]; + + return ( +
+
+
+

User Policy

+

Control which features and settings users can access

+
+ {dirty && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+

Feature Gates

+

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; + const enabled = policy.features[key]; + return ( +
+
+ {label} +

{description}

+
+ +
+ ); + })} +
+
+ + {categories.map(category => ( +
+
+

{category}

+
+
+ {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { + const restriction = policy.restrictions[setting.key] || {}; + return ( +
+ {setting.label} +
+ + +
+
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/app/admin/_tabs/settings.tsx b/app/admin/_tabs/settings.tsx new file mode 100644 index 00000000..af8f44f1 --- /dev/null +++ b/app/admin/_tabs/settings.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Save, RotateCcw, Loader2 } from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface ConfigEntry { + value: unknown; + source: 'admin' | 'env' | 'default'; +} + +export function SettingsTab() { + const [config, setConfig] = useState>({}); + const [edits, setEdits] = useState>({}); + 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; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+
+

Server Settings

+

General server configuration

+
+ {hasEdits && ( + + )} +
+ + {message && ( +
+ {message.text} +
+ )} + + + + + + {!!currentValue('allowCustomJmapEndpoint') && ( +
+

+ CORS warning: External JMAP servers must include this domain in their CORS Access-Control-Allow-Origin header, or requests from the browser will be blocked. +

+
+ )} + + +
+ + + + + + + + + +
+ ); +} + +function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+
+ {children} +
+
+ ); +} + +function SourceBadge({ source }: { source?: string }) { + if (!source || source === 'default') return null; + return ( + + {source} + + ); +} + +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 ( +
+
+ + +
+
+ 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' && ( + + )} +
+
+ ); +} + +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 ( +
+
+
+ {label} + +
+ {description &&

{description}

} +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} + +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 ( +
+
+ {label} + +
+
+ + {source === 'admin' && ( + + )} +
+
+ ); +} diff --git a/app/admin/plugins/page.tsx b/app/admin/plugins/page.tsx index 9c3bd015..63f076bd 100644 --- a/app/admin/plugins/page.tsx +++ b/app/admin/plugins/page.tsx @@ -19,6 +19,8 @@ interface PluginEntry { permissions: string[]; installedAt: string; updatedAt: string; + /** True when loaded from PLUGIN_DEV_DIR (read-only, managed via filesystem) */ + dev?: boolean; } export default function AdminPluginsPage() { @@ -396,6 +398,11 @@ export default function AdminPluginsPage() { {plugin.enabled ? 'Enabled' : 'Disabled'} + {plugin.dev && ( + + Dev + + )} {plugin.forceEnabled && ( Forced @@ -428,22 +435,25 @@ export default function AdminPluginsPage() { diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index d4fa1610..bf8db9e5 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { readFile } from 'node:fs/promises'; import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry'; -import { getDevPlugin } from '@/lib/admin/plugin-dev'; +import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev'; /** * GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle @@ -21,11 +20,11 @@ export async function GET( return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); } - // Dev plugins are read straight from disk and served with no caching so - // every refresh picks up the latest build. + // Dev plugins are read (and optionally bundled) straight from disk and + // served with no caching so every refresh picks up the latest source. const devEntry = await getDevPlugin(id); if (devEntry) { - const code = await readFile(devEntry.bundlePath, 'utf-8'); + const code = await readDevBundle(devEntry); return new NextResponse(code, { headers: { 'Content-Type': 'application/javascript; charset=utf-8', diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 491dca0b..3f1f4e12 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -8,6 +8,7 @@ import { deletePlugin as removePlugin, type ServerPlugin, } from '@/lib/admin/plugin-registry'; +import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { sanitizeFrameOrigins, invalidateFrameOriginsCache, @@ -34,8 +35,20 @@ export async function GET() { const result = await requireAdminAuth(); if ('error' in result) return result.error; - const registry = await getPluginRegistry(); - return NextResponse.json(registry.plugins, { + const [registry, devEntries] = await Promise.all([ + getPluginRegistry(), + listDevPlugins(), + ]); + + // Dev plugins win on id collision so admins see what users actually load. + const devIds = new Set(devEntries.map(e => e.plugin.id)); + const merged = [ + ...devEntries.map(e => ({ ...e.plugin, dev: true as const })), + ...registry.plugins + .filter(p => !devIds.has(p.id)) + .map(p => ({ ...p, dev: false as const })), + ]; + return NextResponse.json(merged, { headers: { 'Cache-Control': 'no-store' }, }); } catch (error) { diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 5e3f7da8..6f3b1bf8 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -8,27 +8,27 @@ import type { ServerPlugin } from './plugin-registry'; /** * Dev-mode plugin loading. * - * When the `PLUGIN_DEV_DIR` env var points at a directory, every immediate - * subfolder is treated as a candidate plugin and merged into the registry - * served to clients. + * Set PLUGIN_DEV_DIR to a directory whose immediate subfolders are plugin + * sources. Each subfolder must contain a `manifest.json`. The bundle file + * (declared as `entrypoint` in the manifest) is resolved in this order: * - * PLUGIN_DEV_DIR=/path/to/repos/plugins + * 1. `src/` → bundled on-demand via esbuild (preferred). + * Lets you edit source files directly and just refresh the browser. + * 2. `` at the plugin root → served raw. + * 3. `dist/` → served raw (output of a manual build). * - * Each subfolder must contain `manifest.json` and the entrypoint file. If a - * `dist/` subdirectory exists with its own `manifest.json` (typical for - * plugins built via esbuild) we use that instead — so no extra copy step is - * needed during development. - * - * Dev plugins always win on id collision with admin-installed plugins, the - * bundle is served with `Cache-Control: no-store`, and the bundle hash is - * recomputed on every request so that any save propagates to all connected - * clients on their next page refresh. + * Bundles are recomputed on every request so any save in `src/` propagates + * to all connected clients on their next page refresh. The content hash + * doubles as the HTTP ETag and the `?v=` cache-buster. */ export interface DevPluginEntry { plugin: ServerPlugin; + /** Absolute path to either a source file (needs bundling) or a built file. */ bundlePath: string; manifestPath: string; + /** True when bundlePath points at an unbundled source file under `src/`. */ + needsBundle: boolean; } const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; @@ -58,15 +58,65 @@ async function readManifest(manifestPath: string): Promise { + if (!entry.needsBundle) { + return readFile(entry.bundlePath, 'utf-8'); + } + try { + const esbuild = await import('esbuild'); + const result = await esbuild.build({ + entryPoints: [entry.bundlePath], + bundle: true, + format: 'esm', + write: false, + logLevel: 'silent', + sourcemap: 'inline', + target: ['es2020'], + // React/ReactDOM are exposed on globalThis.__PLUGIN_EXTERNALS__ by the + // host, so we mark them external — the bundle won't try to ship them. + external: ['react', 'react-dom', 'react/jsx-runtime'], + }); + const out = result.outputFiles?.[0]?.text; + if (!out) throw new Error('esbuild produced no output'); + return out; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.warn(`[plugin-dev] esbuild failed for ${entry.plugin.id}`, { error: message }); + // Return a module that throws on load so the dev sees the error. + return `throw new Error(${JSON.stringify(`[plugin-dev:${entry.plugin.id}] esbuild failed: ${message}`)});`; + } +} + async function loadDevPlugin(pluginDir: string): Promise { - // Prefer dist/ when present (bundled output) so devs don't have to copy - // manifest.json around. - const distDir = path.join(pluginDir, 'dist'); - let manifestPath = path.join(distDir, 'manifest.json'); - let baseDir = distDir; + // Prefer the root manifest.json. Fall back to dist/manifest.json for + // pre-built plugins that don't keep a manifest at the root. + let manifestPath = path.join(pluginDir, 'manifest.json'); if (!existsSync(manifestPath)) { - manifestPath = path.join(pluginDir, 'manifest.json'); - baseDir = pluginDir; + manifestPath = path.join(pluginDir, 'dist', 'manifest.json'); } if (!existsSync(manifestPath)) return null; @@ -76,12 +126,15 @@ async function loadDevPlugin(pluginDir: string): Promise if (!PLUGIN_ID_RE.test(id)) return null; const entrypoint = asString(manifest.entrypoint, 'index.js'); - const bundlePath = path.join(baseDir, entrypoint); - if (!existsSync(bundlePath)) return null; + const resolved = resolveBundlePath(pluginDir, entrypoint); + if (!resolved) return null; + // Hash from the on-disk source so any edit propagates. For src/ sources + // we hash the source — close enough for dev-time change detection (we + // don't need to re-hash transitive imports). let bundleHash: string; try { - const code = await readFile(bundlePath); + const code = await readFile(resolved.bundlePath); bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16); } catch { return null; @@ -89,7 +142,7 @@ async function loadDevPlugin(pluginDir: string): Promise let installedAt = new Date().toISOString(); try { - const stats = await stat(bundlePath); + const stats = await stat(resolved.bundlePath); installedAt = stats.mtime.toISOString(); } catch { /* ignore */ @@ -117,7 +170,7 @@ async function loadDevPlugin(pluginDir: string): Promise updatedAt: new Date().toISOString(), bundleHash, }; - return { plugin, bundlePath, manifestPath }; + return { plugin, bundlePath: resolved.bundlePath, manifestPath, needsBundle: resolved.needsBundle }; } export async function listDevPlugins(): Promise { diff --git a/next.config.ts b/next.config.ts index 13f6cc77..cdb98b7a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -42,6 +42,10 @@ const nextConfig: NextConfig = { output: "standalone", allowedDevOrigins: ["192.168.1.51"], basePath: basePath || undefined, + // esbuild ships native binaries + a README the bundler can't parse; load + // it from node_modules at runtime instead of trying to bundle it. Used by + // PLUGIN_DEV_DIR's on-the-fly bundler. + serverExternalPackages: ["esbuild"], turbopack: { root: import.meta.dirname, }, diff --git a/package-lock.json b/package-lock.json index ad31f5b0..72a707f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "@typescript-eslint/parser": "^8.59.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/ui": "^4.1.5", + "esbuild": "^0.28.0", "eslint": "^9.39.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", @@ -602,9 +603,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -614,15 +615,14 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -632,15 +632,14 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -650,15 +649,14 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -668,15 +666,14 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -686,15 +683,14 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -704,15 +700,14 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -722,15 +717,14 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -740,15 +734,14 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -758,15 +751,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -776,15 +768,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -794,15 +785,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -812,15 +802,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -830,15 +819,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -848,15 +836,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -866,15 +853,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -884,15 +870,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -902,15 +887,14 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -920,15 +904,14 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -938,15 +921,14 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -956,15 +938,14 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -974,15 +955,14 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -992,15 +972,14 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -1010,15 +989,14 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -1028,15 +1006,14 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -1046,15 +1023,14 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -1064,7 +1040,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -5469,14 +5444,12 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", - "optional": true, - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -5484,32 +5457,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escalade": { diff --git a/package.json b/package.json index ca349352..18ac8a02 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@typescript-eslint/parser": "^8.59.0", "@vitejs/plugin-react": "^6.0.1", "@vitest/ui": "^4.1.5", + "esbuild": "^0.28.0", "eslint": "^9.39.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", From e7264f521c253819aca4c9c75c0cb67b5a94e0db Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 18:50:57 +0200 Subject: [PATCH 07/49] fix: collapse admin panel into single tabbed page --- app/admin/_tabs/logs.tsx | 174 ++++++++++ app/admin/_tabs/marketplace.tsx | 353 ++++++++++++++++++++ app/admin/_tabs/telemetry.tsx | 250 ++++++++++++++ app/admin/_tabs/themes.tsx | 545 +++++++++++++++++++++++++++++++ app/admin/_tabs/version.tsx | 237 ++++++++++++++ app/admin/auth/page.tsx | 384 +--------------------- app/admin/branding/page.tsx | 301 +---------------- app/admin/layout.tsx | 60 ++-- app/admin/logs/page.tsx | 179 +---------- app/admin/marketplace/page.tsx | 367 +-------------------- app/admin/page.tsx | 263 +++------------ app/admin/plugins/page.tsx | 469 +-------------------------- app/admin/policy/page.tsx | 221 +------------ app/admin/settings/page.tsx | 249 +------------- app/admin/telemetry/page.tsx | 251 +-------------- app/admin/themes/page.tsx | 554 +------------------------------- app/admin/version/page.tsx | 238 +------------- stores/admin-tab-store.ts | 41 +++ 18 files changed, 1710 insertions(+), 3426 deletions(-) create mode 100644 app/admin/_tabs/logs.tsx create mode 100644 app/admin/_tabs/marketplace.tsx create mode 100644 app/admin/_tabs/telemetry.tsx create mode 100644 app/admin/_tabs/themes.tsx create mode 100644 app/admin/_tabs/version.tsx create mode 100644 stores/admin-tab-store.ts diff --git a/app/admin/_tabs/logs.tsx b/app/admin/_tabs/logs.tsx new file mode 100644 index 00000000..76ba13d8 --- /dev/null +++ b/app/admin/_tabs/logs.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { RefreshCw } from 'lucide-react'; +import type { AuditEntry } from '@/lib/admin/types'; +import { apiFetch } from '@/lib/browser-navigation'; + +export function LogsTab() { + const [entries, setEntries] = useState([]); + 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 ( +
+
+
+

Audit Log

+

{total} total entries

+
+ +
+ +
+ +
+ +
+ {loading && entries.length === 0 ? ( +
Loading...
+ ) : entries.length === 0 ? ( +
No entries found
+ ) : ( + entries.map((entry, i) => ( +
+
+ + {entry.action} + + + {new Date(entry.ts).toLocaleString()} + +
+
+ {formatDetail(entry.detail)} +
+
+ {entry.ip} +
+
+ )) + )} +
+ +
+ + + + + + + + + + + {loading && entries.length === 0 ? ( + + + + ) : entries.length === 0 ? ( + + + + ) : ( + entries.map((entry, i) => ( + + + + + + + )) + )} + +
TimeActionDetailsIP
Loading...
No entries found
+ {new Date(entry.ts).toLocaleString()} + + + {entry.action} + + + {formatDetail(entry.detail)} + + {entry.ip} +
+
+ + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ + +
+
+ )} +
+ ); +} + +function formatDetail(detail: Record): 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) => `${c.key}`).join(', '); + } + if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`; + return JSON.stringify(detail).slice(0, 100); +} diff --git a/app/admin/_tabs/marketplace.tsx b/app/admin/_tabs/marketplace.tsx new file mode 100644 index 00000000..b2e66311 --- /dev/null +++ b/app/admin/_tabs/marketplace.tsx @@ -0,0 +1,353 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import Link from 'next/link'; +import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +interface Extension { + slug: string; + name: string; + type: 'plugin' | 'theme'; + pluginType: string | null; + description: string; + permissions: string[]; + tags: string[]; + totalDownloads: number; + featured: boolean; + minAppVersion: string | null; + latestVersion: string | null; + installed: boolean; + author: { + displayName: string; + githubLogin: string; + avatarUrl: string | null; + } | null; +} + +interface SearchResult { + data: Extension[]; + meta: { + page: number; + perPage: number; + total: number; + }; +} + +type TypeFilter = 'all' | 'plugin' | 'theme'; + +export function MarketplaceTab() { + const [extensions, setExtensions] = useState([]); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [perPage] = useState(12); + const [installing, setInstalling] = useState(null); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [error, setError] = useState(null); + + const fetchExtensions = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (query) params.set('q', query); + if (typeFilter !== 'all') params.set('type', typeFilter); + params.set('page', String(page)); + params.set('perPage', String(perPage)); + params.set('sort', 'newest'); + + const res = await apiFetch(`/api/admin/marketplace?${params}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || 'Failed to connect to extension directory'); + setExtensions([]); + return; + } + + const data: SearchResult = await res.json(); + setExtensions(data.data || []); + setTotal(data.meta?.total || 0); + } catch { + setError('Failed to connect to extension directory. Make sure it is running.'); + setExtensions([]); + } finally { + setLoading(false); + } + }, [query, typeFilter, page, perPage]); + + useEffect(() => { + fetchExtensions(); + }, [fetchExtensions]); + + const [searchInput, setSearchInput] = useState(''); + useEffect(() => { + const t = setTimeout(() => { + setQuery(searchInput); + setPage(1); + }, 300); + return () => clearTimeout(t); + }, [searchInput]); + + async function handleInstall(ext: Extension) { + setInstalling(ext.slug); + setMessage(null); + + try { + const res = await apiFetch('/api/admin/marketplace', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug: ext.slug, + version: ext.latestVersion || '1.0.0', + type: ext.type, + }), + }); + + const data = await res.json(); + + if (res.ok) { + const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; + setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` }); + setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e)); + } else { + setMessage({ type: 'error', text: data.error || 'Installation failed' }); + } + } catch { + setMessage({ type: 'error', text: 'Installation failed - network error' }); + } finally { + setInstalling(null); + } + } + + const totalPages = Math.ceil(total / perPage); + + return ( +
+
+

Marketplace

+

+ Browse and install plugins and themes from the BulwarkMail extension directory +

+
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+ + setSearchInput(e.target.value)} + className="w-full h-9 pl-9 pr-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" + /> +
+
+ {(['all', 'plugin', 'theme'] as const).map((t) => ( + + ))} +
+
+ + {error && ( +
+ +

{error}

+

+ Start the extension directory server on the configured port +

+ +
+ )} + + {loading && !error && ( +
+ + Searching extensions... +
+ )} + + {!loading && !error && extensions.length === 0 && ( +
+ +

No extensions found

+ {query && ( +

+ Try a different search term +

+ )} +
+ )} + + {!loading && !error && extensions.length > 0 && ( + <> +
+ {total} extension{total !== 1 ? 's' : ''} found +
+
+ {extensions.map((ext) => ( + handleInstall(ext)} + /> + ))} +
+ + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + + )} +
+ ); +} + +function ExtensionCard({ + extension, + installing, + onInstall, +}: { + extension: Extension; + installing: boolean; + onInstall: () => void; +}) { + const isPlugin = extension.type === 'plugin'; + const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; + + return ( +
+ +
+
+ {isPlugin ? ( + + ) : ( + + )} +
+
+
+ + {extension.name} + + {extension.featured && ( + + )} +
+
+ + {isPlugin ? (extension.pluginType || 'plugin') : 'theme'} + + {extension.author && ( + + by {extension.author.displayName} + + )} +
+
+
+ +

+ {extension.description} +

+ + {extension.tags && extension.tags.length > 0 && ( +
+ {extension.tags.slice(0, 3).map(tag => ( + + {tag} + + ))} +
+ )} + +
+
+ + + {extension.totalDownloads.toLocaleString()} + + {extension.permissions && extension.permissions.length > 0 && ( + + {extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''} + + )} +
+ + + Preview + +
+ + +
+ {extension.installed ? ( + + + Installed + + ) : ( + + )} +
+
+ ); +} diff --git a/app/admin/_tabs/telemetry.tsx b/app/admin/_tabs/telemetry.tsx new file mode 100644 index 00000000..6ff3b760 --- /dev/null +++ b/app/admin/_tabs/telemetry.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react'; +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; + 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(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [endpointDraft, setEndpointDraft] = useState(''); + const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null); + + async function refresh(): Promise { + 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 { + 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 { + 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 { + 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 ( +
+ loading… +
+ ); + } + + const envOverridden = status.consentSource === 'env'; + const isOn = status.consent === 'on'; + + return ( +
+
+

Anonymous Usage Stats

+

+ Bulwark sends one anonymous heartbeat per day so we can see how many instances are + running, on what platforms, and which features they use. Enabled by default; + one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '} + + Full schema and policy + +

+
+ +
+
+
+
Status
+
+ {status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'} + {status.consent === 'on' && 'Heartbeats are enabled (default).'} + {status.consent === 'off' && 'Heartbeats are off.'} + {envOverridden && ( + <> Locked by BULWARK_TELEMETRY env var. + )} +
+
+
+ + +
+
+
+
Last sent
+
{timeAgo(status.lastSentAt)}
+
Next scheduled
+
{timeAgo(status.nextScheduledAt)}
+
Consented at
+
{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
+
+
+ +
+
Account activity
+

+ 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. +

+
+
Total (90d)
+
{status.accountCounts?.total ?? 0}
+
Active (7d)
+
{status.accountCounts?.active7d ?? 0}
+
+
+ +
+
Endpoint
+

+ 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. +

+
+ setEndpointDraft(e.target.value)} + placeholder={status.defaultEndpoint} + className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background" + /> + +
+
+ +
+
+
+
Payload preview
+
+ Exactly what the next heartbeat would send from this install, right now. +
+
+ +
+ {sendResult && ( +
+ {sendResult.ok ? : } + {sendResult.msg} +
+ )} +
+          {JSON.stringify(status.payloadPreview, null, 2)}
+        
+
+
+ ); +} diff --git a/app/admin/_tabs/themes.tsx b/app/admin/_tabs/themes.tsx new file mode 100644 index 00000000..80c32759 --- /dev/null +++ b/app/admin/_tabs/themes.tsx @@ -0,0 +1,545 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react'; +import type { SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; +import { apiFetch } from '@/lib/browser-navigation'; + +const BUILTIN_THEME_OPTIONS = [ + { id: 'builtin-nord', name: 'Nord' }, + { id: 'builtin-catppuccin', name: 'Catppuccin' }, + { id: 'builtin-solarized', name: 'Solarized' }, +]; + +interface ThemeEntry { + id: string; + name: string; + version: string; + author: string; + description: string; + variants: string[]; + enabled: boolean; + forceEnabled?: boolean; + installedAt: string; + updatedAt: string; +} + +export function ThemesTab() { + const [themes, setThemes] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const fileInputRef = useRef(null); + const [policy, setPolicy] = useState({ ...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); + } + + 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) { + 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 = { 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 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' }); + } + } + + if (loading) { + return
Loading...
; + } + + const themesEnabled = policy.features.themesEnabled ?? true; + const userThemesEnabled = policy.features.userThemesEnabled ?? true; + + return ( +
+
+
+

Themes

+

Manage themes and theme policy for all users

+
+
+ {policyDirty && ( + + )} + +
+
+ + {message && ( +
+ {message.text} +
+ )} + +
+
+
+ +

Theme Policy

+
+

Control theme availability and defaults for users

+
+ +
+
+
+ Themes Enabled +

Allow users to select and apply themes

+
+ +
+ +
+
+ User Theme Uploads +

Allow users to upload their own theme files

+
+ +
+ + {themes.length > 0 && ( +
+
+ Force Enable / Disable All +

Bulk toggle all deployed themes at once

+
+
+ + +
+
+ )} + +
+
+
+ Default Theme +

Theme applied when users have not chosen one

+
+ +
+
+ +
+ Built-in Themes +
+ {BUILTIN_THEME_OPTIONS.map(theme => { + const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id); + return ( +
+ {theme.name} + +
+ ); + })} +
+
+ + {themes.length > 0 && ( +
+ Admin-deployed Themes +
+ {themes.map(theme => { + const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id); + return ( +
+ {theme.name} + +
+ ); + })} +
+
+ )} +
+
+ +
+
+
+ +

Deployed Themes

+
+

Admin-uploaded themes available to all users

+
+ {themes.length === 0 ? ( +
+ +

No themes installed

+

Upload a theme ZIP file to get started

+
+ ) : ( +
+ {themes.map(theme => ( +
+
+
+ {theme.name} + v{theme.version} + + {theme.enabled ? 'Enabled' : 'Disabled'} + + {theme.forceEnabled && ( + + Forced + + )} +
+ {theme.description && ( +

{theme.description}

+ )} +
+ by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()} +
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/admin/_tabs/version.tsx b/app/admin/_tabs/version.tsx new file mode 100644 index 00000000..e82241a2 --- /dev/null +++ b/app/admin/_tabs/version.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Loader2, + RefreshCw, + CheckCircle2, + AlertTriangle, + ShieldAlert, + ExternalLink, +} from 'lucide-react'; +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(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 { + 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 { + 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 ( +
+ loading… +
+ ); + } + + 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 ( +
+
+
+

Version

+

+ Hourly check against the Bulwark version server. Severity is decided server-side and + disable with BULWARK_UPDATE_CHECK=off. +

+
+ +
+ + {checkResult && ( +
+ {checkResult.msg} +
+ )} + + + + + + {chip.label} + + + + {data.current} + + {newer && ( + + {releaseUrl ? ( + + {newer} + + ) : ( + {newer} + )} + + )} + {status?.advisory && ( + + {status.advisory} + + )} + + + + + {timeAgo(data.lastCheckedAt)} + + + {timeAgo(data.lastSuccessAt)} + + + {timeAgo(data.nextScheduledAt)} + + {status?.checkedAt && ( + + {new Date(status.checkedAt).toLocaleString()} + + )} + + + + + + {data.endpoint} + + + + + {data.disabledByEnv ? 'Yes' : 'No'} + + + +
+ ); +} diff --git a/app/admin/auth/page.tsx b/app/admin/auth/page.tsx index 0bdfea23..a637439a 100644 --- a/app/admin/auth/page.tsx +++ b/app/admin/auth/page.tsx @@ -1,383 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react'; -import { apiFetch } from '@/lib/browser-navigation'; - -interface ConfigEntry { - value: unknown; - source: 'admin' | 'env' | 'default'; -} - -export default function AdminAuthPage() { - const [config, setConfig] = useState>({}); - const [edits, setEdits] = useState>({}); - 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: '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; - - if (loading) { - return
Loading...
; - } - - return ( -
-
-
-

Authentication

-

OAuth, SSO, and session configuration

-
- {hasEdits && ( - - )} -
- - {message && ( -
- {message.text} -
- )} - - {/* Auto-setup */} -
-
-
-
- -

Auto-configure OAuth (Stalwart)

-
-

- 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. -

-
- -
-
- - {/* Auto-setup dialog */} - {setupOpen && ( -
{ if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }} - > -
-
-

Auto-configure OAuth

-

- Verify the URLs below before continuing. The webmail and Stalwart can live on different domains. -

-
-
-
- - 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" - /> -

- Used to register redirect URIs (one per locale: {setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback) on Stalwart. -

- {!setupOriginValid && setupOrigin.length > 0 && ( -

Must be like https://host with no path.

- )} -
-
- - 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" - /> -

- Where Stalwart serves /.well-known/oauth-authorization-server. Saved as OAUTH_ISSUER_URL. Pre-filled from your JMAP server URL. -

- {!setupIssuerValid && setupIssuer.length > 0 && ( -

Must be like https://host with no path.

- )} -
- -
-
- - -
-
-
- )} - - {/* OAuth */} -
- - - - - -
- - {/* SSO */} -
- -
- - {/* Session & Security */} -
- 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' && ( - - )} -
- - ); -} - -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 ( -
-
-
- {label} - -
- {description &&

{description}

} -
-
- - {source === 'admin' && ( - - )} -
-
- ); -} - -function Select({ label, configKey, value, source, options, onChange, onRevert }: { - label: string; configKey: string; value: string; source?: string; options: string[]; - onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; -}) { - return ( -
-
- {label} - -
-
- - {source === 'admin' && ( - - )} -
-
- ); +export default function Page() { + redirect('/admin?tab=auth'); } diff --git a/app/admin/branding/page.tsx b/app/admin/branding/page.tsx index 6d1a1e94..cc6f73b7 100644 --- a/app/admin/branding/page.tsx +++ b/app/admin/branding/page.tsx @@ -1,300 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useRef, useState } from 'react'; -import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react'; -import { apiFetch } from '@/lib/browser-navigation'; - -interface ConfigEntry { - value: unknown; - source: 'admin' | 'env' | 'default'; -} - -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' }, -]; - -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' }, -]; - -export default function AdminBrandingPage() { - const [config, setConfig] = useState>({}); - const [edits, setEdits] = useState>({}); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [uploading, setUploading] = useState(null); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const fileInputRefs = useRef>({}); - - 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: string) { - setEdits(prev => ({ ...prev, [key]: value })); - setMessage(null); - } - - function currentValue(key: string): string { - if (key in edits) return edits[key] as string; - return (config[key]?.value as string) ?? ''; - } - - 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: '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) { - setUploading(slot); - setMessage(null); - - const formData = new FormData(); - formData.append('file', file); - formData.append('slot', slot); - - 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.` }); - // Remove any pending URL edit for this slot since upload sets it - setEdits(prev => { - const next = { ...prev }; - delete next[slot]; - return next; - }); - // Update config to reflect the uploaded URL - setConfig(prev => ({ - ...prev, - [slot]: { value: data.url, source: 'admin' }, - })); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Upload failed' }); - } - setUploading(null); - } - - async function handleDeleteUpload(slot: string) { - setMessage(null); - - const res = await apiFetch('/api/admin/branding', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slot }), - }); - - 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) { - 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 isUploadedFile = (key: string): boolean => { - const val = currentValue(key); - return val.startsWith('/api/admin/branding/'); - }; - - const hasEdits = Object.keys(edits).length > 0; - - if (loading) { - return
Loading...
; - } - - return ( -
-
-
-

Branding

-

Customize logos, favicon, and company information

-
- {hasEdits && ( - - )} -
- - {message && ( -
- {message.text} -
- )} - -
-
-

Images & Logos

-

Upload a file or enter a URL. Supported formats: SVG, PNG, JPEG, WebP, ICO (max 2 MB)

-
-
- {IMAGE_FIELDS.map(field => ( -
-
-
- - {config[field.key]?.source === 'admin' && ( - - {isUploadedFile(field.key) ? 'uploaded' : 'admin'} - - )} -
-
- handleChange(field.key, e.target.value)} - placeholder="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" - /> - { 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 = ''; - }} - /> - - {isUploadedFile(field.key) && ( - - )} - {config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && ( - - )} -
-
- {/* Preview */} - {currentValue(field.key) && ( -
- -
- {field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} - /> -
-
- )} -
- ))} -
-
- -
-
-

Company Information

-
-
- {TEXT_FIELDS.map(field => ( -
-
- - {config[field.key]?.source === 'admin' && ( - admin - )} -
-
- 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" - /> - {config[field.key]?.source === 'admin' && ( - - )} -
-
- ))} -
-
-
- ); +export default function Page() { + redirect('/admin?tab=branding'); } diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 48737ae3..693f0289 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -3,6 +3,7 @@ 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, @@ -20,7 +21,6 @@ import { Calendar, BookUser, HardDrive, - ArrowLeft, Store, Menu, X, @@ -30,40 +30,46 @@ import { useConfig } from '@/hooks/use-config'; import { useThemeStore } from '@/stores/theme-store'; import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot'; -import { useAuthStore } from '@/stores/auth-store'; import { useUpdateStore, selectHasUpdate } from '@/stores/update-store'; import { apiFetch } from '@/lib/browser-navigation'; -const NAV_GROUPS = [ +// 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/ routes and ?tab= search params. +const NAV_GROUPS: ReadonlyArray<{ + label: string; + items: ReadonlyArray<{ tab: AdminTabId; label: string; icon: typeof LayoutDashboard }>; +}> = [ { label: 'Overview', items: [ - { href: '/admin', label: 'Dashboard', icon: LayoutDashboard }, + { tab: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, ], }, { label: 'Configuration', items: [ - { href: '/admin/settings', label: 'Settings', icon: Settings }, - { href: '/admin/branding', label: 'Branding', icon: Palette }, - { href: '/admin/auth', label: 'Authentication', icon: Shield }, - { href: '/admin/policy', label: 'Policy', icon: Scale }, + { 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: [ - { href: '/admin/plugins', label: 'Plugins', icon: Puzzle }, - { href: '/admin/themes', label: 'Themes', icon: SwatchBook }, - { href: '/admin/marketplace', label: 'Marketplace', icon: Store }, + { tab: 'plugins', label: 'Plugins', icon: Puzzle }, + { tab: 'themes', label: 'Themes', icon: SwatchBook }, + { tab: 'marketplace', label: 'Marketplace', icon: Store }, ], }, { label: 'System', items: [ - { href: '/admin/version', label: 'Version', icon: Package }, - { href: '/admin/telemetry', label: 'Telemetry', icon: Activity }, - { href: '/admin/logs', label: 'Audit Log', icon: ScrollText }, + { tab: 'version', label: 'Version', icon: Package }, + { tab: 'telemetry', label: 'Telemetry', icon: Activity }, + { tab: 'logs', label: 'Audit Log', icon: ScrollText }, ], }, ]; @@ -71,6 +77,11 @@ const NAV_GROUPS = [ 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); + // 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(null); const [authError, setAuthError] = useState(null); const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); @@ -178,13 +189,20 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) {group.label}
- {group.items.map(({ href, label, icon: Icon }) => { - const active = href === '/admin' ? pathname === '/admin' : pathname.startsWith(href); - const showDot = href === '/admin/version' && hasUpdate; + {group.items.map(({ tab, label, icon: Icon }) => { + const active = activeTab === tab; + const showDot = tab === 'version' && hasUpdate; + const handleClick = () => { + 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 ( - {label} - + ); })} diff --git a/app/admin/logs/page.tsx b/app/admin/logs/page.tsx index a683d290..669b675a 100644 --- a/app/admin/logs/page.tsx +++ b/app/admin/logs/page.tsx @@ -1,178 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState, useCallback } from 'react'; -import { RefreshCw } from 'lucide-react'; -import type { AuditEntry } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; - -export default function AdminLogsPage() { - const [entries, setEntries] = useState([]); - 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 ( -
-
-
-

Audit Log

-

{total} total entries

-
- -
- - {/* Filter */} -
- -
- - {/* Mobile cards */} -
- {loading && entries.length === 0 ? ( -
Loading...
- ) : entries.length === 0 ? ( -
No entries found
- ) : ( - entries.map((entry, i) => ( -
-
- - {entry.action} - - - {new Date(entry.ts).toLocaleString()} - -
-
- {formatDetail(entry.detail)} -
-
- {entry.ip} -
-
- )) - )} -
- - {/* Desktop table */} -
- - - - - - - - - - - {loading && entries.length === 0 ? ( - - - - ) : entries.length === 0 ? ( - - - - ) : ( - entries.map((entry, i) => ( - - - - - - - )) - )} - -
TimeActionDetailsIP
Loading...
No entries found
- {new Date(entry.ts).toLocaleString()} - - - {entry.action} - - - {formatDetail(entry.detail)} - - {entry.ip} -
-
- - {/* Pagination */} - {totalPages > 1 && ( -
-

- Page {page} of {totalPages} -

-
- - -
-
- )} -
- ); -} - -function formatDetail(detail: Record): 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) => `${c.key}`).join(', '); - } - if (detail.restrictionCount !== undefined) return `${detail.restrictionCount} restriction(s)`; - return JSON.stringify(detail).slice(0, 100); +export default function Page() { + redirect('/admin?tab=logs'); } diff --git a/app/admin/marketplace/page.tsx b/app/admin/marketplace/page.tsx index f9befba3..ac089504 100644 --- a/app/admin/marketplace/page.tsx +++ b/app/admin/marketplace/page.tsx @@ -1,366 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState, useCallback } from 'react'; -import Link from 'next/link'; -import { Search, Download, Check, Loader2, Store, Puzzle, SwatchBook, Star, Eye } from 'lucide-react'; -import { apiFetch } from '@/lib/browser-navigation'; - -interface Extension { - slug: string; - name: string; - type: 'plugin' | 'theme'; - pluginType: string | null; - description: string; - permissions: string[]; - tags: string[]; - totalDownloads: number; - featured: boolean; - minAppVersion: string | null; - latestVersion: string | null; - installed: boolean; - author: { - displayName: string; - githubLogin: string; - avatarUrl: string | null; - } | null; -} - -interface SearchResult { - data: Extension[]; - meta: { - page: number; - perPage: number; - total: number; - }; -} - -type TypeFilter = 'all' | 'plugin' | 'theme'; - -export default function AdminMarketplacePage() { - const [extensions, setExtensions] = useState([]); - const [loading, setLoading] = useState(true); - const [query, setQuery] = useState(''); - const [typeFilter, setTypeFilter] = useState('all'); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [perPage] = useState(12); - const [installing, setInstalling] = useState(null); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const [error, setError] = useState(null); - - const fetchExtensions = useCallback(async () => { - setLoading(true); - setError(null); - try { - const params = new URLSearchParams(); - if (query) params.set('q', query); - if (typeFilter !== 'all') params.set('type', typeFilter); - params.set('page', String(page)); - params.set('perPage', String(perPage)); - params.set('sort', 'newest'); - - const res = await apiFetch(`/api/admin/marketplace?${params}`); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - setError(data.error || 'Failed to connect to extension directory'); - setExtensions([]); - return; - } - - const data: SearchResult = await res.json(); - setExtensions(data.data || []); - setTotal(data.meta?.total || 0); - } catch { - setError('Failed to connect to extension directory. Make sure it is running.'); - setExtensions([]); - } finally { - setLoading(false); - } - }, [query, typeFilter, page, perPage]); - - useEffect(() => { - fetchExtensions(); - }, [fetchExtensions]); - - // Debounced search - const [searchInput, setSearchInput] = useState(''); - useEffect(() => { - const t = setTimeout(() => { - setQuery(searchInput); - setPage(1); - }, 300); - return () => clearTimeout(t); - }, [searchInput]); - - async function handleInstall(ext: Extension) { - setInstalling(ext.slug); - setMessage(null); - - try { - const res = await apiFetch('/api/admin/marketplace', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - slug: ext.slug, - version: ext.latestVersion || '1.0.0', - type: ext.type, - }), - }); - - const data = await res.json(); - - if (res.ok) { - const warnings = data.warnings?.length ? ` (${data.warnings.length} warning(s))` : ''; - setMessage({ type: 'success', text: `"${ext.name}" installed successfully${warnings}` }); - // Mark as installed in the UI - setExtensions(prev => prev.map(e => e.slug === ext.slug ? { ...e, installed: true } : e)); - } else { - setMessage({ type: 'error', text: data.error || 'Installation failed' }); - } - } catch { - setMessage({ type: 'error', text: 'Installation failed - network error' }); - } finally { - setInstalling(null); - } - } - - const totalPages = Math.ceil(total / perPage); - - return ( -
-
-

Marketplace

-

- Browse and install plugins and themes from the BulwarkMail extension directory -

-
- - {message && ( -
- {message.text} -
- )} - - {/* Search & Filters */} -
-
- - setSearchInput(e.target.value)} - className="w-full h-9 pl-9 pr-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" - /> -
-
- {(['all', 'plugin', 'theme'] as const).map((t) => ( - - ))} -
-
- - {/* Error State */} - {error && ( -
- -

{error}

-

- Start the extension directory server on the configured port -

- -
- )} - - {/* Loading State */} - {loading && !error && ( -
- - Searching extensions... -
- )} - - {/* Empty State */} - {!loading && !error && extensions.length === 0 && ( -
- -

No extensions found

- {query && ( -

- Try a different search term -

- )} -
- )} - - {/* Extension Grid */} - {!loading && !error && extensions.length > 0 && ( - <> -
- {total} extension{total !== 1 ? 's' : ''} found -
-
- {extensions.map((ext) => ( - handleInstall(ext)} - /> - ))} -
- - {/* Pagination */} - {totalPages > 1 && ( -
- - - Page {page} of {totalPages} - - -
- )} - - )} -
- ); -} - -function ExtensionCard({ - extension, - installing, - onInstall, -}: { - extension: Extension; - installing: boolean; - onInstall: () => void; -}) { - const isPlugin = extension.type === 'plugin'; - const previewHref = `/admin/marketplace/${encodeURIComponent(extension.slug)}`; - - return ( -
- - {/* Header */} -
-
- {isPlugin ? ( - - ) : ( - - )} -
-
-
- - {extension.name} - - {extension.featured && ( - - )} -
-
- - {isPlugin ? (extension.pluginType || 'plugin') : 'theme'} - - {extension.author && ( - - by {extension.author.displayName} - - )} -
-
-
- - {/* Description */} -

- {extension.description} -

- - {/* Tags */} - {extension.tags && extension.tags.length > 0 && ( -
- {extension.tags.slice(0, 3).map(tag => ( - - {tag} - - ))} -
- )} - - {/* Footer (download count + permissions) */} -
-
- - - {extension.totalDownloads.toLocaleString()} - - {extension.permissions && extension.permissions.length > 0 && ( - - {extension.permissions.length} permission{extension.permissions.length !== 1 ? 's' : ''} - - )} -
- - - Preview - -
- - - {/* Quick install button (sits over the link, stops navigation) */} -
- {extension.installed ? ( - - - Installed - - ) : ( - - )} -
-
- ); +export default function Page() { + redirect('/admin?tab=marketplace'); } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 98eb9c81..71562e54 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,230 +1,49 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { AlertTriangle } from 'lucide-react'; -import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section'; -import type { AuditEntry } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; +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'; -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 default function AdminDashboardPage() { - const [status, setStatus] = useState(null); - const [recentActivity, setRecentActivity] = useState([]); - const [config, setConfig] = useState(null); - const [, setConfigSources] = useState | null>(null); - const [warnings, setWarnings] = useState([]); - 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); - const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown'); +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//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(() => { - fetchDashboardData(); - }, []); + 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]); - 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); - } - } - - if (configData?.jmapServerUrl) { - try { - const jmapRes = await apiFetch('/api/config'); - setJmapHealth(jmapRes.ok ? 'ok' : 'error'); - } catch { - setJmapHealth('error'); - } - } - - const w: string[] = []; - if (adminConfigRes.ok) { - const sources = await adminConfigRes.json(); - setConfigSources(sources); - const sessionSecret = sources?.sessionSecret; - if (!sessionSecret?.value || sessionSecret.value === 'your-secret-key-here') { - 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); + switch (activeTab) { + case 'dashboard': return ; + case 'settings': return ; + case 'branding': return ; + case 'auth': return ; + case 'policy': return ; + case 'plugins': return ; + case 'themes': return ; + case 'marketplace': return ; + case 'version': return ; + case 'telemetry': return ; + case 'logs': return ; } - - const jmapUrl = config?.jmapServerUrl || '-'; - const jmapHostname = jmapUrl !== '-' ? (() => { try { return new URL(jmapUrl).hostname; } catch { return jmapUrl; } })() : '-'; - - return ( -
- {/* Warnings */} - {warnings.map((msg, i) => ( -
- -

{msg}

-
- ))} - - {status && !status.lastLogin && ( -
- -
-

First login detected

-

- Remember to remove ADMIN_PASSWORD from your .env file now that the hash is stored securely. -

-
-
- )} - - {/* Server Info */} - - - {config?.appName || '-'} - - - {jmapHostname} - - - - - {jmapHealth === 'ok' ? 'Connected' : jmapHealth === 'error' ? 'Error' : 'Unknown'} - - - - - {status?.lastLogin ? new Date(status.lastLogin).toLocaleString() : 'Never'} - - - - - {/* Features */} - - - {}} disabled /> - - - {}} disabled /> - - - {}} disabled /> - - - {}} disabled /> - - - - {/* Accounts */} - - - {accountCounts?.total ?? '-'} - - - {accountCounts?.active7d ?? '-'} - - - - {/* Extensions */} - - - {pluginCount} - - - {themeCount} - - - {policyRuleCount} - - - - {/* Recent Activity */} - - {recentActivity.length === 0 ? ( -
- No activity recorded yet -
- ) : ( - recentActivity.map((entry, i) => ( - -
- {entry.ip} - {new Date(entry.ts).toLocaleString()} -
-
- )) - )} -
-
- ); -} - -function formatDetail(detail: Record): 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); } diff --git a/app/admin/plugins/page.tsx b/app/admin/plugins/page.tsx index 63f076bd..804485a2 100644 --- a/app/admin/plugins/page.tsx +++ b/app/admin/plugins/page.tsx @@ -1,468 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState, useRef } from 'react'; -import Link from 'next/link'; -import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen, Settings } from 'lucide-react'; -import type { SettingsPolicy } from '@/lib/admin/types'; -import { DEFAULT_POLICY } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; - -interface PluginEntry { - id: string; - name: string; - version: string; - author: string; - description: string; - type: string; - enabled: boolean; - forceEnabled?: boolean; - permissions: string[]; - installedAt: string; - updatedAt: string; - /** True when loaded from PLUGIN_DEV_DIR (read-only, managed via filesystem) */ - dev?: boolean; -} - -export default function AdminPluginsPage() { - const [plugins, setPlugins] = useState([]); - const [loading, setLoading] = useState(true); - const [uploading, setUploading] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const fileInputRef = useRef(null); - const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); - const [policyDirty, setPolicyDirty] = useState(false); - const [savingPolicy, setSavingPolicy] = useState(false); - - 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) { - 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); - // If force-enabling, also ensure the plugin is enabled - const body: Record = { 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)); - // Also update policy - 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 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' }); - } - } - - if (loading) { - return
Loading...
; - } - - const pluginsEnabled = policy.features.pluginsEnabled ?? true; - const pluginsUploadEnabled = policy.features.pluginsUploadEnabled ?? true; - const requirePluginApproval = policy.features.requirePluginApproval ?? true; - - return ( -
-
-
-

Plugins

-

Manage plugins and plugin policy for all users

-
-
- {policyDirty && ( - - )} - -
-
- - {message && ( -
- {message.text} -
- )} - - {/* Plugin Policy */} -
-
-
- -

Plugin Policy

-
-

Control plugin availability for users

-
-
-
-
- Plugins Enabled -

Allow the plugin system to load and run plugins for users

-
- -
- -
-
- User Plugin Uploads -

Allow users to upload plugin ZIP files in Settings

-
- -
- -
-
- Require Admin Approval -

User-uploaded plugins must be approved by an admin before they can be enabled

-
- -
- - {/* Force enable / disable all */} - {plugins.length > 0 && ( -
-
- Force Enable / Disable All -

Bulk toggle all deployed plugins at once

-
-
- - -
-
- )} -
-
- - {/* Deployed Plugins */} -
-
-
- -

Deployed Plugins

-
-

Admin-uploaded plugins for all users

-
- {plugins.length === 0 ? ( -
- -

No plugins installed

-

Upload a plugin ZIP file to get started

-
- ) : ( -
- {plugins.map(plugin => ( -
-
-
- {plugin.name} - v{plugin.version} - - {plugin.enabled ? 'Enabled' : 'Disabled'} - - {plugin.dev && ( - - Dev - - )} - {plugin.forceEnabled && ( - - Forced - - )} -
- {plugin.description && ( -

{plugin.description}

- )} -
- by {plugin.author} · {plugin.type} · installed {new Date(plugin.installedAt).toLocaleDateString()} -
- {plugin.permissions.length > 0 && ( -
- - - Permissions: {plugin.permissions.join(', ')} - -
- )} -
- -
- - - - - - -
-
- ))} -
- )} -
-
- ); +export default function Page() { + redirect('/admin?tab=plugins'); } diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx index 9eb0588b..9d33304c 100644 --- a/app/admin/policy/page.tsx +++ b/app/admin/policy/page.tsx @@ -1,220 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { Save, Loader2, Lock } from 'lucide-react'; -import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types'; -import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; - -// Feature gates managed on their own admin pages (excluded from this list) -const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled']; - -const FEATURE_GATE_LABELS: Partial> = { - 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' }, - 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' }, - filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' }, -}; - -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: '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', 'permanent'] }, - { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean' }, - { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus'] }, - { 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: '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: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean' }, -]; - -export default function AdminPolicyPage() { - const [policy, setPolicy] = useState({ ...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 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); - } - - if (loading) { - return
Loading...
; - } - - const categories = [...new Set(RESTRICTABLE_SETTINGS.map(s => s.category))]; - - return ( -
-
-
-

User Policy

-

Control which features and settings users can access

-
- {dirty && ( - - )} -
- - {message && ( -
- {message.text} -
- )} - - {/* Feature Gates */} -
-
-

Feature Gates

-

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; - const enabled = policy.features[key]; - return ( -
-
- {label} -

{description}

-
- -
- ); - })} -
-
- - {/* Setting Restrictions */} - {categories.map(category => ( -
-
-

{category}

-
-
- {RESTRICTABLE_SETTINGS.filter(s => s.category === category).map(setting => { - const restriction = policy.restrictions[setting.key] || {}; - return ( -
- {setting.label} -
- - -
-
- ); - })} -
-
- ))} -
- ); +export default function Page() { + redirect('/admin?tab=policy'); } diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index 1c9125e5..7a6e1020 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -1,248 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { Save, RotateCcw, Loader2 } from 'lucide-react'; -import { apiFetch } from '@/lib/browser-navigation'; - -interface ConfigEntry { - value: unknown; - source: 'admin' | 'env' | 'default'; -} - -export default function AdminSettingsPage() { - const [config, setConfig] = useState>({}); - const [edits, setEdits] = useState>({}); - 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; - - if (loading) { - return
Loading...
; - } - - return ( -
-
-
-

Server Settings

-

General server configuration

-
- {hasEdits && ( - - )} -
- - {message && ( -
- {message.text} -
- )} - - {/* General */} - - - - - {!!currentValue('allowCustomJmapEndpoint') && ( -
-

- CORS warning: External JMAP servers must include this domain in their CORS Access-Control-Allow-Origin header, or requests from the browser will be blocked. -

-
- )} - - -
- - {/* Logging */} - - - - - - {/* Settings Sync */} - - - -
- ); -} - -function SettingsSection({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
-

{title}

-
-
- {children} -
-
- ); -} - -function SourceBadge({ source }: { source?: string }) { - if (!source || source === 'default') return null; - return ( - - {source} - - ); -} - -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 ( -
-
- - -
-
- 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' && ( - - )} -
-
- ); -} - -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 ( -
-
-
- {label} - -
- {description &&

{description}

} -
-
- - {source === 'admin' && ( - - )} -
-
- ); -} - -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 ( -
-
- {label} - -
-
- - {source === 'admin' && ( - - )} -
-
- ); +export default function Page() { + redirect('/admin?tab=settings'); } diff --git a/app/admin/telemetry/page.tsx b/app/admin/telemetry/page.tsx index 058df0d8..73343a1d 100644 --- a/app/admin/telemetry/page.tsx +++ b/app/admin/telemetry/page.tsx @@ -1,250 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react'; -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; - 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 default function AdminTelemetryPage() { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - const [busy, setBusy] = useState(null); - const [endpointDraft, setEndpointDraft] = useState(''); - const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null); - - async function refresh(): Promise { - 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 { - 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 { - 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 { - 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 ( -
- loading… -
- ); - } - - const envOverridden = status.consentSource === 'env'; - const isOn = status.consent === 'on'; - - return ( -
-
-

Anonymous Usage Stats

-

- Bulwark sends one anonymous heartbeat per day so we can see how many instances are - running, on what platforms, and which features they use. Enabled by default; - one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '} - - Full schema and policy - -

-
- -
-
-
-
Status
-
- {status.consent === 'pending' && 'Initialising - no heartbeats sent yet.'} - {status.consent === 'on' && 'Heartbeats are enabled (default).'} - {status.consent === 'off' && 'Heartbeats are off.'} - {envOverridden && ( - <> Locked by BULWARK_TELEMETRY env var. - )} -
-
-
- - -
-
-
-
Last sent
-
{timeAgo(status.lastSentAt)}
-
Next scheduled
-
{timeAgo(status.nextScheduledAt)}
-
Consented at
-
{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
-
-
- -
-
Account activity
-

- 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. -

-
-
Total (90d)
-
{status.accountCounts?.total ?? 0}
-
Active (7d)
-
{status.accountCounts?.active7d ?? 0}
-
-
- -
-
Endpoint
-

- 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. -

-
- setEndpointDraft(e.target.value)} - placeholder={status.defaultEndpoint} - className="flex-1 min-w-0 px-3 py-1.5 rounded-md border bg-background" - /> - -
-
- -
-
-
-
Payload preview
-
- Exactly what the next heartbeat would send from this install, right now. -
-
- -
- {sendResult && ( -
- {sendResult.ok ? : } - {sendResult.msg} -
- )} -
-          {JSON.stringify(status.payloadPreview, null, 2)}
-        
-
-
- ); +export default function Page() { + redirect('/admin?tab=telemetry'); } diff --git a/app/admin/themes/page.tsx b/app/admin/themes/page.tsx index c654edfc..3dc03d45 100644 --- a/app/admin/themes/page.tsx +++ b/app/admin/themes/page.tsx @@ -1,553 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState, useRef } from 'react'; -import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock, LockOpen } from 'lucide-react'; -import type { SettingsPolicy } from '@/lib/admin/types'; -import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types'; -import { apiFetch } from '@/lib/browser-navigation'; - -const BUILTIN_THEME_OPTIONS = [ - { id: 'builtin-nord', name: 'Nord' }, - { id: 'builtin-catppuccin', name: 'Catppuccin' }, - { id: 'builtin-solarized', name: 'Solarized' }, -]; - -interface ThemeEntry { - id: string; - name: string; - version: string; - author: string; - description: string; - variants: string[]; - enabled: boolean; - forceEnabled?: boolean; - installedAt: string; - updatedAt: string; -} - -export default function AdminThemesPage() { - const [themes, setThemes] = useState([]); - const [loading, setLoading] = useState(true); - const [uploading, setUploading] = useState(false); - const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const fileInputRef = useRef(null); - const [policy, setPolicy] = useState({ ...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); - } - - 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) { - 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 = { 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 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' }); - } - } - - if (loading) { - return
Loading...
; - } - - const themesEnabled = policy.features.themesEnabled ?? true; - const userThemesEnabled = policy.features.userThemesEnabled ?? true; - - return ( -
-
-
-

Themes

-

Manage themes and theme policy for all users

-
-
- {policyDirty && ( - - )} - -
-
- - {message && ( -
- {message.text} -
- )} - - {/* Theme Policy */} -
-
-
- -

Theme Policy

-
-

Control theme availability and defaults for users

-
- -
- {/* Master toggle */} -
-
- Themes Enabled -

Allow users to select and apply themes

-
- -
- - {/* User uploads toggle */} -
-
- User Theme Uploads -

Allow users to upload their own theme files

-
- -
- - {/* Force enable / disable all */} - {themes.length > 0 && ( -
-
- Force Enable / Disable All -

Bulk toggle all deployed themes at once

-
-
- - -
-
- )} - - {/* Default Theme */} -
-
-
- Default Theme -

Theme applied when users have not chosen one

-
- -
-
- - {/* Built-in themes */} -
- Built-in Themes -
- {BUILTIN_THEME_OPTIONS.map(theme => { - const disabled = (policy.themePolicy?.disabledBuiltinThemes || []).includes(theme.id); - return ( -
- {theme.name} - -
- ); - })} -
-
- - {/* Admin-deployed themes */} - {themes.length > 0 && ( -
- Admin-deployed Themes -
- {themes.map(theme => { - const disabled = (policy.themePolicy?.disabledThemes || []).includes(theme.id); - return ( -
- {theme.name} - -
- ); - })} -
-
- )} -
-
- - {/* Deployed Themes */} -
-
-
- -

Deployed Themes

-
-

Admin-uploaded themes available to all users

-
- {themes.length === 0 ? ( -
- -

No themes installed

-

Upload a theme ZIP file to get started

-
- ) : ( -
- {themes.map(theme => ( -
-
-
- {theme.name} - v{theme.version} - - {theme.enabled ? 'Enabled' : 'Disabled'} - - {theme.forceEnabled && ( - - Forced - - )} -
- {theme.description && ( -

{theme.description}

- )} -
- by {theme.author} · {theme.variants.join(', ')} · installed {new Date(theme.installedAt).toLocaleDateString()} -
-
- -
- - - -
-
- ))} -
- )} -
-
- ); +export default function Page() { + redirect('/admin?tab=themes'); } diff --git a/app/admin/version/page.tsx b/app/admin/version/page.tsx index 398aba28..893b1dfe 100644 --- a/app/admin/version/page.tsx +++ b/app/admin/version/page.tsx @@ -1,237 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { - Loader2, - RefreshCw, - CheckCircle2, - AlertTriangle, - ShieldAlert, - ExternalLink, -} from 'lucide-react'; -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 default function AdminVersionPage() { - const [data, setData] = useState(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 { - 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 { - 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 ( -
- loading… -
- ); - } - - 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 ( -
-
-
-

Version

-

- Hourly check against the Bulwark version server. Severity is decided server-side and - disable with BULWARK_UPDATE_CHECK=off. -

-
- -
- - {checkResult && ( -
- {checkResult.msg} -
- )} - - - - - - {chip.label} - - - - {data.current} - - {newer && ( - - {releaseUrl ? ( - - {newer} - - ) : ( - {newer} - )} - - )} - {status?.advisory && ( - - {status.advisory} - - )} - - - - - {timeAgo(data.lastCheckedAt)} - - - {timeAgo(data.lastSuccessAt)} - - - {timeAgo(data.nextScheduledAt)} - - {status?.checkedAt && ( - - {new Date(status.checkedAt).toLocaleString()} - - )} - - - - - - {data.endpoint} - - - - - {data.disabledByEnv ? 'Yes' : 'No'} - - - -
- ); +export default function Page() { + redirect('/admin?tab=version'); } diff --git a/stores/admin-tab-store.ts b/stores/admin-tab-store.ts new file mode 100644 index 00000000..c9b77622 --- /dev/null +++ b/stores/admin-tab-store.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export const ADMIN_TABS = [ + 'dashboard', + 'settings', + 'branding', + 'auth', + 'policy', + 'plugins', + 'themes', + 'marketplace', + 'version', + 'telemetry', + 'logs', +] as const; + +export type AdminTabId = typeof ADMIN_TABS[number]; + +export function isAdminTab(value: string | null | undefined): value is AdminTabId { + return typeof value === 'string' && (ADMIN_TABS as readonly string[]).includes(value); +} + +interface AdminTabState { + activeTab: AdminTabId; + setActiveTab: (tab: AdminTabId) => void; +} + +// Tab state lives in client memory + localStorage. Sidebar clicks update +// state (no URL navigation) so React can commit the transition immediately, +// avoiding the dev-mode "Rendering…" hang we saw when each tab was its own +// route or distinguished by ?tab= search param. +export const useAdminTabStore = create()( + persist( + (set) => ({ + activeTab: 'dashboard', + setActiveTab: (tab) => set({ activeTab: tab }), + }), + { name: 'admin_active_tab' }, + ), +); From 28054c81ea78df2713d09c322c7a7c1fdd5b128b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 19:04:29 +0200 Subject: [PATCH 08/49] fix: inline plugin configure panel to avoid dev-mode hang --- app/admin/_tabs/plugin-config-panel.tsx | 291 ++++++++++++++++++++++++ app/admin/_tabs/plugins.tsx | 14 +- app/admin/plugins/[id]/page.tsx | 289 +---------------------- 3 files changed, 306 insertions(+), 288 deletions(-) create mode 100644 app/admin/_tabs/plugin-config-panel.tsx diff --git a/app/admin/_tabs/plugin-config-panel.tsx b/app/admin/_tabs/plugin-config-panel.tsx new file mode 100644 index 00000000..e3bea8b4 --- /dev/null +++ b/app/admin/_tabs/plugin-config-panel.tsx @@ -0,0 +1,291 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react'; +import { apiFetch } from '@/lib/browser-navigation'; + +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; +} + +interface Props { + pluginId: string; + onBack: () => void; +} + +export function PluginConfigPanel({ pluginId, onBack }: Props) { + const [plugin, setPlugin] = useState(null); + const [config, setConfig] = useState({}); + const [formValues, setFormValues] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [revealSecrets, setRevealSecrets] = useState>({}); + 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]); + + useEffect(() => { + if (!plugin?.configSchema) return; + const initial: Record = {}; + 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] = ''; + } + } + setFormValues(initial); + }, [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; + 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 { + hasError = true; + } + 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 { + hasError = true; + } + } + + setMessage(hasError + ? { type: 'error', text: '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 ( +
+ + Loading... +
+ ); + } + + if (!plugin) { + return ( +
+ +

Plugin not found: {pluginId}

+
+ ); + } + + const schema = plugin.configSchema; + const hasSchema = schema && Object.keys(schema).length > 0; + + return ( +
+
+ +
+

+ + {plugin.name} Configuration +

+

+ v{plugin.version} by {plugin.author} +

+
+
+ + {message && ( +
+ {message.text} +
+ )} + + {hasSchema ? ( +
+
+

Settings

+
+
+ {Object.entries(schema).map(([key, field]) => ( +
+ + {field.description && ( +

{field.description}

+ )} + + {field.type === 'boolean' ? ( + + ) : field.type === 'select' && field.options ? ( + + ) : field.type === 'secret' ? ( +
+ setFormValues(prev => ({ ...prev, [key]: e.target.value }))} + placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')} + className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono" + /> + +
+ ) : ( + 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" + /> + )} +
+ ))} + + +
+
+ ) : ( +
+

This plugin does not declare any configuration settings.

+
+ )} +
+ ); +} diff --git a/app/admin/_tabs/plugins.tsx b/app/admin/_tabs/plugins.tsx index dcd7cc3e..6569e0c9 100644 --- a/app/admin/_tabs/plugins.tsx +++ b/app/admin/_tabs/plugins.tsx @@ -1,11 +1,11 @@ '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 } from 'lucide-react'; import type { SettingsPolicy } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; +import { PluginConfigPanel } from './plugin-config-panel'; interface PluginEntry { id: string; @@ -30,6 +30,7 @@ export function PluginsTab() { const [policy, setPolicy] = useState({ ...DEFAULT_POLICY }); const [policyDirty, setPolicyDirty] = useState(false); const [savingPolicy, setSavingPolicy] = useState(false); + const [configuringId, setConfiguringId] = useState(null); useEffect(() => { fetchPlugins(); fetchPolicy(); }, []); @@ -250,6 +251,10 @@ export function PluginsTab() { } } + if (configuringId) { + return { setConfiguringId(null); fetchPlugins(); }} />; + } + if (loading) { return
Loading...
; } @@ -414,13 +419,14 @@ export function PluginsTab() {
- setConfiguringId(plugin.id)} title="Configure" className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors" > - + -
- ) : ( - 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" - /> - )} - - ))} - - - - - ) : ( -
-

This plugin does not declare any configuration settings.

-
- )} - - ); +// 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'); } From 2a769c2b0a912b6c840dc73a3c064d657037fa7d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 20:04:00 +0200 Subject: [PATCH 09/49] feat: run onBeforeEmailSend hook before send, expose fromEmail on OutgoingEmail --- components/email/email-composer.tsx | 21 +++++++++++++++++++++ lib/plugin-types.ts | 2 ++ 2 files changed, 23 insertions(+) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 083b2fc6..7f1b093b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -980,6 +980,26 @@ export function EmailComposer({ const inlineAttachments = rewritten?.attachments ?? []; try { + // Let plugins veto the send (external-recipient warning, mistyped-domain + // guards, etc.). Returning false from any handler aborts before either + // the S/MIME or standard JMAP path runs. + const sendablePreview: OutgoingEmail = { + to: toAddresses, + cc: ccAddresses, + bcc: bccAddresses, + subject, + htmlBody: finalHtmlBody || '', + textBody: finalBody, + identityId: currentIdentity?.id || '', + fromEmail, + attachments: attachments + .filter(att => att.blobId && !att.uploading && !att.error) + .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })), + inReplyTo: threadingHeaders?.inReplyTo?.[0], + }; + const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview); + if (!sendAllowed) return; + // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { // 1. Resolve S/MIME key @@ -1115,6 +1135,7 @@ export function EmailComposer({ htmlBody: finalHtmlBody || '', textBody: finalBody, identityId: currentIdentity?.id || '', + fromEmail, attachments: uploadedAttachments.map(a => ({ name: a.name, type: a.type, size: a.size })), inReplyTo: threadingHeaders?.inReplyTo?.[0], }; diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 365778c2..995d1e78 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -541,6 +541,8 @@ export interface OutgoingEmail { htmlBody: string; textBody: string; identityId: string; + /** Sender email derived from the active identity (incl. sub-address tag, when set) */ + fromEmail?: string; attachments: { name: string; type: string; size: number }[]; /** Original message id when this is a reply or forward */ inReplyTo?: string; From 265908b05b3d50d61708ed19d57ccfeb0f322a87 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 20:17:45 +0200 Subject: [PATCH 10/49] fix: resolve PLUGIN_DEV_DIR plugins in admin config route --- app/api/admin/plugins/[id]/config/route.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/api/admin/plugins/[id]/config/route.ts b/app/api/admin/plugins/[id]/config/route.ts index b8ea0029..7b2dfbf5 100644 --- a/app/api/admin/plugins/[id]/config/route.ts +++ b/app/api/admin/plugins/[id]/config/route.ts @@ -1,9 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPlugin } from '@/lib/admin/plugin-registry'; +import { getDevPlugin } from '@/lib/admin/plugin-dev'; import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config'; import { requireAdminAuth } from '@/lib/admin/session'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +/** Resolve a plugin from the persisted registry first, then PLUGIN_DEV_DIR. */ +async function resolvePlugin(id: string) { + const registered = await getPlugin(id); + if (registered) return registered; + const dev = await getDevPlugin(id); + return dev?.plugin ?? null; +} + /** * GET /api/admin/plugins/[id]/config - Read plugin config * @@ -35,7 +44,7 @@ export async function GET( } } - const plugin = await getPlugin(id); + const plugin = await resolvePlugin(id); if (!plugin) { return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); } @@ -80,7 +89,7 @@ export async function PUT( return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); } - const plugin = await getPlugin(id); + const plugin = await resolvePlugin(id); if (!plugin) { return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); } From da411af6d31d8b828a79dc652816ed2ab05555da Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 20:31:11 +0200 Subject: [PATCH 11/49] feat: project EmailReadView for email-banner slot, expose auth results --- app/[locale]/page.tsx | 20 +------------------- components/email/email-viewer.tsx | 3 ++- lib/plugin-projection.ts | 25 +++++++++++++++++++++++++ lib/plugin-types.ts | 11 +++++++++++ 4 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 lib/plugin-projection.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index d4c90318..c0de511b 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -60,25 +60,7 @@ import { useConfig } from "@/hooks/use-config"; import { usePluginStore } from "@/stores/plugin-store"; import { useThemeStore } from "@/stores/theme-store"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; -import type { EmailReadView } from "@/lib/plugin-types"; - -function emailToReadView(email: Email): EmailReadView { - return { - id: email.id, - threadId: email.threadId, - mailboxIds: Object.keys(email.mailboxIds || {}).filter(k => email.mailboxIds[k]), - from: (email.from || []).map(a => ({ name: a.name || '', email: a.email })), - to: (email.to || []).map(a => ({ name: a.name || '', email: a.email })), - cc: (email.cc || []).map(a => ({ name: a.name || '', email: a.email })), - subject: email.subject || '', - receivedAt: email.receivedAt, - isRead: !!email.keywords?.['$seen'], - isFlagged: !!email.keywords?.['$flagged'], - hasAttachment: email.hasAttachment, - preview: email.preview || '', - keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]), - }; -} +import { emailToReadView } from "@/lib/plugin-projection"; export default function Home() { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index f92b435a..b37f5abe 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers"; +import { emailToReadView } from "@/lib/plugin-projection"; import { Reply, ReplyAll, @@ -4886,7 +4887,7 @@ export function EmailViewer({
- + {/* Email Body */}
email.mailboxIds[k]), + from: (email.from || []).map(a => ({ name: a.name || '', email: a.email })), + to: (email.to || []).map(a => ({ name: a.name || '', email: a.email })), + cc: (email.cc || []).map(a => ({ name: a.name || '', email: a.email })), + subject: email.subject || '', + receivedAt: email.receivedAt, + isRead: !!email.keywords?.['$seen'], + isFlagged: !!email.keywords?.['$flagged'], + hasAttachment: email.hasAttachment, + preview: email.preview || '', + keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]), + auth: email.authenticationResults, + }; +} diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 995d1e78..780ec021 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -300,6 +300,17 @@ export interface EmailReadView { hasAttachment: boolean; preview: string; keywords: string[]; + /** + * Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS). + * Absent on stores that didn't parse the header (e.g. bodies not yet + * fetched). Mirrors the structured shape exposed by the host. + */ + auth?: { + spf?: { result: 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; domain?: string }; + dkim?: { result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror'; domain?: string; selector?: string }; + dmarc?: { result: 'pass' | 'fail' | 'none'; policy?: 'reject' | 'quarantine' | 'none'; domain?: string }; + iprev?: { result: 'pass' | 'fail'; ip?: string }; + }; } export interface DraftView { From 9f67bc078a8d02b806a9c4a0ad08d751ad7ace0e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 21:12:23 +0200 Subject: [PATCH 12/49] feat: ingest icon/banner/screenshots from source repo --- app/admin/_tabs/marketplace.tsx | 23 +++++++++++++-- app/admin/marketplace/[slug]/page.tsx | 25 ++++++++++++++-- app/api/admin/marketplace/[slug]/route.ts | 7 +++++ app/api/admin/marketplace/route.ts | 7 +++++ lib/plugin-types.ts | 35 +++++++++++++++++++++++ 5 files changed, 93 insertions(+), 4 deletions(-) diff --git a/app/admin/_tabs/marketplace.tsx b/app/admin/_tabs/marketplace.tsx index b2e66311..7aefa46d 100644 --- a/app/admin/_tabs/marketplace.tsx +++ b/app/admin/_tabs/marketplace.tsx @@ -18,6 +18,8 @@ interface Extension { minAppVersion: string | null; latestVersion: string | null; installed: boolean; + iconUrl: string | null; + bannerUrl: string | null; author: { displayName: string; githubLogin: string; @@ -259,10 +261,27 @@ function ExtensionCard({ return (
+ {extension.bannerUrl && ( + + + + )}
-
- {isPlugin ? ( +
+ {extension.iconUrl ? ( + + ) : isPlugin ? ( ) : ( diff --git a/app/admin/marketplace/[slug]/page.tsx b/app/admin/marketplace/[slug]/page.tsx index 4d369300..b9e4da4c 100644 --- a/app/admin/marketplace/[slug]/page.tsx +++ b/app/admin/marketplace/[slug]/page.tsx @@ -37,6 +37,8 @@ interface PreviewData { githubRepo: string | null; license: string | null; minAppVersion: string | null; + iconUrl: string | null; + bannerUrl: string | null; author: { displayName: string; githubLogin: string; @@ -209,11 +211,30 @@ export default function MarketplacePreviewPage() { Back to Marketplace + {/* Banner / hero */} + {ext.bannerUrl && ( +
+ +
+ )} + {/* Header */}
-
- {isPlugin ? ( +
+ {ext.iconUrl ? ( + + ) : isPlugin ? ( ) : ( diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index d747c632..ead554ae 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -168,6 +168,11 @@ export async function GET( })) : []; + const fileUrl = (path: unknown): string | null => + typeof path === 'string' && path + ? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() + : null; + return NextResponse.json( { extension: { @@ -184,6 +189,8 @@ export async function GET( githubRepo: extension.githubRepo ?? null, license: extension.license ?? null, minAppVersion: extension.minAppVersion ?? null, + iconUrl: fileUrl(extension.iconPath), + bannerUrl: fileUrl(extension.bannerPath), author: extension.author ?? null, latestVersion, versions, diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 98445226..5f0254f1 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -60,9 +60,16 @@ export async function GET(request: NextRequest) { const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id)); const installedThemes = new Set(themeRegistry.themes.map(t => t.id)); + const fileUrl = (path: unknown): string | null => + typeof path === 'string' && path + ? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString() + : null; + if (data.data) { data.data = data.data.map((ext: Record) => ({ ...ext, + iconUrl: fileUrl(ext.iconPath), + bannerUrl: fileUrl(ext.bannerPath), installed: ext.type === 'theme' ? installedThemes.has(ext.slug as string) : installedPlugins.has(ext.slug as string), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 780ec021..a1a58de3 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -53,7 +53,23 @@ export interface ThemeManifest { author: string; description: string; type: 'theme'; + /** @deprecated kept as alias for `banner` so existing themes still work. */ preview?: string; + /** + * Path inside the source repo (relative to manifest.json) to a square + * brand icon shown in marketplace cards and the host's theme picker. + */ + icon?: string; + /** + * Path to a wide promo image shown as the hero on the theme detail + * page. PNG/JPG/WebP, ≤512 KB. + */ + banner?: string; + /** + * Up to 6 screenshot paths shown in the gallery on the detail page. + * Themes typically use this to show light + dark variants. + */ + screenshots?: string[]; variants: ThemeVariant[]; minAppVersion?: string; @@ -100,6 +116,25 @@ export interface PluginManifest { * Validated at install time and merged into the host CSP `frame-src`. */ frameOrigins?: string[]; + + // ─── Marketplace media (NOT shipped in the runtime zip) ────── + /** + * Path inside the source repo (relative to manifest.json) to a square + * brand icon. PNG/SVG/WebP, ≤256 KB, 128×128 or larger recommended. + * The extension directory ingests this from git and serves it on + * marketplace cards and the host's plugin admin UI. + */ + icon?: string; + /** + * Path to a wide promo image (16:9 recommended), shown as the hero on + * the extension detail page. PNG/JPG/WebP, ≤512 KB. + */ + banner?: string; + /** + * Up to 6 screenshot paths shown in the gallery on the detail page. + * Each ≤512 KB; total ≤2 MB. Order is preserved. + */ + screenshots?: string[]; } export interface SettingFieldSchema { From ef8eb1d73bec8b5b5a94f70998a920ee7274b5f9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 21:20:45 +0200 Subject: [PATCH 13/49] fix: read activeAccountId from authStore in account selectors --- components/layout/account-switcher.tsx | 5 ++++- components/layout/navigation-rail.tsx | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 8e044514..6cf3573a 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -40,8 +40,11 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher const [popoverStyle, setPopoverStyle] = useState({}); const accounts = useAccountStore((s) => s.accounts); - const activeAccountId = useAccountStore((s) => s.activeAccountId); const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); + // Read activeAccountId from authStore so the selector matches the actually-loaded + // session (primaryIdentity, JMAP client). accountStore.activeAccountId is a separate + // persisted copy that can drift out of sync across hydration / partial persist writes. + const activeAccountId = useAuthStore((s) => s.activeAccountId); const activeAccount = accounts.find((a) => a.id === activeAccountId); const switchAccount = useAuthStore((s) => s.switchAccount); const logout = useAuthStore((s) => s.logout); diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index ea94803f..ad0594e7 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -184,7 +184,9 @@ export function NavigationRail({ // Account list for rail const accounts = useAccountStore((s) => s.accounts); - const activeAccountId = useAccountStore((s) => s.activeAccountId); + // Read activeAccountId from authStore so the rail's account row matches the actually-loaded + // session — accountStore has its own persisted copy that can drift out of sync. + const activeAccountId = useAuthStore((s) => s.activeAccountId); const switchAccount = useAuthStore((s) => s.switchAccount); const logout = useAuthStore((s) => s.logout); const logoutAll = useAuthStore((s) => s.logoutAll); From 0885d3c13e95e161734edeb7dce5e594a522e5d7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 21:50:04 +0200 Subject: [PATCH 14/49] feat: http:fetch permission + httpOrigins manifest field --- app/api/admin/marketplace/route.ts | 18 +++- app/api/admin/plugins/route.ts | 7 +- app/api/plugins/route.ts | 2 + lib/admin/csp-frame-origins.ts | 8 ++ lib/admin/plugin-dev.ts | 31 +++++-- lib/admin/plugin-registry.ts | 5 ++ lib/plugin-api.ts | 130 +++++++++++++++++++++++++++++ lib/plugin-hooks.ts | 6 +- lib/plugin-types.ts | 18 +++- stores/plugin-store.ts | 9 ++ 10 files changed, 225 insertions(+), 9 deletions(-) diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 5f0254f1..60ebef10 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -12,6 +12,7 @@ import { } from '@/lib/admin/plugin-registry'; import { sanitizeFrameOrigins, + sanitizeHttpOrigins, invalidateFrameOriginsCache, } from '@/lib/admin/csp-frame-origins'; import JSZip from 'jszip'; @@ -253,6 +254,18 @@ export async function POST(request: NextRequest) { ); } + const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); + const droppedHttpOrigins = Array.isArray(manifest.httpOrigins) + ? (manifest.httpOrigins as unknown[]).filter( + (v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v), + ) + : []; + if (droppedHttpOrigins.length > 0) { + warnings.push( + `Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`, + ); + } + const plugin: ServerPlugin = { id: (manifest.id as string) || slug, name: (manifest.name as string) || slug, @@ -268,11 +281,14 @@ export async function POST(request: NextRequest) { ...(declaredFrameOrigins.length > 0 ? { frameOrigins: declaredFrameOrigins } : {}), + ...(declaredHttpOrigins.length > 0 + ? { httpOrigins: declaredHttpOrigins } + : {}), }; await savePlugin(plugin, code); invalidateFrameOriginsCache(); - await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip); + await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip); return NextResponse.json({ success: true, plugin, warnings }); } diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 3f1f4e12..dd81a9d0 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -11,6 +11,7 @@ import { import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { sanitizeFrameOrigins, + sanitizeHttpOrigins, invalidateFrameOriginsCache, } from '@/lib/admin/csp-frame-origins'; @@ -170,6 +171,7 @@ export async function POST(request: NextRequest) { } const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); + const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); const now = new Date().toISOString(); const plugin: ServerPlugin = { @@ -188,13 +190,16 @@ export async function POST(request: NextRequest) { ...(declaredFrameOrigins.length > 0 ? { frameOrigins: declaredFrameOrigins } : {}), + ...(declaredHttpOrigins.length > 0 + ? { httpOrigins: declaredHttpOrigins } + : {}), installedAt: now, updatedAt: now, }; await savePlugin(plugin, code); invalidateFrameOriginsCache(); - await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip); + await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip); return NextResponse.json({ plugin }); } catch (error) { diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 80498639..d3674534 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -41,6 +41,8 @@ export async function GET() { updatedAt: p.updatedAt, // Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge. dev: p.dev, + // Surface so clients can enforce api.http.fetch origin allowlists. + httpOrigins: p.httpOrigins, settingsSchema: undefined, // Will be read from the bundle's manifest })); diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts index 910aca17..4b6f69af 100644 --- a/lib/admin/csp-frame-origins.ts +++ b/lib/admin/csp-frame-origins.ts @@ -52,6 +52,14 @@ export function sanitizeFrameOrigins(input: unknown): string[] { return out; } +/** + * Same syntax + validation as `sanitizeFrameOrigins`, but for the + * `httpOrigins` manifest field. Kept as a separate exported function so the + * intent is explicit at every call site (frame embedding vs. HTTP fetch). + */ +export const sanitizeHttpOrigins = sanitizeFrameOrigins; +export const isValidHttpOrigin = isValidFrameOrigin; + // In-memory cache. The proxy fires on every page navigation; reading the // registry JSON every time is fine but cheap to skip when nothing has // changed. Five seconds is short enough to make plugin install/uninstall diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 6f3b1bf8..0632d8e6 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { logger } from '@/lib/logger'; import type { ServerPlugin } from './plugin-registry'; +import { sanitizeFrameOrigins, sanitizeHttpOrigins } from './csp-frame-origins'; /** * Dev-mode plugin loading. @@ -118,16 +119,28 @@ async function loadDevPlugin(pluginDir: string): Promise if (!existsSync(manifestPath)) { manifestPath = path.join(pluginDir, 'dist', 'manifest.json'); } - if (!existsSync(manifestPath)) return null; + if (!existsSync(manifestPath)) { + logger.warn(`[plugin-dev] no manifest.json at root or dist/ in ${pluginDir}`); + return null; + } const manifest = await readManifest(manifestPath); - if (!manifest) return null; + if (!manifest) { + logger.warn(`[plugin-dev] manifest unreadable or not a JSON object: ${manifestPath}`); + return null; + } const id = asString(manifest.id); - if (!PLUGIN_ID_RE.test(id)) return null; + if (!PLUGIN_ID_RE.test(id)) { + logger.warn(`[plugin-dev] manifest id "${id}" rejected by id regex (${manifestPath})`); + return null; + } const entrypoint = asString(manifest.entrypoint, 'index.js'); const resolved = resolveBundlePath(pluginDir, entrypoint); - if (!resolved) return null; + if (!resolved) { + logger.warn(`[plugin-dev] entrypoint "${entrypoint}" not found at src/, root, or dist/ for ${id}`); + return null; + } // Hash from the on-disk source so any edit propagates. For src/ sources // we hash the source — close enough for dev-time change detection (we @@ -136,7 +149,10 @@ async function loadDevPlugin(pluginDir: string): Promise try { const code = await readFile(resolved.bundlePath); bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16); - } catch { + } catch (err) { + logger.warn(`[plugin-dev] failed to read ${resolved.bundlePath} for ${id}`, { + error: err instanceof Error ? err.message : String(err), + }); return null; } @@ -152,6 +168,9 @@ async function loadDevPlugin(pluginDir: string): Promise ? manifest.permissions.filter((p): p is string => typeof p === 'string') : []; + const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); + const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); + const plugin: ServerPlugin = { id, name: asString(manifest.name, id), @@ -166,6 +185,8 @@ async function loadDevPlugin(pluginDir: string): Promise ...(manifest.configSchema && typeof manifest.configSchema === 'object' ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } : {}), + ...(frameOrigins.length > 0 ? { frameOrigins } : {}), + ...(httpOrigins.length > 0 ? { httpOrigins } : {}), installedAt, updatedAt: new Date().toISOString(), bundleHash, diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 13fab06c..f3bc582e 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -53,6 +53,11 @@ export interface ServerPlugin { * embed. Merged into the host frame-src by the proxy. */ frameOrigins?: string[]; + /** + * Validated HTTPS origins the plugin may target via `api.http.fetch()`. + * Same syntax as `frameOrigins`. Surfaced to clients via /api/plugins. + */ + httpOrigins?: string[]; } export interface ServerTheme { diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index ee717b10..e8547650 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -108,6 +108,75 @@ function createPluginLogger(pluginId: string) { }; } +// --- Cross-origin fetch helpers ------------------------------ + +/** + * Returns true when `url`'s origin is allowed by one of the plugin's + * declared `httpOrigins` patterns. Patterns are either a literal origin + * (`https://host[:port]`) or a wildcard subdomain form (`https://*.host`). + * + * Wildcards match exactly one subdomain layer above `host` — e.g. + * `https://*.example.com` matches `https://a.example.com` but NOT + * `https://example.com` and NOT `https://a.b.example.com`. This mirrors how + * the CSP frame-src handles wildcards and avoids accidentally widening + * access when the manifest only intended a single tier. + */ +function originMatchesAllowlist(url: URL, allowlist: string[]): boolean { + if (url.protocol !== 'https:') return false; + for (const entry of allowlist) { + let parsed: URL; + try { + parsed = new URL(entry.replace('*.', '')); + } catch { + continue; + } + if (parsed.protocol !== 'https:') continue; + const port = url.port || ''; + const expectedPort = parsed.port || ''; + if (port !== expectedPort) continue; + if (entry.includes('*.')) { + const suffix = '.' + parsed.hostname.toLowerCase(); + if (url.hostname.toLowerCase().endsWith(suffix)) { + const prefix = url.hostname.slice(0, url.hostname.length - suffix.length); + // Require exactly one non-empty subdomain label. + if (prefix.length > 0 && !prefix.includes('.')) return true; + } + } else { + if (url.hostname.toLowerCase() === parsed.hostname.toLowerCase()) return true; + } + } + return false; +} + +// --- Cross-origin fetch types -------------------------------- + +export interface PluginFetchInit { + /** HTTP method. Defaults to GET. */ + method?: string; + /** Request headers. Plain object only — no Headers / cookies forwarded. */ + headers?: Record; + /** Body. Plain string, ArrayBuffer, Uint8Array, Blob, or FormData. */ + body?: string | ArrayBuffer | ArrayBufferView | Blob | FormData | null; + /** Optional AbortSignal for cancellation. */ + signal?: AbortSignal; +} + +export interface PluginFetchResponse { + ok: boolean; + status: number; + statusText: string; + /** Response headers, lower-cased keys. */ + headers: Record; + /** Resolves the body as text. */ + text: () => Promise; + /** Resolves the body as parsed JSON, or null on parse error. */ + json: () => Promise; + /** Resolves the body as raw bytes. */ + arrayBuffer: () => Promise; + /** Resolves the body as a Blob. */ + blob: () => Promise; +} + // --- PluginAPI interface ------------------------------------- export interface PluginAPI { @@ -137,6 +206,16 @@ export interface PluginAPI { }; http: { post: (path: string, body: Record) => Promise<{ ok: boolean; status: number; data: unknown }>; + /** + * Cross-origin fetch against an origin declared in the manifest's + * `httpOrigins` allowlist. Requires `http:fetch` permission. + * + * No webmail credentials are forwarded — the plugin must supply its own + * `Authorization` (or other auth) header. Each call is gated on origin + * even when the URL came from plugin settings, so a user-pasted URL + * outside the allowlist is rejected at the boundary. + */ + fetch: (url: string, init?: PluginFetchInit) => Promise; }; storage: ReturnType; log: ReturnType; @@ -759,6 +838,57 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { const data = await res.json().catch(() => null); return { ok: res.ok, status: res.status, data }; }, + + fetch: async (rawUrl: string, init?: PluginFetchInit) => { + requirePermission(plugin, 'http:fetch'); + if (typeof rawUrl !== 'string') { + throw new Error('url must be a string'); + } + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error('url must be an absolute https:// URL'); + } + const allowlist = plugin.httpOrigins ?? []; + if (allowlist.length === 0) { + throw new Error(`Plugin "${plugin.id}" has no httpOrigins declared`); + } + if (!originMatchesAllowlist(url, allowlist)) { + throw new Error(`Origin ${url.origin} not in plugin httpOrigins allowlist`); + } + // Defence-in-depth: don't let the plugin smuggle a header that the + // host's same-origin /api flow uses to authenticate the user. + const safeHeaders: Record = {}; + if (init?.headers) { + for (const [k, v] of Object.entries(init.headers)) { + const lower = k.toLowerCase(); + if (lower === 'cookie' || lower === 'x-jmap-username') continue; + safeHeaders[k] = v; + } + } + const res = await fetch(url.toString(), { + method: init?.method ?? 'GET', + headers: safeHeaders, + body: (init?.body ?? undefined) as BodyInit | undefined, + signal: init?.signal, + credentials: 'omit', + mode: 'cors', + redirect: 'follow', + }); + const headersOut: Record = {}; + res.headers.forEach((value, key) => { headersOut[key.toLowerCase()] = value; }); + return { + ok: res.ok, + status: res.status, + statusText: res.statusText, + headers: headersOut, + text: () => res.text(), + json: () => res.json().catch(() => null), + arrayBuffer: () => res.arrayBuffer(), + blob: () => res.blob(), + }; + }, }, storage: createPluginStorage(plugin.id), diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 61f3a6f7..4dc57061 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -59,6 +59,10 @@ export const pluginErrorTracker = new PluginErrorTracker(); // ─── Timeout Helper ────────────────────────────────────────── const DEFAULT_TIMEOUT_MS = 5000; +// Intercept hooks frequently block on user confirmation modals (send, +// reply-all, mailto, attachment upload), so they need a much longer budget +// than observer / transform hooks. +const INTERCEPT_TIMEOUT_MS = 60_000; function withTimeout(promise: T | Promise, ms: number = DEFAULT_TIMEOUT_MS): Promise { if (!(promise instanceof Promise)) return Promise.resolve(promise); @@ -137,7 +141,7 @@ export class HookBus any> { for (const { pluginId, handler } of this.handlers) { if (pluginErrorTracker.isDisabled(pluginId)) continue; try { - const result = await withTimeout(handler(...args)); + const result = await withTimeout(handler(...args), INTERCEPT_TIMEOUT_MS); if (result === false) return false; } catch (err) { pluginErrorTracker.record(pluginId, err); diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index a1a58de3..4aaae59e 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -116,6 +116,17 @@ export interface PluginManifest { * Validated at install time and merged into the host CSP `frame-src`. */ frameOrigins?: string[]; + /** + * External HTTPS origins this plugin may make `api.http.fetch()` requests + * to. Same syntax as `frameOrigins`. Validated at install time. Each + * `api.http.fetch` call's URL must resolve to one of these origins (exact + * host or a `*.host` wildcard match). + * + * Use for plugins that talk directly to a third-party service (e.g. + * Nextcloud, Slack) instead of going through a same-origin /api/* route. + * The remote host must serve CORS headers permitting the webmail origin. + */ + httpOrigins?: string[]; // ─── Marketplace media (NOT shipped in the runtime zip) ────── /** @@ -208,6 +219,11 @@ export interface InstalledPlugin { * detect re-uploads of the same version so clients re-download the JS. */ bundleHash?: string; + /** + * Validated allowlist of external HTTPS origins this plugin may target via + * `api.http.fetch()`. Carried over from the manifest at install time. + */ + httpOrigins?: string[]; } // ─── UI Slots ──────────────────────────────────────────────── @@ -761,7 +777,7 @@ export const ALL_PERMISSIONS = [ 'settings:read', 'settings:write', 'security:read', 'auth:observe', - 'http:post', + 'http:post', 'http:fetch', 'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer', 'ui:composer-toolbar', 'ui:composer-sidebar', 'ui:sidebar-widget', 'ui:settings-section', diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 6b455826..4ad281cf 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -98,6 +98,9 @@ export const usePluginStore = create()( adminApproved: false, // Requires admin approval before it can be enabled settings: existing?.settings ?? {}, settingsSchema: manifest.settingsSchema, + ...(manifest.httpOrigins && manifest.httpOrigins.length > 0 + ? { httpOrigins: manifest.httpOrigins } + : {}), }; // Save code to IndexedDB @@ -308,6 +311,8 @@ interface ServerPluginInfo { updatedAt?: string; /** True when the plugin was loaded from the server's PLUGIN_DEV_DIR */ dev?: boolean; + /** Allowlist of origins this plugin may target via api.http.fetch(). */ + httpOrigins?: string[]; } const SERVER_MANAGED_KEY = 'server-managed-plugin-ids'; @@ -408,6 +413,9 @@ async function syncServerPlugins( adminApproved: true, // Server-managed plugins are always approved settings: {}, bundleHash: sp.bundleHash, + ...(sp.httpOrigins && sp.httpOrigins.length > 0 + ? { httpOrigins: sp.httpOrigins } + : {}), }; set(state => { @@ -443,6 +451,7 @@ async function syncServerPlugins( managed: true, forceEnabled: sp.forceEnabled, bundleHash: sp.bundleHash, + httpOrigins: sp.httpOrigins, } : p ), From a44bd7c3e6045d48d7b8c213454ad6250485db06 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 21:52:58 +0200 Subject: [PATCH 15/49] fix: add missing body type assertion in createPluginAPI fetch options --- lib/plugin-api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index e8547650..e4a04cd6 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -870,6 +870,7 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { const res = await fetch(url.toString(), { method: init?.method ?? 'GET', headers: safeHeaders, + // eslint-disable-next-line no-undef body: (init?.body ?? undefined) as BodyInit | undefined, signal: init?.signal, credentials: 'omit', From b5e01899380588c53f10fb6f9e42ad1ba612d33d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 5 May 2026 21:57:56 +0200 Subject: [PATCH 16/49] fix: adjust toast item border radius and progress bar styles --- components/ui/toast.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index c7d940be..547bfe43 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -81,7 +81,7 @@ export function ToastItem({ toast, onClose }: ToastProps) { return (
{/* Left accent bar */} -
+
{/* Icon */} From 2903e56cf6cf03fa10d450ff66decdf01ee726b8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 6 May 2026 00:45:37 +0200 Subject: [PATCH 17/49] fix: align calendar invitation icon with sender avatar column --- .../email/calendar-invitation-banner.tsx | 543 +++++++++--------- components/email/email-viewer.tsx | 2 +- locales/cs/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/fr/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ru/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh/common.json | 1 + 18 files changed, 304 insertions(+), 257 deletions(-) diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 735f7375..8373de39 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -37,6 +37,7 @@ import { } from '@/lib/calendar-invitation'; import { cn } from '@/lib/utils'; import { sanitizeColor } from '@/components/calendar/event-card'; +import { RecipientPopover } from './recipient-popover'; interface InvitationChangeItem { label: string; @@ -327,25 +328,26 @@ function buildParticipantsForRsvp( ); } -function getMethodAccentClass(method: InvitationMethod, actorStatus?: string | null): string { +function getMethodIconTone(method: InvitationMethod, actorStatus?: string | null): string { switch (method) { case 'cancel': case 'declinecounter': - return 'border-l-red-500 dark:border-l-red-400'; - case 'request': - case 'add': - return 'border-l-blue-500 dark:border-l-blue-400'; + return 'bg-destructive/15 text-destructive'; case 'counter': - return 'border-l-amber-500 dark:border-l-amber-400'; + return 'bg-warning/15 text-warning'; case 'reply': switch (actorStatus) { - case 'accepted': return 'border-l-green-500 dark:border-l-green-400'; - case 'tentative': return 'border-l-amber-500 dark:border-l-amber-400'; - case 'declined': return 'border-l-red-500 dark:border-l-red-400'; - default: return 'border-l-blue-500 dark:border-l-blue-400'; + case 'accepted': return 'bg-success/15 text-success'; + case 'tentative': return 'bg-warning/15 text-warning'; + case 'declined': return 'bg-destructive/15 text-destructive'; + default: return 'bg-primary/15 text-primary'; } + case 'request': + case 'add': + case 'publish': + return 'bg-primary/15 text-primary'; default: - return 'border-l-slate-400 dark:border-l-slate-500'; + return 'bg-muted text-muted-foreground'; } } @@ -500,7 +502,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const actorName = actorSummary?.name || actorSummary?.email || t('actor_unknown'); const actorStatus = getParticipationLabel(t, actorSummary?.participationStatus ?? null); const actorMessage = actorSummary ? getActorMessage(t, method, actorName, actorStatus) : null; - const actionFeedback = actionNotice; // For REQUEST method, allow RSVP even if we can't find the user in participants: // the email was sent TO the user, so they are an attendee. handleRsvp handles // the import-then-find-participant flow for this case. @@ -735,130 +736,151 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp } }; - const accentClass = getMethodAccentClass(method, actorSummary?.participationStatus); - if (state === 'loading') { return ( -
- - - {t('loading')} +
+
+ +
+
+ + {t('loading')} +
); } if (state === 'error') { return ( -
- +
+
+ +
{t('parse_error')}
); } + const iconTone = getMethodIconTone(method, actorSummary?.participationStatus); + + const hasStatusPills = Boolean( + existingEvent + || userIsOrganizer + || (participationLabel && myParticipant) + || actionNotice + || (parsedEvent?.status && parsedEvent.status !== 'confirmed') + ); + + const showActionsRow = showDetails && ( + canRespond + || (supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation) + || canApplyProposal + || (supportsCalendar && (existingEvent || parsedEvent)) + || !supportsCalendar + ); + return ( -
- {/* Header */} -
-
- {isCancellation ? ( - - ) : ( - - )} - {bannerTitle} -
- {canCollapse && ( - +
+ {/* Avatar-style icon */} +
+ {isCancellation ? ( + + ) : ( + )}
- {/* Content */} - {showDetails && ( -
-
- {/* Left: Event info */} -
- {/* Event title */} - {summary?.title && ( -
-

- {summary.title} -

- {parsedEvent?.sequence != null && parsedEvent.sequence > 0 && ( - - {t('event_updated', { sequence: parsedEvent.sequence })} - - )} -
- )} - - {/* Event details */} -
- {summary?.start && ( -
- - - {formatDateTime(summary.start)} - {summary.end && ` – ${formatDateTime(summary.end)}`} - -
- )} - {summary?.location && ( -
- - {summary.location} -
- )} - {summary?.organizer && ( - - - {t('organizer', { name: summary.organizer })} - - )} - {summary && summary.attendeeCount > 0 && ( - {t('attendees', { count: summary.attendeeCount })} - )} + {/* Content column */} +
+ {/* Eyebrow + title + collapse */} +
+
+
+ {bannerTitle}
+ {summary?.title && ( +

+ {summary.title} +

+ )}
- {/* Right: Info & actor messages on large screens */} -
- {bannerInfo && ( -

{bannerInfo}

+
+ {parsedEvent?.sequence != null && parsedEvent.sequence > 0 && ( + + {t('event_updated', { sequence: parsedEvent.sequence })} + )} - {actorMessage && ( -

{actorMessage}

- )} - {actorSummary?.participationComment && ( -

- {t('actor_note', { comment: actorSummary.participationComment })} -

+ {canCollapse && ( + )}
- {/* Status badges */} - {(existingEvent || userIsOrganizer || (participationLabel && myParticipant) || actionFeedback || (parsedEvent?.status && parsedEvent.status !== 'confirmed')) && ( -
+ {/* Meta rows */} + {showDetails && summary && (summary.start || summary.location || summary.attendeeCount > 0) && ( +
+ {summary.start && ( + + + + {formatDateTime(summary.start)} + {summary.end && ` – ${formatDateTime(summary.end)}`} + + + )} + {summary.location && ( + + + {summary.location} + + )} + {summary.attendeeCount > 0 && ( + {t('attendees', { count: summary.attendeeCount })} + )} +
+ )} + + {/* Organizer row (clickable, left-aligned) */} + {showDetails && summary?.organizer && ( +
+ + {t('organizer_label')} + {summary.organizerEmail ? ( + + ) : ( + {summary.organizer} + )} +
+ )} + + {/* Status pills */} + {showDetails && hasStatusPills && ( +
{parsedEvent?.status && parsedEvent.status !== 'confirmed' && ( {t(`event_status_${parsedEvent.status}`)} @@ -876,21 +898,45 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {participationLabel && myParticipant && ( {t('your_response', { status: participationLabel })} )} - {actionFeedback && ( + {actionNotice && ( - {actionFeedback} + {actionNotice} )}
)} + {/* Info / actor messages */} + {showDetails && (bannerInfo || actorMessage || actorSummary?.participationComment) && ( +
+ {bannerInfo &&

{bannerInfo}

} + {actorMessage &&

{actorMessage}

} + {actorSummary?.participationComment && ( +

{t('actor_note', { comment: actorSummary.participationComment })}

+ )} +
+ )} + + {/* Trust warning */} + {showDetails && trustMessage && trustAssessment && ( +
+ + {trustMessage} +
+ )} + {/* Proposed changes */} - {proposedChanges.length > 0 && ( + {showDetails && proposedChanges.length > 0 && (
{t('proposed_changes')}
@@ -904,169 +950,154 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
)} - {/* Trust warning */} - {trustMessage && trustAssessment && ( -
- - {trustMessage} -
- )} - {/* Action error */} - {actionError && ( -
- - {actionError} + {showDetails && actionError && ( +
+ + {actionError}
)} -
- )} - {/* Actions */} - {showDetails && ( -
- {canRespond && ( - <> - - - + {/* Actions */} + {showActionsRow && ( +
+ {canRespond && ( + <> + + + +
+ + )} -
- - )} + {supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && ( + <> + - {supportsCalendar && !existingEvent && allowsImport && !isResponseOnly && !isCancellation && ( - <> + {showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal( +
+
+ {t('select_calendar')} +
+ {calendars.map((cal) => ( + + ))} +
, + document.body, + )} + + )} + + {canApplyProposal && ( + )} - {showCalendarPicker && calendars.length > 1 && pickerPosition && typeof document !== 'undefined' && createPortal( -
-
- {t('select_calendar')} -
- {calendars.map((cal) => ( - - ))} -
, - document.body, - )} - - )} + {supportsCalendar && (existingEvent || parsedEvent) && ( + + )} - {canApplyProposal && ( - - )} + {!supportsCalendar && ( + {t('no_calendar')} + )} - {supportsCalendar && (existingEvent || parsedEvent) && ( - - )} - - {!supportsCalendar && ( - {t('no_calendar')} - )} - - {isProcessing && ( - + {isProcessing && ( + + )} +
)}
- )}
); } diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index b37f5abe..ad5a52cf 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -4837,7 +4837,7 @@ export function EmailViewer({ {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || hasCalendarInvitation) && (
-
+
{/* External Content Controls */} {hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && ( diff --git a/locales/cs/common.json b/locales/cs/common.json index 038e405e..04f73b42 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Návrh na změnu zamítnut", "cancelled_title": "Událost zrušena", "organizer": "Organizátor: {name}", + "organizer_label": "Organizátor:", "attendees": "{count, plural, one {1 účastník} few {# účastníci} other {# účastníků}}", "accept": "Přijmout", "maybe": "Možná", diff --git a/locales/de/common.json b/locales/de/common.json index f4a84719..e9318e59 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Gegenvorschlag abgelehnt", "cancelled_title": "Veranstaltung abgesagt", "organizer": "Organisiert von {name}", + "organizer_label": "Organisiert von", "attendees": "{count, plural, one {# Teilnehmer} other {# Teilnehmer}}", "accept": "Annehmen", "maybe": "Vielleicht", diff --git a/locales/en/common.json b/locales/en/common.json index 8f4b2338..6f7060e3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -415,6 +415,7 @@ "declined_counter_title": "Counter Proposal Declined", "cancelled_title": "Event Cancelled", "organizer": "Organized by {name}", + "organizer_label": "Organized by", "attendees": "{count, plural, one {# attendee} other {# attendees}}", "accept": "Accept", "maybe": "Maybe", diff --git a/locales/es/common.json b/locales/es/common.json index b30ecf6f..bf8ff4c3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Contrapropuesta rechazada", "cancelled_title": "Evento cancelado", "organizer": "Organizado por {name}", + "organizer_label": "Organizado por", "attendees": "{count, plural, one {# asistente} other {# asistentes}}", "accept": "Aceptar", "maybe": "Quizás", diff --git a/locales/fr/common.json b/locales/fr/common.json index 9ee9d7a7..352fb138 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Contre-proposition refusée", "cancelled_title": "Événement annulé", "organizer": "Organisé par {name}", + "organizer_label": "Organisé par", "attendees": "{count, plural, one {# participant} other {# participants}}", "accept": "Accepter", "maybe": "Peut-être", diff --git a/locales/it/common.json b/locales/it/common.json index be102e55..aece9ba3 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Controproposta rifiutata", "cancelled_title": "Evento annullato", "organizer": "Organizzato da {name}", + "organizer_label": "Organizzato da", "attendees": "{count, plural, one {# partecipante} other {# partecipanti}}", "accept": "Accetta", "maybe": "Forse", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7e975d49..70588f99 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "対案は拒否されました", "cancelled_title": "イベントがキャンセルされました", "organizer": "{name} が主催", + "organizer_label": "主催者:", "attendees": "{count}名の参加者", "accept": "承諾", "maybe": "未定", diff --git a/locales/ko/common.json b/locales/ko/common.json index f1f03095..7652a347 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "시간 제안이 거절되었어요", "cancelled_title": "일정이 취소되었어요", "organizer": "주최자: {name}", + "organizer_label": "주최자:", "attendees": "{count, plural, other {참석자 #명}}", "accept": "수락", "maybe": "미정", diff --git a/locales/lv/common.json b/locales/lv/common.json index c1e73fd9..93144c04 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Pretpiedāvājums noraidīts", "cancelled_title": "Pasākums atcelts", "organizer": "Organizators: {name}", + "organizer_label": "Organizators:", "attendees": "{count, plural, one {# dalībnieks} other {# dalībnieki}}", "accept": "Pieņemt", "maybe": "Varbūt", diff --git a/locales/nl/common.json b/locales/nl/common.json index 46b2368a..eec97121 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Tegenvoorstel afgewezen", "cancelled_title": "Evenement geannuleerd", "organizer": "Georganiseerd door {name}", + "organizer_label": "Georganiseerd door", "attendees": "{count, plural, one {# deelnemer} other {# deelnemers}}", "accept": "Accepteren", "maybe": "Misschien", diff --git a/locales/pl/common.json b/locales/pl/common.json index d27e1cce..f9acb478 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Propozycja zmian odrzucona", "cancelled_title": "Wydarzenie anulowane", "organizer": "Organizator: {name}", + "organizer_label": "Organizator:", "attendees": "{count, plural, one {# uczestnik} other {# uczestników}}", "accept": "Akceptuj", "maybe": "Może", diff --git a/locales/pt/common.json b/locales/pt/common.json index 8ce30db6..5ad5ee3e 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -388,6 +388,7 @@ "declined_counter_title": "Contraproposta recusada", "cancelled_title": "Evento cancelado", "organizer": "Organizado por {name}", + "organizer_label": "Organizado por", "attendees": "{count, plural, one {# participante} other {# participantes}}", "accept": "Aceitar", "maybe": "Talvez", diff --git a/locales/ru/common.json b/locales/ru/common.json index b82f20bc..273c7300 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Встречное предложение отклонено", "cancelled_title": "Событие отменено", "organizer": "Организовано {name}", + "organizer_label": "Организовано", "attendees": "{count, plural, one {# участник} other {# участников}}", "accept": "Принять", "maybe": "Возможно", diff --git a/locales/tr/common.json b/locales/tr/common.json index 613a0751..a08cbb20 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Karşı Teklif Reddedildi", "cancelled_title": "Etkinlik İptal Edildi", "organizer": "{name} tarafından düzenleniyor", + "organizer_label": "Düzenleyen:", "attendees": "{count, plural, one {# katılımcı} other {# katılımcı}}", "accept": "Kabul et", "maybe": "Belki", diff --git a/locales/uk/common.json b/locales/uk/common.json index f518133a..9da85359 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "Зустрічна пропозиція відхилена", "cancelled_title": "Подію скасовано", "organizer": "Організовано {name}", + "organizer_label": "Організовано", "attendees": "{count, plural, one {# учасник} few {# учасники} many {# учасників} other {# учасників}}", "accept": "прийняти", "maybe": "можливо", diff --git a/locales/zh/common.json b/locales/zh/common.json index b53542c8..442fe270 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -390,6 +390,7 @@ "declined_counter_title": "改期建议已被拒绝", "cancelled_title": "活动已取消", "organizer": "组织者:{name}", + "organizer_label": "组织者:", "attendees": "{count, plural, one {# 位参与者} other {# 位参与者}}", "accept": "接受", "maybe": "暂定", From 904a62ce796802b4b63d806fed1112735acbdf98 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 6 May 2026 00:49:09 +0200 Subject: [PATCH 18/49] feat: make calendar invitation banner collapsible --- .../email/calendar-invitation-banner.tsx | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 8373de39..59863095 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -378,7 +378,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const pickerTriggerRef = useRef(null); const [selectedCalendarId, setSelectedCalendarId] = useState(''); const [rawIcsMethod, setRawIcsMethod] = useState('unknown'); - const [isCollapsed, setIsCollapsed] = useState(true); + const [isCollapsed, setIsCollapsed] = useState(false); const attachment = findCalendarAttachment(email); @@ -460,8 +460,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const summary = parsedEvent ? formatEventSummary(parsedEvent) : null; const isCancellation = method === 'cancel'; const isResponseOnly = method === 'reply' || method === 'refresh' || method === 'counter' || method === 'declinecounter'; - const canCollapse = method === 'reply'; - const showDetails = !canCollapse || !isCollapsed; + const showDetails = !isCollapsed; const allowsRsvp = method === 'request'; const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown'; @@ -817,17 +816,15 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp {t('event_updated', { sequence: parsedEvent.sequence })} )} - {canCollapse && ( - - )} +
From 9639a6bb750c91bd696c261c8d93b29bf437a5df Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 6 May 2026 00:51:16 +0200 Subject: [PATCH 19/49] feat: expand calendar invitation banner on row click --- .../email/calendar-invitation-banner.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 59863095..93470997 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -779,7 +779,22 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp ); return ( -
+
setIsCollapsed(false) : undefined} + onKeyDown={isCollapsed ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsCollapsed(false); + } + } : undefined} + role={isCollapsed ? 'button' : undefined} + tabIndex={isCollapsed ? 0 : undefined} + aria-expanded={isCollapsed ? false : undefined} + > {/* Avatar-style icon */}
setIsCollapsed((prev) => !prev)} + onClick={(e) => { + e.stopPropagation(); + setIsCollapsed((prev) => !prev); + }} aria-expanded={!isCollapsed} className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors" > From e7be3d1e0cdf7b125d867421f51807c38690b8d1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 6 May 2026 00:54:46 +0200 Subject: [PATCH 20/49] fix: render PDF previews via with blob: in object-src CSP #253 --- components/files/file-preview-modal.tsx | 15 ++++++++++----- proxy.ts | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index d751e0a2..f8e295ac 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -221,12 +221,17 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: )} {!loading && !error && fileType === "pdf" && objectUrl && ( -