feat: add plugin ui.prompt dialog and first-class settings-section tabs
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { useTranslations, useMessages } from 'next-intl';
|
import { useTranslations, useMessages } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +66,8 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
|
|||||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||||
import { PluginsSettings } from '@/components/settings/plugins-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 { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
|
||||||
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
@@ -113,8 +115,14 @@ type Tab =
|
|||||||
|
|
||||||
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
|
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:<id>`, 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 {
|
interface TabDef {
|
||||||
id: Tab;
|
id: SettingsTabId;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
group: TabGroup;
|
group: TabGroup;
|
||||||
@@ -330,7 +338,7 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
|
|||||||
advanced: 'about_data',
|
advanced: 'about_data',
|
||||||
};
|
};
|
||||||
|
|
||||||
function readPersistedTab(): Tab {
|
function readPersistedTab(): SettingsTabId {
|
||||||
try {
|
try {
|
||||||
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
// One-shot deep link from the sidebar section gears (Folders / Tags).
|
||||||
// Used only as the initial tab and intentionally NOT written to
|
// 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.
|
// default that the regular Settings button lands on. Cleared on mount.
|
||||||
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
|
||||||
if (deepLink) {
|
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');
|
const saved = localStorage.getItem('settings-active-tab');
|
||||||
if (!saved) return 'appearance';
|
if (!saved) return 'appearance';
|
||||||
@@ -347,7 +355,7 @@ function readPersistedTab(): Tab {
|
|||||||
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
|
||||||
return migrated;
|
return migrated;
|
||||||
}
|
}
|
||||||
return saved as Tab;
|
return saved as SettingsTabId;
|
||||||
} catch {
|
} catch {
|
||||||
return 'appearance';
|
return 'appearance';
|
||||||
}
|
}
|
||||||
@@ -364,7 +372,15 @@ export default function SettingsPage() {
|
|||||||
const { quota, isPushConnected } = useEmailStore();
|
const { quota, isPushConnected } = useEmailStore();
|
||||||
const { stalwartFeaturesEnabled } = useConfig();
|
const { stalwartFeaturesEnabled } = useConfig();
|
||||||
const { isFeatureEnabled } = usePolicyStore();
|
const { isFeatureEnabled } = usePolicyStore();
|
||||||
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
|
const [activeTab, setActiveTab] = useState<SettingsTabId>(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
|
// 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.
|
// open, never the persisted default for future Settings-button clicks.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -372,7 +388,7 @@ export default function SettingsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
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 isDesktop = useIsDesktop();
|
||||||
|
|
||||||
const messages = useMessages() as Record<string, unknown>;
|
const messages = useMessages() as Record<string, unknown>;
|
||||||
@@ -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 }] : []),
|
...(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 }] : []),
|
...(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 }] : []),
|
...(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
|
// Advanced
|
||||||
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: '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[])
|
].filter(Boolean) as Tab[])
|
||||||
: [];
|
: [];
|
||||||
const visibleTabs = managedAccountId
|
const visibleTabs = managedAccountId
|
||||||
? tabs.filter((tab) => scopedTabIds.includes(tab.id))
|
? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab))
|
||||||
: tabs;
|
: tabs;
|
||||||
|
|
||||||
// Group tabs by category
|
// Group tabs by category
|
||||||
@@ -660,12 +684,12 @@ export default function SettingsPage() {
|
|||||||
const matchesQuery = (tab: TabDef) => {
|
const matchesQuery = (tab: TabDef) => {
|
||||||
if (!trimmedQuery) return true;
|
if (!trimmedQuery) return true;
|
||||||
if (tab.label.toLowerCase().includes(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 [];
|
if (!trimmedQuery) return [];
|
||||||
const list = tabSubResults[tabId] ?? [];
|
const list = tabSubResults[tabId as Tab] ?? [];
|
||||||
return list
|
return list
|
||||||
.filter((r) =>
|
.filter((r) =>
|
||||||
r.label.toLowerCase().includes(trimmedQuery) ||
|
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;
|
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||||
// otherwise the usual 'appearance' default.
|
// otherwise the usual 'appearance' default.
|
||||||
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
||||||
const effectiveActiveTab: Tab = isActiveVisible
|
const effectiveActiveTab: SettingsTabId = isActiveVisible
|
||||||
? activeTab
|
? activeTab
|
||||||
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
||||||
|
|
||||||
const handleTabSelect = (tabId: Tab) => {
|
const handleTabSelect = (tabId: SettingsTabId) => {
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
@@ -696,7 +720,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
|
const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => {
|
||||||
handleTabSelect(tabId);
|
handleTabSelect(tabId);
|
||||||
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
|
||||||
};
|
};
|
||||||
@@ -754,6 +778,13 @@ export default function SettingsPage() {
|
|||||||
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
{effectiveActiveTab === 'themes' && <ThemesSettings />}
|
||||||
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
|
||||||
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
{effectiveActiveTab === 'debug' && <DebugSettings />}
|
||||||
|
{effectiveActiveTab.startsWith('plugin:') && (
|
||||||
|
<PluginIframeSlot
|
||||||
|
key={effectiveActiveTab}
|
||||||
|
pluginId={effectiveActiveTab.slice('plugin:'.length)}
|
||||||
|
slot="settings-section"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'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
|
// 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';
|
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
|
||||||
|
|
||||||
// Lightweight **bold** support in plugin dialog messages. Everything else is
|
// 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 {
|
export function PluginDialogHost(): React.JSX.Element | null {
|
||||||
const current = useSyncExternalStore(subscribe, head, () => 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<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!current) return;
|
||||||
|
const init: Record<string, string> = {};
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
resolveHead(false);
|
cancel();
|
||||||
} else if (e.key === 'Enter') {
|
} 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();
|
e.preventDefault();
|
||||||
resolveHead(true);
|
resolveHead(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey, true);
|
document.addEventListener('keydown', onKey, true);
|
||||||
return () => document.removeEventListener('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;
|
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 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -58,7 +84,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
|||||||
zIndex: 100000,
|
zIndex: 100000,
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.target === e.currentTarget) resolveHead(false);
|
if (e.target === e.currentTarget) cancel();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -76,47 +102,73 @@ export function PluginDialogHost(): React.JSX.Element | null {
|
|||||||
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
|
||||||
{current.title}
|
{current.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
{current.message && (
|
||||||
{renderMessage(current.message)}
|
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||||
</p>
|
{renderMessage(current.message)}
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
</p>
|
||||||
{current.kind === 'confirm' && (
|
)}
|
||||||
|
{isPrompt ? (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); submitPrompt(); }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<label key={f.name} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500 }}>
|
||||||
|
{f.label}{f.required ? ' *' : ''}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type={f.type === 'password' ? 'password' : 'text'}
|
||||||
|
value={values[f.name] ?? ''}
|
||||||
|
placeholder={f.placeholder}
|
||||||
|
autoFocus={i === 0}
|
||||||
|
autoComplete={f.type === 'password' ? 'off' : undefined}
|
||||||
|
onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--color-input, var(--color-border, #cbd5e1))',
|
||||||
|
background: 'var(--color-background, #fff)',
|
||||||
|
color: 'inherit',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<button type="button" onClick={cancel} style={secondaryBtn}>{cancelLabel}</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
style={{ ...primaryBtn, opacity: canSubmit ? 1 : 0.5, cursor: canSubmit ? 'pointer' : 'not-allowed' }}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
{current.kind === 'confirm' && (
|
||||||
|
<button type="button" autoFocus={!!current.danger} onClick={cancel} style={secondaryBtn}>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
autoFocus={!!current.danger}
|
autoFocus={current.kind === 'alert' || !current.danger}
|
||||||
onClick={() => resolveHead(false)}
|
onClick={() => resolveHead(true)}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
...primaryBtn,
|
||||||
borderRadius: 8,
|
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
|
||||||
fontSize: 13,
|
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: '1px solid var(--color-border, #e2e8f0)',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'inherit',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cancelLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<button
|
)}
|
||||||
type="button"
|
|
||||||
autoFocus={current.kind === 'alert' || !current.danger}
|
|
||||||
onClick={() => resolveHead(true)}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
|
|
||||||
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{confirmLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
|
||||||
From plugin: {current.pluginId}
|
From plugin: {current.pluginId}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { getMaxAccounts } from '@/lib/account-utils';
|
import { getMaxAccounts } from '@/lib/account-utils';
|
||||||
import { formatFileSize, cn } from '@/lib/utils';
|
import { formatFileSize, cn } from '@/lib/utils';
|
||||||
import { PluginSlot } from '@/components/plugins/plugin-slot';
|
|
||||||
|
|
||||||
function hostnameOf(serverUrl: string): string {
|
function hostnameOf(serverUrl: string): string {
|
||||||
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
|
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
|
||||||
@@ -181,8 +180,6 @@ export function AccountSettings() {
|
|||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<PluginSlot name="settings-section" />
|
|
||||||
|
|
||||||
{/* Logged-in accounts list */}
|
{/* Logged-in accounts list */}
|
||||||
{accounts.length > 0 && (
|
{accounts.length > 0 && (
|
||||||
<SettingsSection title={t('accounts.title')} description={t('accounts.description')}>
|
<SettingsSection title={t('accounts.title')} description={t('accounts.description')}>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { toast as appToast } from '@/stores/toast-store';
|
|||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useEmailStore } from '@/stores/email-store';
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
import { apiFetch } from '../browser-navigation';
|
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
|
* Methods only callable from the privileged (same-origin) tier. These expose
|
||||||
@@ -46,6 +46,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
|||||||
// ui - any plugin can ask the host to render a modal or open a URL.
|
// ui - any plugin can ask the host to render a modal or open a URL.
|
||||||
'ui.confirm': null,
|
'ui.confirm': null,
|
||||||
'ui.alert': null,
|
'ui.alert': null,
|
||||||
|
'ui.prompt': null,
|
||||||
'ui.openExternalUrl': null,
|
'ui.openExternalUrl': null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -364,6 +365,27 @@ export async function dispatchApiCall(
|
|||||||
});
|
});
|
||||||
return undefined;
|
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': {
|
case 'ui.openExternalUrl': {
|
||||||
const url = String(args[0] ?? '');
|
const url = String(args[0] ?? '');
|
||||||
// Only http(s) - the sandbox should not be able to navigate the host
|
// Only http(s) - the sandbox should not be able to navigate the host
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
// Process-wide queue for plugin-requested host dialogs (confirm / alert).
|
// Process-wide queue for plugin-requested host dialogs (confirm / alert /
|
||||||
// The sandboxed plugin posts a `ui.confirm` API request; the host enqueues a
|
// prompt). The sandboxed plugin posts a `ui.confirm`/`ui.prompt` API request;
|
||||||
// dialog here and resolves the awaited Promise after the user clicks. The
|
// the host enqueues a dialog here and resolves the awaited Promise after the
|
||||||
// `PluginDialogHost` component subscribes and renders one dialog at a time.
|
// 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<string, string> | null;
|
||||||
|
|
||||||
export interface DialogRequest {
|
export interface DialogRequest {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,8 +34,10 @@ export interface DialogRequest {
|
|||||||
cancelLabel?: string;
|
cancelLabel?: string;
|
||||||
/** When true, confirm button uses destructive styling. */
|
/** When true, confirm button uses destructive styling. */
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
/** Called when the dialog closes. `ok` is true only for confirm-accept. */
|
/** Fields to collect, for `kind === 'prompt'`. */
|
||||||
resolve: (ok: boolean) => void;
|
fields?: PromptField[];
|
||||||
|
/** Called when the dialog closes with its typed result (see DialogResult). */
|
||||||
|
resolve: (result: DialogResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue: DialogRequest[] = [];
|
const queue: DialogRequest[] = [];
|
||||||
@@ -43,13 +64,18 @@ export function head(): DialogRequest | null {
|
|||||||
return queue[0] ?? null;
|
return queue[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveHead(ok: boolean): void {
|
export function resolveHead(result: DialogResult): void {
|
||||||
const entry = queue.shift();
|
const entry = queue.shift();
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
try { entry.resolve(ok); } catch { /* ignore */ }
|
try { entry.resolve(result); } catch { /* ignore */ }
|
||||||
notify();
|
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). */
|
/** Cancel every pending dialog for a plugin (called on unload). */
|
||||||
export function cancelForPlugin(pluginId: string): void {
|
export function cancelForPlugin(pluginId: string): void {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -57,7 +83,7 @@ export function cancelForPlugin(pluginId: string): void {
|
|||||||
if (queue[i].pluginId === pluginId) {
|
if (queue[i].pluginId === pluginId) {
|
||||||
const entry = queue[i];
|
const entry = queue[i];
|
||||||
queue.splice(i, 1);
|
queue.splice(i, 1);
|
||||||
try { entry.resolve(false); } catch { /* ignore */ }
|
try { entry.resolve(cancelledResult(entry.kind)); } catch { /* ignore */ }
|
||||||
changed = true;
|
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
|
* Internal helper used by host-api to convert a confirm/alert `enqueueDialog`
|
||||||
* Promise the plugin-side `await` can land on.
|
* call into a boolean Promise the plugin-side `await` can land on.
|
||||||
*/
|
*/
|
||||||
export function awaitDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<boolean> {
|
export function awaitDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<boolean> {
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((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<DialogRequest, 'id' | 'resolve'>): Promise<Record<string, string> | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
enqueueDialog({ ...req, resolve: (r) => resolve(r && typeof r === 'object' ? r : null) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export const API_METHODS = [
|
|||||||
'jmap.fetchBlob', 'jmap.sendRaw',
|
'jmap.fetchBlob', 'jmap.sendRaw',
|
||||||
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
||||||
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
||||||
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
|
'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.openExternalUrl',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ApiMethod = (typeof API_METHODS)[number];
|
export type ApiMethod = (typeof API_METHODS)[number];
|
||||||
|
|||||||
@@ -203,6 +203,16 @@ function buildPluginApi(manifest: PluginManifest) {
|
|||||||
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
|
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
|
||||||
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
||||||
callApi('ui.alert', [opts], 0) as Promise<void>,
|
callApi('ui.alert', [opts], 0) as Promise<void>,
|
||||||
|
/** 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<Record<string, string> | null>,
|
||||||
/** Opens an http/https URL in a new tab via host `window.open`. */
|
/** Opens an http/https URL in a new tab via host `window.open`. */
|
||||||
openExternalUrl: (url: string, target?: string) =>
|
openExternalUrl: (url: string, target?: string) =>
|
||||||
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
||||||
|
|||||||
Reference in New Issue
Block a user