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:
@@ -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<string, ConfigField>;
|
||||
}
|
||||
|
||||
export default function PluginConfigPage() {
|
||||
const params = useParams();
|
||||
const pluginId = params.id as string;
|
||||
const [plugin, setPlugin] = useState<PluginInfo | null>(null);
|
||||
const [config, setConfig] = useState<PluginConfig>({});
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [revealSecrets, setRevealSecrets] = useState<Record<string, boolean>>({});
|
||||
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<string, string> = {};
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!plugin) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Plugins
|
||||
</Link>
|
||||
<p className="text-sm text-destructive">Plugin not found: {pluginId}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const schema = plugin.configSchema;
|
||||
const hasSchema = schema && Object.keys(schema).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/plugins" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Puzzle className="w-5 h-5" />
|
||||
{plugin.name} Configuration
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
v{plugin.version} by {plugin.author}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm rounded-md px-3 py-2 ${message.type === 'success' ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300' : 'bg-destructive/10 text-destructive'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSchema ? (
|
||||
<div className="border border-border rounded-lg">
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
||||
<h2 className="text-sm font-medium text-foreground">Settings</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-5">
|
||||
{Object.entries(schema).map(([key, field]) => (
|
||||
<div key={key}>
|
||||
<label className="text-sm font-medium text-foreground block mb-1">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
|
||||
)}
|
||||
|
||||
{field.type === 'boolean' ? (
|
||||
<select
|
||||
value={formValues[key] ?? String(field.default ?? 'false')}
|
||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
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"
|
||||
>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
) : field.type === 'select' && field.options ? (
|
||||
<select
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
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"
|
||||
>
|
||||
<option value="">— Select —</option>
|
||||
{field.options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === 'secret' ? (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={revealSecrets[key] ? 'text' : 'password'}
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealSecrets(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label={revealSecrets[key] ? 'Hide' : 'Show'}
|
||||
>
|
||||
{revealSecrets[key] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={formValues[key] ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={handleSaveAll}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">This plugin does not declare any configuration settings.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/admin/plugins/${plugin.id}`}
|
||||
title="Configure"
|
||||
className="p-2 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
|
||||
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
|
||||
|
||||
/**
|
||||
* GET /api/admin/plugins/[id]/config — Read all config for a plugin
|
||||
*
|
||||
* Returns the full config object for admin-configured plugin settings.
|
||||
* This endpoint is accessible from the client-side plugin API.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const plugin = await getPlugin(id);
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const config = await getPluginConfig(id);
|
||||
return NextResponse.json(config, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/plugins/[id]/config — Set a config key
|
||||
*
|
||||
* Body: { key: string, value: unknown }
|
||||
* Requires admin authentication (checked via admin session).
|
||||
*/
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const plugin = await getPlugin(id);
|
||||
if (!plugin) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let body: { key?: string; value?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.key || typeof body.key !== 'string') {
|
||||
return NextResponse.json({ error: 'key is required and must be a string' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate key format (alphanumeric, hyphens, underscores, dots)
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(body.key)) {
|
||||
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
|
||||
}
|
||||
|
||||
await setPluginConfig(id, body.key, body.value);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/plugins/[id]/config — Delete a config key
|
||||
*
|
||||
* Body: { key: string }
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
||||
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
let body: { key?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.key || typeof body.key !== 'string') {
|
||||
return NextResponse.json({ error: 'key is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await deletePluginConfigKey(id, body.key);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,9 @@ export async function POST(request: NextRequest) {
|
||||
permissions: (manifest.permissions as string[]) || [],
|
||||
entrypoint: manifest.entrypoint as string,
|
||||
enabled: true,
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getStatusCounts,
|
||||
buildParticipantMap,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
export interface PendingEventPreview {
|
||||
@@ -810,6 +811,23 @@ export function EventModal({
|
||||
placeholder="https://meet.example.com/..."
|
||||
maxLength={2000}
|
||||
/>
|
||||
<PluginSlot
|
||||
name="calendar-event-actions"
|
||||
className="mt-2 flex flex-wrap gap-2"
|
||||
extraProps={{
|
||||
eventData: {
|
||||
title,
|
||||
description,
|
||||
start: startDate + 'T' + startTime,
|
||||
end: endDate + 'T' + endTime,
|
||||
isAllDay: allDay,
|
||||
location,
|
||||
virtualLocation,
|
||||
calendarId,
|
||||
},
|
||||
setVirtualLocation,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -71,6 +71,7 @@ export default [
|
||||
".next/**",
|
||||
"node_modules/**",
|
||||
"repos/**",
|
||||
"data/admin/plugins/**",
|
||||
"*.config.js",
|
||||
"*.config.mjs",
|
||||
"e2e/**",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,8 @@ function resetStore() {
|
||||
'settings-section': [],
|
||||
'context-menu-email': [],
|
||||
'navigation-rail-bottom': [],
|
||||
'calendar-event-actions': [],
|
||||
'admin-plugin-page': [],
|
||||
},
|
||||
initialized: false,
|
||||
});
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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<SlotName, SlotRegistration[]> {
|
||||
|
||||
Reference in New Issue
Block a user