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
+620
View File
@@ -0,0 +1,620 @@
'use client';
import { useEffect, useState } from 'react';
import { Save, Loader2, Plus, X } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface VncDirectoryFormData {
enabled: boolean;
apiUrl: string;
apiKey: string;
samlEnabled: boolean;
samlIdpUrl: string;
samlSpCert: string;
samlIssuer: string;
ldapEnabled: boolean;
ldapUri: string;
ldapBindDn: string;
ldapBindPassword: string;
ldapSearchBase: string;
ldapType: 'openldap' | 'ms-ad';
tfaEnabled: boolean;
oidcEnabled: boolean;
oidcClientId: string;
oidcDiscoveryUrl: string;
sessionTtl: number;
federatedApps: Record<string, string>;
}
const BLANK_FORM: VncDirectoryFormData = {
enabled: false,
apiUrl: '',
apiKey: '',
samlEnabled: false,
samlIdpUrl: '',
samlSpCert: '',
samlIssuer: '',
ldapEnabled: false,
ldapUri: '',
ldapBindDn: '',
ldapBindPassword: '',
ldapSearchBase: '',
ldapType: 'openldap',
tfaEnabled: false,
oidcEnabled: false,
oidcClientId: '',
oidcDiscoveryUrl: '',
sessionTtl: 28800,
federatedApps: {},
};
export function VncDirectoryTab() {
const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => { fetchConfig(); }, []);
async function fetchConfig() {
setLoading(true);
try {
const res = await apiFetch('/api/admin/vncdirectory');
if (res.ok) {
const data = await res.json();
setConfig(data);
}
} finally {
setLoading(false);
}
}
function updateField<K extends keyof VncDirectoryFormData>(key: K, value: VncDirectoryFormData[K]) {
setConfig((prev) => ({ ...prev, [key]: value }));
setDirty(true);
setMessage(null);
}
function toggleBool(key: keyof VncDirectoryFormData) {
setConfig((prev) => ({ ...prev, [key]: !prev[key] }));
setDirty(true);
setMessage(null);
}
function setFederatedApp(name: string, url: string) {
setConfig((prev) => ({
...prev,
federatedApps: { ...prev.federatedApps, [name]: url },
}));
setDirty(true);
setMessage(null);
}
function removeFederatedApp(name: string) {
setConfig((prev) => {
const next = { ...prev.federatedApps };
delete next[name];
return { ...prev, federatedApps: next };
});
setDirty(true);
setMessage(null);
}
async function handleSave() {
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/vncdirectory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (res.ok) {
setMessage({ type: 'success', text: 'VNCdirectory configuration saved.' });
setDirty(false);
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' });
}
setSaving(false);
}
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
Loading...
</div>
);
}
const federatedAppsList = Object.entries(config.federatedApps);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">VNCdirectory</h1>
<p className="text-sm text-muted-foreground mt-1">
Centralized identity and directory integration (SAML, LDAP, 2FA)
</p>
</div>
{dirty && (
<button
onClick={handleSave}
disabled={saving}
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save configuration
</button>
)}
</div>
{message && (
<div
className={`text-sm rounded-md px-3 py-2 ${
message.type === 'success'
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'bg-destructive/10 text-destructive'
}`}
>
{message.text}
</div>
)}
<Section title="Enable VNCdirectory Integration">
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">
Turn on VNCdirectory integration for identity management, SSO, and directory services
</p>
</div>
<button
onClick={() => toggleBool('enabled')}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
config.enabled
? 'bg-primary'
: 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
config.enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
</div>
</Section>
{config.enabled && (
<>
<Section title="Connection">
<div className="divide-y divide-border">
<TextRow
label="VNCdirectory URL"
value={config.apiUrl}
onChange={(v) => updateField('apiUrl', v)}
placeholder="https://vncdirectory.example.com"
/>
<PasswordRow
label="API Key"
value={config.apiKey}
onChange={(v) => updateField('apiKey', v)}
placeholder="Enter API key"
/>
</div>
</Section>
<Section title="SAML / Identity Provider">
<div className="divide-y divide-border">
<ToggleRow
label="SAML Enabled"
description="Enable SAML single sign-on via VNCdirectory"
value={config.samlEnabled}
onChange={() => toggleBool('samlEnabled')}
/>
{config.samlEnabled && (
<>
<TextRow
label="Identity Provider URL"
value={config.samlIdpUrl}
onChange={(v) => updateField('samlIdpUrl', v)}
placeholder="https://idp.example.com/saml2/idp"
/>
<TextRow
label="Issuer Name (Entity ID)"
value={config.samlIssuer}
onChange={(v) => updateField('samlIssuer', v)}
placeholder="urn:example:vncmail"
/>
<div className="px-4 py-3 flex flex-col gap-2">
<label className="text-sm text-foreground">
Service Provider Certificate (X.509)
</label>
<textarea
value={config.samlSpCert}
onChange={(e) => updateField('samlSpCert', e.target.value)}
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
rows={4}
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-vertical"
/>
</div>
</>
)}
</div>
</Section>
<Section title="LDAP Directory">
<div className="divide-y divide-border">
<ToggleRow
label="LDAP Enabled"
description="Query user directory via LDAP for contact lookups and authentication"
value={config.ldapEnabled}
onChange={() => toggleBool('ldapEnabled')}
/>
{config.ldapEnabled && (
<>
<TextRow
label="LDAP Server URI"
value={config.ldapUri}
onChange={(v) => updateField('ldapUri', v)}
placeholder="ldaps://ldap.example.com:636"
/>
<TextRow
label="Bind DN"
value={config.ldapBindDn}
onChange={(v) => updateField('ldapBindDn', v)}
placeholder="cn=readonly,dc=example,dc=com"
/>
<PasswordRow
label="Bind Password"
value={config.ldapBindPassword}
onChange={(v) => updateField('ldapBindPassword', v)}
placeholder="Enter LDAP bind password"
/>
<TextRow
label="Search Base"
value={config.ldapSearchBase}
onChange={(v) => updateField('ldapSearchBase', v)}
placeholder="ou=users,dc=example,dc=com"
/>
<SelectRow
label="LDAP Type"
value={config.ldapType}
options={[
{ value: 'openldap', label: 'OpenLDAP' },
{ value: 'ms-ad', label: 'Microsoft Active Directory' },
]}
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
/>
</>
)}
</div>
</Section>
<Section title="Authentication">
<div className="divide-y divide-border">
<ToggleRow
label="Enforce 2FA/TOTP"
description="Require two-factor authentication for all users"
value={config.tfaEnabled}
onChange={() => toggleBool('tfaEnabled')}
/>
<ToggleRow
label="OpenID Connect (OIDC)"
description="Enable OIDC login alongside or instead of SAML"
value={config.oidcEnabled}
onChange={() => toggleBool('oidcEnabled')}
/>
{config.oidcEnabled && (
<>
<TextRow
label="OIDC Client ID"
value={config.oidcClientId}
onChange={(v) => updateField('oidcClientId', v)}
placeholder="vncmail-client"
/>
<TextRow
label="OIDC Discovery URL"
value={config.oidcDiscoveryUrl}
onChange={(v) => updateField('oidcDiscoveryUrl', v)}
placeholder="https://idp.example.com/.well-known/openid-configuration"
/>
</>
)}
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">Session TTL (seconds)</span>
<p className="text-xs text-muted-foreground mt-0.5">
How long SSO sessions remain valid. Default: 8 hours (28800)
</p>
</div>
<input
type="number"
min={0}
value={config.sessionTtl}
onChange={(e) => updateField('sessionTtl', Number(e.target.value))}
className="h-8 w-full sm:w-32 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
</Section>
<Section title="Federated Applications">
<div className="px-4 py-3">
<p className="text-xs text-muted-foreground mb-3">
Configure SSO redirect URLs for other VNC applications. Users signed into one
app will be transparently authenticated when navigating to another.
</p>
<div className="space-y-2">
{federatedAppsList.map(([appName, url]) => (
<div
key={appName}
className="flex flex-col sm:flex-row items-start sm:items-center gap-2"
>
<input
type="text"
value={appName}
readOnly
className="h-8 w-full sm:w-36 rounded-md border border-input bg-muted/50 px-2.5 text-sm text-muted-foreground"
/>
<input
type="url"
value={url}
onChange={(e) => setFederatedApp(appName, e.target.value)}
placeholder="https://vnc.example.com/auth/sso"
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
onClick={() => removeFederatedApp(appName)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
title={`Remove ${appName}`}
>
<X className="w-4 h-4" />
</button>
</div>
))}
<AddFederatedApp
existingKeys={new Set(Object.keys(config.federatedApps))}
onAdd={(name, url) => setFederatedApp(name, url)}
/>
</div>
</div>
</Section>
</>
)}
</div>
);
}
function AddFederatedApp({
existingKeys,
onAdd,
}: {
existingKeys: Set<string>;
onAdd: (name: string, url: string) => void;
}) {
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [error, setError] = useState<string | null>(null);
if (!adding) {
return (
<button
type="button"
onClick={() => setAdding(true)}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add federated app
</button>
);
}
function handleAdd() {
const trimmed = name.trim();
if (!trimmed) {
setError('Enter an application name');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
setError('Name must contain only letters, numbers, hyphens, and underscores');
return;
}
if (existingKeys.has(trimmed)) {
setError('An app with this name already exists');
return;
}
if (!url.trim()) {
setError('Enter an SSO URL');
return;
}
setError(null);
onAdd(trimmed, url.trim());
setName('');
setUrl('');
setAdding(false);
}
function handleCancel() {
setAdding(false);
setName('');
setUrl('');
setError(null);
}
return (
<div className="flex flex-col gap-1.5">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<input
type="text"
autoFocus
value={name}
onChange={(e) => { setName(e.target.value); setError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
placeholder="App name (e.g. vnctalk)"
className="h-8 w-full sm:w-36 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
type="url"
value={url}
onChange={(e) => { setUrl(e.target.value); setError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
placeholder="https://vnctalk.example.com/auth/sso"
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={handleAdd}
className="inline-flex items-center h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
Add
</button>
<button
type="button"
onClick={handleCancel}
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
</div>
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
</div>
{children}
</div>
);
}
function TextRow({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span className="text-sm text-foreground">{label}</span>
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
);
}
function PasswordRow({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
const isMasked = value === '••••••';
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type={isMasked ? 'text' : 'password'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || (isMasked ? 'Saved - type to replace' : undefined)}
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
);
}
function ToggleRow({
label,
description,
value,
onChange,
}: {
label: string;
description?: string;
value: boolean;
onChange: () => void;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0">
<span className="text-sm text-foreground">{label}</span>
{description && (
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
)}
</div>
<button
onClick={onChange}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
value ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
</div>
);
}
function SelectRow({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: { value: string; label: string }[];
onChange: (v: string) => void;
}) {
return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<span className="text-sm text-foreground">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
+2
View File
@@ -12,6 +12,7 @@ import {
Scale,
ScrollText,
LogOut,
Key,
KeyRound,
Bot,
Puzzle,
@@ -55,6 +56,7 @@ const NAV_GROUPS: ReadonlyArray<{
{ tab: 'settings', label: 'Settings', icon: Settings },
{ tab: 'branding', label: 'Branding', icon: Palette },
{ tab: 'auth', label: 'Authentication', icon: Shield },
{ tab: 'vncdirectory', label: 'VNCdirectory', icon: Key },
{ tab: 'policy', label: 'Policy', icon: Scale },
{ tab: 'ai-policy', label: 'AI', icon: Bot },
],
+2
View File
@@ -14,6 +14,7 @@ import { MarketplaceTab } from './_tabs/marketplace';
import { VersionTab } from './_tabs/version';
import { TelemetryTab } from './_tabs/telemetry';
import { LogsTab } from './_tabs/logs';
import { VncDirectoryTab } from './_tabs/vncdirectory';
export default function AdminPage() {
const activeTab = useAdminTabStore((s) => s.activeTab);
@@ -47,5 +48,6 @@ export default function AdminPage() {
case 'version': return <VersionTab />;
case 'telemetry': return <TelemetryTab />;
case 'logs': return <LogsTab />;
case 'vncdirectory': return <VncDirectoryTab />;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/admin?tab=vncdirectory');
}
+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 });
}
}