@jini-ai/user-management 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/core/runtime-ports.d.ts +20 -0
- package/dist/core/types.d.ts +4 -1
- package/dist/core/types.js +5 -1
- package/dist/react/index.d.ts +7 -0
- package/dist/react/index.js +4 -0
- package/dist/react/members/Members.d.ts +28 -1
- package/dist/react/members/Members.js +15 -11
- package/dist/react/members/hooks/use-members.hooks.d.ts +46 -2
- package/dist/react/members/hooks/use-members.hooks.js +82 -54
- package/dist/react/members/rules.d.ts +31 -4
- package/dist/react/members/rules.js +17 -5
- package/dist/react/models.d.ts +5 -0
- package/dist/react/ports.d.ts +2 -0
- package/dist/react/testing.js +1 -1
- package/dist/react/users/ChangeUsernameDialog.d.ts +8 -0
- package/dist/react/users/ChangeUsernameDialog.js +8 -0
- package/dist/react/users/Users.d.ts +107 -7
- package/dist/react/users/Users.js +108 -43
- package/dist/react/users/hooks/use-change-username.hooks.d.ts +27 -0
- package/dist/react/users/hooks/use-change-username.hooks.js +53 -0
- 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 +129 -3
- package/dist/react/users/hooks/use-users.hooks.js +223 -23
- package/dist/react/users/rules.d.ts +116 -8
- package/dist/react/users/rules.js +112 -6
- package/dist/server/admin-crud-service.d.ts +6 -4
- package/dist/server/admin-crud-service.js +49 -6
- package/dist/server/agent-tool-registrations.d.ts +1 -0
- package/dist/server/agent-tool-registrations.js +14 -0
- package/dist/server/agent-tools.js +11 -0
- package/dist/server/auth-service.d.ts +3 -1
- package/dist/server/seed.d.ts +7 -4
- package/dist/server/seed.js +15 -6
- package/package.json +17 -12
|
@@ -1,26 +1,100 @@
|
|
|
1
|
-
import type { Translate } from "@jini-ai/ui/panel-kit";
|
|
2
1
|
import { type Dispatch, type SetStateAction } from "react";
|
|
3
2
|
import type { AdminIdentityUser, AdminPolicy, AdminRole } from "../models.js";
|
|
4
|
-
import {
|
|
3
|
+
import { type UserAccountCapabilities } from "./rules.js";
|
|
4
|
+
import { useUsers, type UsersController, type UsersDependencies, type UsersOptions } from "./hooks/use-users.hooks.js";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import type { Translate } from "@jini-ai/ui/panel-kit";
|
|
7
|
+
/**
|
|
8
|
+
* @file Admin "Users" screen (SPEC-006 §3 human grant-writing transitions + 0.6.0 CRUD-completion
|
|
9
|
+
* amendment) — markup only.
|
|
10
|
+
*
|
|
11
|
+
* State and API calls live in `hooks/use-users.hooks.ts`; the row-menu logic and the
|
|
12
|
+
* server-error-message overrides live in `rules.ts`. What stays here is what actually renders: the
|
|
13
|
+
* form, the table, and the two dialogs.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors `features/integrations/Integrations.tsx`'s fetch/loading/error/form/table shape.
|
|
16
|
+
* Lists operator users, creates new ones (`CREATE_USER`), and grants roles/
|
|
17
|
+
* policies to an existing user (`ASSIGN_ROLE`/`ATTACH_POLICY`) via an
|
|
18
|
+
* inline expandable "Manage" row. Role/policy *creation* is out of
|
|
19
|
+
* this screen's UI (no `CREATE_ROLE`/`CREATE_POLICY` form here) — the API
|
|
20
|
+
* client exposes `api.createRole`/`api.createPolicy` for the Roles &
|
|
21
|
+
* Permissions screen to reuse.
|
|
22
|
+
*
|
|
23
|
+
* 0.6.0: the same expandable row also carries `UPDATE_USER` (email),
|
|
24
|
+
* `RESET_USER_PASSWORD`, and a quick `DISABLE_PRINCIPAL`/`ENABLE_PRINCIPAL` toggle.
|
|
25
|
+
*
|
|
26
|
+
* Row actions (audit follow-up): all three of the above — Disable/Enable, Manage (opens the
|
|
27
|
+
* expandable row), Reset password — now live behind a single three-dot `RowMenu`, matching
|
|
28
|
+
* `Posts.tsx`/`Pages.tsx`'s row-action shape rather than a row of separate buttons.
|
|
29
|
+
*
|
|
30
|
+
* Delete-user plan v2 (2026-09-24): a fourth "Delete" item joins the menu, shown only when the
|
|
31
|
+
* signed-in caller may manage the user Trash (owner or the built-in `admin` role — OWNER DECISION
|
|
32
|
+
* 2026-09-24, `use-users.hooks.ts`'s `canManageUserTrash`). It moves the target to the Trash
|
|
33
|
+
* (disabled, sessions revoked, restorable there for 60 days) rather than hard-deleting it — see
|
|
34
|
+
* `UserDeleteDialog`'s own doc comment.
|
|
35
|
+
*
|
|
36
|
+
* Complexity-ceiling pass (2026-08-06): `Users` and its row-map closure both scored over the
|
|
37
|
+
* ceiling (19/16 and 11/11 respectively). Split into four top-level, individually-testable
|
|
38
|
+
* functions below — `NewUserForm`, `UsersTable`/`UserRow`, `UserDisableDialog`/`UserDeleteDialog`,
|
|
39
|
+
* `UserResetPasswordDialog` — following `Taxonomy.tsx`'s existing convention of sibling top-level
|
|
40
|
+
* function components in the same file rather than nested closures (a nested `const` inside
|
|
41
|
+
* `Users` would not have moved any branching out of `Users`' own scope). `Users` itself is now a
|
|
42
|
+
* thin composition of those, none of them exported, so no test does anything `Users.tsx`
|
|
43
|
+
* didn't already support — see `__tests__/Users.unit.test.tsx`/`Users.crud.unit.test.tsx` for the
|
|
44
|
+
* full-render tests these were extracted underneath (unchanged), plus new
|
|
45
|
+
* `__tests__/users-components.unit.test.tsx` for direct component-level tests of each piece.
|
|
46
|
+
*
|
|
47
|
+
* Reset-password confirm + reveal (typo-catching pass): `UserResetPasswordDialog` gained a second
|
|
48
|
+
* field ("Confirm new password") and an independent show/hide toggle per field, both driven by the
|
|
49
|
+
* new `hooks/use-reset-password-fields.hooks.ts` (confirm-field state and both reveal flags are
|
|
50
|
+
* purely local/ephemeral — never sent to the server, so they don't belong in `use-users.hooks.ts`'s
|
|
51
|
+
* screen-wide `UsersController`). The confirm-vs-server-error display conflict is resolved by giving
|
|
52
|
+
* the two states one shared slot with a fixed precedence — a live mismatch always wins over a stale
|
|
53
|
+
* `passwordError` from a previous attempt — rather than trying to show both at once or inventing a
|
|
54
|
+
* second error line; see `UserResetPasswordDialog`'s own body for the exact ternary. `RevealablePasswordField`
|
|
55
|
+
* is the shared field+toggle shape both new inputs use, following this file's own `GrantSelect`
|
|
56
|
+
* precedent for a shape used twice.
|
|
57
|
+
*/
|
|
5
58
|
export interface UsersProps extends UsersDependencies, UsersOptions {
|
|
6
59
|
useUsersHook?: typeof useUsers | undefined;
|
|
7
|
-
|
|
60
|
+
/** The host owns localization of protocol status/role labels. */
|
|
61
|
+
renderServerLabel?: ((required: {
|
|
62
|
+
value: string;
|
|
63
|
+
}) => ReactNode) | undefined;
|
|
8
64
|
}
|
|
65
|
+
/** The subset of `AdminRole`/`AdminPolicy` a grant `<select>` actually renders. Named rather than
|
|
66
|
+
* taking the full records, so the shared control below cannot come to depend on anything a role has
|
|
67
|
+
* and a policy does not. */
|
|
9
68
|
export interface GrantOption {
|
|
10
69
|
id: string;
|
|
11
70
|
name: string;
|
|
12
71
|
isBuiltin: boolean;
|
|
13
72
|
}
|
|
73
|
+
/** One "pick something, then grant it to this user" control. Both grants on the Manage panel are
|
|
74
|
+
* this same shape, which is what lets them share {@link GrantSelect} instead of being two
|
|
75
|
+
* near-identical blocks whose props had to be threaded separately. */
|
|
14
76
|
export interface GrantSelectController {
|
|
15
77
|
options: GrantOption[];
|
|
78
|
+
/** The id currently selected in the `<select>`, or `""` for the placeholder row. */
|
|
16
79
|
pendingId: string;
|
|
17
80
|
setPendingId: Dispatch<SetStateAction<string>>;
|
|
18
81
|
submit: (required: {
|
|
19
82
|
principalId: string;
|
|
20
83
|
}) => Promise<void>;
|
|
21
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Everything the expanded "Manage" panel needs, as one object.
|
|
87
|
+
*
|
|
88
|
+
* Replaces the fifteen loose state/setter/callback props this panel used to take and `UserRow` used
|
|
89
|
+
* to relay through an `Omit<UserManagePanelProps, "principalId">` rest spread. The grouping is by
|
|
90
|
+
* interaction, not by convenience: the email editor and the two grants are three independent
|
|
91
|
+
* controls that happen to share one error line and one in-flight flag, and saying so in the type is
|
|
92
|
+
* what lets a test build a Manage panel without also standing up a row, a table, and a page.
|
|
93
|
+
*/
|
|
22
94
|
export interface UserManageController {
|
|
95
|
+
/** The one error line the whole panel shares — any of the three writes can set it. */
|
|
23
96
|
error: string | null;
|
|
97
|
+
/** Shared by both grants: the panel issues one grant at a time. */
|
|
24
98
|
saving: boolean;
|
|
25
99
|
email: {
|
|
26
100
|
value: string;
|
|
@@ -30,43 +104,69 @@ export interface UserManageController {
|
|
|
30
104
|
principalId: string;
|
|
31
105
|
}) => Promise<void>;
|
|
32
106
|
};
|
|
33
|
-
|
|
34
|
-
|
|
107
|
+
/** `null` when the caller may not read roles/policies (`UsersController.canGrant`): no grant controls. */
|
|
108
|
+
roleGrant: GrantSelectController | null;
|
|
109
|
+
policyGrant: GrantSelectController | null;
|
|
35
110
|
}
|
|
36
111
|
export interface UserManagePanelProps {
|
|
37
112
|
principalId: string;
|
|
38
113
|
manage: UserManageController;
|
|
39
114
|
t: (key: string) => string;
|
|
115
|
+
/** The table's column count, so the panel row spans it exactly (fewer without Roles/Policies). */
|
|
116
|
+
colSpan?: number;
|
|
40
117
|
}
|
|
41
|
-
|
|
118
|
+
/** The expanded "Manage" row's contents (email edit, role/policy grant forms).
|
|
119
|
+
*
|
|
120
|
+
* Exported so a test can drive it with a hand-built {@link UserManageController} — the seam only
|
|
121
|
+
* counts as one if something other than `Users` can supply it. */
|
|
122
|
+
export declare function UserManagePanel({ principalId, manage, t, colSpan }: UserManagePanelProps): import("react").JSX.Element;
|
|
123
|
+
/** A user row's own affordances — expand/collapse and the three menu actions. Distinct from
|
|
124
|
+
* {@link UserManageController}, which describes what the expanded panel does once it is open. */
|
|
42
125
|
export interface UserRowActionsController {
|
|
43
126
|
expandedId: string | null;
|
|
44
127
|
toggleExpanded: (user: AdminIdentityUser) => void;
|
|
128
|
+
/** Which row has a status write in flight — drives that row's disabled menu item only. */
|
|
45
129
|
savingId: string | null;
|
|
46
130
|
requestDisable: (user: AdminIdentityUser) => void;
|
|
47
131
|
toggleStatus: (user: AdminIdentityUser) => Promise<void>;
|
|
48
132
|
openResetPassword: (user: AdminIdentityUser) => void;
|
|
133
|
+
renameUserHandler?: UsersController["renameUserHandler"];
|
|
134
|
+
/** Delete-user plan v2 (2026-09-24): whether the row's `RowMenu` should carry a "Delete" item at
|
|
135
|
+
* all — see `use-users.hooks.ts`'s `canManageUserTrash` doc comment for where this comes from. */
|
|
49
136
|
canDelete: boolean;
|
|
137
|
+
rowCapabilities?: (user: AdminIdentityUser) => UserAccountCapabilities;
|
|
50
138
|
requestDelete: (user: AdminIdentityUser) => void;
|
|
51
139
|
}
|
|
52
140
|
export interface UserRowProps {
|
|
53
141
|
user: AdminIdentityUser;
|
|
54
142
|
roleById: ReadonlyMap<string, AdminRole>;
|
|
55
143
|
policyById: ReadonlyMap<string, AdminPolicy>;
|
|
144
|
+
/** Whether the Roles/Policies cells render — see {@link UsersTableProps.showGrants}. */
|
|
145
|
+
showGrants: boolean;
|
|
56
146
|
actions: UserRowActionsController;
|
|
57
147
|
manage: UserManageController;
|
|
148
|
+
/** This row's own distinct handle base — computed once, across every rendered row, by
|
|
149
|
+
* `UsersTable` (via `buildAgentListHandles`); see `Taxonomy.tsx`'s `NewTermForm.agentBase` for
|
|
150
|
+
* why a per-instance uniqueness search does not work here. */
|
|
58
151
|
agentBase: string;
|
|
59
152
|
t: (key: string) => string;
|
|
60
153
|
translate: Translate;
|
|
154
|
+
renderServerLabel?: UsersProps["renderServerLabel"];
|
|
61
155
|
}
|
|
62
156
|
export interface UsersTableProps {
|
|
63
157
|
users: AdminIdentityUser[];
|
|
158
|
+
/** The full records, for the Roles/Policies label columns. The grant selects get their own
|
|
159
|
+
* `options` through `manage`, so neither consumer has to reach into the other's props. */
|
|
64
160
|
roles: AdminRole[];
|
|
65
161
|
policies: AdminPolicy[];
|
|
162
|
+
/** `UsersController.canGrant`: without `role.manage` there are no names to resolve grant ids
|
|
163
|
+
* against, so the Roles/Policies columns are left out rather than showing raw ids. */
|
|
164
|
+
showGrants: boolean;
|
|
66
165
|
actions: UserRowActionsController;
|
|
67
166
|
manage: UserManageController;
|
|
68
167
|
t: (key: string) => string;
|
|
69
168
|
translate: Translate;
|
|
169
|
+
renderServerLabel?: UsersProps["renderServerLabel"];
|
|
70
170
|
}
|
|
71
|
-
export declare function Users({ useUsersHook, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed }: UsersProps): import("react").JSX.Element;
|
|
171
|
+
export declare function Users({ useUsersHook, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice, renderServerLabel }: UsersProps): import("react").JSX.Element;
|
|
72
172
|
//# sourceMappingURL=Users.d.ts.map
|
|
@@ -3,127 +3,192 @@ 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
|
+
import { ChangeUsernameDialog } from "./ChangeUsernameDialog.js";
|
|
11
|
+
/** The "New user" form — extracted from `Users` verbatim; own scope for `formError`/`saving`. */
|
|
10
12
|
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:
|
|
13
|
+
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
14
|
role: "field",
|
|
13
|
-
label:
|
|
14
|
-
}) })] }), _jsx("button", { type: "submit", disabled: saving, ...agentHandle({ handle: "users-new-submit" }, { role: "button", label:
|
|
15
|
+
label: "The new user's password — a credential field, so only a human can fill it",
|
|
16
|
+
}) })] }), _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
17
|
}
|
|
18
|
+
/** The select-plus-button both grants render. */
|
|
16
19
|
function GrantSelect({ principalId, label, placeholder, submitLabel, grant, saving, agentBase, t }) {
|
|
17
20
|
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
21
|
role: "field",
|
|
19
|
-
label:
|
|
20
|
-
}), children: [_jsx("option", { value: "", children: placeholder }), grant.options.map((option) => (_jsxs("option", { value: option.id, children: [option
|
|
22
|
+
label: `${label} — set with page.select_option, not click`,
|
|
23
|
+
}), 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
24
|
}
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
/** The expanded "Manage" row's contents (email edit, role/policy grant forms).
|
|
26
|
+
*
|
|
27
|
+
* Exported so a test can drive it with a hand-built {@link UserManageController} — the seam only
|
|
28
|
+
* counts as one if something other than `Users` can supply it. */
|
|
29
|
+
export function UserManagePanel({ principalId, manage, t, colSpan = 6 }) {
|
|
30
|
+
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
31
|
}
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
/** One user's row plus its optional expanded "Manage" row. `key` lives on the `<UserRow>` element at
|
|
33
|
+
* the call site, not inside here, since this is no longer the array-mapping callback itself. */
|
|
34
|
+
function UserRow({ user, roleById, policyById, showGrants, actions, manage, agentBase, t, translate, renderServerLabel }) {
|
|
35
|
+
const roleLabel = formatGrantLabel({ ids: user.roleIds, byId: roleById, translate });
|
|
27
36
|
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,
|
|
37
|
+
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` }, {
|
|
38
|
+
role: "button",
|
|
39
|
+
label: "Open this user's Manage panel — edit email, assign a role, attach a policy",
|
|
40
|
+
}), 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({
|
|
41
|
+
user, toggleSaving: actions.savingId === user.principalId,
|
|
34
42
|
handlers: {
|
|
35
43
|
onRequestDisable: actions.requestDisable,
|
|
36
44
|
onEnable: (u) => void actions.toggleStatus(u),
|
|
37
45
|
onManage: actions.toggleExpanded,
|
|
38
46
|
onResetPassword: actions.openResetPassword,
|
|
47
|
+
onChangeUsername: actions.renameUserHandler?.({ user }),
|
|
39
48
|
onRequestDelete: actions.requestDelete,
|
|
40
|
-
},
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}) }) })] }), actions.expandedId === user.principalId ? (_jsx(UserManagePanel, { principalId: user.principalId, manage: manage, t: t })) : null] }));
|
|
49
|
+
}, translate, canDelete: actions.canDelete,
|
|
50
|
+
capabilities: actions.rowCapabilities?.(user) ?? actions.canDelete,
|
|
51
|
+
}) }) })] }), actions.expandedId === user.principalId ? (_jsx(UserManagePanel, { principalId: user.principalId, manage: manage, t: t, colSpan: showGrants ? 6 : 4 })) : null] }));
|
|
44
52
|
}
|
|
45
|
-
|
|
53
|
+
/** The users table, or the empty state. Builds the role/policy lookup maps once per render rather
|
|
54
|
+
* than once per row. */
|
|
55
|
+
function UsersTable({ users, roles, policies, showGrants, actions, manage, t, translate, renderServerLabel }) {
|
|
46
56
|
if (users.length === 0) {
|
|
47
57
|
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
58
|
}
|
|
59
|
+
// Not `useMemo`d: computed after the empty-state early return above, so a `useMemo` here would
|
|
60
|
+
// need hoisting above it to keep hook order stable across renders — same constraint `Media.tsx`
|
|
61
|
+
// documents for its own post-early-return computation. Not worth that indirection: these three
|
|
62
|
+
// maps/handles are a single O(n) pass each over one operator's own users/roles/policies (already
|
|
63
|
+
// built once per render rather than once per row, per this function's own doc comment above).
|
|
49
64
|
const roleById = new Map(roles.map((role) => [role.id, role]));
|
|
50
65
|
const policyById = new Map(policies.map((policy) => [policy.id, policy]));
|
|
66
|
+
// Principal ids are stable and unique, so they disambiguate one row's Manage toggle from
|
|
67
|
+
// another's — same reasoning as every other list on this workstream.
|
|
51
68
|
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))) })] }) }));
|
|
69
|
+
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
70
|
}
|
|
71
|
+
/** The Disable confirm dialog — extracted from `Users` verbatim. */
|
|
54
72
|
function UserDisableDialog({ confirmingDisable, setConfirmingDisable, toggleSavingId, confirmDisable, t }) {
|
|
55
73
|
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
74
|
}
|
|
75
|
+
/** The Delete confirm dialog (delete-user plan v2, 2026-09-24) — mirrors {@link UserDisableDialog}'s
|
|
76
|
+
* shape exactly (own props, own state), naming the Trash rather than a permanent removal since
|
|
77
|
+
* that is what "Delete" now does here (decisions 2/7 of the plan). */
|
|
57
78
|
function UserDeleteDialog({ confirmingDelete, setConfirmingDelete, deleteSaving, confirmDelete, t }) {
|
|
58
79
|
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
80
|
}
|
|
81
|
+
/** Open-eye glyph — standard "reveal" affordance, shown while a `RevealablePasswordField` is
|
|
82
|
+
* hidden (click to show). House stroke-icon convention (`viewBox="0 0 18 18"`, `fill="none"`,
|
|
83
|
+
* `stroke="currentColor"`, `strokeWidth={1.5}`, `aria-hidden`) — same shape `Media.tsx`'s
|
|
84
|
+
* `PlaceholderIcon`/`ExpandIcon`/`CloseIcon` and `nav.ts`'s panel icons use throughout this app. */
|
|
60
85
|
function EyeIcon() {
|
|
61
86
|
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
87
|
}
|
|
88
|
+
/** Same glyph with a slash through it — standard "hide" affordance, shown while the field is
|
|
89
|
+
* currently revealed (click to hide). */
|
|
63
90
|
function EyeOffIcon() {
|
|
64
91
|
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
92
|
}
|
|
93
|
+
/** One password input plus its own independent show/hide toggle. `UserResetPasswordDialog` renders
|
|
94
|
+
* this twice (new password, confirm new password) so each field can be revealed on its own — the
|
|
95
|
+
* whole point of the confirm field is catching a typo by eye, which a single toggle driving both
|
|
96
|
+
* fields would not let the operator do selectively. Follows this file's `GrantSelect` precedent: a
|
|
97
|
+
* shape used by more than one caller gets its own top-level function instead of staying inline
|
|
98
|
+
* twice.
|
|
99
|
+
*
|
|
100
|
+
* The toggle itself is icon-only (standard eye / eye-with-slash convention, per owner request —
|
|
101
|
+
* not a text "Show"/"Hide" link): the accessible name still flips between "Show password"/"Hide
|
|
102
|
+
* password" via `aria-label` (screen reader), and `aria-pressed` mirrors `visible` so assistive
|
|
103
|
+
* tech gets the same on/off signal a text label would have given.
|
|
104
|
+
*
|
|
105
|
+
* The input's inline `flex`/`minWidth` override neutralizes `styles.css`'s `.field input { width:
|
|
106
|
+
* 100% }` fighting the toggle button for room inside the `.editor-actions` row below — an inline
|
|
107
|
+
* style wins on specificity without adding a new class to a stylesheet this feature doesn't own. */
|
|
66
108
|
function RevealablePasswordField({ id, label, value, onChange, visible, onToggleVisible, agentBase, t }) {
|
|
67
109
|
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",
|
|
110
|
+
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",
|
|
111
|
+
// `new-password`, not `off` (Chrome ignores `off`): without it Chrome filled a saved
|
|
112
|
+
// login into "New password" only. Kept while revealed as `type="text"`, too.
|
|
113
|
+
autoComplete: "new-password", value: value, onChange: (e) => onChange(e.target.value), style: { flex: "1 1 auto", minWidth: 0 }, ...agentHandle({ handle: agentBase }, {
|
|
69
114
|
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:
|
|
115
|
+
label: `${label} — a credential field, so only a human can fill it`,
|
|
116
|
+
}) }), _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
117
|
}
|
|
118
|
+
/** Resolves `useFields` to the real hook when no override is passed, because ESLint's
|
|
119
|
+
* cyclomatic-complexity rule counts a default parameter value evaluated inside a function's OWN
|
|
120
|
+
* body as one of that function's own branches; a call out to a separately-scoped resolver does
|
|
121
|
+
* not. */
|
|
73
122
|
function resolveResetPasswordFieldsHook(override) {
|
|
74
123
|
return override ?? useResetPasswordFields;
|
|
75
124
|
}
|
|
125
|
+
/** The reset-password dialog — extracted from `Users` verbatim, since extended with the confirm
|
|
126
|
+
* field and both reveal toggles (see this file's header). */
|
|
76
127
|
function UserResetPasswordDialog({ resetPasswordFor, setResetPasswordFor, newPassword, setNewPassword, passwordError, setPasswordError, passwordSaving, confirmResetPassword, t, useFields: useFieldsProp, }) {
|
|
77
128
|
const useFields = resolveResetPasswordFieldsHook(useFieldsProp);
|
|
78
129
|
const fields = useFields({ resetPasswordFor, newPassword });
|
|
130
|
+
/** Blocks the reset entirely when the two fields disagree — a mismatch never reaches
|
|
131
|
+
* `confirmResetPassword`/the server. The inline message below is driven by the same `mismatch`
|
|
132
|
+
* value, so it is already visible by the time a blocked click can happen; there is nothing
|
|
133
|
+
* further to surface here. */
|
|
79
134
|
function handleConfirm() {
|
|
80
135
|
if (fields.mismatch)
|
|
81
136
|
return;
|
|
82
137
|
void confirmResetPassword();
|
|
83
138
|
}
|
|
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,
|
|
139
|
+
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,
|
|
140
|
+
// NOT "users-reset-password-confirm" — that string is already the dialog's own
|
|
141
|
+
// ConfirmDialog Confirm button handle (`agentHandle="users-reset-password"` above,
|
|
142
|
+
// `-confirm` sub-handle). Two elements answering to the same handle is a
|
|
143
|
+
// page-authoring bug the page driver refuses to resolve (see
|
|
144
|
+
// `dom-page-driver.ts`'s `ambiguousHandleError`), so this field gets its own name.
|
|
145
|
+
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
146
|
setResetPasswordFor(null);
|
|
86
147
|
setNewPassword("");
|
|
87
148
|
setPasswordError(null);
|
|
88
149
|
} }));
|
|
89
150
|
}
|
|
151
|
+
/** The page title plus the "New user"/"Cancel" toggle button — extracted from `Users` verbatim so
|
|
152
|
+
* its two `formOpen` ternaries (class, label) count against this function, not `Users`'. */
|
|
90
153
|
function UsersPageHeader({ formOpen, setFormOpen, t }) {
|
|
91
|
-
return (_jsxs("div", { className: "page-header", ...agentHandle({ handle: "users-header" }, { role: "region", label:
|
|
154
|
+
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
155
|
}
|
|
156
|
+
/** The Disable/Enable-toggle error banner and the reset-password success notice — extracted from
|
|
157
|
+
* `Users` verbatim. `Users` was still cyc 10 (over the tightened <=9/<=9 bar) with these two
|
|
158
|
+
* independent ternaries inline; moving them out is the same "extract to a top-level function"
|
|
159
|
+
* rule the rest of this pass follows. */
|
|
93
160
|
function UsersNotices({ toggleError, notice }) {
|
|
94
161
|
return (_jsxs(_Fragment, { children: [toggleError ? _jsx("div", { className: "notice error", children: toggleError }) : null, notice ? _jsx("div", { className: "notice", children: notice }) : null] }));
|
|
95
162
|
}
|
|
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 }, {
|
|
163
|
+
export function Users({ useUsersHook = useUsers, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice, renderServerLabel }) {
|
|
164
|
+
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, ownProfile, canRenameOwnProfile, openOwnRename, renameUserHandler, changeUsername, confirmingDelete, setConfirmingDelete, requestDelete, deleteSaving, confirmDelete, t, translate, } = useUsersHook({ port, translate: injectedTranslate, queryScope }, {
|
|
165
|
+
openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice,
|
|
166
|
+
});
|
|
98
167
|
if (error)
|
|
99
168
|
return _jsx("div", { className: "notice error", children: error });
|
|
100
169
|
if (!users || !roles || !policies)
|
|
101
170
|
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: {
|
|
171
|
+
return (_jsxs("div", { className: "page", children: [_jsx(UsersPageHeader, { formOpen: formOpen, setFormOpen: setFormOpen, t: t }), ownProfile ? _jsxs("section", { className: "notice", "aria-label": t("My profile"), children: [_jsx("h2", { children: t("My profile") }), _jsx("p", { children: ownProfile.username }), canRenameOwnProfile ? _jsx("button", { type: "button", onClick: openOwnRename, ...agentHandle({ handle: "users-profile-change-username" }, { role: "button", label: "Change my login username" }), children: t("Change username") }) : null] }) : null, 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
172
|
expandedId,
|
|
104
173
|
toggleExpanded,
|
|
105
174
|
savingId: toggleSavingId,
|
|
106
175
|
requestDisable,
|
|
107
176
|
toggleStatus: onToggleStatus,
|
|
108
177
|
openResetPassword,
|
|
178
|
+
renameUserHandler,
|
|
109
179
|
canDelete: canManageUserTrash,
|
|
180
|
+
rowCapabilities,
|
|
110
181
|
requestDelete,
|
|
111
182
|
}, manage: {
|
|
112
183
|
error: grantError,
|
|
113
184
|
saving: grantSaving,
|
|
114
185
|
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 })] }));
|
|
186
|
+
roleGrant: canGrant
|
|
187
|
+
? { options: roles, pendingId: pendingRoleId, setPendingId: setPendingRoleId, submit: onAssignRole }
|
|
188
|
+
: null,
|
|
189
|
+
policyGrant: canGrant
|
|
190
|
+
? { options: policies, pendingId: pendingPolicyId, setPendingId: setPendingPolicyId, submit: onAttachPolicy }
|
|
191
|
+
: null,
|
|
192
|
+
}, 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 }), changeUsername ? _jsx(ChangeUsernameDialog, { controller: changeUsername, translate: t }) : null] }));
|
|
128
193
|
}
|
|
129
194
|
//# sourceMappingURL=Users.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Dispatch, type SetStateAction } from "react";
|
|
2
|
+
import { type Translate } from "@jini-ai/ui/panel-kit";
|
|
3
|
+
import type { AdminIdentityUser } from "../../models.js";
|
|
4
|
+
import type { UsersPort } from "../../ports.js";
|
|
5
|
+
export interface ChangeUsernameController {
|
|
6
|
+
target: AdminIdentityUser | null;
|
|
7
|
+
username: string;
|
|
8
|
+
setUsername: Dispatch<SetStateAction<string>>;
|
|
9
|
+
error: string | null;
|
|
10
|
+
pending: boolean;
|
|
11
|
+
open: (required: {
|
|
12
|
+
user: AdminIdentityUser;
|
|
13
|
+
}, optional?: Record<string, never>) => void;
|
|
14
|
+
cancel: (required?: Record<string, never>, optional?: Record<string, never>) => void;
|
|
15
|
+
confirm: (required?: Record<string, never>, optional?: Record<string, never>) => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** Shared rename dialog lifecycle: guard, write through the existing user port, invalidate list.
|
|
18
|
+
* Failures retain the target/draft and the server's actual reason; duplicate submits are refused.
|
|
19
|
+
* @complexity O(1) client state and one PATCH per submission. No credential/session effects.
|
|
20
|
+
*/
|
|
21
|
+
export declare function useChangeUsername(required: {
|
|
22
|
+
port: Pick<UsersPort, "updateUser">;
|
|
23
|
+
queryScope: string;
|
|
24
|
+
translate: Translate;
|
|
25
|
+
canRename: (user: AdminIdentityUser) => boolean;
|
|
26
|
+
}, _optional?: Record<string, never>): ChangeUsernameController;
|
|
27
|
+
//# sourceMappingURL=use-change-username.hooks.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { useFetchMutation } from "@jini-ai/ui/panel-kit";
|
|
3
|
+
import { describeApiError, KEYS } from "../rules.js";
|
|
4
|
+
/** Shared rename dialog lifecycle: guard, write through the existing user port, invalidate list.
|
|
5
|
+
* Failures retain the target/draft and the server's actual reason; duplicate submits are refused.
|
|
6
|
+
* @complexity O(1) client state and one PATCH per submission. No credential/session effects.
|
|
7
|
+
*/
|
|
8
|
+
export function useChangeUsername(required, _optional = {}) {
|
|
9
|
+
const [target, setTarget] = useState(null);
|
|
10
|
+
const [username, setUsername] = useState("");
|
|
11
|
+
const [error, setError] = useState(null);
|
|
12
|
+
const busy = useRef(false);
|
|
13
|
+
const mutation = useFetchMutation({
|
|
14
|
+
run: ({ input }) => required.port.updateUser({ principalId: input.principalId }, { username: input.username }),
|
|
15
|
+
}, { invalidates: [[required.queryScope, ...KEYS.list]] });
|
|
16
|
+
function open({ user }, _options = {}) {
|
|
17
|
+
if (busy.current || !required.canRename(user))
|
|
18
|
+
return;
|
|
19
|
+
setTarget(user);
|
|
20
|
+
setUsername(user.username);
|
|
21
|
+
setError(null);
|
|
22
|
+
}
|
|
23
|
+
function cancel(_input = {}, _options = {}) {
|
|
24
|
+
if (busy.current)
|
|
25
|
+
return;
|
|
26
|
+
setTarget(null);
|
|
27
|
+
setUsername("");
|
|
28
|
+
setError(null);
|
|
29
|
+
}
|
|
30
|
+
async function confirm(_input = {}, _options = {}) {
|
|
31
|
+
if (!target || busy.current || !required.canRename(target))
|
|
32
|
+
return;
|
|
33
|
+
if (!username.trim()) {
|
|
34
|
+
setError(required.translate("username must not be blank"));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
busy.current = true;
|
|
38
|
+
setError(null);
|
|
39
|
+
try {
|
|
40
|
+
await mutation.mutate({ input: { principalId: target.principalId, username } });
|
|
41
|
+
setTarget(null);
|
|
42
|
+
setUsername("");
|
|
43
|
+
}
|
|
44
|
+
catch (cause) {
|
|
45
|
+
setError(describeApiError({ error: cause, fallback: required.translate("failed to change username"), translate: required.translate }));
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
busy.current = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { target, username, setUsername, error, pending: mutation.status === "pending", open, cancel, confirm };
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=use-change-username.hooks.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);
|