From aaa283357e418f095380d4a8db2bcad568d231c5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:12:05 +0200 Subject: [PATCH] feat: implemented plugin configuration UI and calendar event action slot - Add configSchema support to plugin manifest and ServerPlugin registry - Add schema-driven admin config page (string, secret, boolean, number, select fields) - Add per-plugin config storage backend (JSON files + REST API) - Add calendar-event-actions and admin-plugin-page slot names to plugin store - Add registerCalendarEventAction and registerAdminPage to plugin API - Add calendarFormHooks (onCalendarEventFormOpen/Save) to hook bus - Add PluginSlot in calendar event modal for plugin action buttons - Style calendar event action buttons to match Bulwark outline button design - Add Configure link per plugin in admin plugins dashboard - Add Jitsi Meet plugin with tests (repos/plugins/jitsi-meet) - Exclude data/admin/plugins from ESLint (deployed plugin bundles) --- app/admin/plugins/[id]/page.tsx | 285 +++++++++++++++++++++ app/admin/plugins/page.tsx | 10 +- app/api/admin/plugins/[id]/config/route.ts | 113 ++++++++ app/api/admin/plugins/route.ts | 3 + components/calendar/event-modal.tsx | 18 ++ eslint.config.mjs | 1 + lib/__tests__/jitsi-plugin.test.ts | 238 +++++++++++++++++ lib/__tests__/plugin-store.test.ts | 2 + lib/admin/plugin-config.ts | 81 ++++++ lib/admin/plugin-registry.ts | 11 + lib/plugin-api.ts | 82 +++++- lib/plugin-hooks.ts | 8 +- lib/plugin-types.ts | 32 ++- stores/plugin-store.ts | 1 + 14 files changed, 881 insertions(+), 4 deletions(-) create mode 100644 app/admin/plugins/[id]/page.tsx create mode 100644 app/api/admin/plugins/[id]/config/route.ts create mode 100644 lib/__tests__/jitsi-plugin.test.ts create mode 100644 lib/admin/plugin-config.ts diff --git a/app/admin/plugins/[id]/page.tsx b/app/admin/plugins/[id]/page.tsx new file mode 100644 index 00000000..95ca6be1 --- /dev/null +++ b/app/admin/plugins/[id]/page.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { Puzzle, ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react'; +import Link from 'next/link'; + +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; +} + +export default function PluginConfigPage() { + const params = useParams(); + const pluginId = params.id as string; + 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(() => { + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pluginId]); + + // Initialize form values from config + schema defaults when data loads + 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 fetchData() { + setLoading(true); + try { + const [pluginsRes, configRes] = await Promise.all([ + fetch('/api/admin/plugins'), + fetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`), + ]); + + 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 { + setLoading(false); + } + } + + async function handleSaveAll() { + if (!plugin?.configSchema) return; + setSaving(true); + setMessage(null); + + // Validate required fields + 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 { + // Save each changed field + let hasError = false; + for (const [key, field] of Object.entries(plugin.configSchema)) { + const newVal = formValues[key] ?? ''; + const oldVal = config[key] !== undefined ? String(config[key]) : ''; + + // Skip unchanged fields (and skip secret fields that show as empty when they have a stored value) + if (newVal === oldVal) continue; + if (field.type === 'secret' && !newVal && config[key]) continue; + + // Convert types + let value: unknown = newVal; + if (field.type === 'boolean') value = newVal === 'true'; + else if (field.type === 'number') value = Number(newVal); + + // Delete if clearing a non-required field + if (!newVal && !field.required) { + const res = await fetch(`/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 fetch(`/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 ( +
+ + Back to Plugins + +

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/plugins/page.tsx b/app/admin/plugins/page.tsx index c969d150..74b33dbb 100644 --- a/app/admin/plugins/page.tsx +++ b/app/admin/plugins/page.tsx @@ -1,7 +1,8 @@ 'use client'; import { useEffect, useState, useRef } from 'react'; -import { Upload, Trash2, Power, PowerOff, AlertTriangle, Loader2, Package, Save, Shield, Lock, LockOpen } from 'lucide-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'; @@ -396,6 +397,13 @@ export default function AdminPluginsPage() {
+ + +
diff --git a/eslint.config.mjs b/eslint.config.mjs index 8dbf8cfc..c491ca2c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -71,6 +71,7 @@ export default [ ".next/**", "node_modules/**", "repos/**", + "data/admin/plugins/**", "*.config.js", "*.config.mjs", "e2e/**", diff --git a/lib/__tests__/jitsi-plugin.test.ts b/lib/__tests__/jitsi-plugin.test.ts new file mode 100644 index 00000000..b6b2c36b --- /dev/null +++ b/lib/__tests__/jitsi-plugin.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect } from 'vitest'; +// ─── Inline copies of the plugin helpers (pure functions, no deps) ────────── + +// These mirror the implementations in repos/plugins/jitsi-meet/src/index.js +// so we can unit-test them without esbuild bundling. + +function generateRoomName(eventTitle: string): string { + const slug = eventTitle + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 60); + + const suffix = crypto.randomUUID().slice(0, 8); + return slug ? `${slug}-${suffix}` : suffix; +} + +function buildMeetingUrl(jitsiUrl: string, roomName: string): string { + const base = jitsiUrl.replace(/\/+$/, ''); + return `${base}/${encodeURIComponent(roomName)}`; +} + +function base64url(input: string | ArrayBuffer): string { + const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function createJitsiJwt(options: { + secret: string; + roomName: string; + userEmail?: string; + userName?: string; + jitsiUrl: string; +}): Promise { + const { secret, roomName, userEmail, userName, jitsiUrl } = options; + + let domain: string; + try { + domain = new URL(jitsiUrl).hostname; + } catch { + domain = jitsiUrl; + } + + const now = Math.floor(Date.now() / 1000); + const header = { alg: 'HS256', typ: 'JWT' }; + const payload: Record = { + iss: 'bulwark-webmail', + sub: domain, + aud: 'jitsi', + room: roomName, + iat: now, + exp: now + 86400, + context: { + user: { + ...(userName ? { name: userName } : {}), + ...(userEmail ? { email: userEmail } : {}), + }, + }, + }; + + const enc = new TextEncoder(); + const headerB64 = base64url(JSON.stringify(header)); + const payloadB64 = base64url(JSON.stringify(payload)); + const signingInput = `${headerB64}.${payloadB64}`; + + const key = await crypto.subtle.importKey( + 'raw', + enc.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const signature = await crypto.subtle.sign('HMAC', key, enc.encode(signingInput)); + const signatureB64 = base64url(signature); + + return `${signingInput}.${signatureB64}`; +} + +// ─── Tests ────────────────────────────────────────────────────── + +describe('generateRoomName', () => { + it('should slugify the event title and append a random suffix', () => { + const room = generateRoomName('Team Standup'); + expect(room).toMatch(/^team-standup-[a-f0-9]{8}$/); + }); + + it('should handle special characters', () => { + const room = generateRoomName('Q&A Session: "Ask Me Anything!"'); + expect(room).toMatch(/^q-a-session-ask-me-anything-[a-f0-9]{8}$/); + }); + + it('should handle empty title', () => { + const room = generateRoomName(''); + expect(room).toMatch(/^[a-f0-9]{8}$/); + }); + + it('should handle whitespace-only title', () => { + const room = generateRoomName(' '); + expect(room).toMatch(/^[a-f0-9]{8}$/); + }); + + it('should truncate long titles to 60 chars plus suffix', () => { + const longTitle = 'A'.repeat(100); + const room = generateRoomName(longTitle); + // 60 chars of slug + '-' + 8 char suffix = 69 max + expect(room.length).toBeLessThanOrEqual(69); + }); + + it('should generate unique room names for the same title', () => { + const room1 = generateRoomName('Standup'); + const room2 = generateRoomName('Standup'); + expect(room1).not.toBe(room2); + }); +}); + +describe('buildMeetingUrl', () => { + it('should combine base URL and room name', () => { + const url = buildMeetingUrl('https://meet.example.com', 'my-room-abc12345'); + expect(url).toBe('https://meet.example.com/my-room-abc12345'); + }); + + it('should strip trailing slashes from base URL', () => { + const url = buildMeetingUrl('https://meet.example.com/', 'room'); + expect(url).toBe('https://meet.example.com/room'); + }); + + it('should strip multiple trailing slashes', () => { + const url = buildMeetingUrl('https://meet.example.com///', 'room'); + expect(url).toBe('https://meet.example.com/room'); + }); + + it('should URL-encode the room name', () => { + const url = buildMeetingUrl('https://meet.example.com', 'room with spaces'); + expect(url).toBe('https://meet.example.com/room%20with%20spaces'); + }); +}); + +describe('createJitsiJwt', () => { + it('should create a valid HS256 JWT', async () => { + const token = await createJitsiJwt({ + secret: 'test-secret-key', + roomName: 'test-room', + userEmail: 'user@example.com', + userName: 'Test User', + jitsiUrl: 'https://meet.example.com', + }); + + const parts = token.split('.'); + expect(parts).toHaveLength(3); + + const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/'))); + expect(header.alg).toBe('HS256'); + expect(header.typ).toBe('JWT'); + + const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); + expect(payload.iss).toBe('bulwark-webmail'); + expect(payload.sub).toBe('meet.example.com'); + expect(payload.aud).toBe('jitsi'); + expect(payload.room).toBe('test-room'); + expect(payload.context.user.name).toBe('Test User'); + expect(payload.context.user.email).toBe('user@example.com'); + expect(payload.exp).toBe(payload.iat + 86400); + }); + + it('should set the sub claim to the Jitsi hostname', async () => { + const token = await createJitsiJwt({ + secret: 'secret', + roomName: 'room', + jitsiUrl: 'https://jitsi.corp.example.com/subfolder', + }); + + const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + expect(payload.sub).toBe('jitsi.corp.example.com'); + }); + + it('should omit undefined user fields', async () => { + const token = await createJitsiJwt({ + secret: 'secret', + roomName: 'room', + jitsiUrl: 'https://meet.example.com', + }); + + const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + expect(payload.context.user.name).toBeUndefined(); + expect(payload.context.user.email).toBeUndefined(); + }); + + it('should produce a different signature with different secrets', async () => { + const token1 = await createJitsiJwt({ + secret: 'secret-one', + roomName: 'room', + jitsiUrl: 'https://meet.example.com', + }); + const token2 = await createJitsiJwt({ + secret: 'secret-two', + roomName: 'room', + jitsiUrl: 'https://meet.example.com', + }); + + expect(token1.split('.')[2]).not.toBe(token2.split('.')[2]); + }); + + it('should produce a verifiable HMAC-SHA256 signature', async () => { + const secret = 'my-test-secret'; + const token = await createJitsiJwt({ + secret, + roomName: 'verify-room', + jitsiUrl: 'https://meet.example.com', + }); + + const [headerB64, payloadB64, signatureB64] = token.split('.'); + const signingInput = `${headerB64}.${payloadB64}`; + + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + enc.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ); + + const sigPadded = signatureB64.replace(/-/g, '+').replace(/_/g, '/'); + const sigBinary = atob(sigPadded); + const sigBytes = new Uint8Array(sigBinary.length); + for (let i = 0; i < sigBinary.length; i++) { + sigBytes[i] = sigBinary.charCodeAt(i); + } + + const valid = await crypto.subtle.verify('HMAC', key, sigBytes, enc.encode(signingInput)); + expect(valid).toBe(true); + }); +}); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 3f1f9301..4ea88743 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -52,6 +52,8 @@ function resetStore() { 'settings-section': [], 'context-menu-email': [], 'navigation-rail-bottom': [], + 'calendar-event-actions': [], + 'admin-plugin-page': [], }, initialized: false, }); diff --git a/lib/admin/plugin-config.ts b/lib/admin/plugin-config.ts new file mode 100644 index 00000000..bd2d8cfc --- /dev/null +++ b/lib/admin/plugin-config.ts @@ -0,0 +1,81 @@ +import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { logger } from '@/lib/logger'; + +function getAdminDir(): string { + return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin'); +} + +function getPluginConfigDir(): string { + return path.join(getAdminDir(), 'plugin-config'); +} + +function configPath(pluginId: string): string { + return path.join(getPluginConfigDir(), `${pluginId}.json`); +} + +async function ensureDir(dir: string): Promise { + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } +} + +/** + * Get all config for a plugin. + */ +export async function getPluginConfig(pluginId: string): Promise> { + try { + const raw = await readFile(configPath(pluginId), 'utf-8'); + return JSON.parse(raw); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + logger.warn(`Failed to read plugin config for ${pluginId}`, { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return {}; + } +} + +/** + * Set a single config key for a plugin. + */ +export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise { + const dir = getPluginConfigDir(); + await ensureDir(dir); + + const config = await getPluginConfig(pluginId); + config[key] = value; + + const filePath = configPath(pluginId); + const tmpPath = filePath + '.tmp'; + await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8'); + await rename(tmpPath, filePath); +} + +/** + * Delete a single config key for a plugin. + */ +export async function deletePluginConfigKey(pluginId: string, key: string): Promise { + const config = await getPluginConfig(pluginId); + delete config[key]; + + if (Object.keys(config).length === 0) { + try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ } + return; + } + + const dir = getPluginConfigDir(); + await ensureDir(dir); + const filePath = configPath(pluginId); + const tmpPath = filePath + '.tmp'; + await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8'); + await rename(tmpPath, filePath); +} + +/** + * Delete all config for a plugin (used when uninstalling). + */ +export async function deleteAllPluginConfig(pluginId: string): Promise { + try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ } +} diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 87c6c861..ab44bfab 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -17,6 +17,16 @@ function getThemesDir(): string { // ─── Types ─────────────────────────────────────────────────── +export interface PluginConfigField { + type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; + label: string; + description?: string; + required?: boolean; + default?: unknown; + placeholder?: string; + options?: { label: string; value: string }[]; +} + export interface ServerPlugin { id: string; name: string; @@ -28,6 +38,7 @@ export interface ServerPlugin { entrypoint: string; enabled: boolean; forceEnabled?: boolean; + configSchema?: Record; installedAt: string; updatedAt: string; } diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index 9831d61d..d01f4b1e 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -11,11 +11,13 @@ import type { SidebarWidget, ContextMenuItem, KeyboardShortcut, + AdminPageSection, + CalendarEventAction, SlotName, } from './plugin-types'; import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types'; import { - emailHooks, calendarHooks, contactHooks, fileHooks, + emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, taskHooks, templateHooks, smimeHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, @@ -116,6 +118,8 @@ export interface PluginAPI { registerDetailSidebar: (widget: SidebarWidget) => Disposable; registerContextMenuItem: (item: ContextMenuItem) => Disposable; registerNavigationRailItem: (component: React.ComponentType) => Disposable; + registerCalendarEventAction: (action: CalendarEventAction) => Disposable; + registerAdminPage: (page: AdminPageSection) => Disposable; }; hooks: PluginHooksAPI; toast: { @@ -126,6 +130,12 @@ export interface PluginAPI { }; storage: ReturnType; log: ReturnType; + admin: { + getConfig: (key: string) => Promise; + getAllConfig: () => Promise>; + setConfig: (key: string, value: unknown) => Promise; + deleteConfig: (key: string) => Promise; + }; } // Simplified hooks API type (all hooks return Disposable) @@ -176,6 +186,9 @@ export interface PluginHooksAPI { onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable; + // Calendar Form + onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable; + onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable; // Contacts onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable; @@ -321,6 +334,7 @@ const HOOK_PERMISSIONS: Record = { onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read', onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read', onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read', + onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write', onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write', onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write', onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write', @@ -407,6 +421,8 @@ const HOOK_BUSES: Record>, 100); }, + + registerCalendarEventAction: (action: CalendarEventAction) => { + requirePermission(plugin, 'ui:calendar-action'); + const Component = (props: Record) => { + const externals = getPluginExternals(); + const React = externals?.React; + if (!React) return null; + const createElement = (React as { createElement: typeof import('react').createElement }).createElement; + const iconSpan = createElement('span', { + 'aria-hidden': 'true', + style: { display: 'contents' }, + dangerouslySetInnerHTML: { + __html: '', + }, + }); + return createElement('button', { + onClick: () => action.onClick( + props.eventData as import('./plugin-types').CalendarEventFormView, + { setVirtualLocation: props.setVirtualLocation as (url: string) => void }, + ), + className: 'inline-flex items-center gap-1.5 h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background cursor-pointer', + title: action.label, + type: 'button', + }, iconSpan, action.label); + }; + return registerSlot(plugin.id, 'calendar-event-actions', Component as React.ComponentType>, action.order ?? 100); + }, + + registerAdminPage: (page: AdminPageSection) => { + requirePermission(plugin, 'ui:admin-page'); + return registerSlot(plugin.id, 'admin-plugin-page', page.render as React.ComponentType>, 100); + }, }, hooks, @@ -594,5 +642,37 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { storage: createPluginStorage(plugin.id), log: createPluginLogger(plugin.id), + + admin: { + getConfig: async (key: string) => { + requirePermission(plugin, 'admin:config'); + const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`); + if (!res.ok) return null; + const data = await res.json(); + return data[key] ?? null; + }, + getAllConfig: async () => { + requirePermission(plugin, 'admin:config'); + const res = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`); + if (!res.ok) return {}; + return res.json(); + }, + setConfig: async (key: string, value: unknown) => { + requirePermission(plugin, 'admin:config'); + await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value }), + }); + }, + deleteConfig: async (key: string) => { + requirePermission(plugin, 'admin:config'); + await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }); + }, + }, }; } diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 2f7db299..baaf9055 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -218,6 +218,12 @@ export const calendarHooks = { onCalendarAlertAcknowledge: new HookBus(), }; +// §7.2b Calendar Form Hooks (UI integration) +export const calendarFormHooks = { + onCalendarEventFormOpen: new HookBus(), + onCalendarEventFormSave: new HookBus(), +}; + // §7.3 Contact Hooks export const contactHooks = { onContactOpen: new HookBus(), @@ -396,7 +402,7 @@ export const sidebarAppHooks = { // ─── Aggregate: remove all handlers for a plugin across all buses ─── const allHookGroups = [ - emailHooks, calendarHooks, contactHooks, fileHooks, + emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, taskHooks, templateHooks, smimeHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 0335e2a6..d5536eeb 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -94,7 +94,9 @@ export type SlotName = | 'email-detail-sidebar' | 'settings-section' | 'context-menu-email' - | 'navigation-rail-bottom'; + | 'navigation-rail-bottom' + | 'calendar-event-actions' + | 'admin-plugin-page'; export interface SlotRegistration { pluginId: string; @@ -147,6 +149,32 @@ export interface ContextMenuItem { order?: number; } +export interface AdminPageSection { + id: string; + label: string; + icon?: string; + render: React.ComponentType; +} + +export interface CalendarEventAction { + id: string; + label: string; + icon?: string; + onClick: (eventData: CalendarEventFormView, helpers: { setVirtualLocation: (url: string) => void }) => void; + order?: number; +} + +export interface CalendarEventFormView { + title: string; + description: string; + start: string; + end: string; + isAllDay: boolean; + location: string; + virtualLocation: string; + calendarId: string; +} + export interface KeyboardShortcut { id: string; keys: string; @@ -377,6 +405,8 @@ export const ALL_PERMISSIONS = [ 'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer', 'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard', + 'ui:calendar-action', 'ui:admin-page', + 'admin:config', 'app:lifecycle', ] as const; diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 56406aa2..eb2bc160 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -21,6 +21,7 @@ import { usePolicyStore } from '@/stores/policy-store'; const SLOT_NAMES: SlotName[] = [ 'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom', + 'calendar-event-actions', 'admin-plugin-page', ]; function emptySlots(): Record {