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