@datalayer/core 1.0.21 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/lib/components/billing/BillableAccountSelect.d.ts +35 -0
  2. package/lib/components/billing/BillableAccountSelect.js +340 -0
  3. package/lib/components/billing/index.d.ts +1 -0
  4. package/lib/components/billing/index.js +5 -0
  5. package/lib/components/index.d.ts +2 -0
  6. package/lib/components/index.js +2 -0
  7. package/lib/components/principal/Principal.d.ts +37 -0
  8. package/lib/components/principal/Principal.js +54 -0
  9. package/lib/components/principal/PrincipalAvatar.d.ts +10 -0
  10. package/lib/components/principal/PrincipalAvatar.js +55 -0
  11. package/lib/components/principal/PrincipalBadge.d.ts +20 -0
  12. package/lib/components/principal/PrincipalBadge.js +208 -0
  13. package/lib/components/principal/PrincipalBanner.d.ts +7 -0
  14. package/lib/components/principal/PrincipalBanner.js +84 -0
  15. package/lib/components/principal/PrincipalDetailsOverlay.d.ts +23 -0
  16. package/lib/components/principal/PrincipalDetailsOverlay.js +86 -0
  17. package/lib/components/principal/PrincipalSwitcherMenu.d.ts +14 -0
  18. package/lib/components/principal/PrincipalSwitcherMenu.js +287 -0
  19. package/lib/components/principal/index.d.ts +7 -0
  20. package/lib/components/principal/index.js +10 -0
  21. package/lib/components/sharing/ShareAccessComponent.d.ts +18 -0
  22. package/lib/components/sharing/ShareAccessComponent.js +1368 -0
  23. package/lib/components/sharing/SharingEditor.d.ts +20 -0
  24. package/lib/components/sharing/SharingEditor.js +65 -0
  25. package/lib/components/sharing/index.d.ts +9 -0
  26. package/lib/components/sharing/index.js +12 -0
  27. package/lib/hooks/index.d.ts +5 -0
  28. package/lib/hooks/index.js +5 -0
  29. package/lib/hooks/useBillableAccountStore.d.ts +31 -0
  30. package/lib/hooks/useBillableAccountStore.js +53 -0
  31. package/lib/hooks/usePrincipalStore.d.ts +41 -0
  32. package/lib/hooks/usePrincipalStore.js +101 -0
  33. package/lib/hooks/useSelectedBillableAccount.d.ts +14 -0
  34. package/lib/hooks/useSelectedBillableAccount.js +36 -0
  35. package/lib/hooks/useSelectedPrincipal.d.ts +17 -0
  36. package/lib/hooks/useSelectedPrincipal.js +29 -0
  37. package/lib/hooks/useUsageRefreshStore.d.ts +8 -0
  38. package/lib/hooks/useUsageRefreshStore.js +16 -0
  39. package/lib/utils/Handles.d.ts +3 -0
  40. package/lib/utils/Handles.js +53 -0
  41. package/lib/utils/index.d.ts +1 -0
  42. package/lib/utils/index.js +1 -0
  43. package/package.json +1 -3
@@ -0,0 +1,1368 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2023-2025 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
+ import { KeyIcon, PersonIcon, OrganizationIcon, PeopleIcon, } from '@primer/octicons-react';
8
+ import { Box } from '@datalayer/primer-addons';
9
+ import { ActionList, ActionMenu, Button, Dialog, Label, Spinner, Text, TextInput, } from '@primer/react';
10
+ import { useToast } from '../../hooks/index.js';
11
+ import { useCoreStore, useIAMStore } from '../../state/index.js';
12
+ import { PrincipalAvatar } from '../principal/PrincipalAvatar.js';
13
+ import { PrincipalBadge } from '../principal/PrincipalBadge.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Constants.
16
+ // ---------------------------------------------------------------------------
17
+ const ACCESS_LEVELS = ['view', 'update', 'execute'];
18
+ const DEFAULT_PRINCIPAL_KINDS = [
19
+ 'user',
20
+ 'team',
21
+ 'organization',
22
+ ];
23
+ const ACCESS_LEVEL_LABELS = {
24
+ view: 'Viewer',
25
+ update: 'Editor',
26
+ execute: 'Executor',
27
+ };
28
+ // ---------------------------------------------------------------------------
29
+ // String / payload helpers.
30
+ // ---------------------------------------------------------------------------
31
+ function pickFirstString(...values) {
32
+ for (const value of values) {
33
+ if (typeof value === 'string' && value.trim()) {
34
+ return value.trim();
35
+ }
36
+ }
37
+ return '';
38
+ }
39
+ function normalizePrincipalKind(kindRaw) {
40
+ const kind = (kindRaw || '').trim().toLowerCase();
41
+ if (kind === 'team') {
42
+ return 'team';
43
+ }
44
+ if (kind === 'organization' || kind === 'org') {
45
+ return 'organization';
46
+ }
47
+ return 'user';
48
+ }
49
+ function toTitleCase(value) {
50
+ if (!value) {
51
+ return value;
52
+ }
53
+ return value.charAt(0).toUpperCase() + value.slice(1);
54
+ }
55
+ function normalizeUserOrigin(originRaw) {
56
+ const value = (originRaw || '').trim();
57
+ if (!value) {
58
+ return undefined;
59
+ }
60
+ const lower = value.toLowerCase();
61
+ if (lower === 'datalayer') {
62
+ return 'Datalayer';
63
+ }
64
+ const extPrefix = 'urn:dla:iam:ext::';
65
+ if (lower.startsWith(extPrefix)) {
66
+ const suffix = value.slice(extPrefix.length);
67
+ const provider = suffix.split(':')[0]?.trim();
68
+ if (!provider) {
69
+ return 'External';
70
+ }
71
+ return toTitleCase(provider.toLowerCase());
72
+ }
73
+ return toTitleCase(lower);
74
+ }
75
+ function ensurePrincipalDisplayName(kind, ...candidates) {
76
+ for (const candidate of candidates) {
77
+ if (typeof candidate === 'string' && candidate.trim()) {
78
+ return candidate.trim();
79
+ }
80
+ }
81
+ return kind === 'organization' ? 'Organization' : 'Principal';
82
+ }
83
+ function isSharingAuthorizationMessage(message) {
84
+ const normalized = (message || '').trim().toLowerCase();
85
+ return normalized.includes('not authorized');
86
+ }
87
+ function principalKey(kind, uid) {
88
+ return `${kind}:${uid.toLowerCase()}`;
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Owner extraction (preserves all current fallbacks).
92
+ // ---------------------------------------------------------------------------
93
+ function extractOwnerPrincipals(payload) {
94
+ const ownersFromSharing = Array.isArray(payload?.sharing?.owners)
95
+ ? payload.sharing.owners
96
+ : [];
97
+ const ownersFromSpaceField = [
98
+ ...(Array.isArray(payload?.space?.shared_owner_user_uids_ss)
99
+ ? payload.space.shared_owner_user_uids_ss
100
+ : []),
101
+ ...(Array.isArray(payload?.space?.shared_ower_user_uids_ss)
102
+ ? payload.space.shared_ower_user_uids_ss
103
+ : []),
104
+ ];
105
+ const ownerPayload = payload?.owner ||
106
+ payload?.data?.owner ||
107
+ payload?.item?.owner ||
108
+ payload?.space?.owner ||
109
+ payload?.notebook?.owner ||
110
+ payload?.lexical?.owner ||
111
+ payload?.document?.owner ||
112
+ payload?.cell?.owner ||
113
+ payload?.resource?.owner ||
114
+ payload?.sharing?.owner ||
115
+ {};
116
+ const ownerUid = pickFirstString(ownerPayload?.uid, ownerPayload?.owner_uid, ownerPayload?.ownerUid, ownerPayload?.id, payload?.owner_uid, payload?.ownerUid);
117
+ const ownerHandle = pickFirstString(ownerPayload?.handle_s, ownerPayload?.handle, ownerPayload?.owner_handle, ownerPayload?.ownerHandle, payload?.owner_handle, payload?.ownerHandle);
118
+ const kindFromOwnerPayload = normalizePrincipalKind(pickFirstString(ownerPayload?.kind, ownerPayload?.type, ownerPayload?.principal_kind, ownerPayload?.principalKind, payload?.owner_kind, payload?.ownerKind, payload?.owner_type, payload?.ownerType));
119
+ const accountHandle = pickFirstString(ownerPayload?.organization_handle_s, ownerPayload?.organizationHandle, ownerPayload?.organization_handle, payload?.space?.organization_handle_s, payload?.space?.organizationHandle, payload?.space?.organization_handle);
120
+ const firstName = pickFirstString(ownerPayload?.first_name_t, ownerPayload?.firstName);
121
+ const lastName = pickFirstString(ownerPayload?.last_name_t, ownerPayload?.lastName);
122
+ const fullName = `${firstName} ${lastName}`.trim();
123
+ const displayName = fullName ||
124
+ pickFirstString(ownerPayload?.display_name_t, ownerPayload?.display_name, ownerPayload?.name_t, ownerPayload?.name, ownerHandle, ownerUid);
125
+ const origin = normalizeUserOrigin(pickFirstString(ownerPayload?.origin, ownerPayload?.origin_s, ownerPayload?.origin_t));
126
+ const fallbackOwner = ownerUid || ownerHandle
127
+ ? {
128
+ kind: kindFromOwnerPayload,
129
+ uid: ownerUid || ownerHandle,
130
+ handle: ownerHandle || accountHandle || ownerUid,
131
+ displayName,
132
+ avatarUrl: pickFirstString(ownerPayload?.avatar_url_s, ownerPayload?.avatarUrl, ownerPayload?.avatar_url, payload?.owner_avatar_url, payload?.owner_avatar_url_s, payload?.ownerAvatarUrl) || undefined,
133
+ origin,
134
+ accountHandle: accountHandle || undefined,
135
+ }
136
+ : null;
137
+ const ownersFromSharingMapped = ownersFromSharing
138
+ .map((entry) => {
139
+ if (typeof entry === 'string') {
140
+ const uid = entry.trim();
141
+ return uid
142
+ ? { kind: 'user', uid, handle: uid, displayName: uid }
143
+ : null;
144
+ }
145
+ const uid = pickFirstString(entry?.uid, entry?.owner_uid, entry?.id);
146
+ if (!uid) {
147
+ return null;
148
+ }
149
+ const handle = pickFirstString(entry?.handle_s, entry?.handle, uid);
150
+ const ownerKind = normalizePrincipalKind(pickFirstString(entry?.kind, entry?.type, entry?.principal_kind));
151
+ const ownerOrigin = normalizeUserOrigin(pickFirstString(entry?.origin, entry?.origin_s, entry?.origin_t));
152
+ const ownerDisplayName = pickFirstString(entry?.display_name_t, entry?.display_name, entry?.name_t, entry?.name, handle, uid) || uid;
153
+ const ownerAvatarUrl = pickFirstString(entry?.avatar_url_s, entry?.avatarUrl, entry?.avatar_url) || undefined;
154
+ return {
155
+ kind: ownerKind,
156
+ uid,
157
+ handle,
158
+ displayName: ownerDisplayName,
159
+ avatarUrl: ownerAvatarUrl,
160
+ origin: ownerOrigin,
161
+ accountHandle: pickFirstString(entry?.organization_handle_s, entry?.organization_handle, entry?.organizationHandle) || undefined,
162
+ };
163
+ })
164
+ .filter(Boolean);
165
+ const ownersFromSpaceMapped = ownersFromSpaceField
166
+ .map((uid) => {
167
+ if (typeof uid !== 'string' || !uid.trim()) {
168
+ return null;
169
+ }
170
+ const normalizedUid = uid.trim();
171
+ return {
172
+ kind: 'user',
173
+ uid: normalizedUid,
174
+ handle: normalizedUid,
175
+ displayName: normalizedUid,
176
+ };
177
+ })
178
+ .filter(Boolean);
179
+ const allOwners = [
180
+ ...ownersFromSharingMapped,
181
+ ...ownersFromSpaceMapped,
182
+ ...(fallbackOwner ? [fallbackOwner] : []),
183
+ ];
184
+ const deduped = new Map();
185
+ allOwners.forEach(owner => {
186
+ const key = principalKey(owner.kind, owner.uid);
187
+ if (!deduped.has(key)) {
188
+ deduped.set(key, owner);
189
+ }
190
+ });
191
+ return Array.from(deduped.values());
192
+ }
193
+ // ---------------------------------------------------------------------------
194
+ // AccessByLevel helpers.
195
+ // ---------------------------------------------------------------------------
196
+ function emptyAccessByLevel() {
197
+ return {
198
+ view: { userUids: [], teamUids: [], organizationUids: [] },
199
+ update: { userUids: [], teamUids: [], organizationUids: [] },
200
+ execute: { userUids: [], teamUids: [], organizationUids: [] },
201
+ };
202
+ }
203
+ function bucketFor(kind) {
204
+ return kind === 'user'
205
+ ? 'userUids'
206
+ : kind === 'team'
207
+ ? 'teamUids'
208
+ : 'organizationUids';
209
+ }
210
+ function hasPrincipal(state, level, kind, uid) {
211
+ const lower = uid.toLowerCase();
212
+ return state[level][bucketFor(kind)].some(value => value.toLowerCase() === lower);
213
+ }
214
+ function withPrincipalAdded(state, level, kind, uid) {
215
+ if (hasPrincipal(state, level, kind, uid)) {
216
+ return state;
217
+ }
218
+ const bucket = bucketFor(kind);
219
+ return {
220
+ ...state,
221
+ [level]: {
222
+ ...state[level],
223
+ [bucket]: [...state[level][bucket], uid],
224
+ },
225
+ };
226
+ }
227
+ function withPrincipalRemoved(state, kind, uid) {
228
+ const lower = uid.toLowerCase();
229
+ const bucket = bucketFor(kind);
230
+ const next = {
231
+ view: { ...state.view },
232
+ update: { ...state.update },
233
+ execute: { ...state.execute },
234
+ };
235
+ for (const level of ACCESS_LEVELS) {
236
+ next[level][bucket] = next[level][bucket].filter(value => value.toLowerCase() !== lower);
237
+ }
238
+ return next;
239
+ }
240
+ function buildAclEntries(state, principalKinds) {
241
+ const allowed = new Set(principalKinds);
242
+ const byPrincipal = new Map();
243
+ const upsert = (kind, uid, level) => {
244
+ if (!allowed.has(kind)) {
245
+ return;
246
+ }
247
+ const key = principalKey(kind, uid);
248
+ const existing = byPrincipal.get(key);
249
+ if (!existing) {
250
+ byPrincipal.set(key, { kind, uid, levels: [level] });
251
+ return;
252
+ }
253
+ if (!existing.levels.includes(level)) {
254
+ existing.levels.push(level);
255
+ }
256
+ };
257
+ for (const level of ACCESS_LEVELS) {
258
+ state[level].userUids.forEach(uid => upsert('user', uid, level));
259
+ state[level].teamUids.forEach(uid => upsert('team', uid, level));
260
+ state[level].organizationUids.forEach(uid => upsert('organization', uid, level));
261
+ }
262
+ return Array.from(byPrincipal.values()).sort((a, b) => {
263
+ if (a.kind !== b.kind) {
264
+ return a.kind.localeCompare(b.kind);
265
+ }
266
+ return a.uid.localeCompare(b.uid);
267
+ });
268
+ }
269
+ function hydrateAccessFromSharing(sharing) {
270
+ const access = sharing.access || {};
271
+ const view = access.view || {};
272
+ const update = access.update || {};
273
+ const execute = access.execute || {};
274
+ return {
275
+ view: {
276
+ userUids: [...(view.userUids || [])],
277
+ teamUids: [...(view.teamUids || [])],
278
+ organizationUids: [...(view.organizationUids || [])],
279
+ },
280
+ update: {
281
+ userUids: [...(update.userUids || [])],
282
+ teamUids: [...(update.teamUids || [])],
283
+ organizationUids: [...(update.organizationUids || [])],
284
+ },
285
+ execute: {
286
+ userUids: [...(execute.userUids || [])],
287
+ teamUids: [...(execute.teamUids || [])],
288
+ organizationUids: [...(execute.organizationUids || [])],
289
+ },
290
+ };
291
+ }
292
+ // ---------------------------------------------------------------------------
293
+ // Avatar shimmer (used while a user row is being hydrated).
294
+ // ---------------------------------------------------------------------------
295
+ function AvatarShimmer({ size = 20 }) {
296
+ return (_jsx(Box, { sx: {
297
+ width: size,
298
+ height: size,
299
+ borderRadius: '50%',
300
+ backgroundImage: 'linear-gradient(90deg, var(--bgColor-muted, #d0d7de) 25%, var(--bgColor-default, #ffffff) 50%, var(--bgColor-muted, #d0d7de) 75%)',
301
+ backgroundSize: '220% 100%',
302
+ animation: 'avatarShimmerAcl 1.2s ease-in-out infinite',
303
+ '@keyframes avatarShimmerAcl': {
304
+ '0%': { backgroundPosition: '100% 0' },
305
+ '100%': { backgroundPosition: '-100% 0' },
306
+ },
307
+ } }));
308
+ }
309
+ function OwnerPrincipalRow({ ownerPrincipal, cache, showAvatarSkeleton = false, isPlatformAdmin, }) {
310
+ const entry = cache[principalKey(ownerPrincipal.kind, ownerPrincipal.uid)] || {};
311
+ const cachedHandle = entry.handle;
312
+ const safeCachedHandle = cachedHandle && cachedHandle !== ownerPrincipal.uid ? cachedHandle : '';
313
+ const safeOwnerHandle = ownerPrincipal.handle && ownerPrincipal.handle !== ownerPrincipal.uid
314
+ ? ownerPrincipal.handle
315
+ : '';
316
+ const resolvedHandle = safeCachedHandle || safeOwnerHandle || ownerPrincipal.accountHandle;
317
+ const resolvedAccountHandle = entry.accountHandle || ownerPrincipal.accountHandle;
318
+ const resolvedDisplayName = ensurePrincipalDisplayName(ownerPrincipal.kind, ownerPrincipal.displayName, entry.displayName, resolvedHandle, ownerPrincipal.handle, ownerPrincipal.accountHandle, ownerPrincipal.uid);
319
+ const resolvedAvatarUrl = ownerPrincipal.avatarUrl || entry.avatarUrl;
320
+ const resolvedOrigin = ownerPrincipal.origin ||
321
+ entry.origin ||
322
+ (ownerPrincipal.kind === 'user' ? 'Datalayer' : undefined);
323
+ return (_jsx(Box, { sx: {
324
+ display: 'inline-flex',
325
+ alignItems: 'center',
326
+ gap: 1,
327
+ flexWrap: 'wrap',
328
+ }, children: showAvatarSkeleton ? (_jsxs(_Fragment, { children: [_jsx(AvatarShimmer, { size: 20 }), _jsx(Text, { sx: { fontWeight: 'semibold' }, children: resolvedDisplayName })] })) : (_jsx(PrincipalBadge, { principal: {
329
+ kind: ownerPrincipal.kind,
330
+ uid: ownerPrincipal.uid,
331
+ displayName: resolvedDisplayName,
332
+ handle: resolvedHandle,
333
+ accountHandle: resolvedAccountHandle,
334
+ avatarUrl: resolvedAvatarUrl,
335
+ origin: resolvedOrigin,
336
+ }, showPrincipalLabel: false, showApplyingToText: false, showOriginLabel: ownerPrincipal.kind === 'user', isAdmin: isPlatformAdmin, sx: { px: 0, py: 0, border: 'none', bg: 'transparent' } })) }));
337
+ }
338
+ function AccessPrincipalRow({ entry, cache, showAvatarSkeleton = false, isPlatformAdmin, }) {
339
+ const cached = cache[principalKey(entry.kind, entry.uid)] || {};
340
+ const cachedHandle = cached.handle;
341
+ const safeCachedHandle = cachedHandle && cachedHandle !== entry.uid ? cachedHandle : '';
342
+ const resolvedHandle = safeCachedHandle || cached.accountHandle;
343
+ const resolvedDisplayName = ensurePrincipalDisplayName(entry.kind, cached.displayName, resolvedHandle, entry.uid);
344
+ return (_jsx(Box, { sx: {
345
+ display: 'inline-flex',
346
+ alignItems: 'center',
347
+ gap: 1,
348
+ flexWrap: 'wrap',
349
+ }, children: showAvatarSkeleton ? (_jsxs(_Fragment, { children: [_jsx(AvatarShimmer, { size: 20 }), _jsx(Text, { children: resolvedDisplayName })] })) : (_jsx(PrincipalBadge, { principal: {
350
+ kind: entry.kind,
351
+ uid: entry.uid,
352
+ displayName: resolvedDisplayName,
353
+ handle: resolvedHandle,
354
+ accountHandle: cached.accountHandle,
355
+ avatarUrl: cached.avatarUrl,
356
+ origin: cached.origin || 'Datalayer',
357
+ }, showPrincipalLabel: false, showApplyingToText: false, showOriginLabel: entry.kind === 'user', isAdmin: isPlatformAdmin, sx: { px: 0, py: 0, border: 'none', bg: 'transparent' } })) }));
358
+ }
359
+ // ---------------------------------------------------------------------------
360
+ // Main component.
361
+ // ---------------------------------------------------------------------------
362
+ export function ShareAccessComponent({ isOpen, requestUrl, resourceLabel, resourceName, resourceDescription: _resourceDescription, onSharingAccessRestrictedChange, defaultAccessLevel = 'view', principalKinds = DEFAULT_PRINCIPAL_KINDS, displayMode = 'dialog', onClose, }) {
363
+ void _resourceDescription;
364
+ const { token, user } = useIAMStore();
365
+ const { configuration } = useCoreStore();
366
+ const { enqueueToast } = useToast();
367
+ const isPlatformAdmin = Boolean(Array.isArray(user?.roles) && user.roles.includes('platform_admin'));
368
+ // ----- State -----
369
+ const [isLoading, setIsLoading] = useState(false);
370
+ const [isSaving, setIsSaving] = useState(false);
371
+ const [selectedAccessLevel, setSelectedAccessLevel] = useState(defaultAccessLevel);
372
+ const [access, setAccess] = useState(emptyAccessByLevel());
373
+ const [ownerPrincipals, setOwnerPrincipals] = useState([]);
374
+ const [shareablePrincipals, setShareablePrincipals] = useState([]);
375
+ const [isLoadingShareable, setIsLoadingShareable] = useState(false);
376
+ const [principalCache, setPrincipalCache] = useState({});
377
+ const [hydratingUserUids, setHydratingUserUids] = useState({});
378
+ const [searchQuery, setSearchQuery] = useState('');
379
+ const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
380
+ const [searchResults, setSearchResults] = useState([]);
381
+ const [isSearching, setIsSearching] = useState(false);
382
+ const [isSearchOverlayOpen, setIsSearchOverlayOpen] = useState(false);
383
+ const [sharingAccessMessage, setSharingAccessMessage] = useState(null);
384
+ const [isSharingAccessConfirmed, setIsSharingAccessConfirmed] = useState(false);
385
+ // ----- Refs -----
386
+ const hasLoadedForOpenRef = useRef(false);
387
+ const hasHydratedSharingRef = useRef(false);
388
+ const lastSavedSharingRef = useRef(null);
389
+ const autoSaveTimerRef = useRef(null);
390
+ const activeSearchRequestRef = useRef(0);
391
+ const searchContainerRef = useRef(null);
392
+ const searchInputRef = useRef(null);
393
+ const userHydrationMissesRef = useRef(new Set());
394
+ const enqueueToastRef = useRef(enqueueToast);
395
+ useEffect(() => {
396
+ enqueueToastRef.current = enqueueToast;
397
+ }, [enqueueToast]);
398
+ // ----- Notify caller about restricted access state -----
399
+ useEffect(() => {
400
+ if (!onSharingAccessRestrictedChange) {
401
+ return;
402
+ }
403
+ const restricted = !isSharingAccessConfirmed || Boolean(sharingAccessMessage);
404
+ onSharingAccessRestrictedChange(restricted, sharingAccessMessage || undefined);
405
+ }, [
406
+ isSharingAccessConfirmed,
407
+ sharingAccessMessage,
408
+ onSharingAccessRestrictedChange,
409
+ ]);
410
+ // ----- Derived -----
411
+ const canRequest = Boolean(requestUrl && token);
412
+ const canSearchPrincipals = Boolean(configuration?.iamRunUrl && token);
413
+ const iamRunUrl = configuration?.iamRunUrl;
414
+ const principalKindsSet = useMemo(() => new Set(principalKinds), [principalKinds]);
415
+ const principalKindsKey = useMemo(() => [...principalKinds].sort().join('|'), [principalKinds]);
416
+ const aclEntries = useMemo(() => buildAclEntries(access, principalKinds), [access, principalKinds]);
417
+ const normalizedSearch = searchQuery.trim();
418
+ const normalizedDebouncedSearch = debouncedSearchQuery.trim();
419
+ const canShowSearchResults = isSearchOverlayOpen && normalizedSearch.length > 0;
420
+ // ----- Cache mutators (single consolidated record) -----
421
+ const mergePrincipalCacheEntry = useCallback((kind, uid, patch) => {
422
+ if (!uid) {
423
+ return;
424
+ }
425
+ const key = principalKey(kind, uid);
426
+ setPrincipalCache(prev => {
427
+ const existing = prev[key] || {};
428
+ const merged = { ...existing };
429
+ let changed = false;
430
+ Object.keys(patch).forEach(field => {
431
+ const value = patch[field];
432
+ if (typeof value === 'string') {
433
+ const trimmed = value.trim();
434
+ if (trimmed && existing[field] !== trimmed) {
435
+ merged[field] = trimmed;
436
+ changed = true;
437
+ }
438
+ }
439
+ });
440
+ return changed ? { ...prev, [key]: merged } : prev;
441
+ });
442
+ }, []);
443
+ // ----- Reset on close / load on open -----
444
+ useEffect(() => {
445
+ if (isOpen) {
446
+ setSelectedAccessLevel(defaultAccessLevel);
447
+ }
448
+ }, [isOpen, defaultAccessLevel]);
449
+ useEffect(() => {
450
+ if (!isOpen) {
451
+ hasLoadedForOpenRef.current = false;
452
+ hasHydratedSharingRef.current = false;
453
+ lastSavedSharingRef.current = null;
454
+ if (autoSaveTimerRef.current) {
455
+ clearTimeout(autoSaveTimerRef.current);
456
+ autoSaveTimerRef.current = null;
457
+ }
458
+ setSearchQuery('');
459
+ setIsSearchOverlayOpen(false);
460
+ setSearchResults([]);
461
+ setIsSearching(false);
462
+ setSharingAccessMessage(null);
463
+ setIsSharingAccessConfirmed(false);
464
+ setPrincipalCache({});
465
+ setOwnerPrincipals([]);
466
+ setShareablePrincipals([]);
467
+ setHydratingUserUids({});
468
+ userHydrationMissesRef.current = new Set();
469
+ return;
470
+ }
471
+ if (!canRequest || !requestUrl) {
472
+ setIsLoading(false);
473
+ setIsSharingAccessConfirmed(false);
474
+ return;
475
+ }
476
+ if (hasLoadedForOpenRef.current) {
477
+ return;
478
+ }
479
+ hasLoadedForOpenRef.current = true;
480
+ let cancelled = false;
481
+ const run = async () => {
482
+ setIsLoading(true);
483
+ setIsSharingAccessConfirmed(false);
484
+ setSharingAccessMessage(null);
485
+ try {
486
+ const response = await fetch(requestUrl, {
487
+ method: 'GET',
488
+ headers: {
489
+ 'Content-Type': 'application/json',
490
+ Authorization: `Bearer ${token}`,
491
+ },
492
+ });
493
+ const payload = await response.json();
494
+ const message = payload?.detail ||
495
+ payload?.message ||
496
+ `Unable to load ${resourceLabel.toLowerCase()} sharing.`;
497
+ if (payload?.success === false) {
498
+ if (!cancelled && isSharingAuthorizationMessage(message)) {
499
+ setSharingAccessMessage(message);
500
+ setIsSharingAccessConfirmed(true);
501
+ setAccess(emptyAccessByLevel());
502
+ setOwnerPrincipals([]);
503
+ return;
504
+ }
505
+ throw new Error(message);
506
+ }
507
+ if (!response.ok) {
508
+ if (response.status === 403 ||
509
+ isSharingAuthorizationMessage(message)) {
510
+ if (!cancelled) {
511
+ setSharingAccessMessage(message);
512
+ setIsSharingAccessConfirmed(true);
513
+ setAccess(emptyAccessByLevel());
514
+ setOwnerPrincipals([]);
515
+ }
516
+ return;
517
+ }
518
+ throw new Error(message);
519
+ }
520
+ if (cancelled) {
521
+ return;
522
+ }
523
+ const sharing = (payload?.sharing || {});
524
+ const owners = extractOwnerPrincipals(payload);
525
+ const hydrated = hydrateAccessFromSharing(sharing);
526
+ setOwnerPrincipals(owners);
527
+ owners.forEach(owner => {
528
+ mergePrincipalCacheEntry(owner.kind, owner.uid, {
529
+ displayName: owner.displayName || owner.handle || owner.uid,
530
+ handle: owner.handle || owner.uid,
531
+ avatarUrl: owner.avatarUrl,
532
+ accountHandle: owner.accountHandle,
533
+ origin: owner.kind === 'user' ? owner.origin : undefined,
534
+ });
535
+ });
536
+ setAccess(hydrated);
537
+ lastSavedSharingRef.current = JSON.stringify(hydrated);
538
+ hasHydratedSharingRef.current = true;
539
+ setIsSharingAccessConfirmed(true);
540
+ }
541
+ catch (error) {
542
+ if (cancelled) {
543
+ return;
544
+ }
545
+ const message = error instanceof Error
546
+ ? error.message
547
+ : `Unable to load ${resourceLabel.toLowerCase()} sharing.`;
548
+ enqueueToastRef.current(message, { variant: 'error' });
549
+ }
550
+ finally {
551
+ if (!cancelled) {
552
+ setIsLoading(false);
553
+ }
554
+ }
555
+ };
556
+ void run();
557
+ return () => {
558
+ cancelled = true;
559
+ };
560
+ }, [
561
+ isOpen,
562
+ canRequest,
563
+ requestUrl,
564
+ token,
565
+ resourceLabel,
566
+ resourceName,
567
+ mergePrincipalCacheEntry,
568
+ ]);
569
+ // ----- Fetch shareable principals on open -----
570
+ useEffect(() => {
571
+ if (!isOpen || !canSearchPrincipals || !iamRunUrl || !token) {
572
+ return;
573
+ }
574
+ let cancelled = false;
575
+ const run = async () => {
576
+ setIsLoadingShareable(true);
577
+ try {
578
+ const response = await fetch(`${iamRunUrl}/api/iam/v1/principals/shareable`, {
579
+ method: 'GET',
580
+ headers: {
581
+ 'Content-Type': 'application/json',
582
+ Authorization: `Bearer ${token}`,
583
+ },
584
+ });
585
+ const payload = await response.json();
586
+ if (!response.ok || payload?.success === false) {
587
+ const message = payload?.detail ||
588
+ payload?.message ||
589
+ 'Unable to load shareable principals.';
590
+ throw new Error(message);
591
+ }
592
+ if (cancelled) {
593
+ return;
594
+ }
595
+ const raw = Array.isArray(payload?.principals)
596
+ ? payload.principals
597
+ : [];
598
+ const mapped = raw
599
+ .map((entry) => {
600
+ const uid = pickFirstString(entry?.uid);
601
+ const handle = pickFirstString(entry?.handle, entry?.handle_s);
602
+ if (!uid) {
603
+ return null;
604
+ }
605
+ const kind = normalizePrincipalKind(pickFirstString(entry?.kind));
606
+ return {
607
+ kind,
608
+ uid,
609
+ handle: handle || uid,
610
+ name: pickFirstString(entry?.name) || null,
611
+ email: pickFirstString(entry?.email) || null,
612
+ avatarUrl: pickFirstString(entry?.avatar_url, entry?.avatarUrl) || null,
613
+ organizationUid: pickFirstString(entry?.organization_uid, entry?.organizationUid) || null,
614
+ organizationHandle: pickFirstString(entry?.organization_handle, entry?.organizationHandle) || null,
615
+ };
616
+ })
617
+ .filter(Boolean);
618
+ setShareablePrincipals(mapped);
619
+ mapped.forEach(principal => {
620
+ mergePrincipalCacheEntry(principal.kind, principal.uid, {
621
+ displayName: principal.name || principal.handle,
622
+ handle: principal.handle,
623
+ avatarUrl: principal.avatarUrl || undefined,
624
+ accountHandle: principal.organizationHandle || undefined,
625
+ origin: principal.kind === 'user' ? 'Datalayer' : undefined,
626
+ });
627
+ });
628
+ }
629
+ catch (error) {
630
+ if (cancelled) {
631
+ return;
632
+ }
633
+ const message = error instanceof Error
634
+ ? error.message
635
+ : 'Unable to load shareable principals.';
636
+ enqueueToastRef.current(message, { variant: 'error' });
637
+ }
638
+ finally {
639
+ if (!cancelled) {
640
+ setIsLoadingShareable(false);
641
+ }
642
+ }
643
+ };
644
+ void run();
645
+ return () => {
646
+ cancelled = true;
647
+ };
648
+ }, [isOpen, canSearchPrincipals, iamRunUrl, token, mergePrincipalCacheEntry]);
649
+ // ----- Debounce search query -----
650
+ useEffect(() => {
651
+ const timeout = window.setTimeout(() => {
652
+ setDebouncedSearchQuery(searchQuery);
653
+ }, 350);
654
+ return () => {
655
+ window.clearTimeout(timeout);
656
+ };
657
+ }, [searchQuery]);
658
+ // ----- Run search against /principals/search -----
659
+ useEffect(() => {
660
+ if (!isOpen || !canSearchPrincipals || !iamRunUrl || !token) {
661
+ setSearchResults([]);
662
+ return;
663
+ }
664
+ const query = normalizedDebouncedSearch;
665
+ if (!query || query.length < 2) {
666
+ setSearchResults([]);
667
+ setIsSearching(false);
668
+ return;
669
+ }
670
+ let cancelled = false;
671
+ const requestId = activeSearchRequestRef.current + 1;
672
+ activeSearchRequestRef.current = requestId;
673
+ const run = async () => {
674
+ setIsSearching(true);
675
+ try {
676
+ const controller = new AbortController();
677
+ const timeoutId = window.setTimeout(() => controller.abort(), 8000);
678
+ let response;
679
+ let payload;
680
+ try {
681
+ response = await fetch(`${iamRunUrl}/api/iam/v1/principals/search`, {
682
+ method: 'POST',
683
+ headers: {
684
+ 'Content-Type': 'application/json',
685
+ Authorization: `Bearer ${token}`,
686
+ },
687
+ body: JSON.stringify({
688
+ query,
689
+ principalTypes: [...principalKinds],
690
+ }),
691
+ signal: controller.signal,
692
+ });
693
+ payload = await response.json();
694
+ }
695
+ finally {
696
+ window.clearTimeout(timeoutId);
697
+ }
698
+ if (requestId !== activeSearchRequestRef.current || cancelled) {
699
+ return;
700
+ }
701
+ if (!response.ok || !payload?.success) {
702
+ const message = payload?.detail ||
703
+ payload?.message ||
704
+ 'Unable to search principals.';
705
+ throw new Error(message);
706
+ }
707
+ const data = payload?.data && typeof payload.data === 'object'
708
+ ? payload.data
709
+ : payload;
710
+ const users = Array.isArray(data?.users) ? data.users : [];
711
+ const teams = Array.isArray(data?.teams) ? data.teams : [];
712
+ const organizations = Array.isArray(data?.organizations)
713
+ ? data.organizations
714
+ : [];
715
+ const mappedUsers = users
716
+ .map((entry) => {
717
+ const uid = pickFirstString(entry?.uid);
718
+ const handle = pickFirstString(entry?.handle_s, entry?.handle);
719
+ if (!uid) {
720
+ return null;
721
+ }
722
+ const firstName = pickFirstString(entry?.first_name_t, entry?.firstName);
723
+ const lastName = pickFirstString(entry?.last_name_t, entry?.lastName);
724
+ const displayName = `${firstName} ${lastName}`.trim() ||
725
+ pickFirstString(entry?.display_name_t, entry?.display_name, handle);
726
+ const origin = normalizeUserOrigin(pickFirstString(entry?.origin, entry?.origin_s, entry?.origin_t));
727
+ const avatarUrl = pickFirstString(entry?.avatar_url_s, entry?.avatarUrl, entry?.avatar_url);
728
+ return {
729
+ kind: 'user',
730
+ uid,
731
+ handle: handle || uid,
732
+ displayName: displayName || handle || uid,
733
+ avatarUrl: avatarUrl || undefined,
734
+ origin,
735
+ };
736
+ })
737
+ .filter(Boolean);
738
+ const mappedTeams = teams
739
+ .map((entry) => {
740
+ const uid = pickFirstString(entry?.uid);
741
+ const handle = pickFirstString(entry?.handle_s, entry?.handle);
742
+ if (!uid) {
743
+ return null;
744
+ }
745
+ return {
746
+ kind: 'team',
747
+ uid,
748
+ handle: handle || uid,
749
+ displayName: pickFirstString(entry?.name_t, entry?.name) || handle || uid,
750
+ accountHandle: pickFirstString(entry?.organization_handle_s, entry?.organizationHandle, entry?.organization_handle) || undefined,
751
+ };
752
+ })
753
+ .filter(Boolean);
754
+ const mappedOrganizations = organizations
755
+ .map((entry) => {
756
+ const uid = pickFirstString(entry?.uid);
757
+ const handle = pickFirstString(entry?.handle_s, entry?.handle);
758
+ if (!uid) {
759
+ return null;
760
+ }
761
+ return {
762
+ kind: 'organization',
763
+ uid,
764
+ handle: handle || uid,
765
+ displayName: pickFirstString(entry?.name_t, entry?.name) || handle || uid,
766
+ };
767
+ })
768
+ .filter(Boolean);
769
+ const filtered = [
770
+ ...mappedUsers,
771
+ ...mappedTeams,
772
+ ...mappedOrganizations,
773
+ ].filter(result => principalKindsSet.has(result.kind));
774
+ filtered.forEach(result => {
775
+ mergePrincipalCacheEntry(result.kind, result.uid, {
776
+ displayName: result.displayName || result.handle,
777
+ handle: result.handle,
778
+ avatarUrl: result.avatarUrl,
779
+ accountHandle: result.accountHandle,
780
+ origin: result.kind === 'user' ? result.origin : undefined,
781
+ });
782
+ });
783
+ setSearchResults(filtered);
784
+ }
785
+ catch (error) {
786
+ if (cancelled || requestId !== activeSearchRequestRef.current) {
787
+ return;
788
+ }
789
+ setSearchResults([]);
790
+ const message = error instanceof Error
791
+ ? error.message
792
+ : 'Unable to search principals.';
793
+ enqueueToastRef.current(message, { variant: 'error' });
794
+ }
795
+ finally {
796
+ if (requestId === activeSearchRequestRef.current && !cancelled) {
797
+ setIsSearching(false);
798
+ }
799
+ }
800
+ };
801
+ void run();
802
+ return () => {
803
+ cancelled = true;
804
+ };
805
+ }, [
806
+ isOpen,
807
+ canSearchPrincipals,
808
+ iamRunUrl,
809
+ token,
810
+ normalizedDebouncedSearch,
811
+ principalKindsKey,
812
+ principalKinds,
813
+ principalKindsSet,
814
+ mergePrincipalCacheEntry,
815
+ ]);
816
+ // ----- Hydrate ACL user uids in bulk -----
817
+ useEffect(() => {
818
+ if (!isOpen || !canSearchPrincipals || !iamRunUrl || !token) {
819
+ return;
820
+ }
821
+ const userUids = Array.from(new Set([
822
+ ...aclEntries
823
+ .filter(e => e.kind === 'user')
824
+ .map(e => e.uid)
825
+ .filter(Boolean),
826
+ ...ownerPrincipals
827
+ .filter(o => o.kind === 'user')
828
+ .map(o => o.uid)
829
+ .filter(Boolean),
830
+ ]));
831
+ const unknown = userUids.filter(uid => {
832
+ if (!uid || userHydrationMissesRef.current.has(uid)) {
833
+ return false;
834
+ }
835
+ const cached = principalCache[principalKey('user', uid)] || {};
836
+ return (!cached.displayName ||
837
+ !cached.avatarUrl ||
838
+ !cached.origin ||
839
+ !cached.handle);
840
+ });
841
+ if (unknown.length === 0) {
842
+ setHydratingUserUids({});
843
+ return;
844
+ }
845
+ let cancelled = false;
846
+ const run = async () => {
847
+ setHydratingUserUids(unknown.reduce((acc, uid) => {
848
+ acc[uid] = true;
849
+ return acc;
850
+ }, {}));
851
+ try {
852
+ const response = await fetch(`${iamRunUrl}/api/iam/v1/users/bulk`, {
853
+ method: 'POST',
854
+ headers: {
855
+ 'Content-Type': 'application/json',
856
+ Authorization: `Bearer ${token}`,
857
+ },
858
+ body: JSON.stringify({ userIds: unknown }),
859
+ });
860
+ const payload = await response.json();
861
+ if (!response.ok || !payload?.success || cancelled) {
862
+ unknown.forEach(uid => userHydrationMissesRef.current.add(uid));
863
+ return;
864
+ }
865
+ const data = payload?.data && typeof payload.data === 'object'
866
+ ? payload.data
867
+ : payload;
868
+ const users = Array.isArray(data?.users) ? data.users : [];
869
+ const hydratedSet = new Set();
870
+ users.forEach((entry) => {
871
+ const uid = pickFirstString(entry?.uid);
872
+ if (!uid) {
873
+ return;
874
+ }
875
+ hydratedSet.add(uid);
876
+ const handle = pickFirstString(entry?.handle_s, entry?.handle) || uid;
877
+ const firstName = pickFirstString(entry?.first_name_t, entry?.firstName);
878
+ const lastName = pickFirstString(entry?.last_name_t, entry?.lastName);
879
+ const displayName = `${firstName} ${lastName}`.trim() ||
880
+ pickFirstString(entry?.display_name_t, entry?.display_name) ||
881
+ handle;
882
+ const avatarUrl = pickFirstString(entry?.avatar_url_s, entry?.avatarUrl, entry?.avatar_url);
883
+ const origin = normalizeUserOrigin(pickFirstString(entry?.origin, entry?.origin_s, entry?.origin_t));
884
+ mergePrincipalCacheEntry('user', uid, {
885
+ displayName,
886
+ handle,
887
+ avatarUrl: avatarUrl || undefined,
888
+ origin,
889
+ });
890
+ });
891
+ unknown.forEach(uid => {
892
+ if (!hydratedSet.has(uid)) {
893
+ userHydrationMissesRef.current.add(uid);
894
+ }
895
+ });
896
+ }
897
+ catch {
898
+ unknown.forEach(uid => userHydrationMissesRef.current.add(uid));
899
+ }
900
+ finally {
901
+ if (!cancelled) {
902
+ setHydratingUserUids({});
903
+ }
904
+ }
905
+ };
906
+ void run();
907
+ return () => {
908
+ cancelled = true;
909
+ };
910
+ }, [
911
+ isOpen,
912
+ canSearchPrincipals,
913
+ iamRunUrl,
914
+ token,
915
+ aclEntries,
916
+ ownerPrincipals,
917
+ principalCache,
918
+ mergePrincipalCacheEntry,
919
+ ]);
920
+ // ----- Hydrate ACL team uids individually -----
921
+ useEffect(() => {
922
+ if (!isOpen || !canSearchPrincipals || !iamRunUrl || !token) {
923
+ return;
924
+ }
925
+ const unknownTeams = aclEntries.filter(entry => {
926
+ if (entry.kind !== 'team') {
927
+ return false;
928
+ }
929
+ const cached = principalCache[principalKey('team', entry.uid)] || {};
930
+ return !cached.displayName;
931
+ });
932
+ if (unknownTeams.length === 0) {
933
+ return;
934
+ }
935
+ let cancelled = false;
936
+ void Promise.all(unknownTeams.map(async (entry) => {
937
+ try {
938
+ const response = await fetch(`${iamRunUrl}/api/iam/v1/teams/${encodeURIComponent(entry.uid)}`, {
939
+ method: 'GET',
940
+ headers: {
941
+ 'Content-Type': 'application/json',
942
+ Authorization: `Bearer ${token}`,
943
+ },
944
+ });
945
+ const payload = await response.json();
946
+ if (!response.ok || !payload?.success || cancelled) {
947
+ return;
948
+ }
949
+ const data = payload?.data && typeof payload.data === 'object'
950
+ ? payload.data
951
+ : payload;
952
+ const obj = data?.team || data;
953
+ const name = pickFirstString(obj?.name_t, obj?.name);
954
+ const handle = pickFirstString(obj?.handle_s, obj?.handle);
955
+ const accountHandle = pickFirstString(obj?.organization_handle_s, obj?.organizationHandle, obj?.organization_handle);
956
+ mergePrincipalCacheEntry('team', entry.uid, {
957
+ displayName: name || handle,
958
+ handle,
959
+ accountHandle,
960
+ });
961
+ }
962
+ catch {
963
+ // Best effort.
964
+ }
965
+ }));
966
+ return () => {
967
+ cancelled = true;
968
+ };
969
+ }, [
970
+ isOpen,
971
+ canSearchPrincipals,
972
+ iamRunUrl,
973
+ token,
974
+ aclEntries,
975
+ principalCache,
976
+ mergePrincipalCacheEntry,
977
+ ]);
978
+ // ----- Auto-save on access change after hydration -----
979
+ const saveAccess = useCallback(async (snapshot) => {
980
+ if (!canRequest || !requestUrl) {
981
+ return;
982
+ }
983
+ setIsSaving(true);
984
+ try {
985
+ const body = {
986
+ access: {
987
+ view: {
988
+ userUids: snapshot.view.userUids,
989
+ teamUids: snapshot.view.teamUids,
990
+ organizationUids: snapshot.view.organizationUids,
991
+ },
992
+ update: {
993
+ userUids: snapshot.update.userUids,
994
+ teamUids: snapshot.update.teamUids,
995
+ organizationUids: snapshot.update.organizationUids,
996
+ },
997
+ execute: {
998
+ userUids: snapshot.execute.userUids,
999
+ teamUids: snapshot.execute.teamUids,
1000
+ organizationUids: snapshot.execute.organizationUids,
1001
+ },
1002
+ },
1003
+ };
1004
+ const response = await fetch(requestUrl, {
1005
+ method: 'PUT',
1006
+ headers: {
1007
+ 'Content-Type': 'application/json',
1008
+ Authorization: `Bearer ${token}`,
1009
+ },
1010
+ body: JSON.stringify(body),
1011
+ });
1012
+ const payload = await response.json();
1013
+ const message = payload?.detail ||
1014
+ payload?.message ||
1015
+ `Unable to update ${resourceLabel.toLowerCase()} sharing.`;
1016
+ if (payload?.success === false) {
1017
+ if (isSharingAuthorizationMessage(message)) {
1018
+ setSharingAccessMessage(message);
1019
+ return;
1020
+ }
1021
+ throw new Error(message);
1022
+ }
1023
+ if (!response.ok) {
1024
+ if (response.status === 403 ||
1025
+ isSharingAuthorizationMessage(message)) {
1026
+ setSharingAccessMessage(message);
1027
+ return;
1028
+ }
1029
+ throw new Error(message);
1030
+ }
1031
+ enqueueToastRef.current(`${resourceLabel} sharing updated.`, {
1032
+ variant: 'success',
1033
+ });
1034
+ }
1035
+ catch (error) {
1036
+ const message = error instanceof Error
1037
+ ? error.message
1038
+ : `Unable to update ${resourceLabel.toLowerCase()} sharing.`;
1039
+ enqueueToastRef.current(message, { variant: 'error' });
1040
+ }
1041
+ finally {
1042
+ setIsSaving(false);
1043
+ }
1044
+ }, [canRequest, requestUrl, token, resourceLabel]);
1045
+ useEffect(() => {
1046
+ if (!hasHydratedSharingRef.current) {
1047
+ return;
1048
+ }
1049
+ if (!canRequest || !requestUrl) {
1050
+ return;
1051
+ }
1052
+ if (!isSharingAccessConfirmed || sharingAccessMessage) {
1053
+ return;
1054
+ }
1055
+ const serialized = JSON.stringify(access);
1056
+ if (lastSavedSharingRef.current === serialized) {
1057
+ return;
1058
+ }
1059
+ if (autoSaveTimerRef.current) {
1060
+ clearTimeout(autoSaveTimerRef.current);
1061
+ }
1062
+ autoSaveTimerRef.current = setTimeout(() => {
1063
+ autoSaveTimerRef.current = null;
1064
+ lastSavedSharingRef.current = serialized;
1065
+ void saveAccess(access);
1066
+ }, 400);
1067
+ return () => {
1068
+ if (autoSaveTimerRef.current) {
1069
+ clearTimeout(autoSaveTimerRef.current);
1070
+ autoSaveTimerRef.current = null;
1071
+ }
1072
+ };
1073
+ }, [
1074
+ access,
1075
+ canRequest,
1076
+ requestUrl,
1077
+ isSharingAccessConfirmed,
1078
+ sharingAccessMessage,
1079
+ saveAccess,
1080
+ ]);
1081
+ // ----- Action handlers -----
1082
+ const addPrincipal = useCallback((kind, uid) => {
1083
+ if (!principalKindsSet.has(kind)) {
1084
+ return;
1085
+ }
1086
+ setAccess(prev => withPrincipalAdded(prev, selectedAccessLevel, kind, uid));
1087
+ }, [principalKindsSet, selectedAccessLevel]);
1088
+ const removePrincipal = useCallback((kind, uid) => {
1089
+ setAccess(prev => withPrincipalRemoved(prev, kind, uid));
1090
+ }, []);
1091
+ const handleSearchResultSelect = useCallback((result) => {
1092
+ addPrincipal(result.kind, result.uid);
1093
+ setSearchQuery('');
1094
+ setIsSearchOverlayOpen(false);
1095
+ setSearchResults([]);
1096
+ }, [addPrincipal]);
1097
+ // ----- Search overlay outside-click + escape -----
1098
+ useEffect(() => {
1099
+ if (!isSearchOverlayOpen) {
1100
+ return;
1101
+ }
1102
+ const handlePointer = (event) => {
1103
+ const target = event.target;
1104
+ if (target && searchContainerRef.current?.contains(target)) {
1105
+ return;
1106
+ }
1107
+ setIsSearchOverlayOpen(false);
1108
+ };
1109
+ const handleEscape = (event) => {
1110
+ if (event.key !== 'Escape') {
1111
+ return;
1112
+ }
1113
+ event.preventDefault();
1114
+ setIsSearchOverlayOpen(false);
1115
+ searchInputRef.current?.focus();
1116
+ };
1117
+ document.addEventListener('mousedown', handlePointer);
1118
+ document.addEventListener('keydown', handleEscape);
1119
+ return () => {
1120
+ document.removeEventListener('mousedown', handlePointer);
1121
+ document.removeEventListener('keydown', handleEscape);
1122
+ };
1123
+ }, [isSearchOverlayOpen]);
1124
+ // ----- Shareable picker groupings -----
1125
+ const groupedShareable = useMemo(() => {
1126
+ const filtered = shareablePrincipals.filter(p => principalKindsSet.has(p.kind));
1127
+ const selfUid = pickFirstString(user?.uid);
1128
+ const self = filtered.filter(p => p.kind === 'user' && p.uid === selfUid);
1129
+ const otherUsers = filtered.filter(p => p.kind === 'user' && p.uid !== selfUid);
1130
+ const orgs = filtered.filter(p => p.kind === 'organization');
1131
+ const teams = filtered.filter(p => p.kind === 'team');
1132
+ return { self, otherUsers, orgs, teams };
1133
+ }, [shareablePrincipals, principalKindsSet, user?.uid]);
1134
+ if (!isOpen) {
1135
+ return null;
1136
+ }
1137
+ const isReadOnly = !canRequest ||
1138
+ !isSharingAccessConfirmed ||
1139
+ isLoading ||
1140
+ Boolean(sharingAccessMessage);
1141
+ // ----- Sub-renderers (kept inline for locality) -----
1142
+ const renderShareablePrincipalRow = (principal) => {
1143
+ const alreadyAdded = hasPrincipal(access, selectedAccessLevel, principal.kind, principal.uid);
1144
+ const cached = principalCache[principalKey(principal.kind, principal.uid)] || {};
1145
+ const displayName = principal.name || cached.displayName || principal.handle || principal.uid;
1146
+ const Icon = principal.kind === 'user'
1147
+ ? PersonIcon
1148
+ : principal.kind === 'organization'
1149
+ ? OrganizationIcon
1150
+ : PeopleIcon;
1151
+ return (_jsxs(Box, { as: "button", type: "button", onClick: () => {
1152
+ if (!alreadyAdded) {
1153
+ addPrincipal(principal.kind, principal.uid);
1154
+ }
1155
+ }, disabled: alreadyAdded || isSaving || isReadOnly, sx: {
1156
+ all: 'unset',
1157
+ display: 'flex',
1158
+ alignItems: 'center',
1159
+ justifyContent: 'space-between',
1160
+ gap: 2,
1161
+ px: 2,
1162
+ py: 2,
1163
+ cursor: alreadyAdded || isSaving || isReadOnly ? 'not-allowed' : 'pointer',
1164
+ opacity: alreadyAdded ? 0.55 : 1,
1165
+ borderRadius: 2,
1166
+ borderWidth: 1,
1167
+ borderStyle: 'solid',
1168
+ borderColor: 'border.default',
1169
+ bg: 'canvas.default',
1170
+ ':hover': {
1171
+ bg: alreadyAdded || isSaving || isReadOnly
1172
+ ? 'canvas.default'
1173
+ : 'canvas.subtle',
1174
+ },
1175
+ }, children: [_jsxs(Box, { sx: {
1176
+ display: 'inline-flex',
1177
+ alignItems: 'center',
1178
+ gap: 2,
1179
+ minWidth: 0,
1180
+ }, children: [_jsx(PrincipalAvatar, { kind: principal.kind, avatarUrl: principal.avatarUrl || undefined, alt: displayName, size: 22 }), _jsxs(Box, { sx: { display: 'grid', minWidth: 0 }, children: [_jsxs(Box, { sx: {
1181
+ display: 'inline-flex',
1182
+ alignItems: 'center',
1183
+ gap: 1,
1184
+ flexWrap: 'wrap',
1185
+ }, children: [_jsx(Text, { sx: { fontWeight: 'semibold' }, children: displayName }), principal.kind === 'user' &&
1186
+ user?.uid &&
1187
+ principal.uid === user.uid && (_jsx(Label, { size: "small", variant: "accent", children: "You" }))] }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: ["@", principal.handle, principal.kind === 'team' && principal.organizationHandle && (_jsxs(Text, { as: "span", sx: { color: 'fg.muted' }, children: [' · ', "org @", principal.organizationHandle] }))] })] })] }), _jsxs(Box, { sx: { display: 'inline-flex', alignItems: 'center', gap: 1 }, children: [_jsx(Icon, { size: 14 }), alreadyAdded ? (_jsx(Label, { size: "small", variant: "success", children: "Added" })) : (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "Add" }))] })] }, principalKey(principal.kind, principal.uid)));
1188
+ };
1189
+ const renderShareableGroup = (label, items) => {
1190
+ if (items.length === 0) {
1191
+ return null;
1192
+ }
1193
+ return (_jsxs(Box, { sx: { display: 'grid', gap: 1 }, children: [_jsx(Text, { sx: {
1194
+ fontSize: 0,
1195
+ color: 'fg.muted',
1196
+ textTransform: 'uppercase',
1197
+ letterSpacing: 0.4,
1198
+ }, children: label }), _jsx(Box, { sx: { display: 'grid', gap: 1 }, children: items.map(renderShareablePrincipalRow) })] }));
1199
+ };
1200
+ const content = (_jsxs(Box, { sx: { p: 3, display: 'flex', flexDirection: 'column', gap: 3 }, children: [sharingAccessMessage && (_jsxs(Box, { sx: {
1201
+ px: 3,
1202
+ py: 3,
1203
+ borderRadius: 2,
1204
+ borderWidth: 1,
1205
+ borderStyle: 'solid',
1206
+ borderColor: 'attention.muted',
1207
+ bg: 'attention.subtle',
1208
+ display: 'grid',
1209
+ gap: 1,
1210
+ }, children: [_jsx(Text, { sx: { fontWeight: 600 }, children: "Sharing access is restricted" }), _jsx(Text, { sx: { color: 'fg.muted' }, children: sharingAccessMessage })] })), _jsxs(Box, { sx: {
1211
+ opacity: isReadOnly ? 0.6 : 1,
1212
+ pointerEvents: isReadOnly ? 'none' : 'auto',
1213
+ display: 'flex',
1214
+ flexDirection: 'column',
1215
+ gap: 3,
1216
+ }, "aria-disabled": isReadOnly, children: [_jsxs(Box, { sx: {
1217
+ px: 3,
1218
+ py: 2,
1219
+ borderRadius: 2,
1220
+ borderWidth: 1,
1221
+ borderStyle: 'solid',
1222
+ borderColor: 'border.default',
1223
+ bg: 'canvas.subtle',
1224
+ display: 'flex',
1225
+ alignItems: 'flex-start',
1226
+ justifyContent: 'space-between',
1227
+ gap: 2,
1228
+ }, children: [_jsx(Box, { sx: { display: 'grid', gap: 1 }, children: _jsxs(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: ["Share ", resourceName || `this ${resourceLabel.toLowerCase()}`, ". Pick a principal below \u2014 they will be granted the selected access level."] }) }), _jsxs(ActionMenu, { children: [_jsx(ActionMenu.Anchor, { children: _jsxs(Button, { variant: "default", size: "small", leadingVisual: KeyIcon, disabled: isSaving || isReadOnly, children: ["Access: ", ACCESS_LEVEL_LABELS[selectedAccessLevel]] }) }), _jsx(ActionMenu.Overlay, { width: "small", children: _jsx(ActionList, { selectionVariant: "single", children: ACCESS_LEVELS.map(level => (_jsx(ActionList.Item, { selected: selectedAccessLevel === level, onSelect: () => setSelectedAccessLevel(level), children: ACCESS_LEVEL_LABELS[level] }, level))) }) })] })] }), _jsxs(Box, { sx: {
1229
+ px: 3,
1230
+ py: 2,
1231
+ borderRadius: 2,
1232
+ borderWidth: 1,
1233
+ borderStyle: 'solid',
1234
+ borderColor: 'border.default',
1235
+ bg: 'canvas.default',
1236
+ display: 'grid',
1237
+ gap: 1,
1238
+ }, children: [_jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "Owner" }), ownerPrincipals.length > 0 ? (_jsx(Box, { sx: { display: 'grid' }, children: ownerPrincipals.map((ownerPrincipal, index) => (_jsx(Box, { sx: {
1239
+ py: 1,
1240
+ borderTopWidth: index === 0 ? 0 : 1,
1241
+ borderTopStyle: 'solid',
1242
+ borderColor: 'border.subtle',
1243
+ }, children: _jsx(OwnerPrincipalRow, { ownerPrincipal: ownerPrincipal, cache: principalCache, showAvatarSkeleton: ownerPrincipal.kind === 'user' &&
1244
+ Boolean(hydratingUserUids[ownerPrincipal.uid]), isPlatformAdmin: isPlatformAdmin }) }, principalKey(ownerPrincipal.kind, ownerPrincipal.uid)))) })) : (_jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "Owner information is not available." }))] }), _jsxs(Box, { sx: {
1245
+ px: 3,
1246
+ py: 2,
1247
+ borderRadius: 2,
1248
+ borderWidth: 1,
1249
+ borderStyle: 'solid',
1250
+ borderColor: 'border.default',
1251
+ bg: 'canvas.default',
1252
+ display: 'grid',
1253
+ gap: 2,
1254
+ }, children: [_jsxs(Box, { sx: {
1255
+ display: 'flex',
1256
+ alignItems: 'center',
1257
+ justifyContent: 'space-between',
1258
+ gap: 2,
1259
+ }, children: [_jsx(Text, { sx: { fontSize: 1, fontWeight: 600 }, children: "Share with\u2026" }), isLoadingShareable && (_jsxs(Box, { sx: { display: 'inline-flex', alignItems: 'center', gap: 1 }, children: [_jsx(Spinner, { size: "small" }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "Loading\u2026" })] }))] }), !isLoadingShareable &&
1260
+ groupedShareable.self.length === 0 &&
1261
+ groupedShareable.otherUsers.length === 0 &&
1262
+ groupedShareable.orgs.length === 0 &&
1263
+ groupedShareable.teams.length === 0 ? (_jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "No principals available to share with." })) : (_jsxs(Box, { sx: { display: 'grid', gap: 2 }, children: [renderShareableGroup('You', groupedShareable.self), renderShareableGroup('Other users', groupedShareable.otherUsers), renderShareableGroup('Your organizations', groupedShareable.orgs), renderShareableGroup('Your teams', groupedShareable.teams)] }))] }), _jsxs(Box, { sx: {
1264
+ px: 3,
1265
+ py: 2,
1266
+ borderRadius: 2,
1267
+ borderWidth: 1,
1268
+ borderStyle: 'solid',
1269
+ borderColor: 'border.subtle',
1270
+ bg: 'canvas.subtle',
1271
+ display: 'grid',
1272
+ gap: 1,
1273
+ }, children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "Or search for any user, team, or organization" }), _jsxs(Box, { sx: { position: 'relative' }, ref: searchContainerRef, children: [_jsx(TextInput, { ref: searchInputRef, block: true, value: searchQuery, onChange: e => {
1274
+ const next = e.target.value;
1275
+ setSearchQuery(next);
1276
+ setIsSearchOverlayOpen(next.trim().length > 0);
1277
+ }, onFocus: () => {
1278
+ if (searchQuery.trim().length > 0) {
1279
+ setIsSearchOverlayOpen(true);
1280
+ }
1281
+ }, onKeyDown: e => {
1282
+ if (e.key === 'Escape') {
1283
+ e.preventDefault();
1284
+ setIsSearchOverlayOpen(false);
1285
+ }
1286
+ }, placeholder: "Search by handle, name, or email", "aria-label": "Search principals", disabled: isSaving }), canShowSearchResults && (_jsx(Box, { sx: {
1287
+ position: 'absolute',
1288
+ top: 'calc(100% + 8px)',
1289
+ left: 0,
1290
+ right: 0,
1291
+ zIndex: 100,
1292
+ borderWidth: 1,
1293
+ borderStyle: 'solid',
1294
+ borderColor: 'border.default',
1295
+ borderRadius: 2,
1296
+ maxHeight: '220px',
1297
+ overflowY: 'auto',
1298
+ bg: 'canvas.overlay',
1299
+ boxShadow: 'shadow.medium',
1300
+ }, children: isSearching ? (_jsxs(Box, { sx: {
1301
+ px: 3,
1302
+ py: 2,
1303
+ display: 'inline-flex',
1304
+ alignItems: 'center',
1305
+ gap: 2,
1306
+ }, children: [_jsx(Spinner, { size: "small" }), _jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "Searching\u2026" })] })) : searchResults.length === 0 ? (_jsx(Box, { sx: { px: 3, py: 2 }, children: _jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "No principals found." }) })) : (_jsx(ActionList, { children: searchResults.map(result => (_jsxs(ActionList.Item, { onSelect: () => handleSearchResultSelect(result), children: [_jsx(ActionList.LeadingVisual, { children: _jsx(PrincipalAvatar, { kind: result.kind, avatarUrl: result.avatarUrl, alt: result.displayName, size: 18 }) }), _jsxs(Box, { sx: {
1307
+ display: 'inline-flex',
1308
+ alignItems: 'center',
1309
+ gap: 1,
1310
+ flexWrap: 'wrap',
1311
+ }, children: [_jsx(Text, { children: result.displayName }), result.kind === 'user' && (_jsx(Label, { size: "small", variant: "secondary", children: result.origin ||
1312
+ principalCache[principalKey('user', result.uid)]
1313
+ ?.origin ||
1314
+ 'Datalayer' }))] }), _jsxs(ActionList.Description, { variant: "block", children: ["@", result.handle] })] }, principalKey(result.kind, result.uid)))) })) }))] })] }), _jsxs(Box, { sx: {
1315
+ px: 3,
1316
+ py: 2,
1317
+ borderRadius: 2,
1318
+ borderWidth: 1,
1319
+ borderStyle: 'solid',
1320
+ borderColor: 'border.default',
1321
+ bg: 'canvas.default',
1322
+ display: 'grid',
1323
+ gap: 1,
1324
+ }, children: [_jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "Access Control List (ACL)" }), _jsx(Box, { sx: {
1325
+ borderWidth: 1,
1326
+ borderStyle: 'solid',
1327
+ borderColor: 'border.default',
1328
+ borderRadius: 2,
1329
+ maxHeight: '220px',
1330
+ overflowY: 'auto',
1331
+ bg: 'canvas.subtle',
1332
+ }, children: aclEntries.length === 0 ? (_jsx(Box, { sx: { px: 3, py: 2 }, children: _jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "No principals shared yet." }) })) : (_jsx(Box, { sx: { display: 'grid' }, children: aclEntries.map(entry => (_jsxs(Box, { sx: {
1333
+ px: 3,
1334
+ py: 2,
1335
+ display: 'flex',
1336
+ alignItems: 'center',
1337
+ justifyContent: 'space-between',
1338
+ gap: 2,
1339
+ borderTopWidth: 1,
1340
+ borderTopStyle: 'solid',
1341
+ borderColor: 'border.subtle',
1342
+ '&:first-of-type': { borderTop: 'none' },
1343
+ }, children: [_jsx(AccessPrincipalRow, { entry: entry, cache: principalCache, showAvatarSkeleton: entry.kind === 'user' &&
1344
+ Boolean(hydratingUserUids[entry.uid]), isPlatformAdmin: isPlatformAdmin }), _jsx(Box, { sx: {
1345
+ display: 'flex',
1346
+ flexWrap: 'wrap',
1347
+ gap: 1,
1348
+ justifyContent: 'flex-end',
1349
+ }, children: entry.levels.map(level => (_jsx(Label, { size: "small", variant: "secondary", children: ACCESS_LEVEL_LABELS[level] }, level))) }), _jsx(Button, { size: "small", variant: "invisible", onClick: () => removePrincipal(entry.kind, entry.uid), disabled: isSaving, children: "Remove" })] }, principalKey(entry.kind, entry.uid)))) })) })] })] }), _jsxs(Box, { sx: {
1350
+ display: 'flex',
1351
+ justifyContent: 'flex-end',
1352
+ alignItems: 'center',
1353
+ gap: 2,
1354
+ }, children: [isSaving && (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(Spinner, { size: "small" }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "Saving\u2026" })] })), displayMode === 'dialog' && (_jsx(Button, { onClick: onClose, disabled: isSaving, children: "Close" }))] }), isLoading && (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "Loading current sharing settings\u2026" }))] }));
1355
+ if (displayMode === 'inline') {
1356
+ return (_jsx(Box, { sx: {
1357
+ borderWidth: 1,
1358
+ borderStyle: 'solid',
1359
+ borderColor: 'border.default',
1360
+ borderRadius: 2,
1361
+ bg: 'canvas.default',
1362
+ }, children: content }));
1363
+ }
1364
+ return (_jsx(Dialog, { title: `Share ${resourceLabel.toLowerCase()}`, onClose: onClose, width: "large", children: content }));
1365
+ }
1366
+ export default ShareAccessComponent;
1367
+ // Backward-compatible aliases (deprecated: use ShareAccessComponent).
1368
+ export const ShareAccessDialog = ShareAccessComponent;