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
+285
View File
@@ -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>
);
}
+9 -1
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useEffect, useState, useRef } from 'react'; 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 type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types';
@@ -396,6 +397,13 @@ export default function AdminPluginsPage() {
</div> </div>
<div className="flex items-center gap-2"> <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 <button
onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)} onClick={() => toggleForceEnabled(plugin.id, !plugin.forceEnabled)}
title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'} title={plugin.forceEnabled ? 'Remove force-enable (users can disable)' : 'Force enable (users cannot disable)'}
+113
View File
@@ -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 });
}
}
+3
View File
@@ -157,6 +157,9 @@ export async function POST(request: NextRequest) {
permissions: (manifest.permissions as string[]) || [], permissions: (manifest.permissions as string[]) || [],
entrypoint: manifest.entrypoint as string, entrypoint: manifest.entrypoint as string,
enabled: true, enabled: true,
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
installedAt: now, installedAt: now,
updatedAt: now, updatedAt: now,
}; };
+18
View File
@@ -18,6 +18,7 @@ import {
getStatusCounts, getStatusCounts,
buildParticipantMap, buildParticipantMap,
} from "@/lib/calendar-participants"; } from "@/lib/calendar-participants";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
export interface PendingEventPreview { export interface PendingEventPreview {
@@ -810,6 +811,23 @@ export function EventModal({
placeholder="https://meet.example.com/..." placeholder="https://meet.example.com/..."
maxLength={2000} 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>
<div> <div>
+1
View File
@@ -71,6 +71,7 @@ export default [
".next/**", ".next/**",
"node_modules/**", "node_modules/**",
"repos/**", "repos/**",
"data/admin/plugins/**",
"*.config.js", "*.config.js",
"*.config.mjs", "*.config.mjs",
"e2e/**", "e2e/**",
+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': [], 'settings-section': [],
'context-menu-email': [], 'context-menu-email': [],
'navigation-rail-bottom': [], 'navigation-rail-bottom': [],
'calendar-event-actions': [],
'admin-plugin-page': [],
}, },
initialized: false, 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 ─────────────────────────────────────────────────── // ─── 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 { export interface ServerPlugin {
id: string; id: string;
name: string; name: string;
@@ -28,6 +38,7 @@ export interface ServerPlugin {
entrypoint: string; entrypoint: string;
enabled: boolean; enabled: boolean;
forceEnabled?: boolean; forceEnabled?: boolean;
configSchema?: Record<string, PluginConfigField>;
installedAt: string; installedAt: string;
updatedAt: string; updatedAt: string;
} }
+81 -1
View File
@@ -11,11 +11,13 @@ import type {
SidebarWidget, SidebarWidget,
ContextMenuItem, ContextMenuItem,
KeyboardShortcut, KeyboardShortcut,
AdminPageSection,
CalendarEventAction,
SlotName, SlotName,
} from './plugin-types'; } from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types'; import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import { import {
emailHooks, calendarHooks, contactHooks, fileHooks, emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks, authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
@@ -116,6 +118,8 @@ export interface PluginAPI {
registerDetailSidebar: (widget: SidebarWidget) => Disposable; registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable; registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable; registerNavigationRailItem: (component: React.ComponentType) => Disposable;
registerCalendarEventAction: (action: CalendarEventAction) => Disposable;
registerAdminPage: (page: AdminPageSection) => Disposable;
}; };
hooks: PluginHooksAPI; hooks: PluginHooksAPI;
toast: { toast: {
@@ -126,6 +130,12 @@ export interface PluginAPI {
}; };
storage: ReturnType<typeof createPluginStorage>; storage: ReturnType<typeof createPluginStorage>;
log: ReturnType<typeof createPluginLogger>; 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) // Simplified hooks API type (all hooks return Disposable)
@@ -176,6 +186,9 @@ export interface PluginHooksAPI {
onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable; onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable;
onCalendarAlertAcknowledge: (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 // Contacts
onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeContactCreate: (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', onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read',
onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read', onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read',
onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read', onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read',
onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write',
onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write', onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write',
onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write', onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write',
onBeforeEventDelete: 'calendar:write', onAfterEventDelete: '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)), ...Object.fromEntries(Object.entries(emailHooks)),
// Calendar // Calendar
...Object.fromEntries(Object.entries(calendarHooks)), ...Object.fromEntries(Object.entries(calendarHooks)),
// Calendar Form
...Object.fromEntries(Object.entries(calendarFormHooks)),
// Contacts // Contacts
...Object.fromEntries(Object.entries(contactHooks)), ...Object.fromEntries(Object.entries(contactHooks)),
// Files // Files
@@ -581,6 +597,38 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
requirePermission(plugin, 'ui:navigation-rail'); requirePermission(plugin, 'ui:navigation-rail');
return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType<Record<string, unknown>>, 100); 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, hooks,
@@ -594,5 +642,37 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
storage: createPluginStorage(plugin.id), storage: createPluginStorage(plugin.id),
log: createPluginLogger(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(), onCalendarAlertAcknowledge: new HookBus(),
}; };
// §7.2b Calendar Form Hooks (UI integration)
export const calendarFormHooks = {
onCalendarEventFormOpen: new HookBus(),
onCalendarEventFormSave: new HookBus(),
};
// §7.3 Contact Hooks // §7.3 Contact Hooks
export const contactHooks = { export const contactHooks = {
onContactOpen: new HookBus(), onContactOpen: new HookBus(),
@@ -396,7 +402,7 @@ export const sidebarAppHooks = {
// ─── Aggregate: remove all handlers for a plugin across all buses ─── // ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [ const allHookGroups = [
emailHooks, calendarHooks, contactHooks, fileHooks, emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
authHooks, settingsHooks, identityHooks, filterHooks, authHooks, settingsHooks, identityHooks, filterHooks,
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
+31 -1
View File
@@ -94,7 +94,9 @@ export type SlotName =
| 'email-detail-sidebar' | 'email-detail-sidebar'
| 'settings-section' | 'settings-section'
| 'context-menu-email' | 'context-menu-email'
| 'navigation-rail-bottom'; | 'navigation-rail-bottom'
| 'calendar-event-actions'
| 'admin-plugin-page';
export interface SlotRegistration { export interface SlotRegistration {
pluginId: string; pluginId: string;
@@ -147,6 +149,32 @@ export interface ContextMenuItem {
order?: number; 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 { export interface KeyboardShortcut {
id: string; id: string;
keys: string; keys: string;
@@ -377,6 +405,8 @@ export const ALL_PERMISSIONS = [
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer', 'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section', 'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
'ui:calendar-action', 'ui:admin-page',
'admin:config',
'app:lifecycle', 'app:lifecycle',
] as const; ] as const;
+1
View File
@@ -21,6 +21,7 @@ import { usePolicyStore } from '@/stores/policy-store';
const SLOT_NAMES: SlotName[] = [ const SLOT_NAMES: SlotName[] = [
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom', 'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
'calendar-event-actions', 'admin-plugin-page',
]; ];
function emptySlots(): Record<SlotName, SlotRegistration[]> { function emptySlots(): Record<SlotName, SlotRegistration[]> {