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)
This commit is contained in:
Linus Rath
2026-03-30 21:12:05 +02:00
parent 066ab1bc32
commit aaa283357e
14 changed files with 881 additions and 4 deletions
+238
View File
@@ -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<string> {
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<string, unknown> = {
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);
});
});
+2
View File
@@ -52,6 +52,8 @@ function resetStore() {
'settings-section': [],
'context-menu-email': [],
'navigation-rail-bottom': [],
'calendar-event-actions': [],
'admin-plugin-page': [],
},
initialized: false,
});
+81
View File
@@ -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<void> {
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
/**
* Get all config for a plugin.
*/
export async function getPluginConfig(pluginId: string): Promise<Record<string, unknown>> {
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<void> {
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<void> {
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<void> {
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
}
+11
View File
@@ -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<string, PluginConfigField>;
installedAt: string;
updatedAt: string;
}
+81 -1
View File
@@ -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<typeof createPluginStorage>;
log: ReturnType<typeof createPluginLogger>;
admin: {
getConfig: (key: string) => Promise<unknown>;
getAllConfig: () => Promise<Record<string, unknown>>;
setConfig: (key: string, value: unknown) => Promise<void>;
deleteConfig: (key: string) => Promise<void>;
};
}
// 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<string, Permission> = {
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<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(emailHooks)),
// Calendar
...Object.fromEntries(Object.entries(calendarHooks)),
// Calendar Form
...Object.fromEntries(Object.entries(calendarFormHooks)),
// Contacts
...Object.fromEntries(Object.entries(contactHooks)),
// Files
@@ -581,6 +597,38 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
requirePermission(plugin, 'ui:navigation-rail');
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100);
},
registerCalendarEventAction: (action: CalendarEventAction) => {
requirePermission(plugin, 'ui:calendar-action');
const Component = (props: Record<string, unknown>) => {
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: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8z"/><rect x="2" y="8" width="14" height="12" rx="2"/></svg>',
},
});
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<Record<string, unknown>>, action.order ?? 100);
},
registerAdminPage: (page: AdminPageSection) => {
requirePermission(plugin, 'ui:admin-page');
return registerSlot(plugin.id, 'admin-plugin-page', page.render as React.ComponentType<Record<string, unknown>>, 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 }),
});
},
},
};
}
+7 -1
View File
@@ -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,
+31 -1
View File
@@ -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;