Files
SRCmail/stores/sharing-store.ts
Bernd Rodler b98ab59f0d 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
2026-08-07 14:21:07 +02:00

413 lines
12 KiB
TypeScript

import { create } from "zustand";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type {
Principal,
CalendarRights,
AddressBookRights,
FileNodeRights,
MailboxRights,
} from "@/lib/jmap/types";
import {
type SharedResourceKind,
MAILBOX_ROLE_LABELS,
CALENDAR_ROLE_LABELS,
ADDRESSBOOK_ROLE_LABELS,
FILE_ROLE_LABELS,
resolveRights,
detectMailboxPreset,
detectCalendarPreset,
detectAddressBookPreset,
} from "@/lib/sharing-rights";
export type { SharedResourceKind } from "@/lib/sharing-rights";
export interface SharedFolder {
id: string;
resourceId: string;
resourceName: string;
resourceKind: SharedResourceKind;
principalId: string;
principalName: string;
principalEmail: string | null;
role: string;
direction: "byMe" | "withMe";
pending: boolean;
accountId?: string;
}
interface SharingState {
sharedByMe: SharedFolder[];
sharedWithMe: SharedFolder[];
loading: boolean;
principalsCache: Principal[];
lastMessage: { type: 'success' | 'error'; text: string } | null;
loadPrincipals: (client: IJMAPClient) => Promise<Principal[]>;
fetchShares: (client: IJMAPClient) => Promise<void>;
shareFolder: (
client: IJMAPClient,
resourceId: string,
resourceName: string,
resourceKind: SharedResourceKind,
principalId: string,
role: string,
message?: string,
accountId?: string,
) => Promise<void>;
revokeShare: (
client: IJMAPClient,
resourceId: string,
resourceKind: SharedResourceKind,
principalId: string,
accountId?: string,
) => Promise<void>;
changeRole: (
client: IJMAPClient,
resourceId: string,
resourceKind: SharedResourceKind,
principalId: string,
role: string,
accountId?: string,
) => Promise<void>;
acceptShare: (client: IJMAPClient, share: SharedFolder) => Promise<void>;
declineShare: (client: IJMAPClient, share: SharedFolder) => Promise<void>;
}
function roleLabel(kind: SharedResourceKind, role: string): string {
switch (kind) {
case "mailbox":
return MAILBOX_ROLE_LABELS[role] ?? role;
case "calendar":
return CALENDAR_ROLE_LABELS[role] ?? role;
case "addressBook":
return ADDRESSBOOK_ROLE_LABELS[role] ?? role;
case "file":
return FILE_ROLE_LABELS[role] ?? role;
default:
return role;
}
}
export const useSharingStore = create<SharingState>((set, get) => ({
sharedByMe: [],
sharedWithMe: [],
loading: false,
principalsCache: [],
lastMessage: null,
async loadPrincipals(client) {
const cached = get().principalsCache;
if (cached.length > 0) return cached;
const principals = await client.getPrincipals();
set({ principalsCache: principals });
return principals;
},
async fetchShares(client) {
set({ loading: true });
try {
const principals = await client.getPrincipals();
const principalMap = new Map<string, Principal>();
for (const p of principals) principalMap.set(p.id, p);
const byMe: SharedFolder[] = [];
const withMe: SharedFolder[] = [];
try {
const mailboxes = await client.getAllMailboxes();
for (const mb of mailboxes) {
const shares = mb.shareWith;
if (shares && Object.keys(shares).length > 0) {
for (const [principalId, rights] of Object.entries(shares)) {
const p = principalMap.get(principalId);
byMe.push({
id: `mb-${mb.id}-${principalId}`,
resourceId: mb.id,
resourceName: mb.name,
resourceKind: "mailbox",
principalId,
principalName: p?.name ?? principalId,
principalEmail: p?.email ?? null,
role: roleLabel("mailbox", detectMailboxPreset(rights)),
direction: "byMe",
pending: false,
accountId: mb.accountId,
});
}
}
if (mb.isShared && mb.myRights) {
withMe.push({
id: `mb-withme-${mb.id}`,
resourceId: mb.id,
resourceName: mb.name,
resourceKind: "mailbox",
principalId: mb.accountId || "unknown",
principalName: mb.accountName || "Unknown",
principalEmail: null,
role: roleLabel("mailbox", detectMailboxPreset(mb.myRights)),
direction: "withMe",
pending: false,
accountId: mb.accountId,
});
}
}
} catch {
/* mailboxes may not be available */
}
try {
if (client.supportsCalendars()) {
const calendars = await client.getAllCalendars();
for (const cal of calendars) {
const shares = cal.shareWith;
if (shares && Object.keys(shares).length > 0) {
for (const [principalId, rights] of Object.entries(shares)) {
const p = principalMap.get(principalId);
byMe.push({
id: `cal-${cal.id}-${principalId}`,
resourceId: cal.id,
resourceName: cal.name,
resourceKind: "calendar",
principalId,
principalName: p?.name ?? principalId,
principalEmail: p?.email ?? null,
role: roleLabel("calendar", detectCalendarPreset(rights)),
direction: "byMe",
pending: false,
accountId: cal.accountId,
});
}
}
if (cal.isShared && cal.myRights) {
withMe.push({
id: `cal-withme-${cal.id}`,
resourceId: cal.id,
resourceName: cal.name,
resourceKind: "calendar",
principalId: cal.accountId || "unknown",
principalName: cal.accountName || "Unknown",
principalEmail: null,
role: roleLabel("calendar", detectCalendarPreset(cal.myRights)),
direction: "withMe",
pending: false,
accountId: cal.accountId,
});
}
}
}
} catch {
/* calendars may not be available */
}
try {
if (client.supportsContacts()) {
const books = await client.getAllAddressBooks();
for (const book of books) {
const shares = book.shareWith;
if (shares && Object.keys(shares).length > 0) {
for (const [principalId, rights] of Object.entries(shares)) {
const p = principalMap.get(principalId);
byMe.push({
id: `ab-${book.id}-${principalId}`,
resourceId: book.id,
resourceName: book.name,
resourceKind: "addressBook",
principalId,
principalName: p?.name ?? principalId,
principalEmail: p?.email ?? null,
role: roleLabel(
"addressBook",
detectAddressBookPreset(rights),
),
direction: "byMe",
pending: false,
accountId: book.accountId,
});
}
}
if (book.isShared && book.myRights) {
withMe.push({
id: `ab-withme-${book.id}`,
resourceId: book.id,
resourceName: book.name,
resourceKind: "addressBook",
principalId: book.accountId || "unknown",
principalName: book.accountName || "Unknown",
principalEmail: null,
role: roleLabel("addressBook", detectAddressBookPreset(book.myRights)),
direction: "withMe",
pending: false,
accountId: book.accountId,
});
}
}
}
} catch {
/* address books may not be available */
}
set({ sharedByMe: byMe, sharedWithMe: withMe, loading: false, principalsCache: principals });
} catch {
set({ loading: false });
}
},
async shareFolder(
client,
resourceId,
resourceName,
resourceKind,
principalId,
role,
_message,
accountId,
) {
const rights = resolveRights(resourceKind, role);
await applyShare(client, resourceKind, resourceId, principalId, rights, accountId);
const princ = get().principalsCache.find((p) => p.id === principalId);
const entry: SharedFolder = {
id: `${resourceKind}-${resourceId}-${principalId}`,
resourceId,
resourceName,
resourceKind,
principalId,
principalName: princ?.name ?? principalId,
principalEmail: princ?.email ?? null,
role,
direction: "byMe",
pending: false,
accountId,
};
set((s) => ({
sharedByMe: [
...s.sharedByMe.filter(
(f) =>
!(
f.resourceId === resourceId &&
f.principalId === principalId &&
f.resourceKind === resourceKind
),
),
entry,
],
}));
set({ lastMessage: { type: 'success', text: `Shared "${resourceName}"` } });
},
async revokeShare(client, resourceId, resourceKind, principalId, accountId) {
await applyShare(client, resourceKind, resourceId, principalId, null, accountId);
set((s) => ({
sharedByMe: s.sharedByMe.filter(
(f) =>
!(
f.resourceId === resourceId &&
f.principalId === principalId &&
f.resourceKind === resourceKind
),
),
sharedWithMe: s.sharedWithMe.filter(
(f) =>
!(
f.resourceId === resourceId &&
f.principalId === principalId &&
f.resourceKind === resourceKind
),
),
}));
set({ lastMessage: { type: 'success', text: "Access revoked" } });
},
async changeRole(
client,
resourceId,
resourceKind,
principalId,
role,
accountId,
) {
const rights = resolveRights(resourceKind, role);
await applyShare(client, resourceKind, resourceId, principalId, rights, accountId);
set((s) => ({
sharedByMe: s.sharedByMe.map((f) =>
f.resourceId === resourceId &&
f.principalId === principalId &&
f.resourceKind === resourceKind
? { ...f, role }
: f,
),
}));
set({ lastMessage: { type: 'success', text: "Role updated" } });
},
async acceptShare(_client, share) {
set((s) => ({
sharedWithMe: s.sharedWithMe.map((f) =>
f.id === share.id ? { ...f, pending: false } : f,
),
}));
set({ lastMessage: { type: 'success', text: `Accepted share: ${share.resourceName}` } });
},
async declineShare(_client, share) {
set((s) => ({
sharedWithMe: s.sharedWithMe.filter((f) => f.id !== share.id),
}));
set({ lastMessage: { type: 'success', text: `Declined share: ${share.resourceName}` } });
},
}));
async function applyShare(
client: IJMAPClient,
kind: SharedResourceKind,
resourceId: string,
principalId: string,
rights:
| MailboxRights
| CalendarRights
| AddressBookRights
| FileNodeRights
| null,
accountId?: string,
): Promise<void> {
switch (kind) {
case "mailbox":
await client.setMailboxShare(
resourceId,
principalId,
rights as MailboxRights | null,
accountId,
);
break;
case "calendar":
await client.setCalendarShare(
resourceId,
principalId,
rights as CalendarRights | null,
accountId,
);
break;
case "addressBook":
await client.setAddressBookShare(
resourceId,
principalId,
rights as AddressBookRights | null,
accountId,
);
break;
case "file":
await client.setFileNodeShare(
resourceId,
principalId,
rights as FileNodeRights | null,
accountId,
);
break;
}
}