fix: Phase 2 QA — all 25 remaining HIGH/MEDIUM/LOW issues

HIGH fixes (7):
- H1: VNCdirectory admin i18n — 30+ translation keys added
- H2: handleSave try/catch with error toast
- H3: Free/busy accountId scoping
- H4: cancelEventBookings filter by eventId
- H5: Resource picker static apiFetch import
- H6: Sharing-store toast messages via lastMessage state
- H7: roleLabel for all resource types

MEDIUM fixes (11):
- M1: identitySignatureMap cleanup on delete
- M2: Now-line relative positioning
- M3: Radial menu disabled item keyboard nav
- M4: Radial menu stable event listener via refs
- M5: cancelBooking error on missing booking
- M6: PasswordRow isMasked state flag
- M7: Extract shared rights into lib/sharing-rights.ts
- M8: VNCtalk client server-side guard
- M9: Collabora configManager instead of process.env
- M10: CONFIG_ENV_MAP VNCdirectory fields
- M11: SENSITIVE_CONFIG_KEYS field name unification

LOW fixes (7):
- L1-L3: Unused imports removed
- L4: aria-labels on close, clear, search, spinner
- L5-L7: Comments for intentional patterns, null guard
This commit is contained in:
Bernd Rodler
2026-08-07 14:21:07 +02:00
parent 2e29af50d6
commit b98ab59f0d
24 changed files with 662 additions and 487 deletions
+92 -74
View File
@@ -1,8 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Save, Loader2, Plus, X } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
import { toast } from '@/stores/toast-store';
interface VncDirectoryFormData {
enabled: boolean;
@@ -49,6 +51,7 @@ const BLANK_FORM: VncDirectoryFormData = {
};
export function VncDirectoryTab() {
const t = useTranslations('admin.vncdirectory');
const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -105,19 +108,25 @@ export function VncDirectoryTab() {
setSaving(true);
setMessage(null);
const res = await apiFetch('/api/admin/vncdirectory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
try {
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' });
if (res.ok) {
setMessage({ type: 'success', text: t('saved') });
setDirty(false);
await fetchConfig();
} else {
const data = await res.json();
setMessage({ type: 'error', text: data.error || t('save_error') });
}
} catch (err) {
const msg = err instanceof Error ? err.message : t('save_error');
setMessage({ type: 'error', text: msg });
toast.error(msg);
}
setSaving(false);
}
@@ -125,7 +134,7 @@ export function VncDirectoryTab() {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
Loading...
{t('loading')}
</div>
);
}
@@ -136,9 +145,9 @@ export function VncDirectoryTab() {
<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>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1">
Centralized identity and directory integration (SAML, LDAP, 2FA)
{t('description')}
</p>
</div>
{dirty && (
@@ -148,7 +157,7 @@ export function VncDirectoryTab() {
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
{t('save')}
</button>
)}
</div>
@@ -165,12 +174,12 @@ export function VncDirectoryTab() {
</div>
)}
<Section title="Enable VNCdirectory Integration">
<Section title={t('enable_section')}>
<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>
<span className="text-sm text-foreground">{t('enabled')}</span>
<p className="text-xs text-muted-foreground mt-0.5">
Turn on VNCdirectory integration for identity management, SSO, and directory services
{t('enabled_description')}
</p>
</div>
<button
@@ -192,53 +201,53 @@ export function VncDirectoryTab() {
{config.enabled && (
<>
<Section title="Connection">
<Section title={t('connection')}>
<div className="divide-y divide-border">
<TextRow
label="VNCdirectory URL"
label={t('url')}
value={config.apiUrl}
onChange={(v) => updateField('apiUrl', v)}
placeholder="https://vncdirectory.example.com"
placeholder={t('url_placeholder')}
/>
<PasswordRow
label="API Key"
label={t('api_key')}
value={config.apiKey}
onChange={(v) => updateField('apiKey', v)}
placeholder="Enter API key"
placeholder={t('api_key_placeholder')}
/>
</div>
</Section>
<Section title="SAML / Identity Provider">
<Section title={t('saml')}>
<div className="divide-y divide-border">
<ToggleRow
label="SAML Enabled"
description="Enable SAML single sign-on via VNCdirectory"
label={t('saml_enabled')}
description={t('saml_enabled_description')}
value={config.samlEnabled}
onChange={() => toggleBool('samlEnabled')}
/>
{config.samlEnabled && (
<>
<TextRow
label="Identity Provider URL"
label={t('idp_url')}
value={config.samlIdpUrl}
onChange={(v) => updateField('samlIdpUrl', v)}
placeholder="https://idp.example.com/saml2/idp"
placeholder={t('idp_url_placeholder')}
/>
<TextRow
label="Issuer Name (Entity ID)"
label={t('issuer')}
value={config.samlIssuer}
onChange={(v) => updateField('samlIssuer', v)}
placeholder="urn:example:vncmail"
placeholder={t('issuer_placeholder')}
/>
<div className="px-4 py-3 flex flex-col gap-2">
<label className="text-sm text-foreground">
Service Provider Certificate (X.509)
{t('sp_cert')}
</label>
<textarea
value={config.samlSpCert}
onChange={(e) => updateField('samlSpCert', e.target.value)}
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
placeholder={t('sp_cert_placeholder')}
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"
/>
@@ -248,46 +257,46 @@ export function VncDirectoryTab() {
</div>
</Section>
<Section title="LDAP Directory">
<Section title={t('ldap')}>
<div className="divide-y divide-border">
<ToggleRow
label="LDAP Enabled"
description="Query user directory via LDAP for contact lookups and authentication"
label={t('ldap_enabled')}
description={t('ldap_enabled_description')}
value={config.ldapEnabled}
onChange={() => toggleBool('ldapEnabled')}
/>
{config.ldapEnabled && (
<>
<TextRow
label="LDAP Server URI"
label={t('ldap_uri')}
value={config.ldapUri}
onChange={(v) => updateField('ldapUri', v)}
placeholder="ldaps://ldap.example.com:636"
placeholder={t('ldap_uri_placeholder')}
/>
<TextRow
label="Bind DN"
label={t('bind_dn')}
value={config.ldapBindDn}
onChange={(v) => updateField('ldapBindDn', v)}
placeholder="cn=readonly,dc=example,dc=com"
placeholder={t('bind_dn_placeholder')}
/>
<PasswordRow
label="Bind Password"
label={t('bind_password')}
value={config.ldapBindPassword}
onChange={(v) => updateField('ldapBindPassword', v)}
placeholder="Enter LDAP bind password"
placeholder={t('bind_password_placeholder')}
/>
<TextRow
label="Search Base"
label={t('search_base')}
value={config.ldapSearchBase}
onChange={(v) => updateField('ldapSearchBase', v)}
placeholder="ou=users,dc=example,dc=com"
placeholder={t('search_base_placeholder')}
/>
<SelectRow
label="LDAP Type"
label={t('ldap_type')}
value={config.ldapType}
options={[
{ value: 'openldap', label: 'OpenLDAP' },
{ value: 'ms-ad', label: 'Microsoft Active Directory' },
{ value: 'openldap', label: t('ldap_type_openldap') },
{ value: 'ms-ad', label: t('ldap_type_msad') },
]}
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
/>
@@ -296,41 +305,41 @@ export function VncDirectoryTab() {
</div>
</Section>
<Section title="Authentication">
<Section title={t('auth_section')}>
<div className="divide-y divide-border">
<ToggleRow
label="Enforce 2FA/TOTP"
description="Require two-factor authentication for all users"
label={t('require_2fa')}
description={t('require_2fa_description')}
value={config.tfaEnabled}
onChange={() => toggleBool('tfaEnabled')}
/>
<ToggleRow
label="OpenID Connect (OIDC)"
description="Enable OIDC login alongside or instead of SAML"
label={t('oidc_section')}
description={t('oidc_section_description')}
value={config.oidcEnabled}
onChange={() => toggleBool('oidcEnabled')}
/>
{config.oidcEnabled && (
<>
<TextRow
label="OIDC Client ID"
label={t('oidc_client_id')}
value={config.oidcClientId}
onChange={(v) => updateField('oidcClientId', v)}
placeholder="vncmail-client"
placeholder={t('oidc_client_id_placeholder')}
/>
<TextRow
label="OIDC Discovery URL"
label={t('oidc_discovery_url')}
value={config.oidcDiscoveryUrl}
onChange={(v) => updateField('oidcDiscoveryUrl', v)}
placeholder="https://idp.example.com/.well-known/openid-configuration"
placeholder={t('oidc_discovery_url_placeholder')}
/>
</>
)}
<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>
<span className="text-sm text-foreground">{t('session_ttl')}</span>
<p className="text-xs text-muted-foreground mt-0.5">
How long SSO sessions remain valid. Default: 8 hours (28800)
{t('session_ttl_description')}
</p>
</div>
<input
@@ -344,11 +353,10 @@ export function VncDirectoryTab() {
</div>
</Section>
<Section title="Federated Applications">
<Section title={t('federated')}>
<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.
{t('federated_description')}
</p>
<div className="space-y-2">
{federatedAppsList.map(([appName, url]) => (
@@ -366,13 +374,13 @@ export function VncDirectoryTab() {
type="url"
value={url}
onChange={(e) => setFederatedApp(appName, e.target.value)}
placeholder="https://vnc.example.com/auth/sso"
placeholder={t('app_url_placeholder')}
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}`}
title={t('remove_app', { name: appName })}
>
<X className="w-4 h-4" />
</button>
@@ -398,6 +406,7 @@ function AddFederatedApp({
existingKeys: Set<string>;
onAdd: (name: string, url: string) => void;
}) {
const t = useTranslations('admin.vncdirectory');
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
const [url, setUrl] = useState('');
@@ -411,7 +420,7 @@ function AddFederatedApp({
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
{t('add_app')}
</button>
);
}
@@ -419,19 +428,19 @@ function AddFederatedApp({
function handleAdd() {
const trimmed = name.trim();
if (!trimmed) {
setError('Enter an application name');
setError(t('app_name_error'));
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
setError('Name must contain only letters, numbers, hyphens, and underscores');
setError(t('app_name_format_error'));
return;
}
if (existingKeys.has(trimmed)) {
setError('An app with this name already exists');
setError(t('app_exists_error'));
return;
}
if (!url.trim()) {
setError('Enter an SSO URL');
setError(t('app_url_error'));
return;
}
setError(null);
@@ -457,7 +466,7 @@ function AddFederatedApp({
value={name}
onChange={(e) => { setName(e.target.value); setError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
placeholder="App name (e.g. vnctalk)"
placeholder={t('app_name_placeholder')}
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
@@ -465,7 +474,7 @@ function AddFederatedApp({
value={url}
onChange={(e) => { setUrl(e.target.value); setError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
placeholder="https://vnctalk.example.com/auth/sso"
placeholder={t('app_url_placeholder')}
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">
@@ -474,14 +483,14 @@ function AddFederatedApp({
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
{t('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
{t('cancel')}
</button>
</div>
</div>
@@ -537,7 +546,16 @@ function PasswordRow({
onChange: (v: string) => void;
placeholder?: string;
}) {
const isMasked = value === '••••••';
const [isMasked, setIsMasked] = useState(value === '••••••');
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (isMasked) {
onChange(e.target.value);
setIsMasked(false);
} else {
onChange(e.target.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">
@@ -546,7 +564,7 @@ function PasswordRow({
<input
type={isMasked ? 'text' : 'password'}
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
onChange={handleChange}
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"
/>
+2 -169
View File
@@ -1,4 +1,5 @@
import type { NextRequest } from "next/server";
import { resolveRights, type SharedResourceKind } from "@/lib/sharing-rights";
type JmapMethodCall = [string, Record<string, unknown>, string];
@@ -142,7 +143,7 @@ export async function POST(request: NextRequest) {
);
}
const patchValue = role === null ? null : buildRights(kind as string, role as string);
const patchValue = role === null ? null : resolveRights(kind as SharedResourceKind, role as string);
const methodCalls: JmapMethodCall[] = [
[
@@ -190,172 +191,4 @@ export async function POST(request: NextRequest) {
return Response.json({ ok: true });
}
function buildRights(
kind: string,
role: string,
): Record<string, boolean> | null {
if (role === null) return null;
switch (kind) {
case "mailbox":
return mailboxRights(role);
case "calendar":
return calendarRights(role);
case "addressBook":
return addressBookRights(role);
case "file":
return fileRights(role);
default:
return readRights();
}
}
function mailboxRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
};
case "readWrite":
return {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: false,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: true,
};
case "manager":
return {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
mayShare: true,
};
default:
return mailboxRights("read");
}
}
function calendarRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayShare: false,
mayDelete: false,
};
case "readWrite":
return {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: true,
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayShare: false,
mayDelete: false,
};
case "manager":
return {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: true,
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayShare: true,
mayDelete: true,
};
default:
return calendarRights("read");
}
}
function addressBookRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayRead: true,
mayWrite: false,
mayShare: false,
mayDelete: false,
};
case "readWrite":
return {
mayRead: true,
mayWrite: true,
mayShare: false,
mayDelete: false,
};
case "manager":
return {
mayRead: true,
mayWrite: true,
mayShare: true,
mayDelete: true,
};
default:
return addressBookRights("read");
}
}
function fileRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayRead: true,
mayAddChildren: false,
mayRename: false,
mayDelete: false,
mayModifyContent: false,
mayShare: false,
};
case "readWrite":
return {
mayRead: true,
mayAddChildren: true,
mayRename: true,
mayDelete: true,
mayModifyContent: true,
mayShare: false,
};
case "manager":
return {
mayRead: true,
mayAddChildren: true,
mayRename: true,
mayDelete: true,
mayModifyContent: true,
mayShare: true,
};
default:
return fileRights("read");
}
}
function readRights(): Record<string, boolean> {
return { mayRead: true };
}