Compare commits

..
16 Commits
Author SHA1 Message Date
Bernd Rodler 2116798281 feat(jitsi): video meetings — /api/jitsi/token route + jitsi-meet plugin + feature gate
Publish Docker Image / prepare (push) Successful in 2s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 8s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-25 23:17:29 +02:00
Andrea a457c1770e fix(auth): guard OAuth callback against double token exchange
Publish Docker Image / prepare (push) Successful in 3s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 9s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 17:59:42 +02:00
Andrea 88bca86c1e fix(calendar): send invitation emails client-side via iMIP
Publish Docker Image / prepare (push) Successful in 7s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 7s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 17:23:47 +02:00
Andrea 60fde141b5 fix(calendar): wrap event-modal footer buttons so Save stays visible
Publish Docker Image / prepare (push) Successful in 3s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 20s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 14:16:17 +02:00
Andrea e693072862 fix(calendar): open event modal directly in edit mode
Publish Docker Image / prepare (push) Successful in 1s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 8s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 13:57:02 +02:00
Andrea 7c127d8183 fix(calendar): treat empty-string recurrenceId as non-recurring when editing
Publish Docker Image / prepare (push) Successful in 11s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 51s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 13:34:00 +02:00
Andrea cf93b24abc fix(calendar): allow editing own events when Stalwart omits isOrigin
Publish Docker Image / prepare (push) Successful in 3s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 10s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 13:09:51 +02:00
Andrea d103c47cda fix(signatures): place caret above the signature in compose and above-quote replies
Publish Docker Image / prepare (push) Successful in 4s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 10s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-24 10:36:24 +02:00
Andrea 20dadea2dd fix(signatures): place reply/forward signature above the quote by default
Publish Docker Image / prepare (push) Successful in 1s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 18s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-23 19:21:55 +02:00
Andrea e696c65f75 fix(signatures): apply reply signature to replies and forwards
Publish Docker Image / prepare (push) Successful in 3s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 19s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-23 19:04:49 +02:00
Andrea 33ca4bae37 fix(email): sender popover Email action opens in-app composer instead of mailto:
Publish Docker Image / prepare (push) Successful in 1s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 1m7s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-23 17:24:29 +02:00
Andrea 424dba39f7 fix(theme): SRC theme fonts — Inter + Spectral per styleguide
Publish Docker Image / prepare (push) Successful in 0s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 8s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-23 17:05:57 +02:00
Andrea 16853a364c fix(theme): restore compose FAB icon + circular shape on SRC theme
Publish Docker Image / prepare (push) Successful in 1s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 13s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-23 10:56:53 +02:00
Bernd Rodler 8f2c89f9a8 feat(plugin): register manage-users in FIRST_PARTY_PLUGINS (manageUsersEnabled gate)
Publish Docker Image / prepare (push) Successful in 3s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 16s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-20 19:30:37 +02:00
Bernd Rodler 54c508e8df fix(build): add @types/ws for Electron WebSocket import
Publish Docker Image / prepare (push) Successful in 4s
Publish Docker Image / build (linux/amd64, ubuntu-latest) (push) Failing after 25s
Publish Docker Image / build (linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
Publish Docker Image / merge (push) Canceled after 0s
2026-08-20 19:04:13 +02:00
Bernd Rodler d021a0a87c feat(plugin): Manage users — VNCdirectory deep-link settings-section 2026-08-20 18:40:10 +02:00
34 changed files with 1450 additions and 56 deletions
@@ -9,6 +9,11 @@ import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
// Module-level guard so a Suspense/search-params remount of this client
// component can't exchange the same OAuth code twice — Keycloak rejects a
// reused code with `invalid_grant` ("Code not valid") and the login fails.
const processedAuthCodes = new Set<string>();
function OAuthCallbackInner() {
const router = useRouter();
const params = useParams();
@@ -32,6 +37,11 @@ function OAuthCallbackInner() {
return;
}
// Prevent a second token exchange for the same code (remount / double
// effect). Without this, the second exchange fails with "Code not valid".
if (processedAuthCodes.has(code)) return;
processedAuthCodes.add(code);
// Step-up re-auth for device pairing: the QR generator sent the user here
// via prompt=login. Don't create a login session — just confirm the fresh
// auth (sets the short-lived pairing proof cookie) and bounce back to the
+4 -1
View File
@@ -71,7 +71,10 @@ type PendingScopeAction =
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean };
function isRecurringEvent(event: CalendarEvent): boolean {
return (event.recurrenceRules?.length ?? 0) > 0 || event.recurrenceId != null;
// Stalwart may return an empty-string `recurrenceId` for non-recurring events
// rather than null; treat that as non-recurring so editing doesn't route
// through the recurrence-scope flow for a plain single event.
return (event.recurrenceRules?.length ?? 0) > 0 || Boolean(event.recurrenceId);
}
export default function CalendarPage() {
+12 -3
View File
@@ -61,6 +61,7 @@ import { Input } from "@/components/ui/input";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview";
import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils";
import { useSignatureStore } from "@/stores/signature-store";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { findDraftIdentityId, resolveReplyFrom, type ReplyFromResolution } from "@/lib/reply-identity";
@@ -2615,8 +2616,16 @@ export default function Home() {
// Append signature from the sending identity (fall back to primary
// when the reply-from lives on the same identity but a different alias).
// The signature store's reply signature takes precedence over the legacy
// identity signature, matching the composer's send path.
const signatureStore = useSignatureStore.getState();
const replySigId = signatureStore.getIdentityReplySignatureId(sendingIdentity?.id ?? '');
const replySig = replySigId ? signatureStore.getSignatureById(replySigId) : undefined;
const signatureSource = replySig
? { htmlSignature: replySig.body, textSignature: replySig.plainText }
: sendingIdentity;
const separator = useSettingsStore.getState().signatureSeparatorEnabled;
const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator });
const finalBody = appendPlainTextSignature(body, signatureSource, { separator });
// When the identity has an HTML signature, send a matching HTML body so the
// signature keeps its formatting; appendPlainTextSignature would otherwise
@@ -2627,8 +2636,8 @@ export default function Home() {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
? appendHtmlSignature(`<div>${escapedBody}</div>`, sendingIdentity, { separator })
const finalHtmlBody = signatureSource?.htmlSignature?.trim()
? appendHtmlSignature(`<div>${escapedBody}</div>`, signatureSource, { separator })
: undefined;
const originalEmailId = selectedEmail.id;
+77
View File
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createHmac } from 'node:crypto';
import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { logger } from '@/lib/logger';
const JITSI_URL = (process.env.JITSI_URL || 'https://meet.src-advisory.com').replace(/\/+$/, '');
function base64url(input: Buffer): string {
return input.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function b64u(input: string): string {
return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function POST(request: NextRequest) {
try {
const appId = process.env.JITSI_APP_ID;
const appSecret = process.env.JITSI_APP_SECRET;
if (!appId || !appSecret) {
return NextResponse.json({ error: 'Jitsi is not configured' }, { status: 503 });
}
const cookieStore = await cookies();
const sessionToken = cookieStore.get(sessionCookieName(0))?.value;
if (!sessionToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const session = decryptSession(sessionToken);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const email = session.username;
const body = await request.json().catch(() => ({}));
const room = typeof body.room === 'string' ? body.room.trim() : '';
if (!room || !/^[a-z0-9-]{1,100}$/i.test(room)) {
return NextResponse.json({ error: 'Invalid room name' }, { status: 400 });
}
const domain = new URL(JITSI_URL).hostname;
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'HS256', typ: 'JWT' };
const payload = {
iss: 'bulwark-webmail',
sub: domain,
aud: appId,
room,
iat: now,
exp: now + 86400,
context: {
user: {
email,
name: email.split('@')[0],
},
},
};
const signingInput = `${b64u(JSON.stringify(header))}.${b64u(JSON.stringify(payload))}`;
const signature = createHmac('sha256', appSecret).update(signingInput).digest();
const token = `${signingInput}.${base64url(signature)}`;
logger.info('Jitsi token issued', { room, email });
return NextResponse.json({
token,
room,
url: `${JITSI_URL}/${encodeURIComponent(room)}`,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Jitsi token issuance failed', { error: message });
return NextResponse.json({ error: message }, { status: 500 });
}
}
+2 -1
View File
@@ -182,7 +182,8 @@ export function EventDetailPopover({
const isAttendeeMode = useMemo(() => {
if (!event.participants) return false;
return !event.isOrigin && !userIsOrganizer;
if (userIsOrganizer) return false;
return event.isOrigin === false;
}, [event, userIsOrganizer]);
const userParticipantId = useMemo(
+15 -8
View File
@@ -217,7 +217,9 @@ export function EventModal({
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const isEdit = !!event;
const formatEventDate = useFormatEventDate();
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
// Open directly in edit mode so the fields are immediately editable. The
// read-only summary (view mode) is still reachable via the Cancel button.
const [mode, setMode] = useState<"view" | "edit">("edit");
const userIsOrganizer = useMemo(() => {
if (!event) return true;
@@ -227,7 +229,12 @@ export function EventModal({
const isAttendeeMode = useMemo(() => {
if (!event || !event.participants) return false;
return !event.isOrigin && !userIsOrganizer;
// Only enter attendee (read-only + RSVP) mode when we are definitively NOT
// the organizer AND the event explicitly did not originate from this
// account. Stalwart may omit `isOrigin`, so treat a missing value as "ours"
// (editable) rather than locking the user out of their own events.
if (userIsOrganizer) return false;
return event.isOrigin === false;
}, [event, userIsOrganizer]);
const userParticipantId = useMemo(() => {
@@ -1147,8 +1154,8 @@ export function EventModal({
</div>
{/* Action Bar */}
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between">
<div className="flex items-center gap-1">
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-1 min-w-0">
{onDelete && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
@@ -1187,7 +1194,7 @@ export function EventModal({
)}
</div>
{!showDeleteConfirm && (
<Button onClick={() => setMode("edit")}>
<Button onClick={() => setMode("edit")} className="ml-auto shrink-0">
<Pencil className="w-4 h-4 me-1" />
{t("events.edit")}
</Button>
@@ -1618,8 +1625,8 @@ export function EventModal({
</div>
</div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex items-center gap-1">
<div className="flex flex-wrap items-center gap-2 px-6 py-4 border-t border-border flex-shrink-0">
<div className="flex flex-wrap items-center gap-1 min-w-0">
{isEdit && onDelete && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
@@ -1670,7 +1677,7 @@ export function EventModal({
)}
</div>
<div className="flex gap-2">
<div className="flex gap-2 ml-auto shrink-0">
<Button variant="outline" onClick={isEdit ? () => setMode("view") : onClose}>
{t("form.cancel")}
</Button>
+21 -7
View File
@@ -653,6 +653,15 @@ export function EmailComposer({
? currentIdentity
: primaryIdentity;
// The signature store (default/reply/per-identity) takes precedence over the
// legacy per-identity html/text signature. `selectedSignature` is resolved in
// resolveStoreSignatureId for the current mode (compose → default; reply/
// forward → reply), so replies and forwards pick up the reply signature.
// Falls back to the legacy identity signature when no store signature is set.
const effectiveSignature = selectedSignature
? { htmlSignature: selectedSignature.body, textSignature: selectedSignature.plainText }
: signatureIdentity;
// Hold the TipTap editor instance so we can swap the embedded signature
// when the user switches identity in "above quote" mode without rebuilding
// the whole body (which would lose user edits to the surrounding draft).
@@ -751,8 +760,12 @@ export function EmailComposer({
sigInsertedRef.current = true;
if (mode === 'compose') {
editor.chain().focus('end').insertContent(`<p></p>${sig.body}`).run();
// Place the caret in the empty paragraph above the signature so the user
// starts typing at the top of the new email.
editor.chain().focus('start').run();
} else if ((mode === 'reply' || mode === 'replyAll' || mode === 'forward') && signaturePosition === 'above_quote') {
editor.chain().focus('start').insertContent(sig.body).run();
editor.chain().focus('start').insertContent(`<p></p>${sig.body}`).run();
editor.chain().focus('start').run();
}
}, [selectedSignatureId, plainTextMode, mode, signaturePosition, getSignatureById, editorReady]);
@@ -1883,6 +1896,7 @@ export function EmailComposer({
// duplicate it.
const signatureAlreadyInBody =
shouldEmbedSignatureInNewMail ||
(!plainTextMode && !!selectedSignature && mode === 'compose') ||
((mode === 'reply' || mode === 'replyAll' || mode === 'forward') &&
signaturePosition === 'above_quote');
@@ -1890,11 +1904,11 @@ export function EmailComposer({
const buildSignatureHtml = (): string => {
if (signatureAlreadyInBody) return '';
const sep = signatureSeparatorEnabled ? `<br><br>-- <br>` : `<br><br>`;
if (signatureIdentity?.htmlSignature) {
return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`;
if (effectiveSignature?.htmlSignature) {
return `${sep}${sanitizeSignatureHtml(effectiveSignature.htmlSignature)}`;
}
if (signatureIdentity?.textSignature) {
return `${sep}${signatureIdentity.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
if (effectiveSignature?.textSignature) {
return `${sep}${effectiveSignature.textSignature.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}`;
}
return '';
};
@@ -1907,8 +1921,8 @@ export function EmailComposer({
// In plain text mode, send text/plain only (no HTML body)
const signatureOpts = { separator: signatureSeparatorEnabled };
const finalBody = plainTextMode
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts))
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts));
? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, effectiveSignature, signatureOpts))
: (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), effectiveSignature, signatureOpts));
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
const finalHtmlBody = plainTextMode
+21 -3
View File
@@ -2,11 +2,14 @@
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { Mail, Phone, Building, ExternalLink, Copy, Send, UserPlus } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
import { savePendingMailto, notifyPendingMailto } from "@/lib/protocol-handlers/session";
import { formatRecipient } from "@/lib/email-composer-utils";
import type { ContactCard } from "@/lib/jmap/types";
interface RecipientPopoverProps {
@@ -125,6 +128,21 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
}
};
const router = useRouter();
const handleCompose = () => {
savePendingMailto({
to: [formatRecipient(contactName, email)],
cc: [],
bcc: [],
subject: "",
body: "",
});
notifyPendingMailto();
router.push("/");
handleClose();
};
return (
<>
<button
@@ -210,14 +228,14 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
<Copy className="w-3.5 h-3.5" />
Copy
</button>
<a
href={`mailto:${email}`}
<button
onClick={handleCompose}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
title="Send email"
>
<Send className="w-3.5 h-3.5" />
Email
</a>
</button>
{onViewContact && (
<button
onClick={handleViewContact}
+7
View File
@@ -71,6 +71,13 @@ const FIRST_PARTY_PLUGINS: FirstPartyPlugin[] = [
// feature - the former in-host native pipeline is gone - so the long-standing
// `smimeEnabled` policy gate now controls this plugin.
{ id: 'smime', gate: 'smimeEnabled', forceEnable: true },
// VNCdirectory deep-link. Users are managed in the directory, not the
// webmail; this plugin adds a "User management" Settings entry that opens
// the directory's user list. Force-enabled so it is always present.
{ id: 'manage-users', gate: 'manageUsersEnabled', forceEnable: true },
// SRC video meetings (VNCtalk / Jitsi). "Start a meeting" asks the server
// for a signed JWT and opens the room in meet.src-advisory.com.
{ id: 'jitsi-meet', gate: 'jitsiMeetEnabled', forceEnable: true },
];
const ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
+4
View File
@@ -55,6 +55,8 @@ export interface FeatureGates {
calendarEnabled: boolean;
calendarTasksEnabled: boolean;
smimeEnabled: boolean;
manageUsersEnabled: boolean;
jitsiMeetEnabled: boolean;
externalContentEnabled: boolean;
debugModeEnabled: boolean;
folderIconsEnabled: boolean;
@@ -90,6 +92,8 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
calendarEnabled: true,
calendarTasksEnabled: true,
smimeEnabled: true,
manageUsersEnabled: true,
jitsiMeetEnabled: true,
externalContentEnabled: true,
debugModeEnabled: true,
folderIconsEnabled: true,
+17 -12
View File
@@ -1014,11 +1014,16 @@ body[data-theme-skin="builtin-vnclagoon"] .rounded-2xl.border.bg-background\\/80
// near-black stone-grey neutral. Dark variant brightens the red (#EF4444) on a
// warm near-black. Info stays blue so it never collides with the red accent.
const srcCSS = `
@font-face { font-family: 'DM Sans'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/dmsans-400.woff2') format('woff2'); }
@font-face { font-family: 'DM Sans'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/dmsans-500.woff2') format('woff2'); }
@font-face { font-family: 'DM Sans'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/dmsans-700.woff2') format('woff2'); }
@font-face { font-family: 'Syne'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/syne-700.woff2') format('woff2'); }
@font-face { font-family: 'Syne'; font-style: normal; font-weight: 800; font-display: swap; src: url('/fonts/syne-800.woff2') format('woff2'); }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 100 900; font-display: swap; src: url('/fonts/inter-var-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Inter'; font-style: normal; font-weight: 100 900; font-display: swap; src: url('/fonts/inter-var-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/spectral-400-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/spectral-400-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/spectral-600-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 600; font-display: swap; src: url('/fonts/spectral-600-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/spectral-700-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 700; font-display: swap; src: url('/fonts/spectral-700-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 800; font-display: swap; src: url('/fonts/spectral-800-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Spectral'; font-style: normal; font-weight: 800; font-display: swap; src: url('/fonts/spectral-800-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
:root {
--color-border: #e7e5e4;
--color-input: #e7e5e4;
@@ -1105,12 +1110,12 @@ const srcCSS = `
// All scoped under the skin body attribute so they detach cleanly on switch-off.
const srcSkin = `
body[data-theme-skin="builtin-src"] {
font-family: "DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
}
body[data-theme-skin="builtin-src"] h1,
body[data-theme-skin="builtin-src"] h2,
body[data-theme-skin="builtin-src"] h3 {
font-family: "Syne", "DM Sans", sans-serif;
font-family: "Spectral", "Inter", Georgia, serif;
font-weight: 700;
letter-spacing: -0.01em;
}
@@ -1135,7 +1140,7 @@ body[data-theme-skin="builtin-src"] button:not(.rounded-full):not([role="switch"
}
/* MD3 filled button — primary surface, M3 label-large, state layers */
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground {
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:not(.rounded-full) {
border-radius: 20px !important;
padding-inline: 24px !important;
min-height: 40px !important;
@@ -1146,20 +1151,20 @@ body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground {
transition: box-shadow 200ms ease, filter 200ms ease;
}
/* hover: M3 elevation 1 + 8 % on-primary state layer */
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:hover {
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:not(.rounded-full):hover {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.30),
0 2px 6px 2px rgba(0, 0, 0, 0.15) !important;
filter: brightness(1.06);
}
/* focus: +12 % tint + M3 focus ring */
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:focus-visible {
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:not(.rounded-full):focus-visible {
filter: brightness(1.10) !important;
outline: 3px solid var(--color-ring) !important;
outline-offset: 2px !important;
}
/* pressed: +12 % darker, no shadow */
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:active {
body[data-theme-skin="builtin-src"] .bg-primary.text-primary-foreground:not(.rounded-full):active {
box-shadow: none !important;
filter: brightness(0.94) !important;
}
@@ -1346,7 +1351,7 @@ export const BUILTIN_THEMES: InstalledTheme[] = [
logoLightUrl: '/branding/SRC_Symbol.png',
logoDarkUrl: '/branding/SRC_Symbol.png',
variants: ['light', 'dark'],
typography: { fontSans: '"DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif' },
typography: { fontSans: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif' },
enabled: true,
builtIn: true,
},
+26 -10
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarParticipant, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -3135,9 +3135,18 @@ export class JMAPClient implements IJMAPClient {
throw new Error('No drafts mailbox found');
}
// Find the organizer participant
// Find the organizer participant. Stalwart may not echo `roles.owner`, so
// also fall back to the event-level organizerCalendarAddress, and derive
// participant emails from email / calendarAddress / sendTo.imip.
const participantEmail = (p: CalendarParticipant): string =>
p.email
|| p.calendarAddress?.replace(/^mailto:/i, '')
|| p.sendTo?.imip?.replace(/^mailto:/i, '')
|| '';
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username;
const organizerEmail = (organizerEntry ? participantEmail(organizerEntry) : '')
|| event.organizerCalendarAddress?.replace(/^mailto:/i, '')
|| this.username;
const organizerName = organizerEntry?.name || '';
// Resolve identity
@@ -3152,7 +3161,7 @@ export class JMAPClient implements IJMAPClient {
}
// Collect attendee participants (non-organizer)
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner);
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner && participantEmail(p) !== organizerEmail);
if (attendees.length === 0) return;
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
@@ -3212,7 +3221,7 @@ export class JMAPClient implements IJMAPClient {
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
for (const attendee of attendees) {
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', '');
const email = participantEmail(attendee);
if (!email) continue;
const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : '';
const partstat = attendee.participationStatus
@@ -3228,7 +3237,7 @@ export class JMAPClient implements IJMAPClient {
const subject = `Invitation: ${event.title || 'Event'}`;
const toAddresses = attendees
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' }))
.map(a => ({ name: a.name || undefined, email: participantEmail(a) }))
.filter(a => a.email);
if (toAddresses.length === 0) return;
@@ -3313,8 +3322,15 @@ export class JMAPClient implements IJMAPClient {
throw new Error('No drafts mailbox found');
}
const participantEmail = (p: CalendarParticipant): string =>
p.email
|| p.calendarAddress?.replace(/^mailto:/i, '')
|| p.sendTo?.imip?.replace(/^mailto:/i, '')
|| '';
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username;
const organizerEmail = (organizerEntry ? participantEmail(organizerEntry) : '')
|| event.organizerCalendarAddress?.replace(/^mailto:/i, '')
|| this.username;
const organizerName = organizerEntry?.name || '';
const identityResponse = await this.request([
@@ -3327,7 +3343,7 @@ export class JMAPClient implements IJMAPClient {
identityId = match?.id || identities[0]?.id || this.accountId;
}
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner);
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner && participantEmail(p) !== organizerEmail);
if (attendees.length === 0) return;
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
@@ -3370,7 +3386,7 @@ export class JMAPClient implements IJMAPClient {
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
for (const attendee of attendees) {
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', '');
const email = participantEmail(attendee);
if (!email) continue;
const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : '';
lines.push(`ATTENDEE${cn}:mailto:${email}`);
@@ -3382,7 +3398,7 @@ export class JMAPClient implements IJMAPClient {
const subject = `Cancelled: ${event.title || 'Event'}`;
const toAddresses = attendees
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' }))
.map(a => ({ name: a.name || undefined, email: participantEmail(a) }))
.filter(a => a.email);
if (toAddresses.length === 0) return;
+11
View File
@@ -62,6 +62,7 @@
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^6.0.1",
@@ -4730,6 +4731,16 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
+1
View File
@@ -101,6 +101,7 @@
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^6.0.1",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+35 -9
View File
@@ -487,9 +487,16 @@ export const useCalendarStore = create<CalendarStore>()(
}
set((state) => ({ events: [...state.events, mappedCreated] }));
// Invitation emails are sent by the server: `sendSchedulingMessages`
// on CalendarEvent/set makes Stalwart queue the iTIP REQUEST itself.
// Sending a client-side iMIP copy here produced duplicate emails.
// Send invitation emails (iTIP REQUEST) to participants. Stalwart
// 0.16 does not reliably queue these server-side via
// `sendSchedulingMessages`, so fall back to a client-side iMIP send.
if (sendSchedulingMessages && created.participants) {
try {
await client.sendImipInvitation(created);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
return mappedCreated;
} catch (error) {
debug.error('Failed to create event:', error);
@@ -559,9 +566,23 @@ export const useCalendarStore = create<CalendarStore>()(
return merged;
}),
}));
// Update emails (iTIP REQUEST/REPLY) are sent by the server via the
// `sendSchedulingMessages` argument already passed above - a manual
// iMIP send here produced duplicate emails.
// Send invitation emails (iTIP REQUEST) when scheduling is requested.
if (sendSchedulingMessages) {
const mergedParticipants = cleanUpdates.participants ?? storeEvent?.participants;
if (mergedParticipants) {
const eventForInvitation = {
...(storeEvent ?? {}),
...cleanUpdates,
id: realId,
participants: mergedParticipants,
} as CalendarEvent;
try {
await client.sendImipInvitation(eventForInvitation);
} catch (e) {
debug.error('Failed to send invitation emails:', e);
}
}
}
} catch (error) {
debug.error('Failed to update event:', error);
if (isNetworkError(error)) {
@@ -808,9 +829,6 @@ export const useCalendarStore = create<CalendarStore>()(
try {
// Resolve shared event IDs and client-side expanded occurrence IDs
client = resolveAccountClient(client, storeEvent?.localAccountId);
// Cancellation emails (iTIP CANCEL) are sent by the server via the
// `sendSchedulingMessages` argument on the destroy below - a manual
// iMIP send here produced duplicate emails.
debug.log('calendar', 'Calendar deleteEvent', {
storeId: id,
realId,
@@ -823,6 +841,14 @@ export const useCalendarStore = create<CalendarStore>()(
events: state.events.filter(e => e.id !== id),
selectedEventId: state.selectedEventId === id ? null : state.selectedEventId,
}));
// Send cancellation emails (iTIP CANCEL) to participants.
if (sendSchedulingMessages && storeEvent?.participants) {
try {
await client.sendImipCancellation(storeEvent);
} catch (e) {
debug.error('Failed to send cancellation emails:', e);
}
}
} catch (error) {
debug.error('Failed to delete event:', error);
if (isNetworkError(error)) {
+8 -2
View File
@@ -490,7 +490,7 @@ const DEFAULT_SETTINGS = {
rtlEditingSupport: false,
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
sendDelaySeconds: 0 as SendDelaySeconds,
signaturePosition: 'below_quote' as SignaturePosition,
signaturePosition: 'above_quote' as SignaturePosition,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
readReceiptResponse: 'ask' as ReadReceiptResponse,
@@ -998,7 +998,7 @@ export const useSettingsStore = create<SettingsState>()(
}),
{
name: 'settings-storage',
version: 7,
version: 8,
migrate: migrateSettings,
onRehydrateStorage: () => {
return (state) => {
@@ -1085,6 +1085,12 @@ export function migrateSettings(persisted: unknown, version: number): SettingsSt
if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) {
state.preferredIdentityIds = {};
}
// v8: reply/forward signatures now sit above the quoted message by
// default (previously below). Migrate any persisted value so existing
// accounts pick up the new default.
if (version < 8) {
state.signaturePosition = 'above_quote';
}
return state as unknown as SettingsState;
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "jitsi-meet",
"name": "Video meetings",
"version": "1.0.0",
"author": "SRC Advisory",
"description": "SRC video meetings (VNCtalk / Jitsi). Start a meeting from Settings: the plugin asks the server for a signed JWT and opens the room in meet.src-advisory.com.",
"type": "ui-extension",
"tier": "privileged",
"permissions": [
"ui:settings-section"
],
"entrypoint": "index.js",
"minAppVersion": "1.7.6"
}
+481
View File
@@ -0,0 +1,481 @@
{
"name": "bulwark-plugin-jitsi-meet",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-plugin-jitsi-meet",
"version": "1.0.0",
"devDependencies": {
"esbuild": "^0.24.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "bulwark-plugin-jitsi-meet",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/index.js --bundle --format=cjs --platform=browser --charset=utf8 --outfile=dist/index.js --external:react --external:react-dom --external:react-dom/client --external:react/jsx-runtime --external:@plugin-host",
"dev": "npm run build -- --watch",
"package": "npm run build && cp manifest.json dist/ && cd dist && zip -X ../jitsi-meet.zip manifest.json index.js"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Video meetings SRC Advisory webmail plugin (VNCtalk / Jitsi).
*
* Adds a "Video meeting" entry to Settings. "Start a meeting" generates a room
* name, asks the webmail server for a signed JWT (the server holds the shared
* JITSI_APP_SECRET it never reaches the browser), then opens the room in
* meet.src-advisory.com with the token as a query param.
*/
const host = require('@plugin-host');
const React = require('react');
const h = React.createElement;
const { useState } = React;
const card = {
border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: '8px',
padding: '16px',
background: 'var(--color-card, #fff)',
color: 'var(--color-foreground, #0f172a)',
maxWidth: '720px',
};
const btnPrimary = {
font: 'inherit',
padding: '8px 14px',
borderRadius: '6px',
border: '1px solid var(--color-primary, #2563eb)',
background: 'var(--color-primary, #2563eb)',
color: '#fff',
cursor: 'pointer',
};
const input = {
font: 'inherit',
padding: '6px 8px',
borderRadius: '6px',
border: '1px solid var(--color-input, #cbd5e1)',
background: 'var(--color-background, #fff)',
color: 'var(--color-foreground, #0f172a)',
marginRight: '8px',
};
function generateRoomName() {
const suffix = Math.random().toString(36).slice(2, 10);
return `meeting-${suffix}`;
}
function SettingsSection() {
const [busy, setBusy] = useState(false);
const [joinRoom, setJoinRoom] = useState('');
async function openRoom(room) {
setBusy(true);
try {
const resp = await fetch('/api/jitsi/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room }),
});
const data = await resp.json();
if (!resp.ok || !data.token) {
throw new Error(data.error || 'could not obtain meeting token');
}
const url = `${data.url}?jwt=${encodeURIComponent(data.token)}`;
try { host.ui.openExternalUrl(url); }
catch { window.open(url, '_blank', 'noopener,noreferrer'); }
} catch (err) {
const msg = err && err.message ? err.message : String(err);
try { host.toast.error(`Could not start meeting: ${msg}`); } catch { /* ignore */ }
} finally {
setBusy(false);
}
}
return h('div', { style: card },
h('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'Video meeting'),
h('div', { style: { fontSize: '13px', lineHeight: 1.5, marginBottom: '14px', color: 'var(--color-muted-foreground, #64748b)' } },
'Start or join a SRC video meeting. The room is opened in meet.src-advisory.com.'),
h('div', { style: { display: 'flex', alignItems: 'center', marginBottom: '10px' } },
h('button', { type: 'button', style: btnPrimary, disabled: busy, onClick: () => openRoom(generateRoomName()) },
busy ? 'Starting…' : 'Start a meeting →'),
),
h('div', { style: { display: 'flex', alignItems: 'center' } },
h('input', {
type: 'text',
style: input,
placeholder: 'Room name',
value: joinRoom,
onChange: (e) => setJoinRoom(e.target.value),
}),
h('button', { type: 'button', style: btnPrimary, disabled: busy || !joinRoom.trim(), onClick: () => openRoom(joinRoom.trim()) },
'Join'),
),
);
}
export const slots = {
'settings-section': { component: SettingsSection, order: 90 },
};
export async function activate(api) {
api.log.info('jitsi-meet plugin activated');
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "manage-users",
"name": "Manage users",
"version": "1.0.0",
"author": "SRC Advisory",
"description": "Deep-link to the VNCdirectory: users are created and assigned to SRCmail in the directory, not in the webmail. Adds a User management entry to Settings.",
"type": "ui-extension",
"tier": "privileged",
"permissions": [
"ui:settings-section"
],
"entrypoint": "index.js",
"minAppVersion": "1.7.6"
}
+481
View File
@@ -0,0 +1,481 @@
{
"name": "bulwark-plugin-manage-users",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-plugin-manage-users",
"version": "1.0.0",
"devDependencies": {
"esbuild": "^0.24.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "bulwark-plugin-manage-users",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/index.js --bundle --format=cjs --platform=browser --charset=utf8 --outfile=dist/index.js --external:react --external:react-dom --external:react-dom/client --external:react/jsx-runtime --external:@plugin-host",
"dev": "npm run build -- --watch",
"package": "npm run build && cp manifest.json dist/ && cd dist && zip -X ../manage-users.zip manifest.json index.js"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Manage users SRC Advisory webmail plugin.
*
* Adds a first-class "User management" entry to the Settings page. Users are
* the VNCdirectory's concern (the SRC directory), so this plugin is a thin
* deep-link: it explains where users live and opens the directory's user list
* in a new tab. It deliberately does NOT try to manage users itself.
*/
const host = require('@plugin-host');
const React = require('react');
const h = React.createElement;
const MANAGE_USERS_URL = 'https://vncdirectory.src-advisory.com/users';
const card = {
border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: '8px',
padding: '16px',
background: 'var(--color-card, #fff)',
color: 'var(--color-foreground, #0f172a)',
maxWidth: '720px',
};
const btnPrimary = {
font: 'inherit',
padding: '8px 14px',
borderRadius: '6px',
border: '1px solid var(--color-primary, #2563eb)',
background: 'var(--color-primary, #2563eb)',
color: '#fff',
cursor: 'pointer',
};
function SettingsSection() {
return h('div', { style: card },
h('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'User management'),
h('div', { style: { fontSize: '13px', lineHeight: 1.5, marginBottom: '14px', color: 'var(--color-muted-foreground, #64748b)' } },
'Users are created and managed in the VNCdirectory, not in SRCmail. ' +
'Add a user there and assign them to the SRC organization to give them a mailbox automatically.'),
h('button', {
type: 'button',
style: btnPrimary,
onClick: () => {
try { host.ui.openExternalUrl(MANAGE_USERS_URL); } catch (err) {
// Fallback if the sandbox blocks window.open: navigate a normal link.
window.open(MANAGE_USERS_URL, '_blank', 'noopener,noreferrer');
}
},
}, 'Open VNCdirectory →'),
);
}
export const slots = {
'settings-section': { component: SettingsSection, order: 100 },
};
export async function activate(api) {
api.log.info('manage-users plugin activated');
}