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
+11 -5
View File
@@ -89,6 +89,7 @@ export function FreeBusyView({
}: FreeBusyViewProps) {
const t = useTranslations("calendar");
const client = useAuthStore((s) => s.client);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
const [loading, setLoading] = useState(false);
const [hoveredSlot, setHoveredSlot] = useState<{
@@ -114,7 +115,7 @@ export function FreeBusyView({
if (!client || participants.length === 0) return;
let cancelled = false;
setLoading(true);
fetchFreeBusy(client, participants, startDate, endDate)
fetchFreeBusy(client, participants, startDate, endDate, activeAccountId ?? undefined)
.then((data) => {
if (!cancelled) {
setFreeBusyData(data);
@@ -164,7 +165,8 @@ export function FreeBusyView({
)}
</div>
<div className="overflow-auto border border-border rounded-lg">
<div className="relative">
<div className="overflow-auto border border-border rounded-lg">
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
<table className="w-full border-collapse text-xs">
<thead>
@@ -242,9 +244,12 @@ export function FreeBusyView({
: "opacity-70"
)}
title={format(hourSlot.start, "HH:mm")}
onClick={() =>
isFree ? handleSlotClick(slot!) : undefined
}
onClick={() => {
if (!isFree) return;
const s = slot;
if (!s) return;
handleSlotClick(s);
}}
onMouseEnter={() =>
setHoveredSlot({
participant: key,
@@ -324,6 +329,7 @@ export function FreeBusyView({
}}
/>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span className="inline-flex items-center gap-1">
@@ -58,6 +58,9 @@ export function MiniCalendarDashlet({
const end = format(endOfMonth(displayMonth), "yyyy-MM-dd'T'23:59:59");
const { dateRange } = useCalendarStore.getState();
if (dateRange?.start === start && dateRange?.end === end) return;
// Imperative fetch via getState() is intentional: we only need to
// trigger a data fetch, not react to its completion directly within
// this component. The store handles loading / error states internally.
useCalendarStore.getState().fetchEvents(client, start, end);
}, [displayMonth, client]);
+3 -2
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { apiFetch } from "@/lib/browser-navigation";
import { useResourceStore } from "@/stores/resource-store";
import type { Resource } from "@/lib/resources/client";
import {
@@ -72,7 +73,6 @@ export function ResourcePicker({ start, end, compact = false }: ResourcePickerPr
for (const resource of filtered) {
try {
const params = new URLSearchParams({ start, end });
const { apiFetch } = await import("@/lib/browser-navigation");
const res = await apiFetch(
`/api/resources/${resource.id}/availability?${params.toString()}`
);
@@ -130,11 +130,12 @@ export function ResourcePicker({ start, end, compact = false }: ResourcePickerPr
onChange={(e) => setQuery(e.target.value)}
placeholder={t("resources.search_placeholder")}
className="pl-8"
aria-label="Search resources"
/>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center justify-center py-8" role="status" aria-label="Loading resources">
<div className="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : filtered.length === 0 ? (
@@ -2,7 +2,7 @@
import { useState, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Upload, FileText, AlertTriangle, X, Check, ChevronDown } from "lucide-react";
import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { parseVCard, detectDuplicates } from "@/lib/vcard";
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Upload, FolderOpen, Download, AlertTriangle, Check, X } from "lucide-react";
import { Upload, AlertTriangle, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section";
import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import";
@@ -128,7 +128,7 @@ export function ImportSettings() {
: t("choose_files")}
</Button>
{files.length > 0 && !importing && (
<Button variant="ghost" size="sm" onClick={reset}>
<Button variant="ghost" size="sm" onClick={reset} aria-label="Clear selection">
<X className="w-4 h-4" />
</Button>
)}
@@ -184,7 +184,7 @@ export function SignatureEditorModal({
<h2 className="text-lg font-semibold text-foreground">
{isEditing ? t('edit_signature') : t('new_signature')}
</h2>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8" aria-label="Close">
<X className="w-4 h-4" />
</Button>
</div>
+34 -18
View File
@@ -33,6 +33,13 @@ export function RadialMenu({
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [animatingIn, setAnimatingIn] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const activeIndexRef = useRef(activeIndex);
const itemsRef = useRef(items);
const onCloseRef = useRef(onClose);
activeIndexRef.current = activeIndex;
itemsRef.current = items;
onCloseRef.current = onClose;
useEffect(() => {
setMounted(true);
@@ -51,45 +58,54 @@ export function RadialMenu({
setActiveIndex(-1);
const handleKeyDown = (e: KeyboardEvent) => {
const items = itemsRef.current;
const currentIndex = activeIndexRef.current;
if (e.key === "Escape") {
e.preventDefault();
onClose();
onCloseRef.current();
return;
}
if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) {
e.preventDefault();
const item = items[activeIndex];
if (!item.disabled) {
item.onClick();
onClose();
if (e.key === "Enter") {
if (currentIndex >= 0 && currentIndex < items.length) {
e.preventDefault();
const item = items[currentIndex];
if (!item.disabled) {
item.onClick();
onCloseRef.current();
}
}
return;
}
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((prev) => {
let next = prev + 1;
if (next >= items.length) next = 0;
const hasEnabledItem = items.some((item) => !item.disabled);
if (!hasEnabledItem) return -1;
let next = prev;
let loops = 0;
while (items[next]?.disabled && loops < items.length) {
do {
next = next + 1 >= items.length ? 0 : next + 1;
loops++;
}
return next;
} while (items[next]?.disabled && loops < items.length);
return items[next]?.disabled ? -1 : next;
});
return;
}
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((prev) => {
let next = prev - 1;
if (next < 0) next = items.length - 1;
const hasEnabledItem = items.some((item) => !item.disabled);
if (!hasEnabledItem) return -1;
let next = prev;
let loops = 0;
while (items[next]?.disabled && loops < items.length) {
do {
next = next - 1 < 0 ? items.length - 1 : next - 1;
loops++;
}
return next;
} while (items[next]?.disabled && loops < items.length);
return items[next]?.disabled ? -1 : next;
});
return;
}
@@ -97,7 +113,7 @@ export function RadialMenu({
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, activeIndex, items, onClose]);
}, [isOpen]);
const radius = size / 2 - 28;
const center = size / 2;