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:
+19
-1
@@ -238,13 +238,31 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
|
||||
vnctalkServerUrl: { envVar: 'VNCTALK_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
collaboraServerUrl: { envVar: 'COLLABORA_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
appUrl: { envVar: 'NEXT_PUBLIC_APP_URL', type: 'url', defaultValue: '' },
|
||||
port: { envVar: 'PORT', type: 'string', defaultValue: '3000' },
|
||||
vncdirectoryEnabled: { envVar: 'VNCDIRECTORY_ENABLED', type: 'boolean', defaultValue: false },
|
||||
vncdirectoryApiUrl: { envVar: 'VNCDIRECTORY_API_URL', type: 'url', defaultValue: '' },
|
||||
vncdirectorySamlEnabled: { envVar: 'VNCDIRECTORY_SAML_ENABLED', type: 'boolean', defaultValue: false },
|
||||
vncdirectoryApiKey: { envVar: 'VNCDIRECTORY_API_KEY', type: 'string', defaultValue: '' },
|
||||
vncdirectorySamlIdpUrl: { envVar: 'VNCDIRECTORY_SAML_IDP_URL', type: 'url', defaultValue: '' },
|
||||
vncdirectorySamlSpCert: { envVar: 'VNCDIRECTORY_SAML_SP_CERT', type: 'string', defaultValue: '' },
|
||||
vncdirectorySamlIssuer: { envVar: 'VNCDIRECTORY_SAML_ISSUER', type: 'string', defaultValue: '' },
|
||||
vncdirectoryLdapEnabled: { envVar: 'VNCDIRECTORY_LDAP_ENABLED', type: 'boolean', defaultValue: false },
|
||||
vncdirectoryLdapUri: { envVar: 'VNCDIRECTORY_LDAP_URI', type: 'url', defaultValue: '' },
|
||||
vncdirectoryLdapBindDn: { envVar: 'VNCDIRECTORY_LDAP_BIND_DN', type: 'string', defaultValue: '' },
|
||||
vncdirectoryLdapBindPassword: { envVar: 'VNCDIRECTORY_LDAP_BIND_PASSWORD', fileEnvVar: 'VNCDIRECTORY_LDAP_BIND_PASSWORD_FILE', type: 'string', defaultValue: '' },
|
||||
vncdirectoryLdapSearchBase: { envVar: 'VNCDIRECTORY_LDAP_SEARCH_BASE', type: 'string', defaultValue: '' },
|
||||
vncdirectoryLdapType: { envVar: 'VNCDIRECTORY_LDAP_TYPE', type: 'enum', defaultValue: 'openldap', enumValues: ['openldap', 'ms-ad'] },
|
||||
vncdirectoryTfaEnabled: { envVar: 'VNCDIRECTORY_TFA_ENABLED', type: 'boolean', defaultValue: false },
|
||||
vncdirectoryOidcEnabled: { envVar: 'VNCDIRECTORY_OIDC_ENABLED', type: 'boolean', defaultValue: false },
|
||||
vncdirectoryOidcClientId: { envVar: 'VNCDIRECTORY_OIDC_CLIENT_ID', type: 'string', defaultValue: '' },
|
||||
vncdirectoryOidcDiscoveryUrl: { envVar: 'VNCDIRECTORY_OIDC_DISCOVERY_URL', type: 'url', defaultValue: '' },
|
||||
vncdirectorySessionTtl: { envVar: 'VNCDIRECTORY_SESSION_TTL', type: 'string', defaultValue: '28800' },
|
||||
vncdirectoryFederatedApps: { envVar: 'VNCDIRECTORY_FEDERATED_APPS', type: 'json', defaultValue: {} },
|
||||
};
|
||||
|
||||
/** Keys that should never be exposed to the client config endpoint */
|
||||
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret', 'vncdirectoryApiKey', 'vncdirectoryLdapPassword']);
|
||||
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret', 'vncdirectoryApiKey', 'vncdirectoryLdapBindPassword']);
|
||||
|
||||
/** Admin session cookie name */
|
||||
export const ADMIN_SESSION_COOKIE = 'admin_session';
|
||||
|
||||
@@ -52,6 +52,11 @@ function getEventRange(event: CalendarEvent): EventRange {
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: this duplicates the ISO 8601 duration parsing in
|
||||
// components/calendar/event-card.tsx:parseDuration (which returns minutes
|
||||
// and only handles W/D/H/M via regex). This version returns milliseconds
|
||||
// and additionally handles seconds and sign. They serve different call
|
||||
// sites with different return types, so keep both for now.
|
||||
function parseDurationMs(duration: string): number {
|
||||
let ms = 0;
|
||||
let sign = 1;
|
||||
@@ -120,7 +125,8 @@ export async function fetchFreeBusy(
|
||||
client: IJMAPClient,
|
||||
participants: { email: string }[],
|
||||
start: Date,
|
||||
end: Date
|
||||
end: Date,
|
||||
accountId?: string
|
||||
): Promise<Map<string, FreeBusySlot[]>> {
|
||||
const result = new Map<string, FreeBusySlot[]>();
|
||||
|
||||
@@ -139,7 +145,9 @@ export async function fetchFreeBusy(
|
||||
try {
|
||||
const events = await client.queryAllCalendarEvents(
|
||||
{ after: start.toISOString(), before: end.toISOString() },
|
||||
[{ property: "start", isAscending: true }]
|
||||
[{ property: "start", isAscending: true }],
|
||||
undefined,
|
||||
accountId
|
||||
);
|
||||
|
||||
for (const event of events) {
|
||||
|
||||
@@ -79,8 +79,10 @@ export async function getCollaboraEditUrl(
|
||||
|
||||
// For now, return the base edit URL. A full WOPI implementation would
|
||||
// generate a WOPI src URL with an access token pointing back to this server.
|
||||
const appUrl = configManager.get<string>("appUrl") || process.env.NEXT_PUBLIC_APP_URL;
|
||||
const port = configManager.get<string>("port") || process.env.PORT || "3000";
|
||||
const wopiSrcUrl = `${actionUrl}?WOPISrc=${encodeURIComponent(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`
|
||||
`${appUrl || `http://localhost:${port}`}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`
|
||||
)}`;
|
||||
|
||||
return wopiSrcUrl;
|
||||
|
||||
@@ -888,16 +888,17 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return { destroyed: eventIds, notDestroyed: [] };
|
||||
}
|
||||
|
||||
async queryCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
|
||||
return this.data.calendarEvents.filter(e => {
|
||||
async queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]> {
|
||||
const events = this.data.calendarEvents.filter(e => {
|
||||
if (filter.after && e.start < filter.after) return false;
|
||||
if (filter.before && e.start > filter.before) return false;
|
||||
return true;
|
||||
});
|
||||
return limit ? events.slice(0, limit) : events;
|
||||
}
|
||||
|
||||
async queryAllCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
|
||||
return this.queryCalendarEvents(filter);
|
||||
async queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]> {
|
||||
return this.queryCalendarEvents(filter, sort, limit);
|
||||
}
|
||||
|
||||
async parseCalendarEvents(): Promise<Partial<CalendarEvent>[]> {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { expandImportableEmails } from "@/lib/eml-import";
|
||||
|
||||
export type ConflictResolution = "skip" | "replace" | "copy";
|
||||
@@ -20,15 +19,6 @@ export interface ImportResult {
|
||||
errors: Array<{ file: string; error: string }>;
|
||||
}
|
||||
|
||||
function toBase64(buffer: ArrayBuffer): string {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
interface ParsedEml {
|
||||
messageId: string | null;
|
||||
subject: string;
|
||||
|
||||
@@ -17,10 +17,6 @@ function isTgzName(name: string): boolean {
|
||||
return /\.(tgz|tar\.gz)$/i.test(name);
|
||||
}
|
||||
|
||||
function isArchiveName(name: string): boolean {
|
||||
return isZipName(name) || isTgzName(name);
|
||||
}
|
||||
|
||||
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
|
||||
@@ -300,7 +300,7 @@ export interface IJMAPClient {
|
||||
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
|
||||
batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
||||
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise<CalendarEvent[]>;
|
||||
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>;
|
||||
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, accountId?: string): Promise<CalendarEvent[]>;
|
||||
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
|
||||
|
||||
// ── Calendar Tasks ────────────────────────────────────────────
|
||||
|
||||
+3
-2
@@ -4957,12 +4957,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
async queryAllCalendarEvents(
|
||||
filter: CalendarEventFilter,
|
||||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||
limit?: number
|
||||
limit?: number,
|
||||
accountId?: string
|
||||
): Promise<CalendarEvent[]> {
|
||||
try {
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
const primaryId = this.getCalendarsAccountId();
|
||||
const accountIds = this.getCalendarCapableAccountIds();
|
||||
const accountIds = accountId ? [accountId] : this.getCalendarCapableAccountIds();
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const isPrimary = accountId === primaryId;
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import type {
|
||||
MailboxRights,
|
||||
CalendarRights,
|
||||
AddressBookRights,
|
||||
FileNodeRights,
|
||||
} from "@/lib/jmap/types";
|
||||
|
||||
export type SharedResourceKind =
|
||||
| "mailbox"
|
||||
| "calendar"
|
||||
| "addressBook"
|
||||
| "file";
|
||||
|
||||
export const MAILBOX_RIGHTS_PRESETS: Record<string, MailboxRights> = {
|
||||
read: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: false,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: false,
|
||||
maySetKeywords: false,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: false,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
maySubmit: true,
|
||||
},
|
||||
manager: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
mayRemoveItems: true,
|
||||
maySetSeen: true,
|
||||
maySetKeywords: true,
|
||||
mayCreateChild: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
maySubmit: true,
|
||||
mayShare: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MAILBOX_ROLE_LABELS: Record<string, string> = {
|
||||
read: "Viewer",
|
||||
readWrite: "Editor",
|
||||
manager: "Manager",
|
||||
};
|
||||
|
||||
export const CALENDAR_ROLE_LABELS: Record<string, string> = {
|
||||
read: "Viewer",
|
||||
readWrite: "Editor",
|
||||
manager: "Manager",
|
||||
};
|
||||
|
||||
export const ADDRESSBOOK_ROLE_LABELS: Record<string, string> = {
|
||||
read: "Viewer",
|
||||
readWrite: "Editor",
|
||||
manager: "Manager",
|
||||
};
|
||||
|
||||
export const FILE_ROLE_LABELS: Record<string, string> = {
|
||||
read: "Viewer",
|
||||
readWrite: "Editor",
|
||||
manager: "Manager",
|
||||
};
|
||||
|
||||
export const CALENDAR_RIGHTS_PRESETS: Record<string, CalendarRights> = {
|
||||
read: {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: false,
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
manager: {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: true,
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ADDRESS_BOOK_RIGHTS_PRESETS: Record<string, AddressBookRights> = {
|
||||
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
|
||||
readWrite: {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
manager: {
|
||||
mayRead: true,
|
||||
mayWrite: true,
|
||||
mayShare: true,
|
||||
mayDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const FILE_RIGHTS_PRESETS: Record<string, FileNodeRights> = {
|
||||
read: {
|
||||
mayRead: true,
|
||||
mayAddChildren: false,
|
||||
mayRename: false,
|
||||
mayDelete: false,
|
||||
mayModifyContent: false,
|
||||
mayShare: false,
|
||||
},
|
||||
readWrite: {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: false,
|
||||
},
|
||||
manager: {
|
||||
mayRead: true,
|
||||
mayAddChildren: true,
|
||||
mayRename: true,
|
||||
mayDelete: true,
|
||||
mayModifyContent: true,
|
||||
mayShare: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveRights(
|
||||
kind: SharedResourceKind,
|
||||
role: string,
|
||||
): MailboxRights | CalendarRights | AddressBookRights | FileNodeRights {
|
||||
switch (kind) {
|
||||
case "mailbox":
|
||||
return (
|
||||
MAILBOX_RIGHTS_PRESETS[role] ?? MAILBOX_RIGHTS_PRESETS.read
|
||||
);
|
||||
case "calendar":
|
||||
return (
|
||||
CALENDAR_RIGHTS_PRESETS[role] ?? CALENDAR_RIGHTS_PRESETS.read
|
||||
);
|
||||
case "addressBook":
|
||||
return (
|
||||
ADDRESS_BOOK_RIGHTS_PRESETS[role] ?? ADDRESS_BOOK_RIGHTS_PRESETS.read
|
||||
);
|
||||
case "file":
|
||||
return FILE_RIGHTS_PRESETS[role] ?? FILE_RIGHTS_PRESETS.read;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectMailboxPreset(rights: MailboxRights): string {
|
||||
for (const [name, preset] of Object.entries(MAILBOX_RIGHTS_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof MailboxRights)[];
|
||||
if (
|
||||
keys.every(
|
||||
(k) =>
|
||||
(preset[k] ?? false) === (rights[k] ?? false),
|
||||
)
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export function detectCalendarPreset(rights: CalendarRights): string {
|
||||
for (const [name, preset] of Object.entries(CALENDAR_RIGHTS_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof CalendarRights)[];
|
||||
if (keys.every((k) => (preset[k] ?? false) === (rights[k] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export function detectAddressBookPreset(rights: AddressBookRights): string {
|
||||
for (const [name, preset] of Object.entries(ADDRESS_BOOK_RIGHTS_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
|
||||
if (keys.every((k) => (preset[k] ?? false) === (rights[k] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
export function detectFilePreset(rights: FileNodeRights): string {
|
||||
for (const [name, preset] of Object.entries(FILE_RIGHTS_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof FileNodeRights)[];
|
||||
if (keys.every((k) => (preset[k] ?? false) === (rights[k] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
// Server-side only — imported exclusively from API route handlers.
|
||||
// configManager reads from node:fs/promises and cannot run in the browser.
|
||||
if (typeof window !== "undefined") {
|
||||
throw new Error("lib/vnctalk/client.ts is server-only");
|
||||
}
|
||||
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
|
||||
export interface CreateVncMeetingParams {
|
||||
|
||||
Reference in New Issue
Block a user