@gooddata/sdk-ui-ext 11.51.0-alpha.0 → 11.51.0-alpha.2
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.
- package/NOTICE +4 -4
- package/esm/index.d.ts +3 -2
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +3 -1
- package/esm/internal/translations/en-US.localization-bundle.d.ts +4 -0
- package/esm/internal/translations/en-US.localization-bundle.d.ts.map +1 -1
- package/esm/internal/translations/en-US.localization-bundle.js +4 -0
- package/esm/sdk-ui-ext.d.ts +56 -305
- package/esm/share/ObjectShareDialog.d.ts +19 -23
- package/esm/share/ObjectShareDialog.d.ts.map +1 -1
- package/esm/share/ObjectShareDialog.js +85 -171
- package/esm/share/accessSummary.d.ts +18 -8
- package/esm/share/accessSummary.d.ts.map +1 -1
- package/esm/share/accessSummary.js +25 -13
- package/esm/share/messages.d.ts +7 -0
- package/esm/share/messages.d.ts.map +1 -1
- package/esm/share/messages.js +5 -0
- package/esm/share/objectShareController.helpers.d.ts +83 -5
- package/esm/share/objectShareController.helpers.d.ts.map +1 -1
- package/esm/share/objectShareController.helpers.js +144 -10
- package/esm/share/objectShareController.types.d.ts +63 -64
- package/esm/share/objectShareController.types.d.ts.map +1 -1
- package/esm/share/types.d.ts +4 -5
- package/esm/share/types.d.ts.map +1 -1
- package/esm/share/useAccessList.d.ts +64 -63
- package/esm/share/useAccessList.d.ts.map +1 -1
- package/esm/share/useAccessList.js +174 -221
- package/esm/share/useLabelScope.d.ts +18 -13
- package/esm/share/useLabelScope.d.ts.map +1 -1
- package/esm/share/useLabelScope.js +112 -123
- package/esm/share/useObjectShareController.d.ts +14 -17
- package/esm/share/useObjectShareController.d.ts.map +1 -1
- package/esm/share/useObjectShareController.js +211 -224
- package/esm/share/useObjectShareDialog.d.ts +90 -0
- package/esm/share/useObjectShareDialog.d.ts.map +1 -0
- package/esm/share/useObjectShareDialog.js +82 -0
- package/package.json +21 -21
- package/src/notificationsPanel/NotificationsPanel/DefaultNotificationsPanelButton.scss +4 -1
- package/styles/css/main.css +1 -1
- package/styles/css/main.css.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useAccessList.d.ts","sourceRoot":"","sources":["../../src/share/useAccessList.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC1E,OAAO,
|
|
1
|
+
{"version":3,"file":"useAccessList.d.ts","sourceRoot":"","sources":["../../src/share/useAccessList.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAC1E,OAAO,EACH,KAAK,sBAAsB,EAK9B,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACH,KAAK,gBAAgB,EAKxB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,KAAK,kBAAkB,EAAE,KAAK,sBAAsB,EAAmB,MAAM,sBAAsB,CAAC;AAS7G,OAAO,EAEH,KAAK,SAAS,EASjB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,KAAK,EACR,2BAA2B,EAC3B,mBAAmB,EACnB,aAAa,EACb,0BAA0B,EAC7B,MAAM,kCAAkC,CAAC;AAC1C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAEvD;;;;GAIG;AACH,MAAM,WAAW,WAAW;IACxB,oDAAoD;IACpD,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC;;;;;;OAMG;IACH,sBAAsB,EAAE,OAAO,CAAC;IAChC,6EAA6E;IAC7E,YAAY,EAAE,aAAa,GAAG,SAAS,CAAC;IACxC;;;OAGG;IACH,oBAAoB,EAAE,OAAO,CAAC;IAC9B,8DAA8D;IAC9D,aAAa,EAAE,kBAAkB,CAAC;IAClC,oFAAkF;IAClF,cAAc,EAAE,0BAA0B,CAAC;IAC3C;;;OAGG;IACH,uBAAuB,EAAE,0BAA0B,GAAG,SAAS,CAAC;IAChE,wFAAwF;IACxF,oBAAoB,EAAE,OAAO,CAAC;IAC9B,iFAAiF;IACjF,OAAO,EAAE,oBAAoB,GAAG,SAAS,CAAC;IAC1C,qDAAqD;IACrD,MAAM,EAAE,2BAA2B,CAAC,QAAQ,CAAC,CAAC;IAC9C,mDAAmD;IACnD,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAExC,mFAAmF;IACnF,MAAM,EAAE,CAAC,MAAM,EAAE,sBAAsB,EAAE,EAAE,cAAc,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/F,+FAA6F;IAC7F,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAEjE;;;;OAIG;IACH,eAAe,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,CAAC;IACxD;;;;OAIG;IACH,iBAAiB,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,0BAA0B,KAAK,IAAI,CAAC;IAC3E,gFAAgF;IAChF,kBAAkB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,iEAAiE;IACjE,iBAAiB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;OAGG;IACH,eAAe,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAEtC,oFAAoF;IACpF,aAAa,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,KAAK,IAAI,CAAC;IAC1D,6DAA6D;IAC7D,cAAc,EAAE,MAAM,IAAI,CAAC;IAC3B,8FAA8F;IAC9F,YAAY,EAAE,MAAM,IAAI,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,wBAAwB,GAAG,SAAS,GAAG,WAAW,CAyRvF"}
|
|
@@ -1,140 +1,60 @@
|
|
|
1
1
|
// (C) 2026 GoodData Corporation
|
|
2
|
-
import { useCallback,
|
|
2
|
+
import { useCallback, useMemo, useState } from "react";
|
|
3
3
|
import { idRef, objRefToString, serializeObjRef, } from "@gooddata/sdk-model";
|
|
4
|
-
import { useBackendStrict, useCancelablePromise, useWorkspaceStrict } from "@gooddata/sdk-ui";
|
|
4
|
+
import { convertError, useBackendStrict, useCancelablePromise, useWorkspaceStrict, } from "@gooddata/sdk-ui";
|
|
5
5
|
import { useToastMessage } from "@gooddata/sdk-ui-kit";
|
|
6
|
-
import { isPermissionsNotAvailable } from "./accessErrors.js";
|
|
7
6
|
import { composeEffectiveWorkspaceAccess, deriveGeneralAccess, deriveInheritedWorkspaceLevel, deriveWorkspacePermissionLevel, } from "./accessSummary.js";
|
|
8
7
|
import { objectShareMessages } from "./messages.js";
|
|
9
|
-
import { assigneeIdentityFacts, assigneeMatchesQuery, granteeId, granteesFromAccessList, userDisplayPair, userIdentityFacts, } from "./objectShareController.helpers.js";
|
|
8
|
+
import { assigneeIdentityFacts, assigneeMatchesQuery, effectivePermissionAbove, granteeId, granteesFromAccessList, mergeGrantees, userDisplayPair, userIdentityFacts, } from "./objectShareController.helpers.js";
|
|
10
9
|
/**
|
|
11
|
-
* Owns the backend access list
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* never fights the backend's read-after-write lag. A target switch re-seeds from
|
|
16
|
-
* the new fetch; the seed is gated on a per-target stamp so a late fetch for a
|
|
17
|
-
* previous target can't clobber the current one.
|
|
10
|
+
* Owns the backend access list for ONE dialog session: the hook is mounted while
|
|
11
|
+
* the dialog is open, for a single target that must not change while mounted
|
|
12
|
+
* (remount for a new target — see {@link ObjectShareDialog}). Unmounting discards
|
|
13
|
+
* every transient: the edit overlay, the profile, in-flight finalizers.
|
|
18
14
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
15
|
+
* The flow is one-directional: the list is fetched once, and the displayed state
|
|
16
|
+
* is *derived* from that fetch composed with a small local edit overlay
|
|
17
|
+
* ({@link mergeGrantees}). There is no mirrored state and no post-write refetch —
|
|
18
|
+
* a mutation writes its overlay entry (so the row updates immediately), commits to
|
|
19
|
+
* the backend, then settles the entry on success or reverts it on failure — to the
|
|
20
|
+
* committed entry it superseded (kept as `settled` on the pending edit), or to the
|
|
21
|
+
* fetched row when there was none. The grantee list therefore never blanks and
|
|
22
|
+
* never fights read-after-write lag.
|
|
22
23
|
*
|
|
23
24
|
* @internal
|
|
24
25
|
*/
|
|
25
|
-
export function useAccessList(target
|
|
26
|
+
export function useAccessList(target) {
|
|
26
27
|
const backend = useBackendStrict();
|
|
27
28
|
const workspace = useWorkspaceStrict();
|
|
28
29
|
const toast = useToastMessage();
|
|
29
|
-
//
|
|
30
|
-
const [
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
const
|
|
34
|
-
const [generalAccess, setGeneralAccess] = useState("RESTRICTED");
|
|
35
|
-
const [workspaceLevel, setWorkspaceLevel] = useState("VIEW");
|
|
36
|
-
const [inheritedWorkspaceLevel, setInheritedWorkspaceLevel] = useState(undefined);
|
|
37
|
-
// The target the local state was seeded for; undefined until the first seed.
|
|
38
|
-
// Reading state as belonging to the current target hinges on this matching
|
|
39
|
-
// `targetKey` — a target switch makes the old state stale until the re-seed.
|
|
40
|
-
const [seededTarget, setSeededTarget] = useState(undefined);
|
|
41
|
-
const [loadError, setLoadError] = useState(undefined);
|
|
42
|
-
// Assignee identities from the listing (picker + on-open resolve), keyed by
|
|
43
|
-
// grantee id: de-collapsed facts + the original ObjRef (the serialized id
|
|
44
|
-
// loses Uri-vs-Id). The signed-in user is absent from the listing; their
|
|
45
|
-
// facts derive from the profile instead.
|
|
46
|
-
const [knownAssignees, setKnownAssignees] = useState({});
|
|
47
|
-
// Memoized current-user fetch — the profile doesn't change while mounted,
|
|
48
|
-
// so resolve it at most once and share the promise across callers.
|
|
49
|
-
const currentUserCache = useRef(undefined);
|
|
50
|
-
// Kind-qualified: distinct permission targets can share an identifier (e.g. an
|
|
51
|
-
// attribute and a metric), and every target-scoped guard — the seed stamp, the
|
|
52
|
-
// async-finalizer bail-outs, the dialog's staged-confirm reset — compares this
|
|
53
|
-
// key. serializeObjRef keeps the ref's own `type` qualifier too.
|
|
54
|
-
const targetKey = target ? `${target.kind}:${serializeObjRef(target.ref)}` : undefined;
|
|
55
|
-
// Written from both the picker (loadOptions) and the on-open resolve. Must not
|
|
56
|
-
// depend on the cache it writes, or it would re-trigger loadOptions' fetch.
|
|
57
|
-
const cacheAssignees = useCallback((assignees) => {
|
|
58
|
-
setKnownAssignees((prev) => {
|
|
59
|
-
const next = { ...prev };
|
|
60
|
-
for (const assignee of assignees) {
|
|
61
|
-
next[granteeId(assignee.type === "user" ? "user" : "group", assignee.ref)] = {
|
|
62
|
-
facts: assigneeIdentityFacts(assignee),
|
|
63
|
-
ref: assignee.ref,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
return next;
|
|
67
|
-
});
|
|
68
|
-
}, []);
|
|
69
|
-
// Initial (and target-change) load. Seeds local state once per target; thereafter
|
|
70
|
-
// local state is authoritative and mutations write through it.
|
|
30
|
+
// The only local state: the transient edit overlay. Everything else is derived.
|
|
31
|
+
const [granteeEdits, setGranteeEdits] = useState({});
|
|
32
|
+
const [ruleEdit, setRuleEditState] = useState(undefined);
|
|
33
|
+
// Serialized so an inline idRef(...) — a new object each render — doesn't refetch forever.
|
|
34
|
+
const fetchKey = target ? `${workspace}:${target.kind}:${serializeObjRef(target.ref)}` : undefined;
|
|
71
35
|
const { result: fetchedList, status: fetchStatus, error: fetchError, } = useCancelablePromise({
|
|
72
36
|
promise: target
|
|
73
37
|
? () => backend.workspace(workspace).objectPermissions().getAccessList(target)
|
|
74
38
|
: undefined,
|
|
75
39
|
},
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// `loading` while it refetches. Without this, the still-matching previous
|
|
84
|
-
// `seededTarget` would keep `hasList` true and surface the prior session's
|
|
85
|
-
// grantees as `success` (mutations enabled) until the new list lands.
|
|
86
|
-
const hasList = seededTarget === targetKey && targetKey !== undefined && fetchStatus === "success";
|
|
87
|
-
useEffect(() => {
|
|
88
|
-
if (fetchStatus === "success" && fetchedList) {
|
|
89
|
-
// The cancelable promise is keyed on targetKey, so this list is for it.
|
|
90
|
-
const seededGrantees = granteesFromAccessList(fetchedList);
|
|
91
|
-
setGrantees(seededGrantees);
|
|
92
|
-
setSeededWithoutGrants(seededGrantees.length === 0);
|
|
93
|
-
setGeneralAccess(deriveGeneralAccess(fetchedList.grants));
|
|
94
|
-
setWorkspaceLevel(deriveWorkspacePermissionLevel(fetchedList.grants));
|
|
95
|
-
setInheritedWorkspaceLevel(deriveInheritedWorkspaceLevel(fetchedList.grants));
|
|
96
|
-
setSeededTarget(targetKey);
|
|
97
|
-
setLoadError(undefined);
|
|
98
|
-
}
|
|
99
|
-
else if (fetchStatus === "error") {
|
|
100
|
-
setLoadError(fetchError instanceof Error ? fetchError : new Error(String(fetchError)));
|
|
101
|
-
}
|
|
102
|
-
else if (fetchStatus === "loading") {
|
|
103
|
-
// A (re)fetch is in flight — including when the same object is reopened
|
|
104
|
-
// after navigating away. Drop the seed stamp so the previous session's
|
|
105
|
-
// list isn't surfaced as the current one until the fresh fetch is seeded.
|
|
106
|
-
setSeededTarget(undefined);
|
|
107
|
-
}
|
|
108
|
-
}, [fetchStatus, fetchedList, fetchError, targetKey]);
|
|
109
|
-
const getCurrentUser = useCallback(() => {
|
|
110
|
-
if (!currentUserCache.current) {
|
|
111
|
-
currentUserCache.current = backend
|
|
112
|
-
.currentUser()
|
|
113
|
-
.getUser()
|
|
114
|
-
.catch((error) => {
|
|
115
|
-
// Don't cache a rejected promise, or a transient profile-read
|
|
116
|
-
// failure would stick for the rest of the session.
|
|
117
|
-
currentUserCache.current = undefined;
|
|
118
|
-
throw error;
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
return currentUserCache.current;
|
|
122
|
-
}, [backend]);
|
|
123
|
-
// Resolve the current user only while the dialog is open — keeps the summary-only
|
|
124
|
-
// path free of a profile request (the self-row facts it feeds aren't shown there).
|
|
40
|
+
// fetchKey already encodes workspace, kind and ref.
|
|
41
|
+
[backend, fetchKey]);
|
|
42
|
+
const hasList = fetchStatus === "success" && !!fetchedList;
|
|
43
|
+
const base = useMemo(() => (hasList ? granteesFromAccessList(fetchedList) : []), [hasList, fetchedList]);
|
|
44
|
+
// The profile identifies the caller's own row (self-managed classification, the
|
|
45
|
+
// admin empty-state row). The session exists only while the dialog is open, so
|
|
46
|
+
// fetch it once per session; the client caches it across sessions.
|
|
125
47
|
const { result: currentUser, status: currentUserStatus } = useCancelablePromise({
|
|
126
|
-
promise:
|
|
48
|
+
promise: () => backend.currentUser().getUser(),
|
|
127
49
|
onError: () => { },
|
|
128
|
-
},
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
[getCurrentUser, targetKey !== undefined, dialogOpen]);
|
|
132
|
-
// Success only: while pending OR after a (silently swallowed) failure the
|
|
133
|
-
// rows' isSelf is just its unresolved default, and consumers must not treat
|
|
134
|
-
// an unidentified row as safe to mutate. Retried naturally on dialog reopen
|
|
135
|
-
// (the promise is keyed on `dialogOpen`; the failure isn't cached).
|
|
50
|
+
}, [backend]);
|
|
51
|
+
// Success only: while pending or after a swallowed failure a sole row can't be told
|
|
52
|
+
// apart from the caller's own grant, so it must not be treated as safe to mutate.
|
|
136
53
|
const selfIdentityResolved = currentUserStatus === "success";
|
|
137
54
|
const selfId = currentUser ? granteeId("user", idRef(currentUser.login)) : undefined;
|
|
55
|
+
// Derived from the immutable SEED (see the interface doc): a caller whose own
|
|
56
|
+
// grant was the way in must not read as grant-independent after removing it.
|
|
57
|
+
const seededWithoutSelfGrant = hasList && selfIdentityResolved && !base.some((g) => g.id === selfId);
|
|
138
58
|
// De-collapsed like every listing fact — on tiger the user id is often the email.
|
|
139
59
|
const selfIdentity = useMemo(() => currentUser
|
|
140
60
|
? {
|
|
@@ -142,75 +62,38 @@ export function useAccessList(target, onSaved, dialogOpen) {
|
|
|
142
62
|
id: currentUser.login,
|
|
143
63
|
}
|
|
144
64
|
: undefined, [currentUser]);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return grantees.map((g) => {
|
|
154
|
-
const known = knownAssignees[g.id]?.facts ?? (g.id === selfId ? selfIdentity : undefined);
|
|
155
|
-
const isSelf = g.id === selfId;
|
|
156
|
-
return known
|
|
157
|
-
? { ...g, isSelf, name: g.name ?? known.name, email: g.email ?? known.email }
|
|
158
|
-
: { ...g, isSelf };
|
|
159
|
-
});
|
|
160
|
-
}, [hasList, grantees, knownAssignees, selfIdentity, selfId]);
|
|
161
|
-
// Resolve identity facts on dialog open — grants often carry only raw ids
|
|
162
|
-
// (the post-reload state). Unconditional: no missing-facts gate to keep in
|
|
163
|
-
// sync with the cache, and summary-only consumers never fetch. Keyed on the
|
|
164
|
-
// serialized targetKey so an inline-ref consumer re-rendering mid-fetch
|
|
165
|
-
// doesn't cancel it.
|
|
166
|
-
useCancelablePromise({
|
|
167
|
-
promise: target && dialogOpen
|
|
168
|
-
? () => backend.workspace(workspace).objectPermissions().getAvailableAssignees(target)
|
|
169
|
-
: undefined,
|
|
170
|
-
onSuccess: cacheAssignees,
|
|
171
|
-
// Best-effort backfill: on error the raw id stays (pre-fix behavior, no
|
|
172
|
-
// regression) and the picker can still resolve it on demand. No toast.
|
|
173
|
-
onError: () => { },
|
|
174
|
-
}, [backend, workspace, targetKey, dialogOpen]);
|
|
175
|
-
// Stable sorted key of the currently-granted ids — drives the picker's
|
|
176
|
-
// "exclude already-granted" filter. Keyed on ids only (not names), so the
|
|
177
|
-
// picker's own name-cache writes can't change loadOptions' identity and
|
|
178
|
-
// re-trigger its fetch.
|
|
179
|
-
const excludedIdsKey = useMemo(() => grantees
|
|
180
|
-
.filter((g) => g.pending !== "removing")
|
|
181
|
-
.map((g) => g.id)
|
|
182
|
-
.sort()
|
|
183
|
-
.join(","), [grantees]);
|
|
65
|
+
const grantees = useMemo(() => mergeGrantees(base, granteeEdits, selfId, selfIdentity), [base, granteeEdits, selfId, selfIdentity]);
|
|
66
|
+
const generalAccess = ruleEdit?.generalAccess ?? deriveGeneralAccess(fetchedList?.grants ?? []);
|
|
67
|
+
const workspaceLevel = ruleEdit?.level ?? deriveWorkspacePermissionLevel(fetchedList?.grants ?? []);
|
|
68
|
+
const workspaceLevelSaving = ruleEdit?.pending ?? false;
|
|
69
|
+
const workspaceInheritedLevel = useMemo(() => deriveInheritedWorkspaceLevel(fetchedList?.grants ?? []), [fetchedList]);
|
|
70
|
+
// Keyed on plain values so the summary's identity changes exactly when its
|
|
71
|
+
// content does — consumers (the dialog's onSummaryChange) rely on that.
|
|
72
|
+
const granteeCount = grantees.filter((g) => g.pending !== "removing").length;
|
|
184
73
|
const summary = useMemo(() => {
|
|
185
74
|
if (!hasList) {
|
|
186
75
|
return undefined;
|
|
187
76
|
}
|
|
188
|
-
//
|
|
189
|
-
// workspace access even when this workspace holds no rule of its own.
|
|
77
|
+
// Effective access: an inherited rule counts as WORKSPACE even without a local rule.
|
|
190
78
|
return {
|
|
191
|
-
...composeEffectiveWorkspaceAccess(generalAccess, workspaceLevel,
|
|
192
|
-
granteeCount
|
|
79
|
+
...composeEffectiveWorkspaceAccess(generalAccess, workspaceLevel, workspaceInheritedLevel),
|
|
80
|
+
granteeCount,
|
|
193
81
|
};
|
|
194
|
-
}, [hasList, generalAccess, workspaceLevel,
|
|
195
|
-
// "success"
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
// with the previous target's 404 before the load effect updates loadError.
|
|
210
|
-
const accessUnavailable = fetchStatus === "error" && isPermissionsNotAvailable(fetchError);
|
|
211
|
-
// Write a single grant change to the backend, then toast. The caller applies
|
|
212
|
-
// the optimistic local write-through and rolls it back on failure; there is no
|
|
213
|
-
// refetch — local state stays authoritative.
|
|
82
|
+
}, [hasList, generalAccess, workspaceLevel, workspaceInheritedLevel, granteeCount]);
|
|
83
|
+
// "success" waits for `hasList` so consumers never see success with nothing to show.
|
|
84
|
+
const deriveStatus = () => {
|
|
85
|
+
if (!target) {
|
|
86
|
+
return "idle";
|
|
87
|
+
}
|
|
88
|
+
if (fetchStatus === "error") {
|
|
89
|
+
return "error";
|
|
90
|
+
}
|
|
91
|
+
return hasList ? "success" : "loading";
|
|
92
|
+
};
|
|
93
|
+
const status = deriveStatus();
|
|
94
|
+
// Typed SDK error, the house pattern (see InsightView) — not a hand-rolled Error.
|
|
95
|
+
const loadError = useMemo(() => (fetchStatus === "error" ? convertError(fetchError) : undefined), [fetchStatus, fetchError]);
|
|
96
|
+
// The caller owns the optimistic overlay write and its rollback; there is no refetch.
|
|
214
97
|
const commit = useCallback(async (mutate, successMessage) => {
|
|
215
98
|
if (!target) {
|
|
216
99
|
return false;
|
|
@@ -221,18 +104,20 @@ export function useAccessList(target, onSaved, dialogOpen) {
|
|
|
221
104
|
.objectPermissions()
|
|
222
105
|
.manageObjectPermissions(target, mutate);
|
|
223
106
|
toast.addSuccess(successMessage);
|
|
224
|
-
onSaved?.();
|
|
225
107
|
return true;
|
|
226
108
|
}
|
|
227
109
|
catch {
|
|
228
110
|
toast.addError(objectShareMessages.toastError);
|
|
229
111
|
return false;
|
|
230
112
|
}
|
|
231
|
-
}, [backend, workspace, target, toast
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
113
|
+
}, [backend, workspace, target, toast]);
|
|
114
|
+
// Ids the picker excludes as already-granted — INCLUDING rows whose removal is
|
|
115
|
+
// still in flight: offering those would let a re-add overlap the pending revoke
|
|
116
|
+
// on one id, and the settle/fail finalizers act on the id's current overlay
|
|
117
|
+
// entry, not the write that started them. A settled removal drops out of the
|
|
118
|
+
// merged view, so the id becomes offerable exactly when the revoke has landed.
|
|
119
|
+
const excludedIds = useMemo(() => new Set(grantees.map((g) => g.id)), [grantees]);
|
|
120
|
+
// Each option carries its backend `ref` so the add flow grants against the exact ref.
|
|
236
121
|
const loadOptions = useCallback(async (search) => {
|
|
237
122
|
if (!target) {
|
|
238
123
|
return { groups: [], users: [] };
|
|
@@ -242,54 +127,122 @@ export function useAccessList(target, onSaved, dialogOpen) {
|
|
|
242
127
|
.objectPermissions()
|
|
243
128
|
.getAvailableAssignees(target);
|
|
244
129
|
const query = search.trim().toLowerCase();
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
130
|
+
const users = [];
|
|
131
|
+
const groups = [];
|
|
132
|
+
for (const assignee of assignees) {
|
|
133
|
+
const kind = assignee.type === "user" ? "user" : "group";
|
|
134
|
+
const id = granteeId(kind, assignee.ref);
|
|
135
|
+
if (excludedIds.has(id) || !assigneeMatchesQuery(assignee, query)) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (assignee.type === "user") {
|
|
139
|
+
users.push({
|
|
140
|
+
id,
|
|
141
|
+
ref: assignee.ref,
|
|
142
|
+
kind: "user",
|
|
143
|
+
// Same name → email → id fallback the grantee rows use.
|
|
144
|
+
...userDisplayPair(assigneeIdentityFacts(assignee), objRefToString(assignee.ref)),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
groups.push({ id, ref: assignee.ref, kind: "group", name: assignee.name });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { users, groups };
|
|
152
|
+
}, [backend, workspace, target, excludedIds]);
|
|
153
|
+
const applyGranteeAdd = useCallback((grantee) => {
|
|
154
|
+
setGranteeEdits((prev) => {
|
|
155
|
+
const current = prev[grantee.id];
|
|
156
|
+
// Re-adding a removed base row supersedes the committed removal — carry
|
|
157
|
+
// it, so a failed add restores the removal instead of dropping the entry
|
|
158
|
+
// and resurrecting the base row at its pre-removal level.
|
|
159
|
+
const settled = current ? { ...current, settled: undefined } : undefined;
|
|
160
|
+
return { ...prev, [grantee.id]: { kind: "added", grantee, pending: true, settled } };
|
|
161
|
+
});
|
|
162
|
+
}, []);
|
|
163
|
+
const applyGranteeLevel = useCallback((id, level) => {
|
|
164
|
+
setGranteeEdits((prev) => {
|
|
165
|
+
const current = prev[id];
|
|
166
|
+
// Keep one level of history: the committed entry this edit supersedes.
|
|
167
|
+
const settled = current ? { ...current, settled: undefined } : undefined;
|
|
168
|
+
if (current?.kind === "added") {
|
|
169
|
+
// Overlay-born row: stay an `added` entry, or the merge would drop it.
|
|
170
|
+
const grantee = {
|
|
171
|
+
...current.grantee,
|
|
172
|
+
level,
|
|
173
|
+
effectivePermission: effectivePermissionAbove(level, current.grantee.inheritedLevel),
|
|
174
|
+
};
|
|
175
|
+
return { ...prev, [id]: { kind: "added", grantee, pending: true, settled } };
|
|
176
|
+
}
|
|
177
|
+
return { ...prev, [id]: { kind: "level", level, pending: true, settled } };
|
|
178
|
+
});
|
|
179
|
+
}, []);
|
|
180
|
+
const applyGranteeRemove = useCallback((id) => {
|
|
181
|
+
setGranteeEdits((prev) => {
|
|
182
|
+
const current = prev[id];
|
|
183
|
+
const settled = current ? { ...current, settled: undefined } : undefined;
|
|
184
|
+
return { ...prev, [id]: { kind: "removed", pending: true, settled } };
|
|
185
|
+
});
|
|
186
|
+
}, []);
|
|
187
|
+
const settleGranteeEdit = useCallback((id) => {
|
|
188
|
+
setGranteeEdits((prev) => {
|
|
189
|
+
const current = prev[id];
|
|
190
|
+
if (!current) {
|
|
191
|
+
return prev;
|
|
192
|
+
}
|
|
193
|
+
// A settled removal always keeps its entry: for a base row (including a
|
|
194
|
+
// removed re-added one) it is what keeps the row hidden; for an
|
|
195
|
+
// overlay-born row it renders nothing and is harmless.
|
|
196
|
+
return { ...prev, [id]: { ...current, pending: false, settled: undefined } };
|
|
197
|
+
});
|
|
198
|
+
}, []);
|
|
199
|
+
const failGranteeEdit = useCallback((id) => {
|
|
200
|
+
setGranteeEdits((prev) => {
|
|
201
|
+
const current = prev[id];
|
|
202
|
+
if (!current) {
|
|
203
|
+
return prev;
|
|
204
|
+
}
|
|
205
|
+
if (current.settled) {
|
|
206
|
+
return { ...prev, [id]: current.settled };
|
|
207
|
+
}
|
|
208
|
+
const { [id]: _omit, ...rest } = prev;
|
|
209
|
+
return rest;
|
|
210
|
+
});
|
|
211
|
+
}, []);
|
|
212
|
+
const applyRuleEdit = useCallback((edit) => {
|
|
213
|
+
setRuleEditState((current) => ({
|
|
214
|
+
...edit,
|
|
215
|
+
settled: current ? { ...current, settled: undefined } : undefined,
|
|
248
216
|
}));
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
.filter(({ assignee }) => assigneeMatchesQuery(assignee, query));
|
|
257
|
-
return {
|
|
258
|
-
users: selectable
|
|
259
|
-
.filter(({ assignee }) => assignee.type === "user")
|
|
260
|
-
.map(({ assignee, id }) => ({
|
|
261
|
-
id,
|
|
262
|
-
kind: "user",
|
|
263
|
-
// Same fallback pairs as the grantee rows.
|
|
264
|
-
...userDisplayPair(assigneeIdentityFacts(assignee), objRefToString(assignee.ref)),
|
|
265
|
-
})),
|
|
266
|
-
groups: selectable
|
|
267
|
-
.filter(({ assignee }) => assignee.type !== "user")
|
|
268
|
-
.map(({ assignee, id }) => ({ id, kind: "group", name: assignee.name })),
|
|
269
|
-
};
|
|
270
|
-
}, [backend, workspace, target, excludedIdsKey, cacheAssignees]);
|
|
271
|
-
// Reuse the picker's original ref (preserves UriRef vs IdentifierRef);
|
|
272
|
-
// fall back to the serialized id only if it wasn't cached.
|
|
273
|
-
const refForId = useCallback((id) => knownAssignees[id]?.ref ?? { identifier: id.split(":", 2)[1] }, [knownAssignees]);
|
|
217
|
+
}, []);
|
|
218
|
+
const settleRuleEdit = useCallback(() => {
|
|
219
|
+
setRuleEditState((current) => current ? { ...current, pending: false, settled: undefined } : current);
|
|
220
|
+
}, []);
|
|
221
|
+
const failRuleEdit = useCallback(() => {
|
|
222
|
+
setRuleEditState((current) => current?.settled);
|
|
223
|
+
}, []);
|
|
274
224
|
return {
|
|
275
|
-
targetKey,
|
|
276
225
|
hasList,
|
|
277
|
-
grantees
|
|
278
|
-
|
|
226
|
+
grantees,
|
|
227
|
+
seededWithoutSelfGrant,
|
|
279
228
|
selfIdentity,
|
|
280
229
|
selfIdentityResolved,
|
|
281
230
|
generalAccess,
|
|
282
231
|
workspaceLevel,
|
|
283
|
-
|
|
232
|
+
workspaceInheritedLevel,
|
|
233
|
+
workspaceLevelSaving,
|
|
284
234
|
summary,
|
|
285
235
|
status,
|
|
286
236
|
loadError,
|
|
287
|
-
accessUnavailable,
|
|
288
237
|
commit,
|
|
289
238
|
loadOptions,
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
239
|
+
applyGranteeAdd,
|
|
240
|
+
applyGranteeLevel,
|
|
241
|
+
applyGranteeRemove,
|
|
242
|
+
settleGranteeEdit,
|
|
243
|
+
failGranteeEdit,
|
|
244
|
+
applyRuleEdit,
|
|
245
|
+
settleRuleEdit,
|
|
246
|
+
failRuleEdit,
|
|
294
247
|
};
|
|
295
248
|
}
|
|
@@ -25,22 +25,27 @@ export interface ILabelScope {
|
|
|
25
25
|
* on the "assume all labels" placeholder.
|
|
26
26
|
*/
|
|
27
27
|
labelsResolved: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* True until the session's FIRST scope resolution settles (labels metadata +
|
|
30
|
+
* the initial per-label probe). Unlike {@link labelsResolved} it never turns
|
|
31
|
+
* back on: a later re-probe (a grantee or label-set change) only re-disables
|
|
32
|
+
* controls. Lets the dialog hold its loading placeholders until controls are
|
|
33
|
+
* actionable without flashing back to them mid-session; a resolution that
|
|
34
|
+
* settles with failures counts too, so an error can't hold placeholders
|
|
35
|
+
* forever.
|
|
36
|
+
*/
|
|
37
|
+
labelsInitializing: boolean;
|
|
28
38
|
/** Per-grantee label scope: grantee id → label ids in scope (primary always in). */
|
|
29
39
|
selectedLabelIdsByGrantee: Record<string, string[]>;
|
|
30
40
|
setSelectedLabelIdsByGrantee: React.Dispatch<React.SetStateAction<Record<string, string[]>>>;
|
|
31
41
|
/**
|
|
32
42
|
* The single per-label write path. Diffs `desired` vs `current` over the
|
|
33
|
-
* permissionable labels and
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Multi-principal {@link reconcileLabelScope}: one `manageObjectPermissions`
|
|
39
|
-
* per label carrying all principals that change on it (used by Add). Reports
|
|
40
|
-
* `failedLabelIds` so the caller can keep the labels that landed rather than
|
|
41
|
-
* discard the whole scope on a partial failure.
|
|
43
|
+
* permissionable labels and issues one `manageObjectPermissions` per changed
|
|
44
|
+
* label, carrying every principal that changes on it. Reports `failedLabelIds`
|
|
45
|
+
* so callers can keep the labels that landed rather than treat a partial
|
|
46
|
+
* failure as all-or-nothing (each label write settles independently).
|
|
42
47
|
*/
|
|
43
|
-
|
|
48
|
+
reconcileLabelScope: (principals: LabelScopePrincipal[], desiredLabelIds: ReadonlySet<string>, currentLabelIds: ReadonlySet<string>) => Promise<{
|
|
44
49
|
ok: boolean;
|
|
45
50
|
failedLabelIds: string[];
|
|
46
51
|
}>;
|
|
@@ -53,10 +58,10 @@ export interface ILabelScope {
|
|
|
53
58
|
*
|
|
54
59
|
* The resolved scope is local-authoritative: the probe seeds a scope only for
|
|
55
60
|
* grantees it doesn't already know, so an optimistic scope written for a freshly
|
|
56
|
-
* added grantee is never overwritten by the backend's lagging read.
|
|
57
|
-
*
|
|
61
|
+
* added grantee is never overwritten by the backend's lagging read. Session-scoped
|
|
62
|
+
* like its owner — the target is fixed for the mount (see {@link ObjectShareDialog}).
|
|
58
63
|
*
|
|
59
64
|
* @internal
|
|
60
65
|
*/
|
|
61
|
-
export declare function useLabelScope(target: IObjectPermissionsObject | undefined,
|
|
66
|
+
export declare function useLabelScope(target: IObjectPermissionsObject | undefined, labels: IObjectShareLabel[], hasList: boolean, committedGranteeIds: string[], labelsError: boolean, labelsLoading: boolean): ILabelScope;
|
|
62
67
|
//# sourceMappingURL=useLabelScope.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useLabelScope.d.ts","sourceRoot":"","sources":["../../src/share/useLabelScope.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"useLabelScope.d.ts","sourceRoot":"","sources":["../../src/share/useLabelScope.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAK1E,OAAO,EACH,KAAK,mBAAmB,EAG3B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AA6BpD;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IACxB;;;;OAIG;IACH,eAAe,EAAE,iBAAiB,EAAE,CAAC;IACrC;;;;;;;;OAQG;IACH,cAAc,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,kBAAkB,EAAE,OAAO,CAAC;IAC5B,sFAAoF;IACpF,yBAAyB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACpD,4BAA4B,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC;IAC7F;;;;;;OAMG;IACH,mBAAmB,EAAE,CACjB,UAAU,EAAE,mBAAmB,EAAE,EACjC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,EACpC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,KACnC,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CAC3D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CACzB,MAAM,EAAE,wBAAwB,GAAG,SAAS,EAC5C,MAAM,EAAE,iBAAiB,EAAE,EAC3B,OAAO,EAAE,OAAO,EAChB,mBAAmB,EAAE,MAAM,EAAE,EAC7B,WAAW,EAAE,OAAO,EACpB,aAAa,EAAE,OAAO,GACvB,WAAW,CAmKb"}
|