@jini-ai/user-management 0.2.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/admin/people.rules.js +2 -1
  3. package/dist/core/index.d.ts +1 -0
  4. package/dist/core/index.js +1 -0
  5. package/dist/core/principal-kind-policy.d.ts +30 -0
  6. package/dist/core/principal-kind-policy.js +28 -0
  7. package/dist/core/types.d.ts +7 -2
  8. package/dist/react/index.d.ts +4 -0
  9. package/dist/react/index.js +2 -0
  10. package/dist/react/members/Members.d.ts +28 -1
  11. package/dist/react/members/Members.js +14 -10
  12. package/dist/react/members/hooks/use-members.hooks.d.ts +43 -1
  13. package/dist/react/members/hooks/use-members.hooks.js +37 -9
  14. package/dist/react/members/rules.d.ts +31 -4
  15. package/dist/react/members/rules.js +17 -5
  16. package/dist/react/models.d.ts +3 -0
  17. package/dist/react/users/Users.d.ts +105 -6
  18. package/dist/react/users/Users.js +105 -43
  19. package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +33 -1
  20. package/dist/react/users/hooks/use-reset-password-fields.hooks.js +16 -1
  21. package/dist/react/users/hooks/use-users.hooks.d.ts +120 -3
  22. package/dist/react/users/hooks/use-users.hooks.js +192 -23
  23. package/dist/react/users/rules.d.ts +115 -8
  24. package/dist/react/users/rules.js +109 -6
  25. package/dist/server/admin-crud-service.js +4 -1
  26. package/dist/server/authorize.d.ts +9 -6
  27. package/dist/server/authorize.js +11 -3
  28. package/dist/server/permission-migrations.d.ts +35 -15
  29. package/dist/server/permission-migrations.js +16 -16
  30. package/dist/server/seed.js +6 -1
  31. package/package.json +15 -10
  32. package/dist/react/roles/Roles.d.ts +0 -100
  33. package/dist/react/roles/Roles.hooks.d.ts +0 -17
  34. package/dist/react/roles/Roles.hooks.js +0 -40
  35. package/dist/react/roles/Roles.js +0 -150
  36. package/dist/react/roles/hooks/use-roles.hooks.d.ts +0 -80
  37. package/dist/react/roles/hooks/use-roles.hooks.js +0 -303
  38. package/dist/react/roles/rules.d.ts +0 -35
  39. package/dist/react/roles/rules.js +0 -31
  40. package/dist/react/roles/text.d.ts +0 -27
  41. package/dist/react/roles/text.js +0 -13
@@ -1,12 +1,81 @@
1
- import type { Translate } from "@jini-ai/ui/panel-kit";
2
1
  import { type Dispatch, type FormEvent, type SetStateAction } from "react";
3
- import { type AdminIdentityUser, type AdminPolicy, type AdminRole } from "../../models.js";
2
+ import type { AdminIdentityUser, AdminPolicy, AdminRole } from "../../models.js";
3
+ import { type Translate } from "@jini-ai/ui/panel-kit";
4
+ import { type UserAccountCapabilities } from "../rules.js";
4
5
  import type { UsersPort } from "../../ports.js";
6
+ /**
7
+ * @file Everything the Users screen does, so `Users.tsx` is only markup.
8
+ *
9
+ * Extracted verbatim — same state, same declaration order, same effect bodies, same error strings.
10
+ * This is the highest-state screen in the app (25 `useState` calls): users/roles/policies load, the
11
+ * "New user" form, the per-row expandable "Manage" panel (role/policy grants, email edit), the
12
+ * Disable confirm dialog, and the reset-password dialog all live here so `Users.tsx` can stay a pure
13
+ * render of whatever this hook returns.
14
+ *
15
+ * `describeApiError` (this screen's server-error-code overrides) moved to `rules.ts` alongside the
16
+ * row-menu builder, since both compute a value rather than performing an effect; this hook imports
17
+ * it back for its own async handlers.
18
+ *
19
+ * Naming follows `hooks/use-settings-slice.hooks.ts` and `posts/hooks/use-posts.hooks.ts`:
20
+ * `use-<thing>.hooks.ts`. Feature-local because nothing outside `features/users` needs it.
21
+ *
22
+ * `onCreate` and `confirmResetPassword` — each a single action with its OWN dedicated saving/error
23
+ * pair — now run through `hooks/use-async-action.hooks.ts`'s `useAsyncAction`, replacing their own
24
+ * `setXSaving(true)/setXError(null)/try/catch/finally` boilerplate with one `run()` call each; the
25
+ * public `UsersController` shape (`saving`/`formError`, `passwordSaving`/`passwordError`/
26
+ * `setPasswordError`) is unchanged. `grantSaving`/`grantError` (shared by three different handlers
27
+ * for one "Manage" panel error slot) and `toggleSavingId`/`toggleError` (`toggleSavingId` is the
28
+ * BUSY ROW'S id, not a boolean) stay hand-rolled — see `useAsyncAction`'s own header for why forcing
29
+ * either shape onto that primitive would change behavior rather than just deduplicate it.
30
+ *
31
+ * `t`/`locale` (2026-08-11, standing i18n rule — a component with a hook gets a BOUND `t` from that
32
+ * hook, not its own `useAdminLocale()`/dictionary import): this hook already called
33
+ * `useAdminLocale()` for its own error-string translations, so exposing that SAME already-resolved
34
+ * `locale` as a bound `t` (plus the raw value, still needed for `rules.ts`'s `userRowMenuItems`,
35
+ * which takes `locale` directly) on the return value adds no new fetch — `Users.tsx` used to call
36
+ * `useAdminLocale()` a second time and rebuild its own `translateUsers(locale, key)` closure,
37
+ * entirely redundant with the resolution this hook was already doing internally.
38
+ *
39
+ * `lib/fetch-query` migration (2026-08-12): the combined users+roles+policies read is one
40
+ * `useFetchQuery` keyed on `KEYS.list`; every write below routes its actual API call through a
41
+ * `useFetchMutation` that `invalidates: [KEYS.list]` instead of the `action`/handler body calling
42
+ * `reload()` by hand — except `resetPasswordMutation`, which never called `reload()` either (a
43
+ * password reset doesn't change anything the users table shows). `useAsyncAction` (`createUser`/
44
+ * `resetPassword` below) keeps owning the busy/error UI state exactly as before — this migration
45
+ * only changes what runs INSIDE its `action` callback, not the primitive itself, which is shared
46
+ * with other screens and has no fetch-query concern of its own. `grantSaving`/`grantError`/
47
+ * `toggleSavingId`/`toggleError` stay hand-rolled for the same reason `use-roles.hooks.ts`'s
48
+ * `rowSavingId`/`rowError` do: each is shared across MULTIPLE independent mutations, and a single
49
+ * mutation's own `status`/`error` has no way to carry "which row/action this particular call was
50
+ * for". `emailSaving` is the one exception — it already tracked exactly one mutation 1:1 before this
51
+ * migration, so it now derives from `updateEmailMutation.status` directly.
52
+ *
53
+ * `useX(dependencies)` / `useWiredX()` conversion (2026-08-14): every `api.xxx()` call below is now
54
+ * `port.xxx()` — see `users-port.hooks.ts` for the interface and `users-dependencies.hooks.ts` for
55
+ * the real binding, the only file left that imports `lib/api` as a value for this feature. `port` is
56
+ * captured once from `deps` and read directly inside the `useFetchQuery`/`useFetchMutation` closures
57
+ * below, which is safe without the ref-and-dep-array discipline the host admin guidance's "Two traps"
58
+ * section describes for a hand-rolled `useEffect`: TanStack's `useQuery`/`useMutation` (this file's
59
+ * actual I/O primitive, via `lib/fetch-query`) take a fresh `queryFn`/`mutationFn` closure every
60
+ * render by design and do not require referential stability to avoid a refetch loop, unlike a raw
61
+ * `useEffect([port])`. There is no dependency array in this file for `port` to be listed in wrongly.
62
+ */
5
63
  export interface UsersController {
64
+ /** `null` until the initial load settles — the caller renders a loading state. Loaded together
65
+ * with `roles`/`policies` via `Promise.all`, so all three settle on the same render. */
6
66
  users: AdminIdentityUser[] | null;
67
+ /** `[]` (with `canGrant` false) when the caller may not read roles/policies — see {@link canGrant}. */
7
68
  roles: AdminRole[] | null;
8
69
  policies: AdminPolicy[] | null;
70
+ /**
71
+ * Whether the caller may read (and so grant) roles and policies. The roster route admits
72
+ * `user.manage` OR `member.manage`, but `/roles` and `/policies` require `role.manage`; a 403 from
73
+ * those two hides the grant controls instead of failing the whole screen, so the roster the
74
+ * operator CAN read still shows.
75
+ */
76
+ canGrant: boolean;
9
77
  error: string | null;
78
+ /** Whether the "New user" form is expanded. */
10
79
  formOpen: boolean;
11
80
  setFormOpen: Dispatch<SetStateAction<boolean>>;
12
81
  username: string;
@@ -18,6 +87,7 @@ export interface UsersController {
18
87
  saving: boolean;
19
88
  formError: string | null;
20
89
  onCreate: (e: FormEvent) => Promise<void>;
90
+ /** The row whose "Manage" panel is expanded — `null` when every row is collapsed. */
21
91
  expandedId: string | null;
22
92
  toggleExpanded: (user: AdminIdentityUser) => void;
23
93
  pendingRoleId: string;
@@ -32,20 +102,30 @@ export interface UsersController {
32
102
  onAttachPolicy: (required: {
33
103
  principalId: string;
34
104
  }) => Promise<void>;
105
+ /** The "Manage" panel's own email field — seeded from `user.email` when the panel opens. */
35
106
  editEmail: string;
36
107
  setEditEmail: Dispatch<SetStateAction<string>>;
37
108
  emailSaving: boolean;
38
109
  onSaveEmail: (required: {
39
110
  principalId: string;
40
111
  }) => Promise<void>;
112
+ /** In-flight Disable/Enable request, keyed by `principalId` — one at a time. */
41
113
  toggleSavingId: string | null;
42
114
  toggleError: string | null;
43
115
  notice: string | null;
116
+ /** The user a `RowMenu` "Disable" selection is asking to confirm; `null` when the dialog is
117
+ * shut. `ConfirmDialog` stays mounted unconditionally in the view (see its own doc comment on
118
+ * why); this is what drives its `open` prop. */
44
119
  confirmingDisable: AdminIdentityUser | null;
45
120
  setConfirmingDisable: Dispatch<SetStateAction<AdminIdentityUser | null>>;
121
+ /** Opens the Disable confirm dialog for `user` — the `RowMenu` "Disable" item's `onSelect` when
122
+ * the user is currently active (Enable, by contrast, fires immediately via `onToggleStatus`). */
46
123
  requestDisable: (user: AdminIdentityUser) => void;
47
124
  confirmDisable: () => Promise<void>;
48
125
  onToggleStatus: (user: AdminIdentityUser) => Promise<void>;
126
+ /** The user a `RowMenu` "Reset password" selection is asking about; `null` when the dialog is
127
+ * shut. Kept open on failure (unlike the Disable dialog, which closes either way) so a failed
128
+ * attempt doesn't discard the password the operator just typed. */
49
129
  resetPasswordFor: AdminIdentityUser | null;
50
130
  setResetPasswordFor: Dispatch<SetStateAction<AdminIdentityUser | null>>;
51
131
  newPassword: string;
@@ -55,15 +135,40 @@ export interface UsersController {
55
135
  setPasswordError: Dispatch<SetStateAction<string | null>>;
56
136
  openResetPassword: (user: AdminIdentityUser) => void;
57
137
  confirmResetPassword: () => Promise<void>;
138
+ /** Delete-user plan v2 (2026-09-24), Slice 4: whether the SIGNED-IN caller may trash users at all
139
+ * — from `port.me()`'s `canManageUserTrash`, resolved by the same one-shot effect that already
140
+ * fetches `ownPrincipalId`. `false` until that call settles (or forever if it fails), the same
141
+ * "unknown defaults to no affordance" convention `ownPrincipalId`'s doc comment describes. Drives
142
+ * `rules.ts`'s `userRowMenuItems` `canDelete` param — see that function's own doc comment for why
143
+ * the real boundary is server-side, not this flag. */
58
144
  canManageUserTrash: boolean;
145
+ rowCapabilities: (user: AdminIdentityUser) => UserAccountCapabilities;
146
+ /** The user a `RowMenu` "Delete" selection is asking to confirm; `null` when the dialog is shut.
147
+ * Mirrors `confirmingDisable` exactly (own state, own request/confirm pair) rather than sharing
148
+ * it — Delete and Disable are two independent confirms an operator could otherwise not tell apart
149
+ * if they shared one slot. */
59
150
  confirmingDelete: AdminIdentityUser | null;
60
151
  setConfirmingDelete: Dispatch<SetStateAction<AdminIdentityUser | null>>;
152
+ /** Opens the Delete confirm dialog for `user` — the `RowMenu` "Delete" item's `onSelect`. Never
153
+ * fires immediately (see `rules.ts`'s `UserRowMenuHandlers.onRequestDelete` doc comment). */
61
154
  requestDelete: (user: AdminIdentityUser) => void;
155
+ /** In flight while the delete mutation is pending — drives the confirm dialog's `pending` prop,
156
+ * same role `toggleSavingId` plays for the Disable dialog. */
62
157
  deleteSaving: boolean;
63
158
  confirmDelete: () => Promise<void>;
159
+ /** Bound translator — `key` already resolved against the caller's locale, so `Users.tsx` never
160
+ * imports `useAdminLocale`/`users-i18n` itself. See this file's header. */
64
161
  t: (key: string) => string;
162
+ /** Host-bound translation also resolves row menus without a package locale dependency. */
65
163
  translate: Translate;
66
164
  }
165
+ /** What `useUsers` needs injected from outside — see this file's header for the conversion note.
166
+ *
167
+ * `openOwnPasswordReset`/`navigate` (password-banner plan, 2026-09-24 Slice 3): the dashboard nag's
168
+ * "Change password" link deep-links to `/admin/users/change-password`, which `panels.tsx` turns
169
+ * into this flag. Both are optional so every existing `useUsers({ port })` call (this file's own
170
+ * tests, `Users.tsx`'s default) keeps working unchanged — the deep link is additive behavior, not a
171
+ * new required collaborator. */
67
172
  export interface UsersDependencies {
68
173
  port: UsersPort;
69
174
  translate: Translate;
@@ -72,6 +177,18 @@ export interface UsersDependencies {
72
177
  export interface UsersOptions {
73
178
  openOwnPasswordReset?: boolean | undefined;
74
179
  onOwnPasswordResetClosed?: (() => void) | undefined;
180
+ /** Host formatting keeps dynamic username templates in the host's dictionary. */
181
+ passwordResetNotice?: ((required: {
182
+ username: string;
183
+ }) => string) | undefined;
75
184
  }
76
- export declare function useUsers({ port, translate, queryScope }: UsersDependencies, { openOwnPasswordReset, onOwnPasswordResetClosed }?: UsersOptions): UsersController;
185
+ /**
186
+ * Everything the Users screen does — full state, effects, and every server write, as one hook so
187
+ * `Users.tsx` stays a pure render of whatever this returns. See this file's header for the
188
+ * `useFetchQuery`/`useFetchMutation` migration and the `port` injection it now also carries.
189
+ *
190
+ * @param deps - Injected collaborators; production callers get these from {@link useWiredUsers}.
191
+ * @returns The full `UsersController` the view renders from — see that interface for every field.
192
+ */
193
+ export declare function useUsers({ port, translate, queryScope }: UsersDependencies, { openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice, }?: UsersOptions): UsersController;
77
194
  //# sourceMappingURL=use-users.hooks.d.ts.map
@@ -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,