@jini-ai/user-management 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/core/principal-kind-policy.d.ts +1 -1
  3. package/dist/core/principal-kind-policy.js +1 -1
  4. package/dist/core/runtime-ports.d.ts +20 -0
  5. package/dist/core/types.d.ts +4 -1
  6. package/dist/core/types.js +5 -1
  7. package/dist/react/index.d.ts +7 -0
  8. package/dist/react/index.js +4 -0
  9. package/dist/react/members/Members.d.ts +28 -1
  10. package/dist/react/members/Members.js +15 -11
  11. package/dist/react/members/hooks/use-members.hooks.d.ts +46 -2
  12. package/dist/react/members/hooks/use-members.hooks.js +82 -54
  13. package/dist/react/members/rules.d.ts +31 -4
  14. package/dist/react/members/rules.js +17 -5
  15. package/dist/react/models.d.ts +5 -0
  16. package/dist/react/ports.d.ts +2 -0
  17. package/dist/react/testing.js +1 -1
  18. package/dist/react/users/ChangeUsernameDialog.d.ts +8 -0
  19. package/dist/react/users/ChangeUsernameDialog.js +8 -0
  20. package/dist/react/users/Users.d.ts +107 -7
  21. package/dist/react/users/Users.js +108 -43
  22. package/dist/react/users/hooks/use-change-username.hooks.d.ts +27 -0
  23. package/dist/react/users/hooks/use-change-username.hooks.js +53 -0
  24. package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +33 -1
  25. package/dist/react/users/hooks/use-reset-password-fields.hooks.js +16 -1
  26. package/dist/react/users/hooks/use-users.hooks.d.ts +129 -3
  27. package/dist/react/users/hooks/use-users.hooks.js +223 -23
  28. package/dist/react/users/rules.d.ts +116 -8
  29. package/dist/react/users/rules.js +112 -6
  30. package/dist/server/admin-crud-service.d.ts +6 -4
  31. package/dist/server/admin-crud-service.js +49 -6
  32. package/dist/server/agent-tool-registrations.d.ts +1 -0
  33. package/dist/server/agent-tool-registrations.js +14 -0
  34. package/dist/server/agent-tools.js +11 -0
  35. package/dist/server/auth-service.d.ts +3 -1
  36. package/dist/server/seed.d.ts +7 -4
  37. package/dist/server/seed.js +15 -6
  38. package/package.json +17 -12
@@ -1,12 +1,82 @@
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
+ import { useChangeUsername } from "./use-change-username.hooks.js";
7
+ /**
8
+ * @file Everything the Users screen does, so `Users.tsx` is only markup.
9
+ *
10
+ * Extracted verbatim — same state, same declaration order, same effect bodies, same error strings.
11
+ * This is the highest-state screen in the app (25 `useState` calls): users/roles/policies load, the
12
+ * "New user" form, the per-row expandable "Manage" panel (role/policy grants, email edit), the
13
+ * Disable confirm dialog, and the reset-password dialog all live here so `Users.tsx` can stay a pure
14
+ * render of whatever this hook returns.
15
+ *
16
+ * `describeApiError` (this screen's server-error-code overrides) moved to `rules.ts` alongside the
17
+ * row-menu builder, since both compute a value rather than performing an effect; this hook imports
18
+ * it back for its own async handlers.
19
+ *
20
+ * Naming follows `hooks/use-settings-slice.hooks.ts` and `posts/hooks/use-posts.hooks.ts`:
21
+ * `use-<thing>.hooks.ts`. Feature-local because nothing outside `features/users` needs it.
22
+ *
23
+ * `onCreate` and `confirmResetPassword` — each a single action with its OWN dedicated saving/error
24
+ * pair — now run through `hooks/use-async-action.hooks.ts`'s `useAsyncAction`, replacing their own
25
+ * `setXSaving(true)/setXError(null)/try/catch/finally` boilerplate with one `run()` call each; the
26
+ * public `UsersController` shape (`saving`/`formError`, `passwordSaving`/`passwordError`/
27
+ * `setPasswordError`) is unchanged. `grantSaving`/`grantError` (shared by three different handlers
28
+ * for one "Manage" panel error slot) and `toggleSavingId`/`toggleError` (`toggleSavingId` is the
29
+ * BUSY ROW'S id, not a boolean) stay hand-rolled — see `useAsyncAction`'s own header for why forcing
30
+ * either shape onto that primitive would change behavior rather than just deduplicate it.
31
+ *
32
+ * `t`/`locale` (2026-08-11, standing i18n rule — a component with a hook gets a BOUND `t` from that
33
+ * hook, not its own `useAdminLocale()`/dictionary import): this hook already called
34
+ * `useAdminLocale()` for its own error-string translations, so exposing that SAME already-resolved
35
+ * `locale` as a bound `t` (plus the raw value, still needed for `rules.ts`'s `userRowMenuItems`,
36
+ * which takes `locale` directly) on the return value adds no new fetch — `Users.tsx` used to call
37
+ * `useAdminLocale()` a second time and rebuild its own `translateUsers(locale, key)` closure,
38
+ * entirely redundant with the resolution this hook was already doing internally.
39
+ *
40
+ * `lib/fetch-query` migration (2026-08-12): the combined users+roles+policies read is one
41
+ * `useFetchQuery` keyed on `KEYS.list`; every write below routes its actual API call through a
42
+ * `useFetchMutation` that `invalidates: [KEYS.list]` instead of the `action`/handler body calling
43
+ * `reload()` by hand — except `resetPasswordMutation`, which never called `reload()` either (a
44
+ * password reset doesn't change anything the users table shows). `useAsyncAction` (`createUser`/
45
+ * `resetPassword` below) keeps owning the busy/error UI state exactly as before — this migration
46
+ * only changes what runs INSIDE its `action` callback, not the primitive itself, which is shared
47
+ * with other screens and has no fetch-query concern of its own. `grantSaving`/`grantError`/
48
+ * `toggleSavingId`/`toggleError` stay hand-rolled for the same reason `use-roles.hooks.ts`'s
49
+ * `rowSavingId`/`rowError` do: each is shared across MULTIPLE independent mutations, and a single
50
+ * mutation's own `status`/`error` has no way to carry "which row/action this particular call was
51
+ * for". `emailSaving` is the one exception — it already tracked exactly one mutation 1:1 before this
52
+ * migration, so it now derives from `updateEmailMutation.status` directly.
53
+ *
54
+ * `useX(dependencies)` / `useWiredX()` conversion (2026-08-14): every `api.xxx()` call below is now
55
+ * `port.xxx()` — see `users-port.hooks.ts` for the interface and `users-dependencies.hooks.ts` for
56
+ * the real binding, the only file left that imports `lib/api` as a value for this feature. `port` is
57
+ * captured once from `deps` and read directly inside the `useFetchQuery`/`useFetchMutation` closures
58
+ * below, which is safe without the ref-and-dep-array discipline the host admin guidance's "Two traps"
59
+ * section describes for a hand-rolled `useEffect`: TanStack's `useQuery`/`useMutation` (this file's
60
+ * actual I/O primitive, via `lib/fetch-query`) take a fresh `queryFn`/`mutationFn` closure every
61
+ * render by design and do not require referential stability to avoid a refetch loop, unlike a raw
62
+ * `useEffect([port])`. There is no dependency array in this file for `port` to be listed in wrongly.
63
+ */
5
64
  export interface UsersController {
65
+ /** `null` until the initial load settles — the caller renders a loading state. Loaded together
66
+ * with `roles`/`policies` via `Promise.all`, so all three settle on the same render. */
6
67
  users: AdminIdentityUser[] | null;
68
+ /** `[]` (with `canGrant` false) when the caller may not read roles/policies — see {@link canGrant}. */
7
69
  roles: AdminRole[] | null;
8
70
  policies: AdminPolicy[] | null;
71
+ /**
72
+ * Whether the caller may read (and so grant) roles and policies. The roster route admits
73
+ * `user.manage` OR `member.manage`, but `/roles` and `/policies` require `role.manage`; a 403 from
74
+ * those two hides the grant controls instead of failing the whole screen, so the roster the
75
+ * operator CAN read still shows.
76
+ */
77
+ canGrant: boolean;
9
78
  error: string | null;
79
+ /** Whether the "New user" form is expanded. */
10
80
  formOpen: boolean;
11
81
  setFormOpen: Dispatch<SetStateAction<boolean>>;
12
82
  username: string;
@@ -18,6 +88,7 @@ export interface UsersController {
18
88
  saving: boolean;
19
89
  formError: string | null;
20
90
  onCreate: (e: FormEvent) => Promise<void>;
91
+ /** The row whose "Manage" panel is expanded — `null` when every row is collapsed. */
21
92
  expandedId: string | null;
22
93
  toggleExpanded: (user: AdminIdentityUser) => void;
23
94
  pendingRoleId: string;
@@ -32,20 +103,30 @@ export interface UsersController {
32
103
  onAttachPolicy: (required: {
33
104
  principalId: string;
34
105
  }) => Promise<void>;
106
+ /** The "Manage" panel's own email field — seeded from `user.email` when the panel opens. */
35
107
  editEmail: string;
36
108
  setEditEmail: Dispatch<SetStateAction<string>>;
37
109
  emailSaving: boolean;
38
110
  onSaveEmail: (required: {
39
111
  principalId: string;
40
112
  }) => Promise<void>;
113
+ /** In-flight Disable/Enable request, keyed by `principalId` — one at a time. */
41
114
  toggleSavingId: string | null;
42
115
  toggleError: string | null;
43
116
  notice: string | null;
117
+ /** The user a `RowMenu` "Disable" selection is asking to confirm; `null` when the dialog is
118
+ * shut. `ConfirmDialog` stays mounted unconditionally in the view (see its own doc comment on
119
+ * why); this is what drives its `open` prop. */
44
120
  confirmingDisable: AdminIdentityUser | null;
45
121
  setConfirmingDisable: Dispatch<SetStateAction<AdminIdentityUser | null>>;
122
+ /** Opens the Disable confirm dialog for `user` — the `RowMenu` "Disable" item's `onSelect` when
123
+ * the user is currently active (Enable, by contrast, fires immediately via `onToggleStatus`). */
46
124
  requestDisable: (user: AdminIdentityUser) => void;
47
125
  confirmDisable: () => Promise<void>;
48
126
  onToggleStatus: (user: AdminIdentityUser) => Promise<void>;
127
+ /** The user a `RowMenu` "Reset password" selection is asking about; `null` when the dialog is
128
+ * shut. Kept open on failure (unlike the Disable dialog, which closes either way) so a failed
129
+ * attempt doesn't discard the password the operator just typed. */
49
130
  resetPasswordFor: AdminIdentityUser | null;
50
131
  setResetPasswordFor: Dispatch<SetStateAction<AdminIdentityUser | null>>;
51
132
  newPassword: string;
@@ -55,15 +136,48 @@ export interface UsersController {
55
136
  setPasswordError: Dispatch<SetStateAction<string | null>>;
56
137
  openResetPassword: (user: AdminIdentityUser) => void;
57
138
  confirmResetPassword: () => Promise<void>;
139
+ /** Delete-user plan v2 (2026-09-24), Slice 4: whether the SIGNED-IN caller may trash users at all
140
+ * — from `port.me()`'s `canManageUserTrash`, resolved by the same one-shot effect that already
141
+ * fetches `ownPrincipalId`. `false` until that call settles (or forever if it fails), the same
142
+ * "unknown defaults to no affordance" convention `ownPrincipalId`'s doc comment describes. Drives
143
+ * `rules.ts`'s `userRowMenuItems` `canDelete` param — see that function's own doc comment for why
144
+ * the real boundary is server-side, not this flag. */
58
145
  canManageUserTrash: boolean;
146
+ rowCapabilities: (user: AdminIdentityUser) => UserAccountCapabilities;
147
+ ownProfile?: AdminIdentityUser | null;
148
+ canRenameUser?: ((user: AdminIdentityUser) => boolean) | undefined;
149
+ canRenameOwnProfile?: boolean | undefined;
150
+ openOwnRename?: (() => void) | undefined;
151
+ renameUserHandler?: ((required: {
152
+ user: AdminIdentityUser;
153
+ }) => ((user: AdminIdentityUser) => void) | undefined) | undefined;
154
+ changeUsername?: ReturnType<typeof useChangeUsername> | undefined;
155
+ /** The user a `RowMenu` "Delete" selection is asking to confirm; `null` when the dialog is shut.
156
+ * Mirrors `confirmingDisable` exactly (own state, own request/confirm pair) rather than sharing
157
+ * it — Delete and Disable are two independent confirms an operator could otherwise not tell apart
158
+ * if they shared one slot. */
59
159
  confirmingDelete: AdminIdentityUser | null;
60
160
  setConfirmingDelete: Dispatch<SetStateAction<AdminIdentityUser | null>>;
161
+ /** Opens the Delete confirm dialog for `user` — the `RowMenu` "Delete" item's `onSelect`. Never
162
+ * fires immediately (see `rules.ts`'s `UserRowMenuHandlers.onRequestDelete` doc comment). */
61
163
  requestDelete: (user: AdminIdentityUser) => void;
164
+ /** In flight while the delete mutation is pending — drives the confirm dialog's `pending` prop,
165
+ * same role `toggleSavingId` plays for the Disable dialog. */
62
166
  deleteSaving: boolean;
63
167
  confirmDelete: () => Promise<void>;
168
+ /** Bound translator — `key` already resolved against the caller's locale, so `Users.tsx` never
169
+ * imports `useAdminLocale`/`users-i18n` itself. See this file's header. */
64
170
  t: (key: string) => string;
171
+ /** Host-bound translation also resolves row menus without a package locale dependency. */
65
172
  translate: Translate;
66
173
  }
174
+ /** What `useUsers` needs injected from outside — see this file's header for the conversion note.
175
+ *
176
+ * `openOwnPasswordReset`/`navigate` (password-banner plan, 2026-09-24 Slice 3): the dashboard nag's
177
+ * "Change password" link deep-links to `/admin/users/change-password`, which `panels.tsx` turns
178
+ * into this flag. Both are optional so every existing `useUsers({ port })` call (this file's own
179
+ * tests, `Users.tsx`'s default) keeps working unchanged — the deep link is additive behavior, not a
180
+ * new required collaborator. */
67
181
  export interface UsersDependencies {
68
182
  port: UsersPort;
69
183
  translate: Translate;
@@ -72,6 +186,18 @@ export interface UsersDependencies {
72
186
  export interface UsersOptions {
73
187
  openOwnPasswordReset?: boolean | undefined;
74
188
  onOwnPasswordResetClosed?: (() => void) | undefined;
189
+ /** Host formatting keeps dynamic username templates in the host's dictionary. */
190
+ passwordResetNotice?: ((required: {
191
+ username: string;
192
+ }) => string) | undefined;
75
193
  }
76
- export declare function useUsers({ port, translate, queryScope }: UsersDependencies, { openOwnPasswordReset, onOwnPasswordResetClosed }?: UsersOptions): UsersController;
194
+ /**
195
+ * Everything the Users screen does — full state, effects, and every server write, as one hook so
196
+ * `Users.tsx` stays a pure render of whatever this returns. See this file's header for the
197
+ * `useFetchQuery`/`useFetchMutation` migration and the `port` injection it now also carries.
198
+ *
199
+ * @param deps - Injected collaborators; production callers get these from {@link useWiredUsers}.
200
+ * @returns The full `UsersController` the view renders from — see that interface for every field.
201
+ */
202
+ export declare function useUsers({ port, translate, queryScope }: UsersDependencies, { openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice, }?: UsersOptions): UsersController;
77
203
  //# sourceMappingURL=use-users.hooks.d.ts.map
@@ -1,9 +1,32 @@
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
+ import { useChangeUsername } from "./use-change-username.hooks.js";
7
+ /** The shape `onAssignRole`/`onAttachPolicy` both repeat: set the shared `grantSaving`/`grantError`
8
+ * pair, run one mutation, do a success-only side effect, and clear saving in a `finally` — the
9
+ * "whole-hook" complexity view (brief §2) rolls every closure inside a hook into one score, so two
10
+ * near-identical blocks count against `useUsers` even though `grantError`/`grantSaving` are
11
+ * deliberately NOT a fit for `useAsyncAction` (see that file's own header: shared across three
12
+ * handlers on purpose, not one action's own slot — `onSaveEmail` is the third, kept separate below
13
+ * since it tracks its OWN `emailSaving`). Reproducing the shared-state shape as a local top-level
14
+ * helper — rather than importing the generic primitive — collapses the two call sites without
15
+ * forcing that mismatch onto them. `mutate` itself `invalidates: [KEYS.list]`, replacing the
16
+ * pre-migration `reload()` call this helper used to make explicitly.
17
+ *
18
+ * `onSuccess` (2026-09-05 fix, same bug class as `use-roles.hooks.ts`'s `onSaveRole`/
19
+ * `onWritePermission`): nothing gates opening a DIFFERENT user's Manage panel
20
+ * (`toggleExpanded`) while a grant for the previously expanded one is still in flight, and
21
+ * `onSuccess` clears `pendingRoleId`/`pendingPolicyId` — a shared field, not keyed by principal.
22
+ * `toggleGenerationRef` (bumped once per `toggleExpanded` call, regardless of which panel — see
23
+ * that function) lets a stale grant tell whether the operator switched panels at ALL while it was
24
+ * in flight, independent of which principal is currently expanded: comparing against the panel's
25
+ * OWN identity would wrongly refuse a grant issued before any panel was ever expanded (a
26
+ * legitimate, already-certified call shape), where `expandedId` never gets set to begin with.
27
+ * `onSuccess` is skipped only when a switch actually happened since this call started — otherwise
28
+ * a stale grant's success would erase a NEW, unrelated selection the operator has since made for
29
+ * the panel they actually have open now. */
7
30
  async function runGrantMutation(generationAtStart, toggleGenerationRef, mutate, onSuccess, setGrantSaving, setGrantError, describeError) {
8
31
  setGrantSaving(true);
9
32
  setGrantError(null);
@@ -13,30 +36,69 @@ async function runGrantMutation(generationAtStart, toggleGenerationRef, mutate,
13
36
  onSuccess();
14
37
  }
15
38
  catch (e) {
39
+ // Same generation check as the success branch above (C8, plan-access.md §8, N3): a stale
40
+ // failure must not paint onto a DIFFERENT user's panel the operator has since opened.
16
41
  if (toggleGenerationRef.current === generationAtStart)
17
42
  setGrantError(describeError(e));
18
43
  }
19
44
  finally {
45
+ // Guarded the same way — a stale call's `finally` must not clear a NEWER call's own saving
46
+ // flag. `toggleExpanded` resets `grantSaving` itself when it bumps the generation, so a panel
47
+ // switch still leaves the new panel un-stuck even though this skip means THIS call no longer
48
+ // clears it.
20
49
  if (toggleGenerationRef.current === generationAtStart)
21
50
  setGrantSaving(false);
22
51
  }
23
52
  }
24
- export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset = false, onOwnPasswordResetClosed } = {}) {
25
- const describeApiError = (error, fallback) => describeFeatureError({ error, fallback, translate });
26
- const t = translate;
53
+ /**
54
+ * Roles and policies for the grant controls, or `null` when the server refuses them with 403 (the
55
+ * caller lacks `role.manage`). Any other failure still rejects, so a real outage keeps surfacing as
56
+ * the screen's load error.
57
+ *
58
+ * @complexity O(1) requests (two, concurrent).
59
+ */
60
+ async function loadGrantOptions(port) {
61
+ try {
62
+ const [r, p] = await Promise.all([port.listRoles({}), port.listPolicies({})]);
63
+ return { roles: r.roles, policies: p.policies };
64
+ }
65
+ catch (e) {
66
+ if (e && typeof e === "object" && "status" in e && e.status === 403)
67
+ return null;
68
+ throw e;
69
+ }
70
+ }
71
+ /**
72
+ * Everything the Users screen does — full state, effects, and every server write, as one hook so
73
+ * `Users.tsx` stays a pure render of whatever this returns. See this file's header for the
74
+ * `useFetchQuery`/`useFetchMutation` migration and the `port` injection it now also carries.
75
+ *
76
+ * @param deps - Injected collaborators; production callers get these from {@link useWiredUsers}.
77
+ * @returns The full `UsersController` the view renders from — see that interface for every field.
78
+ */
79
+ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset = false, onOwnPasswordResetClosed, passwordResetNotice, } = {}) {
27
80
  const boundT = translate;
81
+ const describeError = (error, fallback) => describeApiError({ error, fallback, translate });
82
+ // `portRef` (password-banner plan, 2026-09-24 Slice 3 deep-link half): captured once at mount and
83
+ // never written again, same discipline and same reasoning as `use-assistant-chats.hooks.ts`'s own
84
+ // `portRef` — the one-shot `me()` effect below must not spin if a caller builds a fresh port object
85
+ // per render (the host admin guidance's "Two traps" section). Every OTHER `port.xxx()` call in this
86
+ // file stays a direct closure read, unaffected — this ref exists only for the raw `useEffect` below,
87
+ // which is not a `useFetchQuery`/`useFetchMutation` closure and so does not get that discipline for
88
+ // free.
28
89
  const portRef = useRef(port);
29
90
  const list = useFetchQuery({
30
91
  key: [queryScope, ...KEYS.list],
31
92
  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 };
93
+ const [u, grantOptions] = await Promise.all([port.listUsers({}), loadGrantOptions(port)]);
94
+ return { users: u.users, grantOptions };
34
95
  },
35
96
  });
36
97
  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;
98
+ const roles = list.data ? (list.data.grantOptions?.roles ?? []) : null;
99
+ const policies = list.data ? (list.data.grantOptions?.policies ?? []) : null;
100
+ const canGrant = list.data?.grantOptions != null;
101
+ const error = list.error ? describeError(list.error, translate("failed to load users")) : null;
40
102
  const [formOpen, setFormOpen] = useState(false);
41
103
  const [username, setUsername] = useState("");
42
104
  const [email, setEmail] = useState("");
@@ -48,6 +110,10 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
48
110
  invalidates: [[queryScope, ...KEYS.list]],
49
111
  });
50
112
  const [expandedId, setExpandedId] = useState(null);
113
+ // Bumped once per `toggleExpanded` call, regardless of which panel — `runGrantMutation` (a
114
+ // top-level function, outside this closure's re-render cycle) reads this to tell whether the
115
+ // operator switched panels AT ALL while its own grant was in flight. See that function's own doc
116
+ // comment for why this counts switches rather than comparing against `expandedId`'s value.
51
117
  const toggleGenerationRef = useRef(0);
52
118
  const [pendingRoleId, setPendingRoleId] = useState("");
53
119
  const [pendingPolicyId, setPendingPolicyId] = useState("");
@@ -77,11 +143,31 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
77
143
  }, {
78
144
  invalidates: [[queryScope, ...KEYS.list]],
79
145
  });
146
+ // Disable now confirms via a `RowMenu` item -> `ConfirmDialog` modal (replacing the in-place
147
+ // two-click `ConfirmButton`, which has no menu-item equivalent — same migration Posts.tsx/
148
+ // Redirects.tsx already made). `null` when the dialog is closed.
80
149
  const [confirmingDisable, setConfirmingDisable] = useState(null);
150
+ // Reset password moved out of the expanded "Manage" panel into its own `RowMenu` item, which
151
+ // opens this dialog (it needs a text field, so it's a `ConfirmDialog` with an input in the body,
152
+ // not a plain confirm). Kept open on failure (unlike the delete-style dialogs above, which close
153
+ // either way) so a failed attempt doesn't discard the password the operator just typed.
81
154
  const [resetPasswordFor, setResetPasswordFor] = useState(null);
82
155
  const [newPassword, setNewPassword] = useState("");
83
156
  const resetPassword = useAsyncAction();
157
+ /** The signed-in caller's own principal id (password-banner plan, 2026-09-24 Slice 3), from
158
+ * `port.me()`. `null` until that call settles, or forever if it fails — both are treated as
159
+ * "unknown", which the deep-link effect below and `confirmResetPassword`'s notice branch both
160
+ * already handle as "not a self-reset".
161
+ *
162
+ * Fetched unconditionally (not gated on `openOwnPasswordReset`) — a deliberate choice, not an
163
+ * oversight: it makes the "sign in again" notice below correct for ANY self-reset, including an
164
+ * admin resetting their OWN row from the ordinary row-menu (no deep link involved), not just the
165
+ * one opened via `/users/change-password`. One extra `me()` call per screen load is cheaper than
166
+ * threading two different notice code paths for what is, to the operator, the same event. */
84
167
  const [ownPrincipalId, setOwnPrincipalId] = useState(null);
168
+ const [effectivePermissions, setEffectivePermissions] = useState([]);
169
+ // `canManageUserTrash` (delete-user plan v2, Slice 4): resolved by this SAME one-shot `me()` call
170
+ // rather than a second request — see `UsersController.canManageUserTrash`'s own doc comment.
85
171
  const [canManageUserTrash, setCanManageUserTrash] = useState(false);
86
172
  useEffect(() => {
87
173
  let cancelled = false;
@@ -90,16 +176,59 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
90
176
  .then((res) => {
91
177
  if (!cancelled) {
92
178
  setOwnPrincipalId(res.user.id);
179
+ setEffectivePermissions(res.effectivePermissions ?? []);
93
180
  setCanManageUserTrash(res.canManageUserTrash);
94
181
  }
95
182
  })
96
183
  .catch(() => {
184
+ // Best-effort, see `ownPrincipalId`'s own doc comment above — a failure just means the
185
+ // deep link can't auto-open and self-resets fall back to the per-username notice.
186
+ // `canManageUserTrash` stays `false`, the same "unknown means no affordance" default.
97
187
  });
98
188
  return () => {
99
189
  cancelled = true;
100
190
  };
101
191
  }, []);
192
+ /** Guards the one-shot deep-link auto-open effect below so it fires at most once per mount, even
193
+ * as `users`/`ownPrincipalId` keep changing identity across the renders it's waiting on (list
194
+ * reloads, `me()` settling). Separate from {@link openedViaDeepLinkRef}, which tracks a DIFFERENT
195
+ * question ("should closing the dialog navigate away") over the dialog's own open/close lifetime
196
+ * — this one is "has the auto-open already been attempted", which must stay true forever once it
197
+ * fires, including after the dialog this effect opened has since been closed. */
198
+ function rowCapabilities(user) {
199
+ const roleMap = new Map((roles ?? []).map(role => [role.id, role]));
200
+ const ownRow = users?.find(row => row.principalId === ownPrincipalId);
201
+ return userAccountCapabilities({ user, callerPrincipalId: ownPrincipalId,
202
+ callerIsOwner: ownRow ? isOwnerAccount({ user: ownRow, roles: roleMap }) : false,
203
+ canManageUserTrash, roles: roleMap });
204
+ }
205
+ const ownProfile = users?.find(user => user.principalId === ownPrincipalId) ?? null;
206
+ function canRenameUser(user) {
207
+ if (user.canRename === false)
208
+ return false;
209
+ if (!ownPrincipalId || (!effectivePermissions.includes("*") && !effectivePermissions.includes("user.manage")))
210
+ return false;
211
+ const roleMap = new Map((roles ?? []).map(role => [role.id, role]));
212
+ const callerIsOwner = ownProfile ? isOwnerAccount({ user: ownProfile, roles: roleMap }) : false;
213
+ // Server-supplied owner flags remain authoritative when role.manage is unavailable.
214
+ const targetIsOwner = user.isOwner ?? (user.roleIds.some(id => !roleMap.has(id)) || isOwnerAccount({ user, roles: roleMap }));
215
+ return !targetIsOwner || callerIsOwner;
216
+ }
217
+ const changeUsername = useChangeUsername({ port, queryScope, translate, canRename: canRenameUser });
218
+ const canRenameOwnProfile = ownProfile !== null && canRenameUser(ownProfile);
219
+ /** Profile and row actions enter the same guarded dialog; the view only renders affordances. */
220
+ function openOwnRename() {
221
+ if (ownProfile)
222
+ changeUsername.open({ user: ownProfile });
223
+ }
224
+ function renameUserHandler({ user }, _options = {}) {
225
+ return canRenameUser(user) ? (target) => changeUsername.open({ user: target }) : undefined;
226
+ }
102
227
  const deepLinkAttemptedRef = useRef(false);
228
+ /** Set true exactly when this effect opens the dialog, cleared by the close-effect below the first
229
+ * time it observes `resetPasswordFor` go back to `null`. Read there to decide whether to navigate
230
+ * — an operator resetting a DIFFERENT row's password from the ordinary row menu, or cancelling a
231
+ * normal reset, must not also get shoved to `/users`. */
103
232
  const openedViaDeepLinkRef = useRef(false);
104
233
  useEffect(() => {
105
234
  if (!openOwnPasswordReset || deepLinkAttemptedRef.current)
@@ -119,6 +248,8 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
119
248
  openedViaDeepLinkRef.current = false;
120
249
  onOwnPasswordResetClosed?.();
121
250
  }, [resetPasswordFor, onOwnPasswordResetClosed]);
251
+ // No `invalidates` — matches the pre-migration `confirmResetPassword`, which never called
252
+ // `reload()` either (a password reset changes nothing the users table shows).
122
253
  const resetPasswordMutation = useFetchMutation({
123
254
  run: ({ input }) => port.resetUserPassword(input),
124
255
  });
@@ -130,10 +261,13 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
130
261
  setEmail("");
131
262
  setPassword("");
132
263
  setFormOpen(false);
133
- }, describeError: (e) => describeApiError(e, t("failed to create user")) });
264
+ }, describeError: (e) => describeError(e, translate("failed to create user")) });
134
265
  }
135
266
  function toggleExpanded(user) {
136
267
  setGrantError(null);
268
+ // A still-in-flight grant for the panel being LEFT can no longer clear this itself
269
+ // (`runGrantMutation`'s `finally` now skips a stale call's own generation) — reset it here so
270
+ // the newly opened panel never inherits a stuck "saving" indicator from the old one.
137
271
  setGrantSaving(false);
138
272
  setPendingRoleId("");
139
273
  setPendingPolicyId("");
@@ -144,24 +278,35 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
144
278
  async function onAssignRole({ principalId }) {
145
279
  if (!pendingRoleId)
146
280
  return;
147
- await runGrantMutation(toggleGenerationRef.current, toggleGenerationRef, () => assignRoleMutation.mutate({ input: { principalId, roleId: pendingRoleId } }), () => setPendingRoleId(""), setGrantSaving, setGrantError, (e) => describeApiError(e, t("failed to assign role")));
281
+ await runGrantMutation(toggleGenerationRef.current, toggleGenerationRef, () => assignRoleMutation.mutate({ input: { principalId, roleId: pendingRoleId } }), () => setPendingRoleId(""), setGrantSaving, setGrantError, (e) => describeError(e, translate("failed to assign role")));
148
282
  }
149
283
  async function onAttachPolicy({ principalId }) {
150
284
  if (!pendingPolicyId)
151
285
  return;
152
- await runGrantMutation(toggleGenerationRef.current, toggleGenerationRef, () => attachPolicyMutation.mutate({ input: { principalId, policyId: pendingPolicyId } }), () => setPendingPolicyId(""), setGrantSaving, setGrantError, (e) => describeApiError(e, t("failed to attach policy")));
286
+ await runGrantMutation(toggleGenerationRef.current, toggleGenerationRef, () => attachPolicyMutation.mutate({ input: { principalId, policyId: pendingPolicyId } }), () => setPendingPolicyId(""), setGrantSaving, setGrantError, (e) => describeError(e, translate("failed to attach policy")));
153
287
  }
288
+ // `onSaveEmail` was left out of the `runGrantMutation` fold above on purpose: it tracks its OWN
289
+ // `emailSaving` flag rather than the shared `grantSaving` `onAssignRole`/`onAttachPolicy` use, so
290
+ // routing it through the same helper would mean passing a no-op in place of `setGrantSaving` —
291
+ // extraction for the sake of a shared call site, not a shared shape. Left hand-rolled; `emailSaving`
292
+ // now derives from `updateEmailMutation.status` since (unlike `grantSaving`) it was already this
293
+ // one mutation's own dedicated flag.
154
294
  async function onSaveEmail({ principalId }) {
155
295
  setGrantError(null);
156
296
  try {
157
297
  await updateEmailMutation.mutate({ input: { principalId, email: editEmail } });
158
298
  }
159
299
  catch (e) {
160
- setGrantError(describeApiError(e, t("failed to update email")));
300
+ setGrantError(describeError(e, translate("failed to update email")));
161
301
  }
162
302
  }
163
303
  const emailSaving = updateEmailMutation.status === "pending";
304
+ /** Opens the reset-password dialog for `user` — the `RowMenu` item's `onSelect`. Guards against
305
+ * opening a second one while a toggle or a previous reset is still in flight, same discipline
306
+ * Redirects.tsx uses for its own `RowMenu` items (no per-item `disabled` on `RowMenu` itself). */
164
307
  function openResetPassword(user) {
308
+ if (!rowCapabilities(user).canResetPassword)
309
+ return;
165
310
  if (toggleSavingId || resetPassword.saving)
166
311
  return;
167
312
  resetPassword.setError(null);
@@ -169,34 +314,69 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
169
314
  setResetPasswordFor(user);
170
315
  }
171
316
  async function confirmResetPassword() {
172
- if (!resetPasswordFor || !newPassword)
317
+ if (!resetPasswordFor || !newPassword || !rowCapabilities(resetPasswordFor).canResetPassword)
173
318
  return;
319
+ // Dialog stays open on failure (unlike the Disable/Delete-style dialogs elsewhere in this app,
320
+ // which close either way) — closing would discard the password the operator just typed for no
321
+ // reason; there's nothing sensitive left on screen once they retry or cancel. `resetPasswordFor`/
322
+ // `newPassword` are therefore only cleared in the success path below, never as a `finally`.
174
323
  await resetPassword.run({ action: async () => {
175
324
  await resetPasswordMutation.mutate({ input: { principalId: resetPasswordFor.principalId, password: newPassword } });
325
+ // A self-reset (own row, deep-linked or from the ordinary row menu — see `ownPrincipalId`'s
326
+ // own doc comment) gets the "sign in again" notice instead of the per-username one: the reset
327
+ // just revoked the CALLER'S OWN session, so the next request 401s and `App.hooks.tsx` sends
328
+ // them to the login screen — the generic notice naming a DIFFERENT user would be misleading
329
+ // here, since it's actually their own sign-in that just ended.
176
330
  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 } }));
331
+ ? translate("Password changed. Sign in again with your new password.")
332
+ : passwordResetNotice?.({ username: resetPasswordFor.username }) ?? interpolate({
333
+ template: translate('Password reset for "{username}" — every active session for this user was revoked.'),
334
+ vars: { username: resetPasswordFor.username },
335
+ }));
179
336
  setResetPasswordFor(null);
180
337
  setNewPassword("");
181
- }, describeError: (e) => describeApiError(e, t("failed to reset password")) });
338
+ }, describeError: (e) => describeError(e, translate("failed to reset password")) });
182
339
  }
340
+ /** `setToggleSavingId`'s `finally` reset (2026-09-05 fix, same bug class as
341
+ * `use-roles.hooks.ts`'s `onSaveRole`/`runRowDelete`) is a functional update keyed on THIS call's
342
+ * own `principalId`: Enable fires with no confirmation gate, so two calls — on different users —
343
+ * can genuinely overlap, and an unconditional reset would clear the busy indicator (which
344
+ * `openResetPassword` below reads to refuse opening while a toggle is in flight) out from under a
345
+ * still-in-flight, unrelated toggle. */
183
346
  async function onToggleStatus(user) {
347
+ const capabilities = rowCapabilities(user);
348
+ if (user.status === "active" ? !capabilities.canDisable : !capabilities.canEnable)
349
+ return;
184
350
  setToggleSavingId(user.principalId);
185
351
  setToggleError(null);
186
352
  try {
187
353
  await toggleStatusMutation.mutate({ input: user });
188
354
  }
189
355
  catch (e) {
190
- setToggleError(describeApiError(e, t("failed to change status")));
356
+ setToggleError(describeError(e, translate("failed to change status")));
191
357
  }
192
358
  finally {
193
359
  setToggleSavingId((current) => (current === user.principalId ? null : current));
194
360
  }
195
361
  }
362
+ /** Opens the Disable confirm dialog for `user` — the `RowMenu` "Disable" item's `onSelect` when
363
+ * the user is currently active. Was an inline closure inside the old `rowMenuItems`; named here
364
+ * now that the item builder itself moved to `rules.ts` and needs a callback to hand it. */
196
365
  function requestDisable(user) {
366
+ if (!rowCapabilities(user).canDisable)
367
+ return;
197
368
  setToggleError(null);
198
369
  setConfirmingDisable(user);
199
370
  }
371
+ /** Confirms the Disable that `RowMenu`'s "Disable" item asked about. Closes the dialog either
372
+ * way (matching Posts.tsx/Redirects.tsx's own Disable/Delete `ConfirmDialog` convention) — a
373
+ * failure surfaces via `toggleError` above the table, not by leaving the modal open.
374
+ *
375
+ * 2026-09-05 fix, same bug class as `use-roles.hooks.ts`'s `runRowDelete`: `setConfirmingDisable`
376
+ * is exposed directly on the controller, so nothing at the hook level stops the operator opening
377
+ * a DIFFERENT user's Disable confirmation while this one's toggle is still in flight. The close
378
+ * below only clears it when it still names the SAME user this call started for — otherwise a
379
+ * stale toggle's settlement would silently dismiss a newer, still-undecided confirmation. */
200
380
  async function confirmDisable() {
201
381
  if (!confirmingDisable)
202
382
  return;
@@ -204,25 +384,37 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
204
384
  await onToggleStatus(user);
205
385
  setConfirmingDisable((current) => (current?.principalId === user.principalId ? null : current));
206
386
  }
387
+ // Delete-user plan v2 (2026-09-24), Slice 4 — mirrors Disable's request/confirm pair above with
388
+ // its OWN state (see `confirmingDelete`'s own doc comment for why not shared). `deleteUserMutation`
389
+ // invalidates `KEYS.list` like every other write on this screen; the route already filters a newly
390
+ // trashed principal out of `listUsers()`, so the row disappears on the next render with no extra
391
+ // client-side bookkeeping.
207
392
  const deleteUserMutation = useFetchMutation({
208
- run: ({ input: principalId }) => port.deleteUser({ principalId: principalId })
393
+ run: ({ input: principalId }) => port.deleteUser({ principalId })
209
394
  }, {
210
395
  invalidates: [[queryScope, ...KEYS.list]],
211
396
  });
212
397
  const [confirmingDelete, setConfirmingDelete] = useState(null);
213
398
  function requestDelete(user) {
399
+ if (!rowCapabilities(user).canDelete)
400
+ return;
214
401
  setToggleError(null);
215
402
  setConfirmingDelete(user);
216
403
  }
404
+ /** Confirms the Delete that `RowMenu`'s "Delete" item asked about. Closes the dialog either way
405
+ * (matching `confirmDisable`'s and Posts.tsx/Redirects.tsx's own Delete `ConfirmDialog`
406
+ * convention) — a failure surfaces via `toggleError` above the table, not by leaving the modal
407
+ * open. Same stale-call guard as `confirmDisable`: only clears `confirmingDelete` when it still
408
+ * names the SAME user this call started for. */
217
409
  async function confirmDelete() {
218
- if (!confirmingDelete)
410
+ if (!confirmingDelete || !rowCapabilities(confirmingDelete).canDelete)
219
411
  return;
220
412
  const user = confirmingDelete;
221
413
  try {
222
414
  await deleteUserMutation.mutate({ input: user.principalId });
223
415
  }
224
416
  catch (e) {
225
- setToggleError(describeApiError(e, t("failed to delete user")));
417
+ setToggleError(describeError(e, translate("failed to delete user")));
226
418
  }
227
419
  setConfirmingDelete((current) => (current?.principalId === user.principalId ? null : current));
228
420
  }
@@ -230,6 +422,7 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
230
422
  users,
231
423
  roles,
232
424
  policies,
425
+ canGrant,
233
426
  error,
234
427
  formOpen,
235
428
  setFormOpen,
@@ -274,6 +467,13 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
274
467
  openResetPassword,
275
468
  confirmResetPassword,
276
469
  canManageUserTrash,
470
+ rowCapabilities,
471
+ ownProfile,
472
+ canRenameUser,
473
+ canRenameOwnProfile,
474
+ openOwnRename,
475
+ renameUserHandler,
476
+ changeUsername,
277
477
  confirmingDelete,
278
478
  setConfirmingDelete,
279
479
  requestDelete,