From 9ab7339320604a80101d482d118cbf08daea918c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:51:17 +0200 Subject: [PATCH] feat: add plugin ui.prompt dialog and first-class settings-section tabs --- app/(main)/[locale]/settings/page.tsx | 59 ++++++--- components/plugins/plugin-dialog-host.tsx | 138 +++++++++++++++------- components/settings/account-settings.tsx | 3 - lib/plugin-sandbox/host-api.ts | 24 +++- lib/plugin-sandbox/host-dialog.ts | 62 ++++++++-- lib/plugin-sandbox/protocol.ts | 2 +- lib/plugin-sandbox/runtime.tsx | 10 ++ 7 files changed, 223 insertions(+), 75 deletions(-) diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 55d748ea..7f94dc6b 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react'; import { useRouter } from '@/i18n/navigation'; import { useTranslations, useMessages } from 'next-intl'; import { @@ -66,6 +66,8 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings import { NotificationSettings } from '@/components/settings/notification-settings'; import { ThemesSettings } from '@/components/settings/themes-settings'; import { PluginsSettings } from '@/components/settings/plugins-settings'; +import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot'; +import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; @@ -113,8 +115,14 @@ type Tab = type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced'; +// A plugin that exposes a `settings-section` slot gets its own first-class +// Settings entry, keyed `plugin:`, so its UI (e.g. S/MIME key import) is +// discoverable as a menu point rather than buried inside another panel. +type PluginTabId = `plugin:${string}`; +type SettingsTabId = Tab | PluginTabId; + interface TabDef { - id: Tab; + id: SettingsTabId; label: string; icon: LucideIcon; group: TabGroup; @@ -330,7 +338,7 @@ const LEGACY_TAB_MAP: Record = { advanced: 'about_data', }; -function readPersistedTab(): Tab { +function readPersistedTab(): SettingsTabId { try { // One-shot deep link from the sidebar section gears (Folders / Tags). // Used only as the initial tab and intentionally NOT written to @@ -338,7 +346,7 @@ function readPersistedTab(): Tab { // default that the regular Settings button lands on. Cleared on mount. const deepLink = sessionStorage.getItem('settings-deep-link-tab'); if (deepLink) { - return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab; + return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId; } const saved = localStorage.getItem('settings-active-tab'); if (!saved) return 'appearance'; @@ -347,7 +355,7 @@ function readPersistedTab(): Tab { try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ } return migrated; } - return saved as Tab; + return saved as SettingsTabId; } catch { return 'appearance'; } @@ -364,7 +372,15 @@ export default function SettingsPage() { const { quota, isPushConnected } = useEmailStore(); const { stalwartFeaturesEnabled } = useConfig(); const { isFeatureEnabled } = usePolicyStore(); - const [activeTab, setActiveTab] = useState(readPersistedTab); + const [activeTab, setActiveTab] = useState(readPersistedTab); + // Active plugins that expose a `settings-section` slot — each becomes its own + // Settings menu entry. Referentially stable per registry mutation, so it is + // safe to feed useSyncExternalStore directly. + const pluginSettingsOffers = useSyncExternalStore( + pluginRegistrySubscribe, + () => pluginOffersForSlot('settings-section'), + () => pluginOffersForSlot('settings-section'), + ); // Consume the one-shot deep-link key so a section gear only steers this one // open, never the persisted default for future Settings-button clicks. useEffect(() => { @@ -372,7 +388,7 @@ export default function SettingsPage() { }, []); const [mobileShowContent, setMobileShowContent] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null); + const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null); const isDesktop = useIsDesktop(); const messages = useMessages() as Record; @@ -624,6 +640,14 @@ export default function SettingsPage() { ...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []), ...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []), + // Plugin-contributed settings pages: one entry per active plugin that + // offers a `settings-section` slot (e.g. S/MIME key & certificate manager). + ...pluginSettingsOffers.map((offer): TabDef => ({ + id: `plugin:${offer.pluginId}` as PluginTabId, + label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId, + icon: Puzzle, + group: 'apps', + })), // Advanced { id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' }, @@ -644,7 +668,7 @@ export default function SettingsPage() { ].filter(Boolean) as Tab[]) : []; const visibleTabs = managedAccountId - ? tabs.filter((tab) => scopedTabIds.includes(tab.id)) + ? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab)) : tabs; // Group tabs by category @@ -660,12 +684,12 @@ export default function SettingsPage() { const matchesQuery = (tab: TabDef) => { if (!trimmedQuery) return true; if (tab.label.toLowerCase().includes(trimmedQuery)) return true; - return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false; + return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false; }; - const subResultsForTab = (tabId: Tab): SubResult[] => { + const subResultsForTab = (tabId: SettingsTabId): SubResult[] => { if (!trimmedQuery) return []; - const list = tabSubResults[tabId] ?? []; + const list = tabSubResults[tabId as Tab] ?? []; return list .filter((r) => r.label.toLowerCase().includes(trimmedQuery) || @@ -684,11 +708,11 @@ export default function SettingsPage() { // mode hides it), fall back. In scoped mode fall back to the first scoped tab; // otherwise the usual 'appearance' default. const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab); - const effectiveActiveTab: Tab = isActiveVisible + const effectiveActiveTab: SettingsTabId = isActiveVisible ? activeTab : (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance'); - const handleTabSelect = (tabId: Tab) => { + const handleTabSelect = (tabId: SettingsTabId) => { setActiveTab(tabId); try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ } if (!isDesktop) { @@ -696,7 +720,7 @@ export default function SettingsPage() { } }; - const handleSubResultSelect = (tabId: Tab, sub: SubResult) => { + const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => { handleTabSelect(tabId); setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId }); }; @@ -754,6 +778,13 @@ export default function SettingsPage() { {effectiveActiveTab === 'themes' && } {effectiveActiveTab === 'plugins' && } {effectiveActiveTab === 'debug' && } + {effectiveActiveTab.startsWith('plugin:') && ( + + )} ); diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx index 4ffbb8a0..54c6bce0 100644 --- a/components/plugins/plugin-dialog-host.tsx +++ b/components/plugins/plugin-dialog-host.tsx @@ -1,10 +1,12 @@ 'use client'; -// Host-rendered modal for plugin-requested confirm/alert dialogs. +// Host-rendered modal for plugin-requested confirm/alert/prompt dialogs. // Subscribes to the host-dialog queue and renders the head request, one at -// a time. Closing the modal advances the queue. +// a time. Closing the modal advances the queue. Prompts collect one or more +// (optionally masked) fields so plugins never fall back to `window.prompt`, +// which the sandbox blocks. -import React, { useEffect, useSyncExternalStore } from 'react'; +import React, { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog'; // Lightweight **bold** support in plugin dialog messages. Everything else is @@ -23,26 +25,50 @@ function renderMessage(message?: string): React.ReactNode { export function PluginDialogHost(): React.JSX.Element | null { const current = useSyncExternalStore(subscribe, head, () => null); + const isPrompt = current?.kind === 'prompt'; + const fields = useMemo(() => (isPrompt ? current?.fields ?? [] : []), [isPrompt, current]); + + // Field values for a prompt, re-initialised whenever a new dialog surfaces. + const [values, setValues] = useState>({}); + useEffect(() => { + if (!current) return; + const init: Record = {}; + for (const f of current.fields ?? []) init[f.name] = ''; + setValues(init); + }, [current]); + + const canSubmit = fields.every((f) => !f.required || (values[f.name] ?? '').length > 0); + + const cancel = () => resolveHead(current?.kind === 'prompt' ? null : false); + const submitPrompt = () => { if (canSubmit) resolveHead(values); }; + useEffect(() => { if (!current) return; function onKey(e: KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault(); - resolveHead(false); - } else if (e.key === 'Enter') { + cancel(); + } else if (e.key === 'Enter' && !isPrompt) { + // For prompts, Enter is handled by the form (so it respects required + // validation and works from within an input); non-prompt dialogs accept. e.preventDefault(); resolveHead(true); } } document.addEventListener('keydown', onKey, true); return () => document.removeEventListener('keydown', onKey, true); - }, [current]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [current, isPrompt, canSubmit, values]); if (!current) return null; - const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : 'Confirm'); + const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : current.kind === 'prompt' ? 'Submit' : 'Confirm'); const cancelLabel = current.cancelLabel ?? 'Cancel'; + const btnBase: React.CSSProperties = { padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500, cursor: 'pointer' }; + const secondaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid var(--color-border, #e2e8f0)', background: 'transparent', color: 'inherit' }; + const primaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid transparent', background: 'var(--color-primary, #3b82f6)', color: 'var(--color-primary-foreground, #fff)' }; + return (
{ - if (e.target === e.currentTarget) resolveHead(false); + if (e.target === e.currentTarget) cancel(); }} >
{current.title} -

- {renderMessage(current.message)} -

-
- {current.kind === 'confirm' && ( + {current.message && ( +

+ {renderMessage(current.message)} +

+ )} + {isPrompt ? ( +
{ e.preventDefault(); submitPrompt(); }}> +
+ {fields.map((f, i) => ( + + ))} +
+
+ + +
+
+ ) : ( +
+ {current.kind === 'confirm' && ( + + )} - )} - -
+
+ )}
From plugin: {current.pluginId}
diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index 88929794..ae0ccbf8 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -14,7 +14,6 @@ import { Button } from '@/components/ui/button'; import { useRouter } from '@/i18n/navigation'; import { getMaxAccounts } from '@/lib/account-utils'; import { formatFileSize, cn } from '@/lib/utils'; -import { PluginSlot } from '@/components/plugins/plugin-slot'; function hostnameOf(serverUrl: string): string { try { return new URL(serverUrl).hostname; } catch { return serverUrl; } @@ -181,8 +180,6 @@ export function AccountSettings() { )} - - {/* Logged-in accounts list */} {accounts.length > 0 && ( diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 1f6c0cce..f13fbba4 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -8,7 +8,7 @@ import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { apiFetch } from '../browser-navigation'; -import { awaitDialog } from './host-dialog'; +import { awaitDialog, awaitPrompt, type PromptField } from './host-dialog'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -46,6 +46,7 @@ const PERM_PER_METHOD: Record = { // ui - any plugin can ask the host to render a modal or open a URL. 'ui.confirm': null, 'ui.alert': null, + 'ui.prompt': null, 'ui.openExternalUrl': null, }; @@ -364,6 +365,27 @@ export async function dispatchApiCall( }); return undefined; } + case 'ui.prompt': { + const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; fields?: PromptField[] }; + const fields: PromptField[] = Array.isArray(opts.fields) + ? opts.fields.map((f) => ({ + name: String(f.name), + label: String(f.label), + type: f.type === 'password' ? 'password' : 'text', + placeholder: typeof f.placeholder === 'string' ? f.placeholder : undefined, + required: !!f.required, + })) + : []; + return awaitPrompt({ + pluginId: plugin.id, + kind: 'prompt', + title: String(opts.title ?? plugin.name ?? 'Enter details'), + message: String(opts.message ?? ''), + confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined, + cancelLabel: typeof opts.cancelLabel === 'string' ? opts.cancelLabel : undefined, + fields, + }); + } case 'ui.openExternalUrl': { const url = String(args[0] ?? ''); // Only http(s) - the sandbox should not be able to navigate the host diff --git a/lib/plugin-sandbox/host-dialog.ts b/lib/plugin-sandbox/host-dialog.ts index 85789ce3..aae595c4 100644 --- a/lib/plugin-sandbox/host-dialog.ts +++ b/lib/plugin-sandbox/host-dialog.ts @@ -1,9 +1,28 @@ -// Process-wide queue for plugin-requested host dialogs (confirm / alert). -// The sandboxed plugin posts a `ui.confirm` API request; the host enqueues a -// dialog here and resolves the awaited Promise after the user clicks. The -// `PluginDialogHost` component subscribes and renders one dialog at a time. +// Process-wide queue for plugin-requested host dialogs (confirm / alert / +// prompt). The sandboxed plugin posts a `ui.confirm`/`ui.prompt` API request; +// the host enqueues a dialog here and resolves the awaited Promise after the +// user acts. The `PluginDialogHost` component subscribes and renders one dialog +// at a time. Prompts collect one or more (optionally masked) text fields so a +// plugin never has to fall back to the sandbox-blocked `window.prompt`. -export type DialogKind = 'confirm' | 'alert'; +export type DialogKind = 'confirm' | 'alert' | 'prompt'; + +export interface PromptField { + /** Key the field's value is returned under. */ + name: string; + label: string; + /** `password` masks the input; defaults to `text`. */ + type?: 'text' | 'password'; + placeholder?: string; + /** Submit is blocked until every required field is non-empty. */ + required?: boolean; +} + +/** + * confirm/alert resolve to a boolean; prompt resolves to a name→value map on + * submit, or `null` when cancelled. + */ +export type DialogResult = boolean | Record | null; export interface DialogRequest { id: string; @@ -15,8 +34,10 @@ export interface DialogRequest { cancelLabel?: string; /** When true, confirm button uses destructive styling. */ danger?: boolean; - /** Called when the dialog closes. `ok` is true only for confirm-accept. */ - resolve: (ok: boolean) => void; + /** Fields to collect, for `kind === 'prompt'`. */ + fields?: PromptField[]; + /** Called when the dialog closes with its typed result (see DialogResult). */ + resolve: (result: DialogResult) => void; } const queue: DialogRequest[] = []; @@ -43,13 +64,18 @@ export function head(): DialogRequest | null { return queue[0] ?? null; } -export function resolveHead(ok: boolean): void { +export function resolveHead(result: DialogResult): void { const entry = queue.shift(); if (!entry) return; - try { entry.resolve(ok); } catch { /* ignore */ } + try { entry.resolve(result); } catch { /* ignore */ } notify(); } +/** The "cancelled" result for a given dialog kind (null for prompt, else false). */ +function cancelledResult(kind: DialogKind): DialogResult { + return kind === 'prompt' ? null : false; +} + /** Cancel every pending dialog for a plugin (called on unload). */ export function cancelForPlugin(pluginId: string): void { let changed = false; @@ -57,7 +83,7 @@ export function cancelForPlugin(pluginId: string): void { if (queue[i].pluginId === pluginId) { const entry = queue[i]; queue.splice(i, 1); - try { entry.resolve(false); } catch { /* ignore */ } + try { entry.resolve(cancelledResult(entry.kind)); } catch { /* ignore */ } changed = true; } } @@ -70,11 +96,21 @@ export function subscribe(listener: () => void): () => void { } /** - * Internal helper used by host-api to convert an `enqueueDialog` call into a - * Promise the plugin-side `await` can land on. + * Internal helper used by host-api to convert a confirm/alert `enqueueDialog` + * call into a boolean Promise the plugin-side `await` can land on. */ export function awaitDialog(req: Omit): Promise { return new Promise((resolve) => { - enqueueDialog({ ...req, resolve }); + enqueueDialog({ ...req, resolve: (r) => resolve(r === true) }); + }); +} + +/** + * Prompt variant of `awaitDialog`: resolves to the collected name→value map on + * submit, or `null` when the user cancels/dismisses. + */ +export function awaitPrompt(req: Omit): Promise | null> { + return new Promise((resolve) => { + enqueueDialog({ ...req, resolve: (r) => resolve(r && typeof r === 'object' ? r : null) }); }); } diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 53143d89..9385aa76 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -244,7 +244,7 @@ export const API_METHODS = [ 'jmap.fetchBlob', 'jmap.sendRaw', 'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig', 'toast.success', 'toast.error', 'toast.info', 'toast.warning', - 'ui.confirm', 'ui.alert', 'ui.openExternalUrl', + 'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.openExternalUrl', ] as const; export type ApiMethod = (typeof API_METHODS)[number]; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index e1cfdeed..9588ec15 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -203,6 +203,16 @@ function buildPluginApi(manifest: PluginManifest) { /** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */ alert: (opts: { title?: string; message?: string; confirmLabel?: string }) => callApi('ui.alert', [opts], 0) as Promise, + /** Opens a host-rendered prompt collecting one or more (optionally masked) + * fields. Resolves to a name→value map on submit, or null if cancelled. + * No timeout. */ + prompt: (opts: { + title?: string; + message?: string; + confirmLabel?: string; + cancelLabel?: string; + fields?: Array<{ name: string; label: string; type?: 'text' | 'password'; placeholder?: string; required?: boolean }>; + }) => callApi('ui.prompt', [opts], 0) as Promise | null>, /** Opens an http/https URL in a new tab via host `window.open`. */ openExternalUrl: (url: string, target?: string) => callApi('ui.openExternalUrl', [url, target]) as Promise,