@jini-ai/user-management 0.3.0 → 0.3.1

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