@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.
@@ -1,9 +1,31 @@
1
- import { interpolate } from "@jini-ai/ui/panel-kit";
2
1
  import { useEffect, useRef, useState } from "react";
3
- import {} from "../../models.js";
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 as describeFeatureError, KEYS } from "../rules.js";
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
- export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset = false, onOwnPasswordResetClosed } = {}) {
25
- const describeApiError = (error, fallback) => describeFeatureError({ error, fallback, translate });
26
- const t = translate;
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, r, p] = await Promise.all([port.listUsers({}), port.listRoles({}), port.listPolicies({})]);
33
- return { users: u.users, roles: r.roles, policies: p.policies };
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 error = list.error ? describeApiError(list.error, t("failed to load users")) : null;
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) => describeApiError(e, t("failed to create user")) });
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) => describeApiError(e, t("failed to assign role")));
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) => describeApiError(e, t("failed to attach policy")));
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(describeApiError(e, t("failed to update email")));
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
- ? t("Password changed. Sign in again with your new password.")
178
- : interpolate({ template: t('Password reset for "{username}" — every active session for this user was revoked.'), vars: { username: resetPasswordFor.username } }));
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) => describeApiError(e, t("failed to reset password")) });
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(describeApiError(e, t("failed to change status")));
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: 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(describeApiError(e, t("failed to delete user")));
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
- import type { QueryKey } from "@jini-ai/ui/panel-kit";
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
- export declare function describeApiError({ error, fallback, translate }: {
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 declare function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete }: {
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
- }): RowMenuItem[];
27
- export declare function formatGrantLabel({ ids, byId }: {
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
- }): string | null;
138
+ translate?: Translate | undefined;
139
+ }, _optional?: Record<string, never>): string | null;
33
140
  //# sourceMappingURL=rules.d.ts.map