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';
|
'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)'}
|
||||||
|
|||||||
@@ -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[]) || [],
|
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,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>
|
||||||
|
|||||||
@@ -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/**",
|
||||||
|
|||||||
@@ -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': [],
|
'settings-section': [],
|
||||||
'context-menu-email': [],
|
'context-menu-email': [],
|
||||||
'navigation-rail-bottom': [],
|
'navigation-rail-bottom': [],
|
||||||
|
'calendar-event-actions': [],
|
||||||
|
'admin-plugin-page': [],
|
||||||
},
|
},
|
||||||
initialized: false,
|
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 ───────────────────────────────────────────────────
|
// ─── 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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user