@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,26 +1,100 @@
1
- import type { Translate } from "@jini-ai/ui/panel-kit";
2
1
  import { type Dispatch, type SetStateAction } from "react";
3
2
  import type { AdminIdentityUser, AdminPolicy, AdminRole } from "../models.js";
3
+ import { type UserAccountCapabilities } from "./rules.js";
4
4
  import { useUsers, type UsersDependencies, type UsersOptions } from "./hooks/use-users.hooks.js";
5
+ import type { ReactNode } from "react";
6
+ import type { Translate } from "@jini-ai/ui/panel-kit";
7
+ /**
8
+ * @file Admin "Users" screen (SPEC-006 §3 human grant-writing transitions + 0.6.0 CRUD-completion
9
+ * amendment) — markup only.
10
+ *
11
+ * State and API calls live in `hooks/use-users.hooks.ts`; the row-menu logic and the
12
+ * server-error-message overrides live in `rules.ts`. What stays here is what actually renders: the
13
+ * form, the table, and the two dialogs.
14
+ *
15
+ * Mirrors `features/integrations/Integrations.tsx`'s fetch/loading/error/form/table shape.
16
+ * Lists operator users, creates new ones (`CREATE_USER`), and grants roles/
17
+ * policies to an existing user (`ASSIGN_ROLE`/`ATTACH_POLICY`) via an
18
+ * inline expandable "Manage" row. Role/policy *creation* is out of
19
+ * this screen's UI (no `CREATE_ROLE`/`CREATE_POLICY` form here) — the API
20
+ * client exposes `api.createRole`/`api.createPolicy` for the Roles &
21
+ * Permissions screen to reuse.
22
+ *
23
+ * 0.6.0: the same expandable row also carries `UPDATE_USER` (email),
24
+ * `RESET_USER_PASSWORD`, and a quick `DISABLE_PRINCIPAL`/`ENABLE_PRINCIPAL` toggle.
25
+ *
26
+ * Row actions (audit follow-up): all three of the above — Disable/Enable, Manage (opens the
27
+ * expandable row), Reset password — now live behind a single three-dot `RowMenu`, matching
28
+ * `Posts.tsx`/`Pages.tsx`'s row-action shape rather than a row of separate buttons.
29
+ *
30
+ * Delete-user plan v2 (2026-09-24): a fourth "Delete" item joins the menu, shown only when the
31
+ * signed-in caller may manage the user Trash (owner or the built-in `admin` role — OWNER DECISION
32
+ * 2026-09-24, `use-users.hooks.ts`'s `canManageUserTrash`). It moves the target to the Trash
33
+ * (disabled, sessions revoked, restorable there for 60 days) rather than hard-deleting it — see
34
+ * `UserDeleteDialog`'s own doc comment.
35
+ *
36
+ * Complexity-ceiling pass (2026-08-06): `Users` and its row-map closure both scored over the
37
+ * ceiling (19/16 and 11/11 respectively). Split into four top-level, individually-testable
38
+ * functions below — `NewUserForm`, `UsersTable`/`UserRow`, `UserDisableDialog`/`UserDeleteDialog`,
39
+ * `UserResetPasswordDialog` — following `Taxonomy.tsx`'s existing convention of sibling top-level
40
+ * function components in the same file rather than nested closures (a nested `const` inside
41
+ * `Users` would not have moved any branching out of `Users`' own scope). `Users` itself is now a
42
+ * thin composition of those, none of them exported, so no test does anything `Users.tsx`
43
+ * didn't already support — see `__tests__/Users.unit.test.tsx`/`Users.crud.unit.test.tsx` for the
44
+ * full-render tests these were extracted underneath (unchanged), plus new
45
+ * `__tests__/users-components.unit.test.tsx` for direct component-level tests of each piece.
46
+ *
47
+ * Reset-password confirm + reveal (typo-catching pass): `UserResetPasswordDialog` gained a second
48
+ * field ("Confirm new password") and an independent show/hide toggle per field, both driven by the
49
+ * new `hooks/use-reset-password-fields.hooks.ts` (confirm-field state and both reveal flags are
50
+ * purely local/ephemeral — never sent to the server, so they don't belong in `use-users.hooks.ts`'s
51
+ * screen-wide `UsersController`). The confirm-vs-server-error display conflict is resolved by giving
52
+ * the two states one shared slot with a fixed precedence — a live mismatch always wins over a stale
53
+ * `passwordError` from a previous attempt — rather than trying to show both at once or inventing a
54
+ * second error line; see `UserResetPasswordDialog`'s own body for the exact ternary. `RevealablePasswordField`
55
+ * is the shared field+toggle shape both new inputs use, following this file's own `GrantSelect`
56
+ * precedent for a shape used twice.
57
+ */
5
58
  export interface UsersProps extends UsersDependencies, UsersOptions {
6
59
  useUsersHook?: typeof useUsers | undefined;
7
- openOwnPasswordReset?: boolean | undefined;
60
+ /** The host owns localization of protocol status/role labels. */
61
+ renderServerLabel?: ((required: {
62
+ value: string;
63
+ }) => ReactNode) | undefined;
8
64
  }
65
+ /** The subset of `AdminRole`/`AdminPolicy` a grant `<select>` actually renders. Named rather than
66
+ * taking the full records, so the shared control below cannot come to depend on anything a role has
67
+ * and a policy does not. */
9
68
  export interface GrantOption {
10
69
  id: string;
11
70
  name: string;
12
71
  isBuiltin: boolean;
13
72
  }
73
+ /** One "pick something, then grant it to this user" control. Both grants on the Manage panel are
74
+ * this same shape, which is what lets them share {@link GrantSelect} instead of being two
75
+ * near-identical blocks whose props had to be threaded separately. */
14
76
  export interface GrantSelectController {
15
77
  options: GrantOption[];
78
+ /** The id currently selected in the `<select>`, or `""` for the placeholder row. */
16
79
  pendingId: string;
17
80
  setPendingId: Dispatch<SetStateAction<string>>;
18
81
  submit: (required: {
19
82
  principalId: string;
20
83
  }) => Promise<void>;
21
84
  }
85
+ /**
86
+ * Everything the expanded "Manage" panel needs, as one object.
87
+ *
88
+ * Replaces the fifteen loose state/setter/callback props this panel used to take and `UserRow` used
89
+ * to relay through an `Omit<UserManagePanelProps, "principalId">` rest spread. The grouping is by
90
+ * interaction, not by convenience: the email editor and the two grants are three independent
91
+ * controls that happen to share one error line and one in-flight flag, and saying so in the type is
92
+ * what lets a test build a Manage panel without also standing up a row, a table, and a page.
93
+ */
22
94
  export interface UserManageController {
95
+ /** The one error line the whole panel shares — any of the three writes can set it. */
23
96
  error: string | null;
97
+ /** Shared by both grants: the panel issues one grant at a time. */
24
98
  saving: boolean;
25
99
  email: {
26
100
  value: string;
@@ -30,43 +104,68 @@ export interface UserManageController {
30
104
  principalId: string;
31
105
  }) => Promise<void>;
32
106
  };
33
- roleGrant: GrantSelectController;
34
- policyGrant: GrantSelectController;
107
+ /** `null` when the caller may not read roles/policies (`UsersController.canGrant`): no grant controls. */
108
+ roleGrant: GrantSelectController | null;
109
+ policyGrant: GrantSelectController | null;
35
110
  }
36
111
  export interface UserManagePanelProps {
37
112
  principalId: string;
38
113
  manage: UserManageController;
39
114
  t: (key: string) => string;
115
+ /** The table's column count, so the panel row spans it exactly (fewer without Roles/Policies). */
116
+ colSpan?: number;
40
117
  }
41
- export declare function UserManagePanel({ principalId, manage, t }: UserManagePanelProps): import("react").JSX.Element;
118
+ /** The expanded "Manage" row's contents (email edit, role/policy grant forms).
119
+ *
120
+ * Exported so a test can drive it with a hand-built {@link UserManageController} — the seam only
121
+ * counts as one if something other than `Users` can supply it. */
122
+ export declare function UserManagePanel({ principalId, manage, t, colSpan }: UserManagePanelProps): import("react").JSX.Element;
123
+ /** A user row's own affordances — expand/collapse and the three menu actions. Distinct from
124
+ * {@link UserManageController}, which describes what the expanded panel does once it is open. */
42
125
  export interface UserRowActionsController {
43
126
  expandedId: string | null;
44
127
  toggleExpanded: (user: AdminIdentityUser) => void;
128
+ /** Which row has a status write in flight — drives that row's disabled menu item only. */
45
129
  savingId: string | null;
46
130
  requestDisable: (user: AdminIdentityUser) => void;
47
131
  toggleStatus: (user: AdminIdentityUser) => Promise<void>;
48
132
  openResetPassword: (user: AdminIdentityUser) => void;
133
+ /** Delete-user plan v2 (2026-09-24): whether the row's `RowMenu` should carry a "Delete" item at
134
+ * all — see `use-users.hooks.ts`'s `canManageUserTrash` doc comment for where this comes from. */
49
135
  canDelete: boolean;
136
+ rowCapabilities?: (user: AdminIdentityUser) => UserAccountCapabilities;
50
137
  requestDelete: (user: AdminIdentityUser) => void;
51
138
  }
52
139
  export interface UserRowProps {
53
140
  user: AdminIdentityUser;
54
141
  roleById: ReadonlyMap<string, AdminRole>;
55
142
  policyById: ReadonlyMap<string, AdminPolicy>;
143
+ /** Whether the Roles/Policies cells render — see {@link UsersTableProps.showGrants}. */
144
+ showGrants: boolean;
56
145
  actions: UserRowActionsController;
57
146
  manage: UserManageController;
147
+ /** This row's own distinct handle base — computed once, across every rendered row, by
148
+ * `UsersTable` (via `buildAgentListHandles`); see `Taxonomy.tsx`'s `NewTermForm.agentBase` for
149
+ * why a per-instance uniqueness search does not work here. */
58
150
  agentBase: string;
59
151
  t: (key: string) => string;
60
152
  translate: Translate;
153
+ renderServerLabel?: UsersProps["renderServerLabel"];
61
154
  }
62
155
  export interface UsersTableProps {
63
156
  users: AdminIdentityUser[];
157
+ /** The full records, for the Roles/Policies label columns. The grant selects get their own
158
+ * `options` through `manage`, so neither consumer has to reach into the other's props. */
64
159
  roles: AdminRole[];
65
160
  policies: AdminPolicy[];
161
+ /** `UsersController.canGrant`: without `role.manage` there are no names to resolve grant ids
162
+ * against, so the Roles/Policies columns are left out rather than showing raw ids. */
163
+ showGrants: boolean;
66
164
  actions: UserRowActionsController;
67
165
  manage: UserManageController;
68
166
  t: (key: string) => string;
69
167
  translate: Translate;
168
+ renderServerLabel?: UsersProps["renderServerLabel"];
70
169
  }
71
- export declare function Users({ useUsersHook, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed }: UsersProps): import("react").JSX.Element;
170
+ export declare function Users({ useUsersHook, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice, renderServerLabel }: UsersProps): import("react").JSX.Element;
72
171
  //# sourceMappingURL=Users.d.ts.map
@@ -3,103 +3,170 @@ import { Fragment } from "react";
3
3
  import { RowMenu, ConfirmDialog } from "@jini-ai/admin/react";
4
4
  import { agentHandle } from "@jini-ai/agentic";
5
5
  import { buildAgentListHandles } from "@jini-ai/ui/panel-kit";
6
- import { formatGrantLabel, userRowMenuItems } from "./rules.js";
6
+ import { formatGrantLabel, grantOptionLabel, userRowMenuItems, isOwnerAccount } from "./rules.js";
7
7
  import { useUsers } from "./hooks/use-users.hooks.js";
8
8
  import { InfoTip } from "@jini-ai/ui/admin-widgets";
9
9
  import { useResetPasswordFields } from "./hooks/use-reset-password-fields.hooks.js";
10
+ /** The "New user" form — extracted from `Users` verbatim; own scope for `formError`/`saving`. */
10
11
  function NewUserForm({ username, setUsername, email, setEmail, password, setPassword, saving, formError, onCreate, t }) {
11
- return (_jsxs("form", { onSubmit: onCreate, className: "notice integrations-form form-measure", children: [formError ? _jsx("span", { className: "save-error", children: formError }) : null, _jsxs("label", { children: [t("Username"), _jsx("input", { value: username, onChange: (e) => setUsername(e.target.value), required: true, ...agentHandle({ handle: "users-new-username" }, { role: "field", label: t("The new user's username") }) })] }), _jsxs("label", { children: [t("Email (optional)"), _jsx("input", { type: "email", value: email, onChange: (e) => setEmail(e.target.value), ...agentHandle({ handle: "users-new-email" }, { role: "field", label: t("The new user's email address") }) })] }), _jsxs("label", { children: [t("Password"), _jsx("input", { type: "password", autoComplete: "new-password", value: password, onChange: (e) => setPassword(e.target.value), required: true, minLength: 1, ...agentHandle({ handle: "users-new-password" }, {
12
+ return (_jsxs("form", { onSubmit: onCreate, className: "notice integrations-form form-measure", children: [formError ? _jsx("span", { className: "save-error", children: formError }) : null, _jsxs("label", { children: [t("Username"), _jsx("input", { value: username, onChange: (e) => setUsername(e.target.value), required: true, ...agentHandle({ handle: "users-new-username" }, { role: "field", label: "The new user's username" }) })] }), _jsxs("label", { children: [t("Email (optional)"), _jsx("input", { type: "email", value: email, onChange: (e) => setEmail(e.target.value), ...agentHandle({ handle: "users-new-email" }, { role: "field", label: "The new user's email address" }) })] }), _jsxs("label", { children: [t("Password"), _jsx("input", { type: "password", autoComplete: "new-password", value: password, onChange: (e) => setPassword(e.target.value), required: true, minLength: 1, ...agentHandle({ handle: "users-new-password" }, {
12
13
  role: "field",
13
- label: t("The new user's password — a credential field, so only a human can fill it"),
14
- }) })] }), _jsx("button", { type: "submit", disabled: saving, ...agentHandle({ handle: "users-new-submit" }, { role: "button", label: t("Create this operator account") }), children: saving ? t("Creating…") : t("Create user") })] }));
14
+ label: "The new user's password — a credential field, so only a human can fill it",
15
+ }) })] }), _jsx("button", { type: "submit", disabled: saving, ...agentHandle({ handle: "users-new-submit" }, { role: "button", label: "Create this operator account" }), children: saving ? t("Creating…") : t("Create user") })] }));
15
16
  }
17
+ /** The select-plus-button both grants render. */
16
18
  function GrantSelect({ principalId, label, placeholder, submitLabel, grant, saving, agentBase, t }) {
17
19
  return (_jsxs("label", { children: [label, _jsxs("span", { className: "editor-actions", children: [_jsxs("select", { value: grant.pendingId, onChange: (e) => grant.setPendingId(e.target.value), ...agentHandle({ handle: `${agentBase}-select` }, {
18
20
  role: "field",
19
- label: t(`${label} — set with page.select_option, not click`),
20
- }), children: [_jsx("option", { value: "", children: placeholder }), grant.options.map((option) => (_jsxs("option", { value: option.id, children: [option.name, option.isBuiltin ? ` ${t("(built-in)")}` : ""] }, option.id)))] }), _jsx("button", { type: "button", disabled: !grant.pendingId || saving, onClick: () => grant.submit({ principalId }), ...agentHandle({ handle: `${agentBase}-submit` }, { role: "button", label: t(`${submitLabel} the selected option to this user`) }), children: saving ? t("Saving…") : submitLabel })] })] }));
21
+ label: `${label} — set with page.select_option, not click`,
22
+ }), children: [_jsx("option", { value: "", children: placeholder }), grant.options.map((option) => (_jsxs("option", { value: option.id, children: [grantOptionLabel({ option, translate: t }), option.isBuiltin ? ` ${t("(built-in)")}` : ""] }, option.id)))] }), _jsx("button", { type: "button", disabled: !grant.pendingId || saving, onClick: () => grant.submit({ principalId }), ...agentHandle({ handle: `${agentBase}-submit` }, { role: "button", label: `${submitLabel} the selected option to this user` }), children: saving ? t("Saving…") : submitLabel })] })] }));
21
23
  }
22
- export function UserManagePanel({ principalId, manage, t }) {
23
- return (_jsx("tr", { children: _jsx("td", { colSpan: 6, children: _jsxs("div", { className: "notice integrations-form", children: [manage.error ? _jsx("span", { className: "save-error", children: manage.error }) : null, _jsxs("label", { children: [t("Email"), _jsxs("span", { className: "editor-actions", children: [_jsx("input", { type: "email", value: manage.email.value, onChange: (e) => manage.email.set(e.target.value), placeholder: t("(none)"), ...agentHandle({ handle: "user-manage-email" }, { role: "field", label: t("This user's email address") }) }), _jsx("button", { type: "button", disabled: manage.email.saving, onClick: () => manage.email.save({ principalId }), ...agentHandle({ handle: "user-manage-email-save" }, { role: "button", label: t("Save this user's email address") }), children: manage.email.saving ? t("Saving…") : t("Save email") })] })] }), _jsx(GrantSelect, { principalId: principalId, label: t("Assign role"), placeholder: t("Select a role…"), submitLabel: t("Assign"), grant: manage.roleGrant, saving: manage.saving, agentBase: "user-manage-role", t: t }), _jsx(GrantSelect, { principalId: principalId, label: t("Attach policy"), placeholder: t("Select a policy…"), submitLabel: t("Attach"), grant: manage.policyGrant, saving: manage.saving, agentBase: "user-manage-policy", t: t })] }) }) }));
24
+ /** The expanded "Manage" row's contents (email edit, role/policy grant forms).
25
+ *
26
+ * Exported so a test can drive it with a hand-built {@link UserManageController} — the seam only
27
+ * counts as one if something other than `Users` can supply it. */
28
+ export function UserManagePanel({ principalId, manage, t, colSpan = 6 }) {
29
+ return (_jsx("tr", { children: _jsx("td", { colSpan: colSpan, children: _jsxs("div", { className: "notice integrations-form", children: [manage.error ? _jsx("span", { className: "save-error", children: manage.error }) : null, _jsxs("label", { children: [t("Email"), _jsxs("span", { className: "editor-actions", children: [_jsx("input", { type: "email", value: manage.email.value, onChange: (e) => manage.email.set(e.target.value), placeholder: t("(none)"), ...agentHandle({ handle: "user-manage-email" }, { role: "field", label: "This user's email address" }) }), _jsx("button", { type: "button", disabled: manage.email.saving, onClick: () => manage.email.save({ principalId }), ...agentHandle({ handle: "user-manage-email-save" }, { role: "button", label: "Save this user's email address" }), children: manage.email.saving ? t("Saving…") : t("Save email") })] })] }), manage.roleGrant ? (_jsx(GrantSelect, { principalId: principalId, label: t("Assign role"), placeholder: t("Select a role…"), submitLabel: t("Assign"), grant: manage.roleGrant, saving: manage.saving, agentBase: "user-manage-role", t: t })) : null, manage.policyGrant ? (_jsx(GrantSelect, { principalId: principalId, label: t("Attach policy"), placeholder: t("Select a policy…"), submitLabel: t("Attach"), grant: manage.policyGrant, saving: manage.saving, agentBase: "user-manage-policy", t: t })) : null] }) }) }));
24
30
  }
25
- function UserRow({ user, roleById, policyById, actions, manage, agentBase, t, translate }) {
26
- const roleLabel = formatGrantLabel({ ids: user.roleIds, byId: roleById });
31
+ /** One user's row plus its optional expanded "Manage" row. `key` lives on the `<UserRow>` element at
32
+ * the call site, not inside here, since this is no longer the array-mapping callback itself. */
33
+ function UserRow({ user, roleById, policyById, showGrants, actions, manage, agentBase, t, translate, renderServerLabel }) {
34
+ const roleLabel = formatGrantLabel({ ids: user.roleIds, byId: roleById, translate });
27
35
  const policyLabel = formatGrantLabel({ ids: user.policyIds, byId: policyById });
28
- return (_jsxs(_Fragment, { children: [_jsxs("tr", { children: [_jsx("td", { children: _jsx("button", { type: "button", className: "link-button", onClick: () => actions.toggleExpanded(user), "aria-expanded": actions.expandedId === user.principalId, ...agentHandle({ handle: `${agentBase}-manage` }, {
29
- role: "button",
30
- label: t("Open this user's Manage panel — edit email, assign a role, attach a policy"),
31
- }), children: user.username }) }), _jsx("td", { children: user.email ?? _jsx("span", { className: "muted-cell", children: "\u2014" }) }), _jsx("td", { children: _jsx("span", { className: `status status-${user.status}`, children: t(user.status) }) }), _jsx("td", { children: roleLabel !== null ? t(roleLabel) : _jsx("span", { className: "muted-cell", children: t("none") }) }), _jsx("td", { children: policyLabel !== null ? policyLabel : _jsx("span", { className: "muted-cell", children: t("none") }) }), _jsx("td", { children: _jsx(RowMenu, { triggerLabel: `${t("Actions for user")} "${user.username}"`, agentHandle: `${agentBase}-menu`, items: userRowMenuItems({
32
- user,
33
- toggleSaving: actions.savingId === user.principalId,
36
+ return (_jsxs(_Fragment, { children: [_jsxs("tr", { children: [_jsxs("td", { children: [_jsx("button", { type: "button", className: "link-button", onClick: () => actions.toggleExpanded(user), "aria-expanded": actions.expandedId === user.principalId, ...agentHandle({ handle: `${agentBase}-manage` }, {
37
+ role: "button",
38
+ label: "Open this user's Manage panel — edit email, assign a role, attach a policy",
39
+ }), children: user.username }), isOwnerAccount({ user, roles: roleById }) ? _jsx("span", { className: "status", children: t("Owner") }) : null] }), _jsx("td", { children: user.email ?? _jsx("span", { className: "muted-cell", children: "\u2014" }) }), _jsx("td", { children: _jsx("span", { className: `status status-${user.status}`, children: renderServerLabel?.({ value: user.status }) ?? t(user.status) }) }), showGrants ? (_jsxs(_Fragment, { children: [_jsx("td", { children: roleLabel !== null ? (renderServerLabel?.({ value: roleLabel }) ?? roleLabel) : _jsx("span", { className: "muted-cell", children: t("none") }) }), _jsx("td", { children: policyLabel !== null ? policyLabel : _jsx("span", { className: "muted-cell", children: t("none") }) })] })) : null, _jsx("td", { children: _jsx(RowMenu, { triggerLabel: `${t("Actions for user")} "${user.username}"`, agentHandle: `${agentBase}-menu`, items: userRowMenuItems({
40
+ user, toggleSaving: actions.savingId === user.principalId,
34
41
  handlers: {
35
42
  onRequestDisable: actions.requestDisable,
36
43
  onEnable: (u) => void actions.toggleStatus(u),
37
44
  onManage: actions.toggleExpanded,
38
45
  onResetPassword: actions.openResetPassword,
39
46
  onRequestDelete: actions.requestDelete,
40
- },
41
- translate,
42
- canDelete: actions.canDelete,
43
- }) }) })] }), actions.expandedId === user.principalId ? (_jsx(UserManagePanel, { principalId: user.principalId, manage: manage, t: t })) : null] }));
47
+ }, translate, canDelete: actions.canDelete,
48
+ capabilities: actions.rowCapabilities?.(user) ?? actions.canDelete,
49
+ }) }) })] }), actions.expandedId === user.principalId ? (_jsx(UserManagePanel, { principalId: user.principalId, manage: manage, t: t, colSpan: showGrants ? 6 : 4 })) : null] }));
44
50
  }
45
- function UsersTable({ users, roles, policies, actions, manage, t, translate }) {
51
+ /** The users table, or the empty state. Builds the role/policy lookup maps once per render rather
52
+ * than once per row. */
53
+ function UsersTable({ users, roles, policies, showGrants, actions, manage, t, translate, renderServerLabel }) {
46
54
  if (users.length === 0) {
47
55
  return (_jsx("div", { className: "card", children: _jsxs("div", { className: "empty-state", children: [_jsx("p", { children: t("No users yet.") }), _jsx("p", { className: "page-description", children: t("Create your first operator account to get started.") })] }) }));
48
56
  }
57
+ // Not `useMemo`d: computed after the empty-state early return above, so a `useMemo` here would
58
+ // need hoisting above it to keep hook order stable across renders — same constraint `Media.tsx`
59
+ // documents for its own post-early-return computation. Not worth that indirection: these three
60
+ // maps/handles are a single O(n) pass each over one operator's own users/roles/policies (already
61
+ // built once per render rather than once per row, per this function's own doc comment above).
49
62
  const roleById = new Map(roles.map((role) => [role.id, role]));
50
63
  const policyById = new Map(policies.map((policy) => [policy.id, policy]));
64
+ // Principal ids are stable and unique, so they disambiguate one row's Manage toggle from
65
+ // another's — same reasoning as every other list on this workstream.
51
66
  const rowHandles = buildAgentListHandles({ prefix: "users-row", ids: users.map((user) => user.principalId) });
52
- return (_jsx("div", { className: "table-scroll", children: _jsxs("table", { className: "list-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: t("Username") }), _jsx("th", { children: t("Email") }), _jsx("th", { children: t("Status") }), _jsx("th", { children: t("Roles") }), _jsx("th", { children: t("Policies") }), _jsx("th", { children: t("More") })] }) }), _jsx("tbody", { children: users.map((user, index) => (_jsx(Fragment, { children: _jsx(UserRow, { user: user, roleById: roleById, policyById: policyById, actions: actions, manage: manage, agentBase: rowHandles[index], t: t, translate: translate }) }, user.principalId))) })] }) }));
67
+ return (_jsx("div", { className: "table-scroll", children: _jsxs("table", { className: "list-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: t("Username") }), _jsx("th", { children: t("Email") }), _jsx("th", { children: t("Status") }), showGrants ? (_jsxs(_Fragment, { children: [_jsx("th", { children: t("Roles") }), _jsx("th", { children: t("Policies") })] })) : null, _jsx("th", { children: t("More") })] }) }), _jsx("tbody", { children: users.map((user, index) => (_jsx(Fragment, { children: _jsx(UserRow, { user: user, roleById: roleById, policyById: policyById, showGrants: showGrants, actions: actions, manage: manage, agentBase: rowHandles[index], t: t, translate: translate, renderServerLabel: renderServerLabel }) }, user.principalId))) })] }) }));
53
68
  }
69
+ /** The Disable confirm dialog — extracted from `Users` verbatim. */
54
70
  function UserDisableDialog({ confirmingDisable, setConfirmingDisable, toggleSavingId, confirmDisable, t }) {
55
71
  return (_jsx(ConfirmDialog, { cancelLabel: t("Cancel"), open: confirmingDisable !== null, agentHandle: "users-disable", title: t("Disable this user?"), body: confirmingDisable ? (_jsxs("p", { children: [t("Disable"), " \"", confirmingDisable.username, "\"? ", t("They will not be able to sign in until re-enabled.")] })) : null, confirmLabel: t("Disable"), tone: "warning", pending: confirmingDisable !== null && toggleSavingId === confirmingDisable.principalId, onConfirm: confirmDisable, onCancel: () => setConfirmingDisable(null) }));
56
72
  }
73
+ /** The Delete confirm dialog (delete-user plan v2, 2026-09-24) — mirrors {@link UserDisableDialog}'s
74
+ * shape exactly (own props, own state), naming the Trash rather than a permanent removal since
75
+ * that is what "Delete" now does here (decisions 2/7 of the plan). */
57
76
  function UserDeleteDialog({ confirmingDelete, setConfirmingDelete, deleteSaving, confirmDelete, t }) {
58
77
  return (_jsx(ConfirmDialog, { cancelLabel: t("Cancel"), open: confirmingDelete !== null, agentHandle: "users-delete", title: t("Delete this user?"), body: confirmingDelete ? (_jsxs("p", { children: [t("Delete"), " \"", confirmingDelete.username, "\"?", " ", t("They will be signed out and moved to the Trash. You can restore them there; they are deleted permanently after 60 days.")] })) : null, confirmLabel: t("Move to trash"), tone: "danger", pending: deleteSaving, onConfirm: confirmDelete, onCancel: () => setConfirmingDelete(null) }));
59
78
  }
79
+ /** Open-eye glyph — standard "reveal" affordance, shown while a `RevealablePasswordField` is
80
+ * hidden (click to show). House stroke-icon convention (`viewBox="0 0 18 18"`, `fill="none"`,
81
+ * `stroke="currentColor"`, `strokeWidth={1.5}`, `aria-hidden`) — same shape `Media.tsx`'s
82
+ * `PlaceholderIcon`/`ExpandIcon`/`CloseIcon` and `nav.ts`'s panel icons use throughout this app. */
60
83
  function EyeIcon() {
61
84
  return (_jsxs("svg", { viewBox: "0 0 18 18", fill: "none", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: [_jsx("path", { d: "M1.5 9S4.5 4 9 4s7.5 5 7.5 5-3 5-7.5 5-7.5-5-7.5-5Z", strokeLinejoin: "round" }), _jsx("circle", { cx: "9", cy: "9", r: "2" })] }));
62
85
  }
86
+ /** Same glyph with a slash through it — standard "hide" affordance, shown while the field is
87
+ * currently revealed (click to hide). */
63
88
  function EyeOffIcon() {
64
89
  return (_jsxs("svg", { viewBox: "0 0 18 18", fill: "none", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: [_jsx("path", { d: "M1.5 9S4.5 4 9 4s7.5 5 7.5 5-3 5-7.5 5-7.5-5-7.5-5Z", strokeLinejoin: "round" }), _jsx("circle", { cx: "9", cy: "9", r: "2" }), _jsx("path", { d: "M3 3 15 15", strokeLinecap: "round" })] }));
65
90
  }
91
+ /** One password input plus its own independent show/hide toggle. `UserResetPasswordDialog` renders
92
+ * this twice (new password, confirm new password) so each field can be revealed on its own — the
93
+ * whole point of the confirm field is catching a typo by eye, which a single toggle driving both
94
+ * fields would not let the operator do selectively. Follows this file's `GrantSelect` precedent: a
95
+ * shape used by more than one caller gets its own top-level function instead of staying inline
96
+ * twice.
97
+ *
98
+ * The toggle itself is icon-only (standard eye / eye-with-slash convention, per owner request —
99
+ * not a text "Show"/"Hide" link): the accessible name still flips between "Show password"/"Hide
100
+ * password" via `aria-label` (screen reader), and `aria-pressed` mirrors `visible` so assistive
101
+ * tech gets the same on/off signal a text label would have given.
102
+ *
103
+ * The input's inline `flex`/`minWidth` override neutralizes `styles.css`'s `.field input { width:
104
+ * 100% }` fighting the toggle button for room inside the `.editor-actions` row below — an inline
105
+ * style wins on specificity without adding a new class to a stylesheet this feature doesn't own. */
66
106
  function RevealablePasswordField({ id, label, value, onChange, visible, onToggleVisible, agentBase, t }) {
67
107
  const toggleLabel = visible ? t("Hide password") : t("Show password");
68
- return (_jsxs("div", { className: "field", children: [_jsx("label", { className: "field-label", htmlFor: id, children: label }), _jsxs("span", { className: "editor-actions", children: [_jsx("input", { id: id, type: visible ? "text" : "password", autoComplete: "new-password", value: value, onChange: (e) => onChange(e.target.value), style: { flex: "1 1 auto", minWidth: 0 }, ...agentHandle({ handle: agentBase }, {
108
+ return (_jsxs("div", { className: "field", children: [_jsx("label", { className: "field-label", htmlFor: id, children: label }), _jsxs("span", { className: "editor-actions", children: [_jsx("input", { id: id, type: visible ? "text" : "password",
109
+ // `new-password`, not `off` (Chrome ignores `off`): without it Chrome filled a saved
110
+ // login into "New password" only. Kept while revealed as `type="text"`, too.
111
+ autoComplete: "new-password", value: value, onChange: (e) => onChange(e.target.value), style: { flex: "1 1 auto", minWidth: 0 }, ...agentHandle({ handle: agentBase }, {
69
112
  role: "field",
70
- label: t(`${label} — a credential field, so only a human can fill it`),
71
- }) }), _jsx("button", { type: "button", className: "link-button", "aria-label": toggleLabel, "aria-pressed": visible, title: toggleLabel, onClick: onToggleVisible, ...agentHandle({ handle: `${agentBase}-reveal` }, { role: "button", label: t(`Show or hide the ${label.toLowerCase()} field's characters`) }), children: _jsx("span", { style: { display: "inline-flex", width: 16, height: 16 }, children: visible ? _jsx(EyeOffIcon, {}) : _jsx(EyeIcon, {}) }) })] })] }));
113
+ label: `${label} — a credential field, so only a human can fill it`,
114
+ }) }), _jsx("button", { type: "button", className: "link-button", "aria-label": toggleLabel, "aria-pressed": visible, title: toggleLabel, onClick: onToggleVisible, ...agentHandle({ handle: `${agentBase}-reveal` }, { role: "button", label: `Show or hide the ${label.toLowerCase()} field's characters` }), children: _jsx("span", { style: { display: "inline-flex", width: 16, height: 16 }, children: visible ? _jsx(EyeOffIcon, {}) : _jsx(EyeIcon, {}) }) })] })] }));
72
115
  }
116
+ /** Resolves `useFields` to the real hook when no override is passed, because ESLint's
117
+ * cyclomatic-complexity rule counts a default parameter value evaluated inside a function's OWN
118
+ * body as one of that function's own branches; a call out to a separately-scoped resolver does
119
+ * not. */
73
120
  function resolveResetPasswordFieldsHook(override) {
74
121
  return override ?? useResetPasswordFields;
75
122
  }
123
+ /** The reset-password dialog — extracted from `Users` verbatim, since extended with the confirm
124
+ * field and both reveal toggles (see this file's header). */
76
125
  function UserResetPasswordDialog({ resetPasswordFor, setResetPasswordFor, newPassword, setNewPassword, passwordError, setPasswordError, passwordSaving, confirmResetPassword, t, useFields: useFieldsProp, }) {
77
126
  const useFields = resolveResetPasswordFieldsHook(useFieldsProp);
78
127
  const fields = useFields({ resetPasswordFor, newPassword });
128
+ /** Blocks the reset entirely when the two fields disagree — a mismatch never reaches
129
+ * `confirmResetPassword`/the server. The inline message below is driven by the same `mismatch`
130
+ * value, so it is already visible by the time a blocked click can happen; there is nothing
131
+ * further to surface here. */
79
132
  function handleConfirm() {
80
133
  if (fields.mismatch)
81
134
  return;
82
135
  void confirmResetPassword();
83
136
  }
84
- return (_jsx(ConfirmDialog, { cancelLabel: t("Cancel"), open: resetPasswordFor !== null, agentHandle: "users-reset-password", title: t("Reset password?"), body: resetPasswordFor ? (_jsxs(_Fragment, { children: [_jsxs("p", { children: [t("Set a new password for"), " \"", resetPasswordFor.username, "\".", " ", t("Every active session for this user will be signed out.")] }), _jsx(RevealablePasswordField, { id: "users-reset-password-input", label: t("New password"), value: newPassword, onChange: setNewPassword, visible: fields.showNewPassword, onToggleVisible: fields.toggleShowNewPassword, agentBase: "users-reset-password", t: t }), _jsx(RevealablePasswordField, { id: "users-reset-password-confirm-input", label: t("Confirm new password"), value: fields.confirmPassword, onChange: fields.setConfirmPassword, visible: fields.showConfirmPassword, onToggleVisible: fields.toggleShowConfirmPassword, agentBase: "users-reset-password-retype", t: t }), fields.mismatch ? (_jsx("p", { className: "save-error", role: "alert", children: t("Passwords do not match.") })) : passwordError ? (_jsx("p", { className: "save-error", role: "alert", children: passwordError })) : null] })) : null, confirmLabel: t("Reset password"), tone: "warning", pending: passwordSaving, onConfirm: handleConfirm, onCancel: () => {
137
+ return (_jsx(ConfirmDialog, { cancelLabel: t("Cancel"), open: resetPasswordFor !== null, agentHandle: "users-reset-password", title: t("Reset password?"), body: resetPasswordFor ? (_jsxs(_Fragment, { children: [_jsxs("p", { children: [t("Set a new password for"), " \"", resetPasswordFor.username, "\".", " ", t("Every active session for this user will be signed out.")] }), _jsx(RevealablePasswordField, { id: "users-reset-password-input", label: t("New password"), value: newPassword, onChange: setNewPassword, visible: fields.showNewPassword, onToggleVisible: fields.toggleShowNewPassword, agentBase: "users-reset-password", t: t }), _jsx(RevealablePasswordField, { id: "users-reset-password-confirm-input", label: t("Confirm new password"), value: fields.confirmPassword, onChange: fields.setConfirmPassword, visible: fields.showConfirmPassword, onToggleVisible: fields.toggleShowConfirmPassword,
138
+ // NOT "users-reset-password-confirm" — that string is already the dialog's own
139
+ // ConfirmDialog Confirm button handle (`agentHandle="users-reset-password"` above,
140
+ // `-confirm` sub-handle). Two elements answering to the same handle is a
141
+ // page-authoring bug the page driver refuses to resolve (see
142
+ // `dom-page-driver.ts`'s `ambiguousHandleError`), so this field gets its own name.
143
+ agentBase: "users-reset-password-retype", t: t }), fields.mismatch ? (_jsx("p", { className: "save-error", role: "alert", children: t("Passwords do not match.") })) : passwordError ? (_jsx("p", { className: "save-error", role: "alert", children: passwordError })) : null] })) : null, confirmLabel: t("Reset password"), tone: "warning", pending: passwordSaving, onConfirm: handleConfirm, onCancel: () => {
85
144
  setResetPasswordFor(null);
86
145
  setNewPassword("");
87
146
  setPasswordError(null);
88
147
  } }));
89
148
  }
149
+ /** The page title plus the "New user"/"Cancel" toggle button — extracted from `Users` verbatim so
150
+ * its two `formOpen` ternaries (class, label) count against this function, not `Users`'. */
90
151
  function UsersPageHeader({ formOpen, setFormOpen, t }) {
91
- return (_jsxs("div", { className: "page-header", ...agentHandle({ handle: "users-header" }, { role: "region", label: t("Users header — page title and the New user button") }), children: [_jsxs("div", { className: "page-header-text", children: [_jsx("p", { className: "page-kicker", children: t("People") }), _jsxs("h1", { className: "page-title", children: [t("Users"), _jsx(InfoTip, { label: t("No 'forgot password' email yet. If someone is locked out, the owner can reset their password here."), agentHandle: "users-reset-password-info" })] }), _jsx("p", { className: "page-description", children: t("Operator accounts with access to this admin — assign roles and policies, or disable access.") })] }), _jsx("div", { className: "page-actions", children: _jsx("button", { className: formOpen ? "btn-secondary" : undefined, onClick: () => setFormOpen((v) => !v), ...agentHandle({ handle: "users-new-toggle" }, { role: "button", label: t("Open or close the new-user form") }), children: formOpen ? t("Cancel") : t("New user") }) })] }));
152
+ return (_jsxs("div", { className: "page-header", ...agentHandle({ handle: "users-header" }, { role: "region", label: "Users header — page title and the New user button" }), children: [_jsxs("div", { className: "page-header-text", children: [_jsx("p", { className: "page-kicker", children: t("People") }), _jsxs("h1", { className: "page-title", children: [t("Users"), _jsx(InfoTip, { label: t("No 'forgot password' email yet. If someone is locked out, the owner can reset their password here."), agentHandle: "users-reset-password-info" })] }), _jsx("p", { className: "page-description", children: t("Operator accounts with access to this admin — assign roles and policies, or disable access.") })] }), _jsx("div", { className: "page-actions", children: _jsx("button", { className: formOpen ? "btn-secondary" : undefined, onClick: () => setFormOpen((v) => !v), ...agentHandle({ handle: "users-new-toggle" }, { role: "button", label: "Open or close the new-user form" }), children: formOpen ? t("Cancel") : t("New user") }) })] }));
92
153
  }
154
+ /** The Disable/Enable-toggle error banner and the reset-password success notice — extracted from
155
+ * `Users` verbatim. `Users` was still cyc 10 (over the tightened <=9/<=9 bar) with these two
156
+ * independent ternaries inline; moving them out is the same "extract to a top-level function"
157
+ * rule the rest of this pass follows. */
93
158
  function UsersNotices({ toggleError, notice }) {
94
159
  return (_jsxs(_Fragment, { children: [toggleError ? _jsx("div", { className: "notice error", children: toggleError }) : null, notice ? _jsx("div", { className: "notice", children: notice }) : null] }));
95
160
  }
96
- export function Users({ useUsersHook = useUsers, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed }) {
97
- const { users, roles, policies, error, formOpen, setFormOpen, username, setUsername, email, setEmail, password, setPassword, saving, formError, onCreate, expandedId, toggleExpanded, pendingRoleId, setPendingRoleId, pendingPolicyId, setPendingPolicyId, grantSaving, grantError, onAssignRole, onAttachPolicy, editEmail, setEditEmail, emailSaving, onSaveEmail, toggleSavingId, toggleError, notice, confirmingDisable, setConfirmingDisable, requestDisable, confirmDisable, onToggleStatus, resetPasswordFor, setResetPasswordFor, newPassword, setNewPassword, passwordSaving, passwordError, setPasswordError, openResetPassword, confirmResetPassword, canManageUserTrash, confirmingDelete, setConfirmingDelete, requestDelete, deleteSaving, confirmDelete, t, translate, } = useUsersHook({ port, translate: injectedTranslate, queryScope }, { openOwnPasswordReset, onOwnPasswordResetClosed });
161
+ export function Users({ useUsersHook = useUsers, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice, renderServerLabel }) {
162
+ const { users, roles, policies, canGrant, error, formOpen, setFormOpen, username, setUsername, email, setEmail, password, setPassword, saving, formError, onCreate, expandedId, toggleExpanded, pendingRoleId, setPendingRoleId, pendingPolicyId, setPendingPolicyId, grantSaving, grantError, onAssignRole, onAttachPolicy, editEmail, setEditEmail, emailSaving, onSaveEmail, toggleSavingId, toggleError, notice, confirmingDisable, setConfirmingDisable, requestDisable, confirmDisable, onToggleStatus, resetPasswordFor, setResetPasswordFor, newPassword, setNewPassword, passwordSaving, passwordError, setPasswordError, openResetPassword, confirmResetPassword, canManageUserTrash, rowCapabilities, confirmingDelete, setConfirmingDelete, requestDelete, deleteSaving, confirmDelete, t, translate, } = useUsersHook({ port, translate: injectedTranslate, queryScope }, {
163
+ openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice,
164
+ });
98
165
  if (error)
99
166
  return _jsx("div", { className: "notice error", children: error });
100
167
  if (!users || !roles || !policies)
101
168
  return _jsx("div", { className: "notice", children: t("Loading users…") });
102
- return (_jsxs("div", { className: "page", children: [_jsx(UsersPageHeader, { formOpen: formOpen, setFormOpen: setFormOpen, t: t }), formOpen ? (_jsx(NewUserForm, { username: username, setUsername: setUsername, email: email, setEmail: setEmail, password: password, setPassword: setPassword, saving: saving, formError: formError, onCreate: onCreate, t: t })) : null, _jsx(UsersNotices, { toggleError: toggleError, notice: notice }), _jsx(UsersTable, { users: users, roles: roles, policies: policies, actions: {
169
+ return (_jsxs("div", { className: "page", children: [_jsx(UsersPageHeader, { formOpen: formOpen, setFormOpen: setFormOpen, t: t }), formOpen ? (_jsx(NewUserForm, { username: username, setUsername: setUsername, email: email, setEmail: setEmail, password: password, setPassword: setPassword, saving: saving, formError: formError, onCreate: onCreate, t: t })) : null, _jsx(UsersNotices, { toggleError: toggleError, notice: notice }), _jsx(UsersTable, { users: users, roles: roles, policies: policies, showGrants: canGrant, actions: {
103
170
  expandedId,
104
171
  toggleExpanded,
105
172
  savingId: toggleSavingId,
@@ -107,23 +174,18 @@ export function Users({ useUsersHook = useUsers, port, translate: injectedTransl
107
174
  toggleStatus: onToggleStatus,
108
175
  openResetPassword,
109
176
  canDelete: canManageUserTrash,
177
+ rowCapabilities,
110
178
  requestDelete,
111
179
  }, manage: {
112
180
  error: grantError,
113
181
  saving: grantSaving,
114
182
  email: { value: editEmail, set: setEditEmail, saving: emailSaving, save: onSaveEmail },
115
- roleGrant: {
116
- options: roles,
117
- pendingId: pendingRoleId,
118
- setPendingId: setPendingRoleId,
119
- submit: onAssignRole,
120
- },
121
- policyGrant: {
122
- options: policies,
123
- pendingId: pendingPolicyId,
124
- setPendingId: setPendingPolicyId,
125
- submit: onAttachPolicy,
126
- },
127
- }, t: t, translate: translate }), _jsx(UserDisableDialog, { confirmingDisable: confirmingDisable, setConfirmingDisable: setConfirmingDisable, toggleSavingId: toggleSavingId, confirmDisable: confirmDisable, t: t }), _jsx(UserDeleteDialog, { confirmingDelete: confirmingDelete, setConfirmingDelete: setConfirmingDelete, deleteSaving: deleteSaving, confirmDelete: confirmDelete, t: t }), _jsx(UserResetPasswordDialog, { resetPasswordFor: resetPasswordFor, setResetPasswordFor: setResetPasswordFor, newPassword: newPassword, setNewPassword: setNewPassword, passwordError: passwordError, setPasswordError: setPasswordError, passwordSaving: passwordSaving, confirmResetPassword: confirmResetPassword, t: t })] }));
183
+ roleGrant: canGrant
184
+ ? { options: roles, pendingId: pendingRoleId, setPendingId: setPendingRoleId, submit: onAssignRole }
185
+ : null,
186
+ policyGrant: canGrant
187
+ ? { options: policies, pendingId: pendingPolicyId, setPendingId: setPendingPolicyId, submit: onAttachPolicy }
188
+ : null,
189
+ }, t: t, translate: translate, renderServerLabel: renderServerLabel }), _jsx(UserDisableDialog, { confirmingDisable: confirmingDisable, setConfirmingDisable: setConfirmingDisable, toggleSavingId: toggleSavingId, confirmDisable: confirmDisable, t: t }), _jsx(UserDeleteDialog, { confirmingDelete: confirmingDelete, setConfirmingDelete: setConfirmingDelete, deleteSaving: deleteSaving, confirmDelete: confirmDelete, t: t }), _jsx(UserResetPasswordDialog, { resetPasswordFor: resetPasswordFor, setResetPasswordFor: setResetPasswordFor, newPassword: newPassword, setNewPassword: setNewPassword, passwordError: passwordError, setPasswordError: setPasswordError, passwordSaving: passwordSaving, confirmResetPassword: confirmResetPassword, t: t })] }));
128
190
  }
129
191
  //# sourceMappingURL=Users.js.map
@@ -1,16 +1,48 @@
1
1
  import type { AdminIdentityUser } from "../../models.js";
2
+ /**
3
+ * @file The reset-password dialog's own confirm-field and reveal-toggle state — client-side only,
4
+ * never sent to the server (`use-users.hooks.ts`'s `resetPasswordMutation` still posts only
5
+ * `principalId`/`password`). Split out of `Users.tsx` because this is one dialog's own ephemeral UI
6
+ * concern, not part of the screen-wide `UsersController` `use-users.hooks.ts` already owns, so it
7
+ * gets its own small hook rather than growing that one further. No `-port.hooks.ts`/
8
+ * `-dependencies.hooks.ts` pair — this hook does no I/O of its own.
9
+ */
2
10
  export interface ResetPasswordFieldsInput {
11
+ /** Which user the dialog is open for — `null` when closed. Only `principalId` is read (see
12
+ * `useResetPasswordFields`'s own doc comment for why). */
3
13
  resetPasswordFor: AdminIdentityUser | null;
14
+ /** The dialog's own "New password" value, owned by `use-users.hooks.ts` — passed in so this hook
15
+ * can derive `mismatch` without `UserResetPasswordDialog` having to compare the two fields itself. */
4
16
  newPassword: string;
5
17
  }
6
18
  export interface ResetPasswordFieldsController {
19
+ /** The typed confirmation value. Never read by anything outside this dialog — it exists only to
20
+ * be compared against `newPassword`, and is discarded (never becomes part of the request body). */
7
21
  confirmPassword: string;
8
22
  setConfirmPassword: (value: string) => void;
23
+ /** True whenever `newPassword` and `confirmPassword` differ, including while `confirmPassword` is
24
+ * still empty. `UserResetPasswordDialog` uses this to block the confirm action and to choose
25
+ * which message occupies its single error slot. */
9
26
  mismatch: boolean;
10
27
  showNewPassword: boolean;
11
28
  toggleShowNewPassword: () => void;
12
29
  showConfirmPassword: boolean;
13
30
  toggleShowConfirmPassword: () => void;
14
31
  }
15
- export declare function useResetPasswordFields({ resetPasswordFor, newPassword }: ResetPasswordFieldsInput): ResetPasswordFieldsController;
32
+ /**
33
+ * Owns the "Confirm new password" field and both fields' independent reveal toggles.
34
+ *
35
+ * Resets `confirmPassword` and both reveal flags to their defaults on every open transition —
36
+ * keyed on `resetPasswordFor?.principalId` rather than the `user` object's own identity, so a
37
+ * reopen of the SAME user (e.g. Cancel, then "Reset password" again from the row menu) still
38
+ * resets even if the `users` list query happens to hand back the same object reference. This is
39
+ * also why closing the dialog (`principalId` going back to `null`) resets these fields too: there
40
+ * is no user-visible difference between "reset on close" and "reset on open" for a dialog that is
41
+ * unmounted-in-effect (`ConfirmDialog` stays in the DOM but hidden) between the two.
42
+ *
43
+ * @param input - `resetPasswordFor` (open/closed + which user) and the sibling `newPassword` value.
44
+ * @returns The confirm field's value/setter, the derived `mismatch` flag, and both reveal toggles.
45
+ * @complexity Time/space: O(1) — fixed number of state cells, no iteration.
46
+ */
47
+ export declare function useResetPasswordFields({ resetPasswordFor, newPassword }: ResetPasswordFieldsInput, _optional?: Record<string, never>): ResetPasswordFieldsController;
16
48
  //# sourceMappingURL=use-reset-password-fields.hooks.d.ts.map
@@ -1,5 +1,20 @@
1
1
  import { useEffect, useState } from "react";
2
- export function useResetPasswordFields({ resetPasswordFor, newPassword }) {
2
+ /**
3
+ * Owns the "Confirm new password" field and both fields' independent reveal toggles.
4
+ *
5
+ * Resets `confirmPassword` and both reveal flags to their defaults on every open transition —
6
+ * keyed on `resetPasswordFor?.principalId` rather than the `user` object's own identity, so a
7
+ * reopen of the SAME user (e.g. Cancel, then "Reset password" again from the row menu) still
8
+ * resets even if the `users` list query happens to hand back the same object reference. This is
9
+ * also why closing the dialog (`principalId` going back to `null`) resets these fields too: there
10
+ * is no user-visible difference between "reset on close" and "reset on open" for a dialog that is
11
+ * unmounted-in-effect (`ConfirmDialog` stays in the DOM but hidden) between the two.
12
+ *
13
+ * @param input - `resetPasswordFor` (open/closed + which user) and the sibling `newPassword` value.
14
+ * @returns The confirm field's value/setter, the derived `mismatch` flag, and both reveal toggles.
15
+ * @complexity Time/space: O(1) — fixed number of state cells, no iteration.
16
+ */
17
+ export function useResetPasswordFields({ resetPasswordFor, newPassword }, _optional = {}) {
3
18
  const [confirmPassword, setConfirmPassword] = useState("");
4
19
  const [showNewPassword, setShowNewPassword] = useState(false);
5
20
  const [showConfirmPassword, setShowConfirmPassword] = useState(false);