@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,84 +1,59 @@
|
|
|
1
1
|
// (C) 2026 GoodData Corporation
|
|
2
|
-
import { useCallback, useMemo,
|
|
2
|
+
import { useCallback, useMemo, useState } from "react";
|
|
3
3
|
import { useToastMessage } from "@gooddata/sdk-ui-kit";
|
|
4
4
|
import { composeEffectiveWorkspaceAccess } from "./accessSummary.js";
|
|
5
5
|
import { objectShareMessages } from "./messages.js";
|
|
6
|
-
import { EMPTY_IDS, NO_LABELS,
|
|
6
|
+
import { EMPTY_IDS, NO_LABELS, granularGranteeFor, levelsAbove, levelsBelow, sortGrantees, toGranularGrantee, userDisplayPair, } from "./objectShareController.helpers.js";
|
|
7
7
|
import { useAccessList } from "./useAccessList.js";
|
|
8
8
|
import { useLabelScope } from "./useLabelScope.js";
|
|
9
9
|
/**
|
|
10
|
-
* Manages the share
|
|
10
|
+
* Manages the share-dialog state and backend I/O for ONE dialog session: the hook
|
|
11
|
+
* is mounted while the dialog is open, for a single target that must not change
|
|
12
|
+
* while mounted (see {@link ObjectShareDialog}). Unmounting discards every
|
|
13
|
+
* transient — pending rows, staged confirms, optimistic overlays — so no explicit
|
|
14
|
+
* reset exists or is needed.
|
|
11
15
|
*
|
|
12
|
-
* The access list is fetched once and
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* The access list is fetched once, and the displayed state is *derived* from that
|
|
17
|
+
* fetch composed with a small local edit overlay (owned by `useAccessList`). Each
|
|
18
|
+
* access change writes its overlay entry (so the row updates immediately), commits
|
|
19
|
+
* to the backend, then settles the entry on success or reverts it on failure (to
|
|
20
|
+
* the last committed entry, or the fetched value). There is no post-write refetch,
|
|
21
|
+
* so the grantee list never blanks and never fights read-after-write lag.
|
|
18
22
|
*
|
|
19
23
|
* Mutations follow a **commit-on-interaction** model: each access change is sent
|
|
20
24
|
* immediately; the general-access toggle goes through a confirm step because it
|
|
21
|
-
* is high-impact. There is no batched Save.
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
* Most consumers do not call this directly — render {@link ObjectShareDialog} with
|
|
25
|
-
* plain props and it owns its controller. Call this (exported as `useObjectShare`)
|
|
26
|
-
* only to share a single access-list fetch between the dialog and an inline summary
|
|
27
|
-
* row: call it once, read `state.summary` for the row, and pass the controller into
|
|
28
|
-
* the dialog. Grantee rows carry identity facts only (`name`/`email` are undefined
|
|
29
|
-
* when unknown); the display fallback order is not exposed, so {@link ObjectShareDialog}
|
|
30
|
-
* is the only sanctioned row renderer.
|
|
25
|
+
* is high-impact. There is no batched Save. Grantee rows carry identity facts only
|
|
26
|
+
* (`name`/`email` are undefined when unknown); the display fallback order is not
|
|
27
|
+
* exposed, so {@link ObjectShareDialog} is the only sanctioned row renderer.
|
|
31
28
|
*
|
|
32
29
|
* @internal
|
|
33
30
|
*/
|
|
34
31
|
export function useObjectShareController(target, options) {
|
|
35
|
-
const {
|
|
32
|
+
const { labels = NO_LABELS, labelsError = false, labelsLoading = false } = options ?? {};
|
|
36
33
|
const toast = useToastMessage();
|
|
37
34
|
// UI-local buffers — never backend data.
|
|
38
35
|
const [subview, setSubview] = useState("main");
|
|
39
36
|
const [pendingGrantees, setPendingGrantees] = useState([]);
|
|
40
37
|
const [pendingGeneralAccess, setPendingGeneralAccess] = useState(undefined);
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// revert B's general access). Each finalizer captures the target it started for
|
|
54
|
-
// and bails if it no longer matches.
|
|
55
|
-
const targetKeyRef = useRef(targetKey);
|
|
56
|
-
targetKeyRef.current = targetKey;
|
|
57
|
-
// The committed grantee ids the label-scope probe resolves against (a removing
|
|
58
|
-
// row is excluded so its scope is dropped, not re-seeded).
|
|
59
|
-
const committedGranteeIds = useMemo(() => grantees.filter((g) => g.pending !== "removing").map((g) => g.id), [grantees]);
|
|
38
|
+
const { hasList, grantees, seededWithoutSelfGrant, selfIdentity, selfIdentityResolved, generalAccess, workspaceLevel, workspaceInheritedLevel, workspaceLevelSaving, summary, status, loadError, commit, loadOptions, applyGranteeAdd, applyGranteeLevel, applyGranteeRemove, settleGranteeEdit, failGranteeEdit, applyRuleEdit, settleRuleEdit, failRuleEdit, } = useAccessList(target);
|
|
39
|
+
// Grantee ids the label-scope probe resolves against — a removing row is excluded
|
|
40
|
+
// so its scope is dropped, not re-seeded.
|
|
41
|
+
const committedGranteeIds = useMemo(() => {
|
|
42
|
+
const ids = [];
|
|
43
|
+
for (const g of grantees) {
|
|
44
|
+
if (g.pending !== "removing") {
|
|
45
|
+
ids.push(g.id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return ids;
|
|
49
|
+
}, [grantees]);
|
|
60
50
|
// Per-label scope resolution + the single label-write path live in their own hook.
|
|
61
|
-
const { effectiveLabels, labelsResolved, selectedLabelIdsByGrantee, setSelectedLabelIdsByGrantee, reconcileLabelScope,
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
// than in an effect, so the stale buffers never reach a commit.
|
|
68
|
-
const [lastTargetKey, setLastTargetKey] = useState(targetKey);
|
|
69
|
-
if (lastTargetKey !== targetKey) {
|
|
70
|
-
setLastTargetKey(targetKey);
|
|
71
|
-
setSubview("main");
|
|
72
|
-
setPendingGrantees([]);
|
|
73
|
-
setPendingGeneralAccess(undefined);
|
|
74
|
-
setWorkspaceLevelSaving(false);
|
|
75
|
-
}
|
|
76
|
-
const reset = useCallback(() => {
|
|
77
|
-
setSubview("main");
|
|
78
|
-
setPendingGrantees([]);
|
|
79
|
-
setPendingGeneralAccess(undefined);
|
|
80
|
-
setWorkspaceLevelSaving(false);
|
|
81
|
-
}, []);
|
|
51
|
+
const { effectiveLabels, labelsResolved, labelsInitializing, selectedLabelIdsByGrantee, setSelectedLabelIdsByGrantee, reconcileLabelScope, } = useLabelScope(target, labels, hasList, committedGranteeIds, labelsError, labelsLoading);
|
|
52
|
+
// Effective access: the direct state composed with inherited rule access.
|
|
53
|
+
// Also pins the level to VIEW while restricted, so the (hidden) dropdown
|
|
54
|
+
// never shows a stale SHARE left over from a prior grant.
|
|
55
|
+
const effectiveWorkspace = useMemo(() => composeEffectiveWorkspaceAccess(generalAccess, workspaceLevel, workspaceInheritedLevel), [generalAccess, workspaceLevel, workspaceInheritedLevel]);
|
|
56
|
+
// ── Add-grantee subview ──────────────────────────────────────────────────────
|
|
82
57
|
const openAddGrantee = useCallback(() => {
|
|
83
58
|
setSubview("addGrantee");
|
|
84
59
|
setPendingGrantees([]);
|
|
@@ -87,6 +62,7 @@ export function useObjectShareController(target, options) {
|
|
|
87
62
|
setSubview("main");
|
|
88
63
|
setPendingGrantees([]);
|
|
89
64
|
}, []);
|
|
65
|
+
// ── Shared write path (object grant + label-scope mirror) ────────────────────
|
|
90
66
|
// The single object+labels write path shared by remove and general access (the
|
|
91
67
|
// two changes that must keep the object grant and its label mirror consistent).
|
|
92
68
|
// Mirrors the labels first, then writes the object; if the object write fails it
|
|
@@ -95,47 +71,48 @@ export function useObjectShareController(target, options) {
|
|
|
95
71
|
// are one logical change), remove proceeds (the object revoke is what matters; a
|
|
96
72
|
// leftover label grant is only surfaced as a warning). Returns the object-write
|
|
97
73
|
// result and whether the label mirror fully applied; the caller owns the local
|
|
98
|
-
//
|
|
74
|
+
// overlay, the revert and any toast beyond `commit`'s own.
|
|
99
75
|
const applyAccessChange = useCallback(async (params) => {
|
|
100
76
|
const { principal, objectMutation, successMessage, labelDesired, labelCurrent } = params;
|
|
101
|
-
const labelsOk = await reconcileLabelScope(principal, labelDesired, labelCurrent);
|
|
77
|
+
const { ok: labelsOk } = await reconcileLabelScope([principal], labelDesired, labelCurrent);
|
|
102
78
|
if (!labelsOk && params.abortIfLabelsFail) {
|
|
103
79
|
// Don't write the object on a half-applied label scope: undo whatever
|
|
104
80
|
// mirrored and surface the failure to the caller.
|
|
105
|
-
await reconcileLabelScope(principal, labelCurrent, labelDesired);
|
|
81
|
+
await reconcileLabelScope([principal], labelCurrent, labelDesired);
|
|
106
82
|
return { ok: false, labelsOk: false };
|
|
107
83
|
}
|
|
108
84
|
const ok = await commit([objectMutation], successMessage);
|
|
109
85
|
if (!ok) {
|
|
110
86
|
// Object write failed — undo the label mirror so labels and object
|
|
111
87
|
// don't drift.
|
|
112
|
-
await reconcileLabelScope(principal, labelCurrent, labelDesired);
|
|
88
|
+
await reconcileLabelScope([principal], labelCurrent, labelDesired);
|
|
113
89
|
}
|
|
114
90
|
return { ok, labelsOk };
|
|
115
91
|
}, [commit, reconcileLabelScope]);
|
|
92
|
+
// ── Grantee mutations (add / level / remove / labels) ────────────────────────
|
|
116
93
|
const confirmAddGrantees = useCallback(async () => {
|
|
117
94
|
if (pendingGrantees.length === 0) {
|
|
118
95
|
return;
|
|
119
96
|
}
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// rows render from the facts cache the picker fetch already populated.
|
|
97
|
+
// Insert the picked grantees as pending overlay rows, carrying the picker
|
|
98
|
+
// option's ref (exact backend ref) and display name/email straight through —
|
|
99
|
+
// there is no post-write refetch, so the row shows what the picker showed.
|
|
124
100
|
const addedIds = pendingGrantees.map((g) => g.id);
|
|
125
101
|
const addedRows = pendingGrantees.map((g) => ({
|
|
126
102
|
id: g.id,
|
|
127
103
|
kind: g.kind,
|
|
128
|
-
granteeRef:
|
|
104
|
+
granteeRef: g.ref,
|
|
105
|
+
name: g.name,
|
|
106
|
+
email: g.email,
|
|
129
107
|
level: g.permissionLevel,
|
|
130
|
-
pending: "saving",
|
|
131
108
|
}));
|
|
132
|
-
const mutations = pendingGrantees.map((g) => toGranularGrantee(g.kind,
|
|
109
|
+
const mutations = pendingGrantees.map((g) => toGranularGrantee(g.kind, g.ref, g.permissionLevel));
|
|
133
110
|
// Default each new grantee to ALL labels (the picker's Add is gated until
|
|
134
111
|
// labels have loaded, so the set is known here). Reflect the full scope
|
|
135
112
|
// before the writes so the row shows it immediately.
|
|
136
113
|
const allLabelIds = effectiveLabels.map((l) => l.id);
|
|
137
114
|
const allLabelIdSet = new Set(allLabelIds);
|
|
138
|
-
|
|
115
|
+
addedRows.forEach(applyGranteeAdd);
|
|
139
116
|
setSelectedLabelIdsByGrantee((prev) => {
|
|
140
117
|
const next = { ...prev };
|
|
141
118
|
for (const id of addedIds) {
|
|
@@ -143,18 +120,11 @@ export function useObjectShareController(target, options) {
|
|
|
143
120
|
}
|
|
144
121
|
return next;
|
|
145
122
|
});
|
|
146
|
-
// Leave the add subview at once so the new rows show in the main list.
|
|
147
123
|
closeAddGrantee();
|
|
148
124
|
const ok = await commit(mutations, objectShareMessages.toastGranteeAdded);
|
|
149
|
-
// Bail if the target switched mid-add: the new object's list was already
|
|
150
|
-
// seeded, so rolling back / clearing markers / mirroring labels by these ids
|
|
151
|
-
// would hit the wrong object.
|
|
152
|
-
if (targetKeyRef.current !== startedFor) {
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
125
|
if (!ok) {
|
|
156
126
|
// Roll back the failed adds.
|
|
157
|
-
|
|
127
|
+
addedIds.forEach((id) => failGranteeEdit(id));
|
|
158
128
|
setSelectedLabelIdsByGrantee((prev) => {
|
|
159
129
|
const next = { ...prev };
|
|
160
130
|
for (const id of addedIds) {
|
|
@@ -164,14 +134,9 @@ export function useObjectShareController(target, options) {
|
|
|
164
134
|
});
|
|
165
135
|
return;
|
|
166
136
|
}
|
|
167
|
-
// Object grant landed — clear the saving marker on the new rows.
|
|
168
|
-
setGrantees((prev) => prev.map((g) => (addedIds.includes(g.id) ? { ...g, pending: undefined } : g)));
|
|
169
137
|
// One label write per label carrying all added grantees, not one per grantee.
|
|
170
|
-
const principals = pendingGrantees.map((g) => ({ kind: g.kind, granteeRef:
|
|
171
|
-
const { ok: labelsOk, failedLabelIds } = await
|
|
172
|
-
if (targetKeyRef.current !== startedFor) {
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
138
|
+
const principals = pendingGrantees.map((g) => ({ kind: g.kind, granteeRef: g.ref }));
|
|
139
|
+
const { ok: labelsOk, failedLabelIds } = await reconcileLabelScope(principals, allLabelIdSet, EMPTY_IDS);
|
|
175
140
|
if (!labelsOk) {
|
|
176
141
|
toast.addWarning(objectShareMessages.toastLabelScopePartial);
|
|
177
142
|
// Drop only the failed labels, keeping those that landed — otherwise local
|
|
@@ -186,16 +151,21 @@ export function useObjectShareController(target, options) {
|
|
|
186
151
|
return next;
|
|
187
152
|
});
|
|
188
153
|
}
|
|
154
|
+
// Settle only now — the add is one logical operation (object grant + label
|
|
155
|
+
// grants), and an unlocked row mid-way would let a remove or label edit race
|
|
156
|
+
// the in-flight label grants. The `added` entries persist (base never had
|
|
157
|
+
// these rows); only the saving marker clears.
|
|
158
|
+
addedIds.forEach((id) => settleGranteeEdit(id));
|
|
189
159
|
}, [
|
|
190
160
|
pendingGrantees,
|
|
191
|
-
targetKey,
|
|
192
161
|
commit,
|
|
193
162
|
closeAddGrantee,
|
|
194
163
|
effectiveLabels,
|
|
195
|
-
|
|
196
|
-
refForId,
|
|
164
|
+
reconcileLabelScope,
|
|
197
165
|
toast,
|
|
198
|
-
|
|
166
|
+
applyGranteeAdd,
|
|
167
|
+
settleGranteeEdit,
|
|
168
|
+
failGranteeEdit,
|
|
199
169
|
setSelectedLabelIdsByGrantee,
|
|
200
170
|
]);
|
|
201
171
|
const changePermissionLevel = useCallback(async (granteeId, level) => {
|
|
@@ -203,67 +173,48 @@ export function useObjectShareController(target, options) {
|
|
|
203
173
|
if (!grantee || grantee.level === level || grantee.pending) {
|
|
204
174
|
return;
|
|
205
175
|
}
|
|
206
|
-
|
|
207
|
-
const previousLevel = grantee.level;
|
|
208
|
-
// Recompute the inherited-SHARE badge for the new direct level: raising a
|
|
209
|
-
// VIEW grant to SHARE makes the inherited SHARE no longer "above" the
|
|
210
|
-
// direct grant (badge hidden); lowering it back surfaces the warning again.
|
|
211
|
-
const nextEffective = effectivePermissionAbove(level, grantee.inheritsShare ? ["SHARE"] : []);
|
|
212
|
-
// Show the new level immediately (marked saving), then write it through.
|
|
213
|
-
setGrantees((prev) => prev.map((g) => g.id === granteeId
|
|
214
|
-
? { ...g, level, effectivePermission: nextEffective, pending: "saving" }
|
|
215
|
-
: g));
|
|
176
|
+
applyGranteeLevel(granteeId, level);
|
|
216
177
|
const ok = await commit([toGranularGrantee(grantee.kind, grantee.granteeRef, level)], objectShareMessages.toastAccessUpdated);
|
|
217
|
-
// The
|
|
218
|
-
//
|
|
219
|
-
if (
|
|
220
|
-
|
|
178
|
+
// The settled entry persists on success (base still holds the old level);
|
|
179
|
+
// failure restores the last committed entry, or the fetched row.
|
|
180
|
+
if (ok) {
|
|
181
|
+
settleGranteeEdit(granteeId);
|
|
221
182
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
effectivePermission: ok
|
|
227
|
-
? nextEffective
|
|
228
|
-
: effectivePermissionAbove(previousLevel, g.inheritsShare ? ["SHARE"] : []),
|
|
229
|
-
pending: undefined,
|
|
230
|
-
}
|
|
231
|
-
: g));
|
|
232
|
-
}, [grantees, targetKey, commit, setGrantees]);
|
|
183
|
+
else {
|
|
184
|
+
failGranteeEdit(granteeId);
|
|
185
|
+
}
|
|
186
|
+
}, [grantees, commit, applyGranteeLevel, settleGranteeEdit, failGranteeEdit]);
|
|
233
187
|
const removeGrantee = useCallback(async (granteeId) => {
|
|
234
188
|
const grantee = grantees.find((g) => g.id === granteeId);
|
|
235
189
|
if (!grantee || grantee.pending) {
|
|
236
190
|
return;
|
|
237
191
|
}
|
|
238
|
-
const startedFor = targetKey;
|
|
239
192
|
// Mark the row removed but keep it visible (muted) until the write lands.
|
|
240
|
-
|
|
193
|
+
applyGranteeRemove(granteeId);
|
|
241
194
|
// Revoke the grantee's per-label grants too — they are independent
|
|
242
|
-
// access-list entries, so the object revoke alone would leave them behind
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
195
|
+
// access-list entries, so the object revoke alone would leave them behind.
|
|
196
|
+
// The revoke runs over the grantee's KNOWN scope: if the object revoke
|
|
197
|
+
// then fails, the compensation re-grants exactly that scope, never labels
|
|
198
|
+
// the grantee didn't hold. The object revoke is what matters; a label
|
|
199
|
+
// revoke that fails is non-fatal (surfaced as a warning), so don't abort
|
|
200
|
+
// on it.
|
|
246
201
|
const { ok, labelsOk } = await applyAccessChange({
|
|
247
202
|
principal: { kind: grantee.kind, granteeRef: grantee.granteeRef },
|
|
248
203
|
objectMutation: toGranularGrantee(grantee.kind, grantee.granteeRef, "none"),
|
|
249
204
|
successMessage: objectShareMessages.toastAccessUpdated,
|
|
250
205
|
labelDesired: EMPTY_IDS,
|
|
251
|
-
labelCurrent: new Set(effectiveLabels.map((l) => l.id)),
|
|
206
|
+
labelCurrent: new Set(selectedLabelIdsByGrantee[granteeId] ?? effectiveLabels.map((l) => l.id)),
|
|
252
207
|
abortIfLabelsFail: false,
|
|
253
208
|
});
|
|
254
|
-
// Bail if the target switched mid-write: the row no longer belongs to the
|
|
255
|
-
// seeded list, and dropping/restoring "it" would hit the other object.
|
|
256
|
-
if (targetKeyRef.current !== startedFor) {
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
209
|
if (!ok) {
|
|
260
210
|
// Object revoke failed (applyAccessChange already re-granted the
|
|
261
211
|
// labels) — restore the row.
|
|
262
|
-
|
|
212
|
+
failGranteeEdit(granteeId);
|
|
263
213
|
return;
|
|
264
214
|
}
|
|
265
|
-
// Object revoke landed
|
|
266
|
-
|
|
215
|
+
// Object revoke landed. For a fetched row the settled `removed` entry
|
|
216
|
+
// persists to keep it hidden; a revoked overlay-born row drops entirely.
|
|
217
|
+
settleGranteeEdit(granteeId);
|
|
267
218
|
setSelectedLabelIdsByGrantee((prev) => {
|
|
268
219
|
const { [granteeId]: _omit, ...rest } = prev;
|
|
269
220
|
return rest;
|
|
@@ -275,16 +226,18 @@ export function useObjectShareController(target, options) {
|
|
|
275
226
|
}
|
|
276
227
|
}, [
|
|
277
228
|
grantees,
|
|
278
|
-
targetKey,
|
|
279
229
|
applyAccessChange,
|
|
280
230
|
effectiveLabels,
|
|
231
|
+
selectedLabelIdsByGrantee,
|
|
281
232
|
toast,
|
|
282
|
-
|
|
233
|
+
applyGranteeRemove,
|
|
234
|
+
settleGranteeEdit,
|
|
235
|
+
failGranteeEdit,
|
|
283
236
|
setSelectedLabelIdsByGrantee,
|
|
284
237
|
]);
|
|
285
238
|
const changeGranteeLabels = useCallback(async (granteeId, requested) => {
|
|
286
239
|
const grantee = grantees.find((g) => g.id === granteeId);
|
|
287
|
-
if (!grantee || !target) {
|
|
240
|
+
if (!grantee || grantee.pending || !target) {
|
|
288
241
|
return;
|
|
289
242
|
}
|
|
290
243
|
// Primary label is always in scope; never let it be dropped.
|
|
@@ -293,49 +246,65 @@ export function useObjectShareController(target, options) {
|
|
|
293
246
|
const currentScope = selectedLabelIdsByGrantee[granteeId] ?? effectiveLabels.map((l) => l.id);
|
|
294
247
|
const desired = new Set(nextScope);
|
|
295
248
|
const current = new Set(currentScope);
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
setSelectedLabelIdsByGrantee((prev) => ({ ...prev, [granteeId]: nextScope }));
|
|
300
|
-
const ok = await reconcileLabelScope({ kind: grantee.kind, granteeRef: grantee.granteeRef }, desired, current);
|
|
301
|
-
// Bail if the target switched mid-write — the scope map now belongs to the
|
|
302
|
-
// other object, so neither the success no-op nor the rollback applies here.
|
|
303
|
-
if (targetKeyRef.current !== startedFor) {
|
|
249
|
+
// Applying the checklist unchanged is a no-op — don't lock the row or
|
|
250
|
+
// toast success over zero writes.
|
|
251
|
+
if (desired.size === current.size && nextScope.every((id) => current.has(id))) {
|
|
304
252
|
return;
|
|
305
253
|
}
|
|
254
|
+
// Reflect the new scope immediately and lock the row until the writes
|
|
255
|
+
// settle — an overlapping label edit or removal would race these writes
|
|
256
|
+
// on the same labels. The lock reuses the level overlay at the unchanged
|
|
257
|
+
// level; pending is the only fact it adds.
|
|
258
|
+
applyGranteeLevel(granteeId, grantee.level);
|
|
259
|
+
setSelectedLabelIdsByGrantee((prev) => ({ ...prev, [granteeId]: nextScope }));
|
|
260
|
+
const { ok, failedLabelIds } = await reconcileLabelScope([{ kind: grantee.kind, granteeRef: grantee.granteeRef }], desired, current);
|
|
306
261
|
if (ok) {
|
|
262
|
+
settleGranteeEdit(granteeId);
|
|
307
263
|
toast.addSuccess(objectShareMessages.toastAccessUpdated);
|
|
308
|
-
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
failGranteeEdit(granteeId);
|
|
267
|
+
// Label writes settle independently: keep what landed and flip only the
|
|
268
|
+
// failed labels back, so the local scope matches the backend exactly —
|
|
269
|
+
// restoring the whole previous scope would un-track the writes that
|
|
270
|
+
// succeeded, and later edits would diff against a false state.
|
|
271
|
+
const failed = new Set(failedLabelIds);
|
|
272
|
+
const landedScope = Array.from(new Set([...nextScope, ...currentScope])).filter((id) => (failed.has(id) ? current : desired).has(id));
|
|
273
|
+
setSelectedLabelIdsByGrantee((prev) => ({ ...prev, [granteeId]: landedScope }));
|
|
274
|
+
const anyLanded = landedScope.length !== currentScope.length || landedScope.some((id) => !current.has(id));
|
|
275
|
+
if (anyLanded) {
|
|
276
|
+
toast.addWarning(objectShareMessages.toastLabelScopePartial);
|
|
309
277
|
}
|
|
310
278
|
else {
|
|
311
279
|
toast.addError(objectShareMessages.toastError);
|
|
312
|
-
setSelectedLabelIdsByGrantee((prev) => ({ ...prev, [granteeId]: previousScope }));
|
|
313
280
|
}
|
|
314
281
|
}, [
|
|
315
282
|
grantees,
|
|
316
283
|
target,
|
|
317
|
-
targetKey,
|
|
318
284
|
effectiveLabels,
|
|
319
285
|
selectedLabelIdsByGrantee,
|
|
320
286
|
reconcileLabelScope,
|
|
321
287
|
toast,
|
|
322
|
-
|
|
288
|
+
applyGranteeLevel,
|
|
289
|
+
settleGranteeEdit,
|
|
290
|
+
failGranteeEdit,
|
|
323
291
|
setSelectedLabelIdsByGrantee,
|
|
324
292
|
]);
|
|
293
|
+
// ── Workspace-rule mutations (general access + rule level) ───────────────────
|
|
325
294
|
const requestGeneralAccessChange = useCallback((next) => {
|
|
326
295
|
// Inherited rule access can't be revoked from this workspace, so a
|
|
327
296
|
// Restricted request can never be honored — refuse it (the radio row
|
|
328
297
|
// is also disabled). Compare against the effective value the radio
|
|
329
298
|
// shows: with inherited access it is WORKSPACE even when this
|
|
330
299
|
// workspace holds no rule of its own.
|
|
331
|
-
if (
|
|
300
|
+
if (workspaceInheritedLevel && next === "RESTRICTED") {
|
|
332
301
|
return;
|
|
333
302
|
}
|
|
334
|
-
const effective =
|
|
303
|
+
const effective = workspaceInheritedLevel ? "WORKSPACE" : generalAccess;
|
|
335
304
|
if (next !== effective) {
|
|
336
305
|
setPendingGeneralAccess(next);
|
|
337
306
|
}
|
|
338
|
-
}, [generalAccess,
|
|
307
|
+
}, [generalAccess, workspaceInheritedLevel]);
|
|
339
308
|
const confirmGeneralAccessChange = useCallback(async () => {
|
|
340
309
|
const next = pendingGeneralAccess;
|
|
341
310
|
if (!next || next === generalAccess) {
|
|
@@ -349,17 +318,16 @@ export function useObjectShareController(target, options) {
|
|
|
349
318
|
if (workspaceLevelSaving) {
|
|
350
319
|
return;
|
|
351
320
|
}
|
|
352
|
-
const startedFor = targetKey;
|
|
353
|
-
const previous = generalAccess;
|
|
354
|
-
const previousWorkspaceLevel = workspaceLevel;
|
|
355
321
|
// Apply the new value and close the confirm at once — the radio + summary
|
|
356
322
|
// reflect `next` immediately; the write commits in the background. The
|
|
357
323
|
// workspace rule is always written as VIEW, so reflect VIEW now too —
|
|
358
324
|
// otherwise the summary would keep a SHARE level left over from the fetch.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
325
|
+
// PENDING until the whole labels+object save settles: the radio and the
|
|
326
|
+
// level menu must stay locked, or a re-grade started meanwhile would race
|
|
327
|
+
// this save on the same allWorkspaceUsers rule and could land first, letting
|
|
328
|
+
// the delayed VIEW write downgrade what the UI shows.
|
|
329
|
+
const nextLevel = next === "WORKSPACE" ? "VIEW" : workspaceLevel;
|
|
330
|
+
applyRuleEdit({ generalAccess: next, level: nextLevel, pending: true });
|
|
363
331
|
setPendingGeneralAccess(undefined);
|
|
364
332
|
const principal = { allWorkspaceUsers: true };
|
|
365
333
|
const allIds = new Set(effectiveLabels.map((l) => l.id));
|
|
@@ -376,18 +344,15 @@ export function useObjectShareController(target, options) {
|
|
|
376
344
|
labelCurrent: current,
|
|
377
345
|
abortIfLabelsFail: true,
|
|
378
346
|
});
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (targetKeyRef.current !== startedFor) {
|
|
382
|
-
return;
|
|
347
|
+
if (ok) {
|
|
348
|
+
settleRuleEdit();
|
|
383
349
|
}
|
|
384
|
-
|
|
350
|
+
else {
|
|
385
351
|
// Labels or object write failed (applyAccessChange already undid the
|
|
386
|
-
// label mirror) — revert the
|
|
387
|
-
// label-abort path produced no toast, so surface the error here; a
|
|
352
|
+
// label mirror) — revert to the last committed rule state, or the fetch.
|
|
353
|
+
// The label-abort path produced no toast, so surface the error here; a
|
|
388
354
|
// failed object write already toasted via commit, so don't double up.
|
|
389
|
-
|
|
390
|
-
setWorkspaceLevel(previousWorkspaceLevel);
|
|
355
|
+
failRuleEdit();
|
|
391
356
|
if (!labelsOk) {
|
|
392
357
|
toast.addError(objectShareMessages.toastError);
|
|
393
358
|
}
|
|
@@ -397,54 +362,52 @@ export function useObjectShareController(target, options) {
|
|
|
397
362
|
generalAccess,
|
|
398
363
|
workspaceLevel,
|
|
399
364
|
workspaceLevelSaving,
|
|
400
|
-
targetKey,
|
|
401
365
|
applyAccessChange,
|
|
402
366
|
effectiveLabels,
|
|
403
367
|
toast,
|
|
404
|
-
|
|
405
|
-
|
|
368
|
+
applyRuleEdit,
|
|
369
|
+
settleRuleEdit,
|
|
370
|
+
failRuleEdit,
|
|
406
371
|
]);
|
|
407
372
|
const changeWorkspaceLevel = useCallback(async (level) => {
|
|
373
|
+
// The dropdown displays the EFFECTIVE level (direct composed with
|
|
374
|
+
// inherited), so no-ops must be judged against it: with an inherited
|
|
375
|
+
// SHARE over a direct VIEW the menu shows SHARE checked, and clicking
|
|
376
|
+
// it must not silently escalate the persisted direct rule to SHARE.
|
|
377
|
+
const effectiveLevel = effectiveWorkspace.workspaceLevel;
|
|
408
378
|
// Only re-grade an already-granted workspace rule; ignore no-ops, calls made
|
|
409
379
|
// while this workspace grants no rule of its own (inherited-only access has
|
|
410
|
-
// nothing to re-grade), calls
|
|
411
|
-
//
|
|
380
|
+
// nothing to re-grade), calls for levels below an inherited one (a direct
|
|
381
|
+
// downgrade couldn't lower the effective level), and re-entry while a
|
|
412
382
|
// previous re-grade is still in flight (would overlap writes).
|
|
413
383
|
if (generalAccess !== "WORKSPACE" ||
|
|
414
|
-
|
|
415
|
-
level ===
|
|
384
|
+
levelsBelow(workspaceInheritedLevel ?? "VIEW").includes(level) ||
|
|
385
|
+
level === effectiveLevel ||
|
|
416
386
|
workspaceLevelSaving) {
|
|
417
387
|
return;
|
|
418
388
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
// it. Labels are untouched — they were already mirrored when workspace access
|
|
423
|
-
// was granted; this changes only the rule's permission grade.
|
|
424
|
-
setWorkspaceLevel(level);
|
|
425
|
-
setWorkspaceLevelSaving(true);
|
|
389
|
+
// Labels are untouched — already mirrored when workspace access was granted;
|
|
390
|
+
// this only re-grades the rule's permission level.
|
|
391
|
+
applyRuleEdit({ generalAccess: "WORKSPACE", level, pending: true });
|
|
426
392
|
const ok = await commit([granularGranteeFor({ allWorkspaceUsers: true }, level)], objectShareMessages.toastGeneralAccessUpdated);
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
// saving flag is reset on the new target's seed, so don't touch it.
|
|
430
|
-
if (targetKeyRef.current !== startedFor) {
|
|
431
|
-
return;
|
|
393
|
+
if (ok) {
|
|
394
|
+
settleRuleEdit();
|
|
432
395
|
}
|
|
433
|
-
|
|
434
|
-
|
|
396
|
+
else {
|
|
397
|
+
failRuleEdit();
|
|
435
398
|
}
|
|
436
|
-
setWorkspaceLevelSaving(false);
|
|
437
399
|
}, [
|
|
438
400
|
generalAccess,
|
|
439
|
-
|
|
440
|
-
|
|
401
|
+
workspaceInheritedLevel,
|
|
402
|
+
effectiveWorkspace,
|
|
441
403
|
workspaceLevelSaving,
|
|
442
|
-
targetKey,
|
|
443
404
|
commit,
|
|
444
|
-
|
|
405
|
+
applyRuleEdit,
|
|
406
|
+
settleRuleEdit,
|
|
407
|
+
failRuleEdit,
|
|
445
408
|
]);
|
|
409
|
+
// ── Assembly: actions + state view exposed to consumers ──────────────────────
|
|
446
410
|
const actions = useMemo(() => ({
|
|
447
|
-
reset,
|
|
448
411
|
openAddGrantee,
|
|
449
412
|
closeAddGrantee,
|
|
450
413
|
setPendingGrantees,
|
|
@@ -458,7 +421,6 @@ export function useObjectShareController(target, options) {
|
|
|
458
421
|
confirmGeneralAccessChange,
|
|
459
422
|
changeWorkspaceLevel,
|
|
460
423
|
}), [
|
|
461
|
-
reset,
|
|
462
424
|
openAddGrantee,
|
|
463
425
|
closeAddGrantee,
|
|
464
426
|
loadOptions,
|
|
@@ -470,48 +432,73 @@ export function useObjectShareController(target, options) {
|
|
|
470
432
|
confirmGeneralAccessChange,
|
|
471
433
|
changeWorkspaceLevel,
|
|
472
434
|
]);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
selfIdentityResolved
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
435
|
+
// Sorted for display only; the overlay stays in write-through order, so
|
|
436
|
+
// add/rollback logic is unaffected. Memoized apart from the state assembly so
|
|
437
|
+
// the array identity changes only when the rows do, not on every state change.
|
|
438
|
+
const sortedGrantees = useMemo(() => sortGrantees(grantees), [grantees]);
|
|
439
|
+
const state = useMemo(() => {
|
|
440
|
+
// Row policy is classified here, not in the dialog, so a consumer
|
|
441
|
+
// injecting this controller into its own UI gets the same classification.
|
|
442
|
+
const selfManagedGranteeId = grantees.length === 1 && grantees[0].isSelf ? grantees[0].id : undefined;
|
|
443
|
+
const granteeControlsLocked = grantees.length === 1 && grantees[0].kind === "user" && !selfIdentityResolved;
|
|
444
|
+
// A workspace-wide share-capable rule (SHARE or EDIT) is the one way other
|
|
445
|
+
// than admin/manager rights through the manage gate — no Admin badge then.
|
|
446
|
+
const workspaceShareCapable = effectiveWorkspace.generalAccess === "WORKSPACE" && effectiveWorkspace.workspaceLevel !== "VIEW";
|
|
447
|
+
// The synthesized Admin row shows only while NO other permissions are set:
|
|
448
|
+
// the caller must have reached the list without a grant of their own
|
|
449
|
+
// (`seededWithoutSelfGrant` — a non-admin who locally removes their own
|
|
450
|
+
// grant must not gain the badge) and the list must currently be empty, so
|
|
451
|
+
// adding a grantee hides it and removing the last one brings it back.
|
|
452
|
+
const adminSelfRow = status === "success" &&
|
|
453
|
+
seededWithoutSelfGrant &&
|
|
454
|
+
grantees.length === 0 &&
|
|
455
|
+
!workspaceShareCapable &&
|
|
456
|
+
selfIdentity
|
|
457
|
+
? userDisplayPair(selfIdentity, selfIdentity.id)
|
|
458
|
+
: undefined;
|
|
459
|
+
return {
|
|
460
|
+
subview,
|
|
461
|
+
status,
|
|
462
|
+
error: loadError,
|
|
463
|
+
summary,
|
|
464
|
+
grantees: sortedGrantees,
|
|
465
|
+
selfManagedGranteeId,
|
|
466
|
+
// Policy, not just classification: the caller's own sole row can't be
|
|
467
|
+
// raised above itself, an inherited workspace level can't be undercut.
|
|
468
|
+
selfManagedDisabledLevels: selfManagedGranteeId ? levelsAbove(grantees[0].level) : undefined,
|
|
469
|
+
workspaceDisabledLevels: workspaceInheritedLevel
|
|
470
|
+
? levelsBelow(workspaceInheritedLevel)
|
|
471
|
+
: undefined,
|
|
472
|
+
granteeControlsLocked,
|
|
473
|
+
adminSelfRow,
|
|
474
|
+
...effectiveWorkspace,
|
|
475
|
+
workspaceInheritedLevel: workspaceInheritedLevel,
|
|
476
|
+
workspaceLevelLocked: generalAccess !== "WORKSPACE" || workspaceInheritedLevel === "EDIT",
|
|
477
|
+
workspaceLevelSaving,
|
|
478
|
+
labels: effectiveLabels,
|
|
479
|
+
labelsResolved,
|
|
480
|
+
labelsInitializing,
|
|
481
|
+
selectedLabelIdsByGrantee,
|
|
482
|
+
pendingGeneralAccess,
|
|
483
|
+
pendingGrantees,
|
|
484
|
+
};
|
|
485
|
+
}, [
|
|
499
486
|
subview,
|
|
500
487
|
status,
|
|
501
488
|
loadError,
|
|
502
|
-
accessUnavailable,
|
|
503
489
|
summary,
|
|
504
|
-
targetKey,
|
|
505
490
|
selfIdentity,
|
|
506
491
|
selfIdentityResolved,
|
|
507
|
-
|
|
492
|
+
seededWithoutSelfGrant,
|
|
508
493
|
grantees,
|
|
494
|
+
sortedGrantees,
|
|
495
|
+
effectiveWorkspace,
|
|
509
496
|
generalAccess,
|
|
510
|
-
|
|
511
|
-
inheritedWorkspaceLevel,
|
|
497
|
+
workspaceInheritedLevel,
|
|
512
498
|
workspaceLevelSaving,
|
|
513
499
|
effectiveLabels,
|
|
514
500
|
labelsResolved,
|
|
501
|
+
labelsInitializing,
|
|
515
502
|
selectedLabelIdsByGrantee,
|
|
516
503
|
pendingGeneralAccess,
|
|
517
504
|
pendingGrantees,
|