feat: P2.9 VNCtalk + P2.10 Collabora + P2.11 Calendar Enhancements + P2.13 VNCdirectory Admin

- P2.9: VNCtalk video meeting — create/update meeting from event modal,
  'Join Meeting' link in event detail. Admin config vnctalkServerUrl.
- P2.10: Collabora online editing — 'Edit with Collabora' for office files,
  WOPI discovery + edit URL. Admin config collaboraServerUrl.
- P2.11: Calendar enhancements — clickable links in descriptions,
  participant contact popover, Reply/Reply All from event, timezone picker,
  map links for locations.
- P2.13: VNCdirectory IDP admin panel — Connection, SAML/IDP, LDAP,
  Authentication, Federated Apps configuration. Secret masking on display.
This commit is contained in:
Bernd Rodler
2026-08-07 13:38:12 +02:00
parent e7acf56753
commit 13ec05da83
16 changed files with 1512 additions and 34 deletions
+146
View File
@@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
getVncDirectoryConfig,
saveVncDirectoryConfig,
DEFAULT_VNCDIRECTORY_CONFIG,
VNCDIRECTORY_SENSITIVE_KEYS,
type VncDirectoryConfig,
} from '@/lib/admin/vncdirectory-config';
const VALID_LDAP_TYPES = new Set(['openldap', 'ms-ad']);
const KNOWN_KEYS = new Set(Object.keys(DEFAULT_VNCDIRECTORY_CONFIG));
function maskConfigForClient(config: VncDirectoryConfig): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config)) {
if (VNCDIRECTORY_SENSITIVE_KEYS.has(key)) {
result[key] = typeof value === 'string' && value.length > 0 ? '••••••' : '';
} else {
result[key] = value;
}
}
return result;
}
export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
const config = await getVncDirectoryConfig();
return NextResponse.json(maskConfigForClient(config), {
headers: { 'Cache-Control': 'no-store' },
});
} catch (error) {
logger.error('VNCdirectory config read error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const authResult = await requireAdminAuth(request);
if ('error' in authResult) return authResult.error;
const ip = getClientIP(request);
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
}
// Validate known keys only
const unknownKeys = Object.keys(body).filter((k) => !KNOWN_KEYS.has(k));
if (unknownKeys.length > 0) {
return NextResponse.json(
{ error: `Unknown config keys: ${unknownKeys.join(', ')}` },
{ status: 400 },
);
}
// Validate boolean fields
const boolFields = ['enabled', 'samlEnabled', 'ldapEnabled', 'tfaEnabled', 'oidcEnabled'];
for (const key of boolFields) {
if (key in body && typeof body[key] !== 'boolean') {
return NextResponse.json(
{ error: `${key} must be a boolean` },
{ status: 400 },
);
}
}
// Validate sessionTtl
if ('sessionTtl' in body) {
const ttl = Number(body.sessionTtl);
if (!Number.isFinite(ttl) || ttl < 0) {
return NextResponse.json(
{ error: 'sessionTtl must be a non-negative number' },
{ status: 400 },
);
}
body.sessionTtl = ttl;
}
// Validate ldapType
if ('ldapType' in body && !VALID_LDAP_TYPES.has(body.ldapType)) {
return NextResponse.json(
{ error: `Invalid ldapType: ${body.ldapType}. Must be 'openldap' or 'ms-ad'.` },
{ status: 400 },
);
}
// Validate federatedApps
if ('federatedApps' in body) {
if (!body.federatedApps || typeof body.federatedApps !== 'object' || Array.isArray(body.federatedApps)) {
return NextResponse.json(
{ error: 'federatedApps must be an object mapping app names to URLs' },
{ status: 400 },
);
}
for (const [appName, url] of Object.entries(body.federatedApps as Record<string, unknown>)) {
if (typeof url !== 'string') {
return NextResponse.json(
{ error: `federatedApps.${appName} must be a string URL` },
{ status: 400 },
);
}
}
}
// If apiKey or ldapBindPassword are "••••••", preserve existing value
const currentConfig = await getVncDirectoryConfig();
if (body.apiKey === '••••••') {
body.apiKey = currentConfig.apiKey;
}
if (body.ldapBindPassword === '••••••') {
body.ldapBindPassword = currentConfig.ldapBindPassword;
}
const changedKeys = Object.keys(body).filter((k) => {
const currentVal = currentConfig[k as keyof VncDirectoryConfig];
const newVal = body[k];
if (k === 'federatedApps') {
return JSON.stringify(currentVal) !== JSON.stringify(newVal);
}
return String(currentVal ?? '') !== String(newVal ?? '');
});
await saveVncDirectoryConfig(body as Partial<VncDirectoryConfig>);
if (changedKeys.length > 0) {
await auditLog('vncdirectory.update', { changedKeys }, ip);
}
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('VNCdirectory config update error', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+11
View File
@@ -10,6 +10,17 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configManager } from '@/lib/admin/config-manager';
// TODO(P2.13): Wire SAML IDP integration once VNCdirectory is configured.
// When VNCdirectory is enabled and SAML is configured (see
// lib/admin/vncdirectory-config.ts), the SSO start flow should:
// 1. Check isVncDirectoryEnabled() — if false, fall through to existing OAuth flow.
// 2. Read getVncDirectoryConfig() for samlIdpUrl, samlIssuer, samlSpCert.
// 3. Build a SAML AuthnRequest and redirect to the IdP instead of OAuth.
// 4. The /sso/complete handler should process the SAML Response assertion,
// validate the signature against the SP certificate, extract the subject,
// and create a session.
// Reference: docs/admin/VNCDIRECTORY.md in the VNCmail+ plan (P2.13).
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { getCollaboraEditUrl } from "@/lib/collabora/client";
import { logger } from "@/lib/logger";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
if (!body.fileId || !body.fileName) {
return NextResponse.json(
{ error: "Missing required fields: fileId, fileName" },
{ status: 400 }
);
}
const url = await getCollaboraEditUrl(
String(body.fileId),
String(body.fileName)
);
return NextResponse.json({ url });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
logger.error("Collabora edit URL failed", { error: message });
if (message.includes("not configured")) {
return NextResponse.json({ error: message }, { status: 503 });
}
return NextResponse.json({ error: message }, { status: 500 });
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { createVncMeeting } from "@/lib/vnctalk/client";
import { logger } from "@/lib/logger";
function getClientIP(request: NextRequest): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return "127.0.0.1";
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
if (!body.name || !body.start || !body.end) {
return NextResponse.json(
{ error: "Missing required fields: name, start, end" },
{ status: 400 }
);
}
const invitees: string[] = Array.isArray(body.invitees) ? body.invitees : [];
const result = await createVncMeeting({
name: String(body.name),
start: String(body.start),
end: String(body.end),
invitees,
password: body.password ? String(body.password) : undefined,
description: body.description ? String(body.description) : undefined,
});
logger.info("VNCtalk meeting created", {
meetingId: result.meetingId,
ip: getClientIP(request),
});
return NextResponse.json(result, { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
logger.error("VNCtalk meeting creation failed", { error: message });
if (message.includes("not configured")) {
return NextResponse.json({ error: message }, { status: 503 });
}
return NextResponse.json({ error: message }, { status: 500 });
}
}