@jini-ai/user-management 0.3.0 → 0.3.1
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/CHANGELOG.md +4 -0
- package/dist/core/principal-kind-policy.d.ts +1 -1
- package/dist/core/principal-kind-policy.js +1 -1
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.js +2 -0
- package/dist/react/members/Members.d.ts +28 -1
- package/dist/react/members/Members.js +14 -10
- package/dist/react/members/hooks/use-members.hooks.d.ts +43 -1
- package/dist/react/members/hooks/use-members.hooks.js +37 -9
- package/dist/react/members/rules.d.ts +31 -4
- package/dist/react/members/rules.js +17 -5
- package/dist/react/models.d.ts +3 -0
- package/dist/react/users/Users.d.ts +105 -6
- package/dist/react/users/Users.js +105 -43
- package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +33 -1
- package/dist/react/users/hooks/use-reset-password-fields.hooks.js +16 -1
- package/dist/react/users/hooks/use-users.hooks.d.ts +120 -3
- package/dist/react/users/hooks/use-users.hooks.js +192 -23
- package/dist/react/users/rules.d.ts +115 -8
- package/dist/react/users/rules.js +109 -6
- package/package.json +15 -10
|
@@ -1,9 +1,31 @@
|
|
|
1
|
-
import { interpolate } from "@jini-ai/ui/panel-kit";
|
|
2
1
|
import { useEffect, useRef, useState } from "react";
|
|
3
|
-
import {} from "
|
|
2
|
+
import { interpolate } from "@jini-ai/ui/panel-kit";
|
|
4
3
|
import { useFetchMutation, useFetchQuery } from "@jini-ai/ui/panel-kit";
|
|
5
4
|
import { useAsyncAction } from "@jini-ai/ui/panel-kit";
|
|
6
|
-
import { describeApiError
|
|
5
|
+
import { describeApiError, KEYS, isOwnerAccount, userAccountCapabilities } from "../rules.js";
|
|
6
|
+
/** The shape `onAssignRole`/`onAttachPolicy` both repeat: set the shared `grantSaving`/`grantError`
|
|
7
|
+
* pair, run one mutation, do a success-only side effect, and clear saving in a `finally` — the
|
|
8
|
+
* "whole-hook" complexity view (brief §2) rolls every closure inside a hook into one score, so two
|
|
9
|
+
* near-identical blocks count against `useUsers` even though `grantError`/`grantSaving` are
|
|
10
|
+
* deliberately NOT a fit for `useAsyncAction` (see that file's own header: shared across three
|
|
11
|
+
* handlers on purpose, not one action's own slot — `onSaveEmail` is the third, kept separate below
|
|
12
|
+
* since it tracks its OWN `emailSaving`). Reproducing the shared-state shape as a local top-level
|
|
13
|
+
* helper — rather than importing the generic primitive — collapses the two call sites without
|
|
14
|
+
* forcing that mismatch onto them. `mutate` itself `invalidates: [KEYS.list]`, replacing the
|
|
15
|
+
* pre-migration `reload()` call this helper used to make explicitly.
|
|
16
|
+
*
|
|
17
|
+
* `onSuccess` (2026-09-05 fix, same bug class as `use-roles.hooks.ts`'s `onSaveRole`/
|
|
18
|
+
* `onWritePermission`): nothing gates opening a DIFFERENT user's Manage panel
|
|
19
|
+
* (`toggleExpanded`) while a grant for the previously expanded one is still in flight, and
|
|
20
|
+
* `onSuccess` clears `pendingRoleId`/`pendingPolicyId` — a shared field, not keyed by principal.
|
|
21
|
+
* `toggleGenerationRef` (bumped once per `toggleExpanded` call, regardless of which panel — see
|
|
22
|
+
* that function) lets a stale grant tell whether the operator switched panels at ALL while it was
|
|
23
|
+
* in flight, independent of which principal is currently expanded: comparing against the panel's
|
|
24
|
+
* OWN identity would wrongly refuse a grant issued before any panel was ever expanded (a
|
|
25
|
+
* legitimate, already-certified call shape), where `expandedId` never gets set to begin with.
|
|
26
|
+
* `onSuccess` is skipped only when a switch actually happened since this call started — otherwise
|
|
27
|
+
* a stale grant's success would erase a NEW, unrelated selection the operator has since made for
|
|
28
|
+
* the panel they actually have open now. */
|
|
7
29
|
async function runGrantMutation(generationAtStart, toggleGenerationRef, mutate, onSuccess, setGrantSaving, setGrantError, describeError) {
|
|
8
30
|
setGrantSaving(true);
|
|
9
31
|
setGrantError(null);
|
|
@@ -13,30 +35,69 @@ async function runGrantMutation(generationAtStart, toggleGenerationRef, mutate,
|
|
|
13
35
|
onSuccess();
|
|
14
36
|
}
|
|
15
37
|
catch (e) {
|
|
38
|
+
// Same generation check as the success branch above (C8, plan-access.md §8, N3): a stale
|
|
39
|
+
// failure must not paint onto a DIFFERENT user's panel the operator has since opened.
|
|
16
40
|
if (toggleGenerationRef.current === generationAtStart)
|
|
17
41
|
setGrantError(describeError(e));
|
|
18
42
|
}
|
|
19
43
|
finally {
|
|
44
|
+
// Guarded the same way — a stale call's `finally` must not clear a NEWER call's own saving
|
|
45
|
+
// flag. `toggleExpanded` resets `grantSaving` itself when it bumps the generation, so a panel
|
|
46
|
+
// switch still leaves the new panel un-stuck even though this skip means THIS call no longer
|
|
47
|
+
// clears it.
|
|
20
48
|
if (toggleGenerationRef.current === generationAtStart)
|
|
21
49
|
setGrantSaving(false);
|
|
22
50
|
}
|
|
23
51
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Roles and policies for the grant controls, or `null` when the server refuses them with 403 (the
|
|
54
|
+
* caller lacks `role.manage`). Any other failure still rejects, so a real outage keeps surfacing as
|
|
55
|
+
* the screen's load error.
|
|
56
|
+
*
|
|
57
|
+
* @complexity O(1) requests (two, concurrent).
|
|
58
|
+
*/
|
|
59
|
+
async function loadGrantOptions(port) {
|
|
60
|
+
try {
|
|
61
|
+
const [r, p] = await Promise.all([port.listRoles({}), port.listPolicies({})]);
|
|
62
|
+
return { roles: r.roles, policies: p.policies };
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
if (e && typeof e === "object" && "status" in e && e.status === 403)
|
|
66
|
+
return null;
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Everything the Users screen does — full state, effects, and every server write, as one hook so
|
|
72
|
+
* `Users.tsx` stays a pure render of whatever this returns. See this file's header for the
|
|
73
|
+
* `useFetchQuery`/`useFetchMutation` migration and the `port` injection it now also carries.
|
|
74
|
+
*
|
|
75
|
+
* @param deps - Injected collaborators; production callers get these from {@link useWiredUsers}.
|
|
76
|
+
* @returns The full `UsersController` the view renders from — see that interface for every field.
|
|
77
|
+
*/
|
|
78
|
+
export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset = false, onOwnPasswordResetClosed, passwordResetNotice, } = {}) {
|
|
27
79
|
const boundT = translate;
|
|
80
|
+
const describeError = (error, fallback) => describeApiError({ error, fallback, translate });
|
|
81
|
+
// `portRef` (password-banner plan, 2026-09-24 Slice 3 deep-link half): captured once at mount and
|
|
82
|
+
// never written again, same discipline and same reasoning as `use-assistant-chats.hooks.ts`'s own
|
|
83
|
+
// `portRef` — the one-shot `me()` effect below must not spin if a caller builds a fresh port object
|
|
84
|
+
// per render (the host admin guidance's "Two traps" section). Every OTHER `port.xxx()` call in this
|
|
85
|
+
// file stays a direct closure read, unaffected — this ref exists only for the raw `useEffect` below,
|
|
86
|
+
// which is not a `useFetchQuery`/`useFetchMutation` closure and so does not get that discipline for
|
|
87
|
+
// free.
|
|
28
88
|
const portRef = useRef(port);
|
|
29
89
|
const list = useFetchQuery({
|
|
30
90
|
key: [queryScope, ...KEYS.list],
|
|
31
91
|
fetch: async () => {
|
|
32
|
-
const [u,
|
|
33
|
-
return { users: u.users,
|
|
92
|
+
const [u, grantOptions] = await Promise.all([port.listUsers({}), loadGrantOptions(port)]);
|
|
93
|
+
return { users: u.users, grantOptions };
|
|
34
94
|
},
|
|
35
95
|
});
|
|
36
96
|
const users = list.data?.users ?? null;
|
|
37
|
-
const roles = list.data?.roles ?? null;
|
|
38
|
-
const policies = list.data?.policies ?? null;
|
|
39
|
-
const
|
|
97
|
+
const roles = list.data ? (list.data.grantOptions?.roles ?? []) : null;
|
|
98
|
+
const policies = list.data ? (list.data.grantOptions?.policies ?? []) : null;
|
|
99
|
+
const canGrant = list.data?.grantOptions != null;
|
|
100
|
+
const error = list.error ? describeError(list.error, translate("failed to load users")) : null;
|
|
40
101
|
const [formOpen, setFormOpen] = useState(false);
|
|
41
102
|
const [username, setUsername] = useState("");
|
|
42
103
|
const [email, setEmail] = useState("");
|
|
@@ -48,6 +109,10 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
|
|
|
48
109
|
invalidates: [[queryScope, ...KEYS.list]],
|
|
49
110
|
});
|
|
50
111
|
const [expandedId, setExpandedId] = useState(null);
|
|
112
|
+
// Bumped once per `toggleExpanded` call, regardless of which panel — `runGrantMutation` (a
|
|
113
|
+
// top-level function, outside this closure's re-render cycle) reads this to tell whether the
|
|
114
|
+
// operator switched panels AT ALL while its own grant was in flight. See that function's own doc
|
|
115
|
+
// comment for why this counts switches rather than comparing against `expandedId`'s value.
|
|
51
116
|
const toggleGenerationRef = useRef(0);
|
|
52
117
|
const [pendingRoleId, setPendingRoleId] = useState("");
|
|
53
118
|
const [pendingPolicyId, setPendingPolicyId] = useState("");
|
|
@@ -77,11 +142,30 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
|
|
|
77
142
|
}, {
|
|
78
143
|
invalidates: [[queryScope, ...KEYS.list]],
|
|
79
144
|
});
|
|
145
|
+
// Disable now confirms via a `RowMenu` item -> `ConfirmDialog` modal (replacing the in-place
|
|
146
|
+
// two-click `ConfirmButton`, which has no menu-item equivalent — same migration Posts.tsx/
|
|
147
|
+
// Redirects.tsx already made). `null` when the dialog is closed.
|
|
80
148
|
const [confirmingDisable, setConfirmingDisable] = useState(null);
|
|
149
|
+
// Reset password moved out of the expanded "Manage" panel into its own `RowMenu` item, which
|
|
150
|
+
// opens this dialog (it needs a text field, so it's a `ConfirmDialog` with an input in the body,
|
|
151
|
+
// not a plain confirm). Kept open on failure (unlike the delete-style dialogs above, which close
|
|
152
|
+
// either way) so a failed attempt doesn't discard the password the operator just typed.
|
|
81
153
|
const [resetPasswordFor, setResetPasswordFor] = useState(null);
|
|
82
154
|
const [newPassword, setNewPassword] = useState("");
|
|
83
155
|
const resetPassword = useAsyncAction();
|
|
156
|
+
/** The signed-in caller's own principal id (password-banner plan, 2026-09-24 Slice 3), from
|
|
157
|
+
* `port.me()`. `null` until that call settles, or forever if it fails — both are treated as
|
|
158
|
+
* "unknown", which the deep-link effect below and `confirmResetPassword`'s notice branch both
|
|
159
|
+
* already handle as "not a self-reset".
|
|
160
|
+
*
|
|
161
|
+
* Fetched unconditionally (not gated on `openOwnPasswordReset`) — a deliberate choice, not an
|
|
162
|
+
* oversight: it makes the "sign in again" notice below correct for ANY self-reset, including an
|
|
163
|
+
* admin resetting their OWN row from the ordinary row-menu (no deep link involved), not just the
|
|
164
|
+
* one opened via `/users/change-password`. One extra `me()` call per screen load is cheaper than
|
|
165
|
+
* threading two different notice code paths for what is, to the operator, the same event. */
|
|
84
166
|
const [ownPrincipalId, setOwnPrincipalId] = useState(null);
|
|
167
|
+
// `canManageUserTrash` (delete-user plan v2, Slice 4): resolved by this SAME one-shot `me()` call
|
|
168
|
+
// rather than a second request — see `UsersController.canManageUserTrash`'s own doc comment.
|
|
85
169
|
const [canManageUserTrash, setCanManageUserTrash] = useState(false);
|
|
86
170
|
useEffect(() => {
|
|
87
171
|
let cancelled = false;
|
|
@@ -94,12 +178,32 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
|
|
|
94
178
|
}
|
|
95
179
|
})
|
|
96
180
|
.catch(() => {
|
|
181
|
+
// Best-effort, see `ownPrincipalId`'s own doc comment above — a failure just means the
|
|
182
|
+
// deep link can't auto-open and self-resets fall back to the per-username notice.
|
|
183
|
+
// `canManageUserTrash` stays `false`, the same "unknown means no affordance" default.
|
|
97
184
|
});
|
|
98
185
|
return () => {
|
|
99
186
|
cancelled = true;
|
|
100
187
|
};
|
|
101
188
|
}, []);
|
|
189
|
+
/** Guards the one-shot deep-link auto-open effect below so it fires at most once per mount, even
|
|
190
|
+
* as `users`/`ownPrincipalId` keep changing identity across the renders it's waiting on (list
|
|
191
|
+
* reloads, `me()` settling). Separate from {@link openedViaDeepLinkRef}, which tracks a DIFFERENT
|
|
192
|
+
* question ("should closing the dialog navigate away") over the dialog's own open/close lifetime
|
|
193
|
+
* — this one is "has the auto-open already been attempted", which must stay true forever once it
|
|
194
|
+
* fires, including after the dialog this effect opened has since been closed. */
|
|
195
|
+
function rowCapabilities(user) {
|
|
196
|
+
const roleMap = new Map((roles ?? []).map(role => [role.id, role]));
|
|
197
|
+
const ownRow = users?.find(row => row.principalId === ownPrincipalId);
|
|
198
|
+
return userAccountCapabilities({ user, callerPrincipalId: ownPrincipalId,
|
|
199
|
+
callerIsOwner: ownRow ? isOwnerAccount({ user: ownRow, roles: roleMap }) : false,
|
|
200
|
+
canManageUserTrash, roles: roleMap });
|
|
201
|
+
}
|
|
102
202
|
const deepLinkAttemptedRef = useRef(false);
|
|
203
|
+
/** Set true exactly when this effect opens the dialog, cleared by the close-effect below the first
|
|
204
|
+
* time it observes `resetPasswordFor` go back to `null`. Read there to decide whether to navigate
|
|
205
|
+
* — an operator resetting a DIFFERENT row's password from the ordinary row menu, or cancelling a
|
|
206
|
+
* normal reset, must not also get shoved to `/users`. */
|
|
103
207
|
const openedViaDeepLinkRef = useRef(false);
|
|
104
208
|
useEffect(() => {
|
|
105
209
|
if (!openOwnPasswordReset || deepLinkAttemptedRef.current)
|
|
@@ -119,6 +223,8 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
|
|
|
119
223
|
openedViaDeepLinkRef.current = false;
|
|
120
224
|
onOwnPasswordResetClosed?.();
|
|
121
225
|
}, [resetPasswordFor, onOwnPasswordResetClosed]);
|
|
226
|
+
// No `invalidates` — matches the pre-migration `confirmResetPassword`, which never called
|
|
227
|
+
// `reload()` either (a password reset changes nothing the users table shows).
|
|
122
228
|
const resetPasswordMutation = useFetchMutation({
|
|
123
229
|
run: ({ input }) => port.resetUserPassword(input),
|
|
124
230
|
});
|
|
@@ -130,10 +236,13 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
|
|
|
130
236
|
setEmail("");
|
|
131
237
|
setPassword("");
|
|
132
238
|
setFormOpen(false);
|
|
133
|
-
}, describeError: (e) =>
|
|
239
|
+
}, describeError: (e) => describeError(e, translate("failed to create user")) });
|
|
134
240
|
}
|
|
135
241
|
function toggleExpanded(user) {
|
|
136
242
|
setGrantError(null);
|
|
243
|
+
// A still-in-flight grant for the panel being LEFT can no longer clear this itself
|
|
244
|
+
// (`runGrantMutation`'s `finally` now skips a stale call's own generation) — reset it here so
|
|
245
|
+
// the newly opened panel never inherits a stuck "saving" indicator from the old one.
|
|
137
246
|
setGrantSaving(false);
|
|
138
247
|
setPendingRoleId("");
|
|
139
248
|
setPendingPolicyId("");
|
|
@@ -144,24 +253,35 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
|
|
|
144
253
|
async function onAssignRole({ principalId }) {
|
|
145
254
|
if (!pendingRoleId)
|
|
146
255
|
return;
|
|
147
|
-
await runGrantMutation(toggleGenerationRef.current, toggleGenerationRef, () => assignRoleMutation.mutate({ input: { principalId, roleId: pendingRoleId } }), () => setPendingRoleId(""), setGrantSaving, setGrantError, (e) =>
|
|
256
|
+
await runGrantMutation(toggleGenerationRef.current, toggleGenerationRef, () => assignRoleMutation.mutate({ input: { principalId, roleId: pendingRoleId } }), () => setPendingRoleId(""), setGrantSaving, setGrantError, (e) => describeError(e, translate("failed to assign role")));
|
|
148
257
|
}
|
|
149
258
|
async function onAttachPolicy({ principalId }) {
|
|
150
259
|
if (!pendingPolicyId)
|
|
151
260
|
return;
|
|
152
|
-
await runGrantMutation(toggleGenerationRef.current, toggleGenerationRef, () => attachPolicyMutation.mutate({ input: { principalId, policyId: pendingPolicyId } }), () => setPendingPolicyId(""), setGrantSaving, setGrantError, (e) =>
|
|
261
|
+
await runGrantMutation(toggleGenerationRef.current, toggleGenerationRef, () => attachPolicyMutation.mutate({ input: { principalId, policyId: pendingPolicyId } }), () => setPendingPolicyId(""), setGrantSaving, setGrantError, (e) => describeError(e, translate("failed to attach policy")));
|
|
153
262
|
}
|
|
263
|
+
// `onSaveEmail` was left out of the `runGrantMutation` fold above on purpose: it tracks its OWN
|
|
264
|
+
// `emailSaving` flag rather than the shared `grantSaving` `onAssignRole`/`onAttachPolicy` use, so
|
|
265
|
+
// routing it through the same helper would mean passing a no-op in place of `setGrantSaving` —
|
|
266
|
+
// extraction for the sake of a shared call site, not a shared shape. Left hand-rolled; `emailSaving`
|
|
267
|
+
// now derives from `updateEmailMutation.status` since (unlike `grantSaving`) it was already this
|
|
268
|
+
// one mutation's own dedicated flag.
|
|
154
269
|
async function onSaveEmail({ principalId }) {
|
|
155
270
|
setGrantError(null);
|
|
156
271
|
try {
|
|
157
272
|
await updateEmailMutation.mutate({ input: { principalId, email: editEmail } });
|
|
158
273
|
}
|
|
159
274
|
catch (e) {
|
|
160
|
-
setGrantError(
|
|
275
|
+
setGrantError(describeError(e, translate("failed to update email")));
|
|
161
276
|
}
|
|
162
277
|
}
|
|
163
278
|
const emailSaving = updateEmailMutation.status === "pending";
|
|
279
|
+
/** Opens the reset-password dialog for `user` — the `RowMenu` item's `onSelect`. Guards against
|
|
280
|
+
* opening a second one while a toggle or a previous reset is still in flight, same discipline
|
|
281
|
+
* Redirects.tsx uses for its own `RowMenu` items (no per-item `disabled` on `RowMenu` itself). */
|
|
164
282
|
function openResetPassword(user) {
|
|
283
|
+
if (!rowCapabilities(user).canResetPassword)
|
|
284
|
+
return;
|
|
165
285
|
if (toggleSavingId || resetPassword.saving)
|
|
166
286
|
return;
|
|
167
287
|
resetPassword.setError(null);
|
|
@@ -169,34 +289,69 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
|
|
|
169
289
|
setResetPasswordFor(user);
|
|
170
290
|
}
|
|
171
291
|
async function confirmResetPassword() {
|
|
172
|
-
if (!resetPasswordFor || !newPassword)
|
|
292
|
+
if (!resetPasswordFor || !newPassword || !rowCapabilities(resetPasswordFor).canResetPassword)
|
|
173
293
|
return;
|
|
294
|
+
// Dialog stays open on failure (unlike the Disable/Delete-style dialogs elsewhere in this app,
|
|
295
|
+
// which close either way) — closing would discard the password the operator just typed for no
|
|
296
|
+
// reason; there's nothing sensitive left on screen once they retry or cancel. `resetPasswordFor`/
|
|
297
|
+
// `newPassword` are therefore only cleared in the success path below, never as a `finally`.
|
|
174
298
|
await resetPassword.run({ action: async () => {
|
|
175
299
|
await resetPasswordMutation.mutate({ input: { principalId: resetPasswordFor.principalId, password: newPassword } });
|
|
300
|
+
// A self-reset (own row, deep-linked or from the ordinary row menu — see `ownPrincipalId`'s
|
|
301
|
+
// own doc comment) gets the "sign in again" notice instead of the per-username one: the reset
|
|
302
|
+
// just revoked the CALLER'S OWN session, so the next request 401s and `App.hooks.tsx` sends
|
|
303
|
+
// them to the login screen — the generic notice naming a DIFFERENT user would be misleading
|
|
304
|
+
// here, since it's actually their own sign-in that just ended.
|
|
176
305
|
setNotice(resetPasswordFor.principalId === ownPrincipalId
|
|
177
|
-
?
|
|
178
|
-
:
|
|
306
|
+
? translate("Password changed. Sign in again with your new password.")
|
|
307
|
+
: passwordResetNotice?.({ username: resetPasswordFor.username }) ?? interpolate({
|
|
308
|
+
template: translate('Password reset for "{username}" — every active session for this user was revoked.'),
|
|
309
|
+
vars: { username: resetPasswordFor.username },
|
|
310
|
+
}));
|
|
179
311
|
setResetPasswordFor(null);
|
|
180
312
|
setNewPassword("");
|
|
181
|
-
}, describeError: (e) =>
|
|
313
|
+
}, describeError: (e) => describeError(e, translate("failed to reset password")) });
|
|
182
314
|
}
|
|
315
|
+
/** `setToggleSavingId`'s `finally` reset (2026-09-05 fix, same bug class as
|
|
316
|
+
* `use-roles.hooks.ts`'s `onSaveRole`/`runRowDelete`) is a functional update keyed on THIS call's
|
|
317
|
+
* own `principalId`: Enable fires with no confirmation gate, so two calls — on different users —
|
|
318
|
+
* can genuinely overlap, and an unconditional reset would clear the busy indicator (which
|
|
319
|
+
* `openResetPassword` below reads to refuse opening while a toggle is in flight) out from under a
|
|
320
|
+
* still-in-flight, unrelated toggle. */
|
|
183
321
|
async function onToggleStatus(user) {
|
|
322
|
+
const capabilities = rowCapabilities(user);
|
|
323
|
+
if (user.status === "active" ? !capabilities.canDisable : !capabilities.canEnable)
|
|
324
|
+
return;
|
|
184
325
|
setToggleSavingId(user.principalId);
|
|
185
326
|
setToggleError(null);
|
|
186
327
|
try {
|
|
187
328
|
await toggleStatusMutation.mutate({ input: user });
|
|
188
329
|
}
|
|
189
330
|
catch (e) {
|
|
190
|
-
setToggleError(
|
|
331
|
+
setToggleError(describeError(e, translate("failed to change status")));
|
|
191
332
|
}
|
|
192
333
|
finally {
|
|
193
334
|
setToggleSavingId((current) => (current === user.principalId ? null : current));
|
|
194
335
|
}
|
|
195
336
|
}
|
|
337
|
+
/** Opens the Disable confirm dialog for `user` — the `RowMenu` "Disable" item's `onSelect` when
|
|
338
|
+
* the user is currently active. Was an inline closure inside the old `rowMenuItems`; named here
|
|
339
|
+
* now that the item builder itself moved to `rules.ts` and needs a callback to hand it. */
|
|
196
340
|
function requestDisable(user) {
|
|
341
|
+
if (!rowCapabilities(user).canDisable)
|
|
342
|
+
return;
|
|
197
343
|
setToggleError(null);
|
|
198
344
|
setConfirmingDisable(user);
|
|
199
345
|
}
|
|
346
|
+
/** Confirms the Disable that `RowMenu`'s "Disable" item asked about. Closes the dialog either
|
|
347
|
+
* way (matching Posts.tsx/Redirects.tsx's own Disable/Delete `ConfirmDialog` convention) — a
|
|
348
|
+
* failure surfaces via `toggleError` above the table, not by leaving the modal open.
|
|
349
|
+
*
|
|
350
|
+
* 2026-09-05 fix, same bug class as `use-roles.hooks.ts`'s `runRowDelete`: `setConfirmingDisable`
|
|
351
|
+
* is exposed directly on the controller, so nothing at the hook level stops the operator opening
|
|
352
|
+
* a DIFFERENT user's Disable confirmation while this one's toggle is still in flight. The close
|
|
353
|
+
* below only clears it when it still names the SAME user this call started for — otherwise a
|
|
354
|
+
* stale toggle's settlement would silently dismiss a newer, still-undecided confirmation. */
|
|
200
355
|
async function confirmDisable() {
|
|
201
356
|
if (!confirmingDisable)
|
|
202
357
|
return;
|
|
@@ -204,25 +359,37 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
|
|
|
204
359
|
await onToggleStatus(user);
|
|
205
360
|
setConfirmingDisable((current) => (current?.principalId === user.principalId ? null : current));
|
|
206
361
|
}
|
|
362
|
+
// Delete-user plan v2 (2026-09-24), Slice 4 — mirrors Disable's request/confirm pair above with
|
|
363
|
+
// its OWN state (see `confirmingDelete`'s own doc comment for why not shared). `deleteUserMutation`
|
|
364
|
+
// invalidates `KEYS.list` like every other write on this screen; the route already filters a newly
|
|
365
|
+
// trashed principal out of `listUsers()`, so the row disappears on the next render with no extra
|
|
366
|
+
// client-side bookkeeping.
|
|
207
367
|
const deleteUserMutation = useFetchMutation({
|
|
208
|
-
run: ({ input: principalId }) => port.deleteUser({ principalId
|
|
368
|
+
run: ({ input: principalId }) => port.deleteUser({ principalId })
|
|
209
369
|
}, {
|
|
210
370
|
invalidates: [[queryScope, ...KEYS.list]],
|
|
211
371
|
});
|
|
212
372
|
const [confirmingDelete, setConfirmingDelete] = useState(null);
|
|
213
373
|
function requestDelete(user) {
|
|
374
|
+
if (!rowCapabilities(user).canDelete)
|
|
375
|
+
return;
|
|
214
376
|
setToggleError(null);
|
|
215
377
|
setConfirmingDelete(user);
|
|
216
378
|
}
|
|
379
|
+
/** Confirms the Delete that `RowMenu`'s "Delete" item asked about. Closes the dialog either way
|
|
380
|
+
* (matching `confirmDisable`'s and Posts.tsx/Redirects.tsx's own Delete `ConfirmDialog`
|
|
381
|
+
* convention) — a failure surfaces via `toggleError` above the table, not by leaving the modal
|
|
382
|
+
* open. Same stale-call guard as `confirmDisable`: only clears `confirmingDelete` when it still
|
|
383
|
+
* names the SAME user this call started for. */
|
|
217
384
|
async function confirmDelete() {
|
|
218
|
-
if (!confirmingDelete)
|
|
385
|
+
if (!confirmingDelete || !rowCapabilities(confirmingDelete).canDelete)
|
|
219
386
|
return;
|
|
220
387
|
const user = confirmingDelete;
|
|
221
388
|
try {
|
|
222
389
|
await deleteUserMutation.mutate({ input: user.principalId });
|
|
223
390
|
}
|
|
224
391
|
catch (e) {
|
|
225
|
-
setToggleError(
|
|
392
|
+
setToggleError(describeError(e, translate("failed to delete user")));
|
|
226
393
|
}
|
|
227
394
|
setConfirmingDelete((current) => (current?.principalId === user.principalId ? null : current));
|
|
228
395
|
}
|
|
@@ -230,6 +397,7 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
|
|
|
230
397
|
users,
|
|
231
398
|
roles,
|
|
232
399
|
policies,
|
|
400
|
+
canGrant,
|
|
233
401
|
error,
|
|
234
402
|
formOpen,
|
|
235
403
|
setFormOpen,
|
|
@@ -274,6 +442,7 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
|
|
|
274
442
|
openResetPassword,
|
|
275
443
|
confirmResetPassword,
|
|
276
444
|
canManageUserTrash,
|
|
445
|
+
rowCapabilities,
|
|
277
446
|
confirmingDelete,
|
|
278
447
|
setConfirmingDelete,
|
|
279
448
|
requestDelete,
|
|
@@ -1,33 +1,140 @@
|
|
|
1
1
|
import type { AdminIdentityUser } from "../models.js";
|
|
2
|
-
import type { Translate } from "@jini-ai/ui/panel-kit";
|
|
2
|
+
import type { Translate, QueryKey } from "@jini-ai/ui/panel-kit";
|
|
3
3
|
import type { RowMenuItem } from "@jini-ai/admin/react";
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* @file Pure logic for the `users` feature — everything that computes a value rather than
|
|
6
|
+
* rendering one. Follows the `posts/rules.ts` convention: no React import, no hooks, directly
|
|
7
|
+
* testable.
|
|
8
|
+
*
|
|
9
|
+
* Moved here from `Users.tsx`: the screen's own `describeApiError` override (a branch per server
|
|
10
|
+
* error `code`), the row-menu item builder (a branch on `user.status` plus the confirm-vs-immediate
|
|
11
|
+
* split for Disable/Enable), and the role/policy grant label formatter (a branch on whether the
|
|
12
|
+
* user holds any grants of that kind). All three were previously closures or free functions inside
|
|
13
|
+
* the component, reachable only by rendering the full screen.
|
|
14
|
+
*
|
|
15
|
+
* `KEYS` (fetch-query migration, 2026-08-12): one identity for the combined users+roles+policies
|
|
16
|
+
* read — matches the pre-migration `reload()`, which always refetched all three together.
|
|
17
|
+
*/
|
|
5
18
|
export declare const KEYS: {
|
|
6
19
|
list: QueryKey;
|
|
7
20
|
};
|
|
8
|
-
|
|
21
|
+
/** Server error `code` -> a plain-language prefix (SPEC-006 errors.spec.md §2), layered on the
|
|
22
|
+
* shared default (`lib/api.ts`'s `describeApiError`) — this screen's `RESOURCE_CONFLICT` means
|
|
23
|
+
* "username already in use", a different meaning than `Roles.tsx`'s "still referenced" or
|
|
24
|
+
* `Workspace.tsx`'s "slug already taken" for the same code (audit cross-cutting finding #2 —
|
|
25
|
+
* deliberately not unified into one table). `VALIDATION_ERROR` stays a dedicated branch rather than
|
|
26
|
+
* joining the table above: its message comes from the error itself (`e.message`), not a fixed
|
|
27
|
+
* string, so it isn't a value a plain lookup can hold.
|
|
28
|
+
*
|
|
29
|
+
* `locale` (C4 fix, 2026-09-20): every branch here used to return its English literal directly,
|
|
30
|
+
* leaking English into every non-`en` locale regardless of the caller's own translated fallback —
|
|
31
|
+
* the fallback was already threaded through `t()` at each call site, but these overrides were not.
|
|
32
|
+
* Each literal is now also a key into `users-i18n.ts`'s `USERS_DICT`. */
|
|
33
|
+
export declare function describeUsersError({ error, fallback, translate }: {
|
|
9
34
|
error: unknown;
|
|
10
35
|
fallback: string;
|
|
11
36
|
translate: Translate;
|
|
12
|
-
}): string;
|
|
37
|
+
}, _optional?: Record<string, never>): string;
|
|
38
|
+
export { describeUsersError as describeApiError };
|
|
39
|
+
/** The callbacks a row menu needs. Passed in rather than imported, so this module stays free of
|
|
40
|
+
* state, and so a test can assert exactly which one a given row wires up (same convention as
|
|
41
|
+
* `posts/rules.ts`'s `PostRowMenuHandlers`). */
|
|
13
42
|
export interface UserRowMenuHandlers {
|
|
43
|
+
/** Asked only when `user.status === "active"` — opens the confirm dialog rather than acting. */
|
|
14
44
|
onRequestDisable: (user: AdminIdentityUser) => void;
|
|
45
|
+
/** Asked only when `user.status !== "active"` — fires immediately, no confirm (Enable was never
|
|
46
|
+
* confirm-gated). */
|
|
15
47
|
onEnable: (user: AdminIdentityUser) => void;
|
|
16
48
|
onManage: (user: AdminIdentityUser) => void;
|
|
17
49
|
onResetPassword: (user: AdminIdentityUser) => void;
|
|
50
|
+
/** Delete-user plan v2 (2026-09-24) — always opens the confirm dialog, never fires immediately:
|
|
51
|
+
* unlike Enable, "Delete" moves the user to the Trash, a destructive-looking action even though
|
|
52
|
+
* it is recoverable there for 60 days. Only asked for when `userRowMenuItems`'s own `canDelete`
|
|
53
|
+
* is true — see that function's doc comment. */
|
|
18
54
|
onRequestDelete: (user: AdminIdentityUser) => void;
|
|
19
55
|
}
|
|
20
|
-
export
|
|
56
|
+
export interface UserAccountCapabilities {
|
|
57
|
+
canDelete: boolean;
|
|
58
|
+
canDisable: boolean;
|
|
59
|
+
canEnable: boolean;
|
|
60
|
+
canResetPassword: boolean;
|
|
61
|
+
}
|
|
62
|
+
/** Courtesy mirror of the server target rule. Unknown identity/roles fail closed. */
|
|
63
|
+
export declare function userAccountCapabilities(required: {
|
|
64
|
+
user: AdminIdentityUser;
|
|
65
|
+
callerPrincipalId: string | null;
|
|
66
|
+
callerIsOwner: boolean;
|
|
67
|
+
canManageUserTrash: boolean;
|
|
68
|
+
roles: ReadonlyMap<string, {
|
|
69
|
+
name: string;
|
|
70
|
+
isBuiltin: boolean;
|
|
71
|
+
}>;
|
|
72
|
+
}, _optional?: Record<string, never>): UserAccountCapabilities;
|
|
73
|
+
export declare function isOwnerAccount(required: {
|
|
74
|
+
user: AdminIdentityUser;
|
|
75
|
+
roles: ReadonlyMap<string, {
|
|
76
|
+
name: string;
|
|
77
|
+
isBuiltin: boolean;
|
|
78
|
+
}>;
|
|
79
|
+
}, _optional?: Record<string, never>): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* `RowMenu` items for one user row — matches `Posts.tsx`/`Pages.tsx`'s three-dot menu shape:
|
|
82
|
+
* Disable/Enable, Manage, Reset password, and — delete-user plan v2 (2026-09-24) — Delete, appended
|
|
83
|
+
* only when `canDelete` is true. The server gate (owner or the built-in `admin` role,
|
|
84
|
+
* `delete-user-service.ts`'s `callerMayManageUserTrash`) is the real boundary; `canDelete` is this
|
|
85
|
+
* row's own affordance-hiding mirror of it, computed once per render from `/auth/me` by
|
|
86
|
+
* `use-users.hooks.ts` — see that hook's `canManageUserTrash` state for where it comes from.
|
|
87
|
+
*
|
|
88
|
+
* Disable/Enable share a single "toggle" item (label follows status, same shape as
|
|
89
|
+
* `Redirects.tsx`'s own toggle item) — Disable confirms via the modal below; Enable fires
|
|
90
|
+
* immediately, matching this screen's existing behavior (Enable was never confirm-gated). The
|
|
91
|
+
* `toggleSaving` guard reproduces the original inline check (`if (toggleSavingId) return`) against
|
|
92
|
+
* whatever row action is currently in flight, read fresh at click time.
|
|
93
|
+
*
|
|
94
|
+
* "Manage" always reads "Manage", never "Close": the item still toggles the expanded panel
|
|
95
|
+
* (`onManage` — closing it again by selecting "Manage" a second time still works exactly as it did
|
|
96
|
+
* as a standalone button), but a `RowMenu` item disappears the instant it is selected, so a label
|
|
97
|
+
* that flips to "Close" is never actually visible mid-interaction — it would only ever describe a
|
|
98
|
+
* state the operator cannot see while the menu that shows it is open. A static label sidesteps that
|
|
99
|
+
* without losing any capability.
|
|
100
|
+
*
|
|
101
|
+
* "Delete" always opens the confirm dialog (`onRequestDelete`), matching Disable's shape rather than
|
|
102
|
+
* Enable's immediate-fire one — see `UserRowMenuHandlers.onRequestDelete`'s own doc comment.
|
|
103
|
+
*
|
|
104
|
+
* @complexity Time/space: O(1) — at most four entries, no iteration.
|
|
105
|
+
*/
|
|
106
|
+
export declare function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete, capabilities }: {
|
|
21
107
|
user: AdminIdentityUser;
|
|
22
108
|
toggleSaving: boolean;
|
|
23
109
|
handlers: UserRowMenuHandlers;
|
|
24
110
|
translate: Translate;
|
|
25
111
|
canDelete: boolean;
|
|
26
|
-
|
|
27
|
-
|
|
112
|
+
capabilities?: boolean | UserAccountCapabilities | undefined;
|
|
113
|
+
}, _optional?: Record<string, never>): RowMenuItem[];
|
|
114
|
+
/**
|
|
115
|
+
* The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
|
|
116
|
+
* user holds none, which the caller renders as the `muted-cell` "none" fallback (a plain ternary on
|
|
117
|
+
* this already-computed value, per this feature's extraction rule).
|
|
118
|
+
*
|
|
119
|
+
* A grant id with no matching entry in `byId` (a role/policy deleted out from under a still-held
|
|
120
|
+
* grant) falls back to rendering the raw id rather than dropping it silently — the operator sees
|
|
121
|
+
* something is wrong instead of an undercount with no explanation.
|
|
122
|
+
*
|
|
123
|
+
* @complexity Time: O(n) in the number of granted ids; space: O(n) for the joined string.
|
|
124
|
+
*/
|
|
125
|
+
export declare function grantOptionLabel(required: {
|
|
126
|
+
option: {
|
|
127
|
+
name: string;
|
|
128
|
+
isBuiltin: boolean;
|
|
129
|
+
};
|
|
130
|
+
translate: (key: string) => string;
|
|
131
|
+
}, _optional?: Record<string, never>): string;
|
|
132
|
+
export declare function formatGrantLabel({ ids, byId, translate }: {
|
|
28
133
|
ids: readonly string[];
|
|
29
134
|
byId: ReadonlyMap<string, {
|
|
30
135
|
name: string;
|
|
136
|
+
isBuiltin?: boolean;
|
|
31
137
|
}>;
|
|
32
|
-
|
|
138
|
+
translate?: Translate | undefined;
|
|
139
|
+
}, _optional?: Record<string, never>): string | null;
|
|
33
140
|
//# sourceMappingURL=rules.d.ts.map
|