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