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,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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user