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
413 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
|
|
|