@jini-ai/user-management 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/admin/adapters/memory.d.ts +3 -5
  3. package/dist/admin/adapters/memory.js +27 -28
  4. package/dist/admin/conformance/index.js +1 -0
  5. package/dist/admin/controllers/auth.controller.js +8 -6
  6. package/dist/admin/controllers/members.controller.js +9 -6
  7. package/dist/admin/controllers/roles.controller.js +99 -105
  8. package/dist/admin/controllers/users.controller.js +56 -36
  9. package/dist/admin/error-copy.d.ts +11 -0
  10. package/dist/admin/error-copy.js +10 -0
  11. package/dist/admin/people.rules.js +37 -31
  12. package/dist/admin/react/components/MemberRow.d.ts +1 -1
  13. package/dist/admin/react/components/MemberRow.js +12 -2
  14. package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +6 -21
  15. package/dist/admin/react/hooks/AuthBinding.hooks.js +3 -8
  16. package/dist/admin/react/hooks/AuthScope.hooks.d.ts +22 -0
  17. package/dist/admin/react/hooks/AuthScope.hooks.js +10 -0
  18. package/dist/admin/react/hooks/LoginPage.hooks.d.ts +1 -1
  19. package/dist/admin/react/hooks/LoginPage.hooks.js +1 -1
  20. package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +6 -17
  21. package/dist/admin/react/hooks/MembersBinding.hooks.js +3 -8
  22. package/dist/admin/react/hooks/MembersPage.hooks.d.ts +1 -1
  23. package/dist/admin/react/hooks/MembersPage.hooks.js +1 -1
  24. package/dist/admin/react/hooks/MembersScope.hooks.d.ts +17 -0
  25. package/dist/admin/react/hooks/MembersScope.hooks.js +10 -0
  26. package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +7 -21
  27. package/dist/admin/react/hooks/RolesBinding.hooks.js +3 -8
  28. package/dist/admin/react/hooks/RolesController.hooks.js +1 -1
  29. package/dist/admin/react/hooks/RolesPage.hooks.d.ts +2 -2
  30. package/dist/admin/react/hooks/RolesPage.hooks.js +22 -8
  31. package/dist/admin/react/hooks/RolesScope.hooks.d.ts +21 -0
  32. package/dist/admin/react/hooks/RolesScope.hooks.js +10 -0
  33. package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +6 -18
  34. package/dist/admin/react/hooks/UsersBinding.hooks.js +3 -8
  35. package/dist/admin/react/hooks/UsersPage.hooks.d.ts +1 -1
  36. package/dist/admin/react/hooks/UsersPage.hooks.js +1 -1
  37. package/dist/admin/react/hooks/UsersScope.hooks.d.ts +19 -0
  38. package/dist/admin/react/hooks/UsersScope.hooks.js +10 -0
  39. package/dist/admin/react/hooks/UsersTab.hooks.d.ts +6 -0
  40. package/dist/admin/react/hooks/UsersTab.hooks.js +34 -22
  41. package/dist/admin/react/index.d.ts +4 -4
  42. package/dist/admin/react/pages/LoginPage.d.ts +1 -1
  43. package/dist/admin/react/pages/MembersPage.d.ts +1 -1
  44. package/dist/admin/react/pages/RolesPage.d.ts +2 -2
  45. package/dist/admin/react/pages/RolesPage.js +6 -2
  46. package/dist/admin/react/pages/UsersPage.d.ts +2 -2
  47. package/dist/admin/react/pages/UsersPage.js +6 -2
  48. package/dist/admin/rules.js +14 -12
  49. package/dist/react/errors.js +16 -9
  50. package/dist/react/index.d.ts +1 -1
  51. package/dist/react/members/hooks/use-members.hooks.js +21 -12
  52. package/dist/react/testing.js +4 -6
  53. package/dist/react/users/UserRowParts.d.ts +53 -0
  54. package/dist/react/users/UserRowParts.js +39 -0
  55. package/dist/react/users/Users.d.ts +31 -2
  56. package/dist/react/users/Users.js +41 -36
  57. package/dist/react/users/hooks/use-users.hooks.js +57 -30
  58. package/dist/server/admin-crud-service.js +25 -13
  59. package/dist/server/agent-tool-input.js +62 -36
  60. package/dist/server/agent-tool-registrations.d.ts +46 -0
  61. package/dist/server/agent-tool-registrations.js +28 -20
  62. package/dist/server/auth-service.js +35 -20
  63. package/dist/server/authorize.js +7 -9
  64. package/dist/server/permission-migrations.js +35 -25
  65. package/dist/server/repo.memory-transactions.js +5 -6
  66. package/dist/server/repo.memory.js +35 -81
  67. package/dist/server/seed.js +100 -84
  68. package/package.json +9 -9
@@ -0,0 +1,53 @@
1
+ import type { ReactNode } from "react";
2
+ import type { RowMenuItem } from "@jini-ai/admin/react";
3
+ import type { Translate } from "@jini-ai/ui/panel-kit";
4
+ import type { AdminIdentityUser, AdminPolicy, AdminRole } from "../models.js";
5
+ import type { UserRowActionsController } from "./Users.js";
6
+ /**
7
+ * @file The cells and menu-item wiring of `Users.tsx`'s `UserRow`, split out of it in the
8
+ * complexity-ceiling pass (lot L182a): `UserRow` scored cyclomatic 16 with every cell's own
9
+ * fallback (`isOwn`, the status/role `renderServerLabel ?? …` chains, the two "none" placeholders,
10
+ * the rename/capability optional calls) counted against the row itself. Each piece below now owns
11
+ * its own branches; the markup each one renders is exactly what `UserRow` rendered inline before.
12
+ */
13
+ type ServerLabelRenderer = ((required: {
14
+ value: string;
15
+ }) => ReactNode) | undefined;
16
+ export interface UserNameCellProps {
17
+ user: AdminIdentityUser;
18
+ isOwn: boolean | undefined;
19
+ expanded: boolean;
20
+ onToggle: (user: AdminIdentityUser) => void;
21
+ agentBase: string;
22
+ t: (key: string) => string;
23
+ }
24
+ /** The username cell: the Manage toggle button plus the "(you)" marker on the caller's own row. */
25
+ export declare function UserNameCell({ user, isOwn, expanded, onToggle, agentBase, t }: UserNameCellProps): import("react").JSX.Element;
26
+ export interface UserStatusCellProps {
27
+ status: AdminIdentityUser["status"];
28
+ t: (key: string) => string;
29
+ renderServerLabel: ServerLabelRenderer;
30
+ }
31
+ /** The status badge cell — the host's `renderServerLabel` wins, falling back to `t(status)`. */
32
+ export declare function UserStatusCell({ status, t, renderServerLabel }: UserStatusCellProps): import("react").JSX.Element;
33
+ export interface UserGrantCellsProps {
34
+ user: AdminIdentityUser;
35
+ roleById: ReadonlyMap<string, AdminRole>;
36
+ policyById: ReadonlyMap<string, AdminPolicy>;
37
+ t: (key: string) => string;
38
+ translate: Translate;
39
+ renderServerLabel: ServerLabelRenderer;
40
+ }
41
+ /** The Roles and Policies cells, each a joined label or a muted "none". As before the split, only
42
+ * the role label is offered to `renderServerLabel`; the policy label renders as-is. */
43
+ export declare function UserGrantCells({ user, roleById, policyById, t, translate, renderServerLabel }: UserGrantCellsProps): import("react").JSX.Element;
44
+ /** `rules.ts`'s `userRowMenuItems`, wired to one row's {@link UserRowActionsController}. The
45
+ * `?? actions.canDelete` capability fallback is a permission default for a controller with no
46
+ * per-row `rowCapabilities` — kept, not "simplified away" (the server stays the real boundary). */
47
+ export declare function userRowMenuItemsFor({ user, actions, translate }: {
48
+ user: AdminIdentityUser;
49
+ actions: UserRowActionsController;
50
+ translate: Translate;
51
+ }): RowMenuItem[];
52
+ export {};
53
+ //# sourceMappingURL=UserRowParts.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { agentHandle } from "@jini-ai/agentic";
3
+ import { formatGrantLabel, userRowMenuItems } from "./rules.js";
4
+ /** The username cell: the Manage toggle button plus the "(you)" marker on the caller's own row. */
5
+ export function UserNameCell({ user, isOwn, expanded, onToggle, agentBase, t }) {
6
+ return (_jsxs("td", { children: [_jsx("button", { type: "button", className: "link-button", onClick: () => onToggle(user), "aria-expanded": expanded, ...agentHandle({ handle: `${agentBase}-manage` }, {
7
+ role: "button",
8
+ label: "Open this user's Manage panel — edit email, assign a role, attach a policy",
9
+ }), children: user.username }), isOwn ? _jsx("span", { style: { color: "var(--jini-text-secondary)", fontSize: "inherit", fontWeight: 400, marginInlineStart: "0.35em" }, children: t("(you)") }) : null] }));
10
+ }
11
+ /** The status badge cell — the host's `renderServerLabel` wins, falling back to `t(status)`. */
12
+ export function UserStatusCell({ status, t, renderServerLabel }) {
13
+ return (_jsx("td", { children: _jsx("span", { className: `status status-${status}`, children: renderServerLabel?.({ value: status }) ?? t(status) }) }));
14
+ }
15
+ /** The Roles and Policies cells, each a joined label or a muted "none". As before the split, only
16
+ * the role label is offered to `renderServerLabel`; the policy label renders as-is. */
17
+ export function UserGrantCells({ user, roleById, policyById, t, translate, renderServerLabel }) {
18
+ const roleLabel = formatGrantLabel({ ids: user.roleIds, byId: roleById, translate });
19
+ const policyLabel = formatGrantLabel({ ids: user.policyIds, byId: policyById });
20
+ return (_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") }) })] }));
21
+ }
22
+ /** `rules.ts`'s `userRowMenuItems`, wired to one row's {@link UserRowActionsController}. The
23
+ * `?? actions.canDelete` capability fallback is a permission default for a controller with no
24
+ * per-row `rowCapabilities` — kept, not "simplified away" (the server stays the real boundary). */
25
+ export function userRowMenuItemsFor({ user, actions, translate }) {
26
+ return userRowMenuItems({
27
+ user, toggleSaving: actions.savingId === user.principalId,
28
+ handlers: {
29
+ onRequestDisable: actions.requestDisable,
30
+ onEnable: (u) => void actions.toggleStatus(u),
31
+ onManage: actions.toggleExpanded,
32
+ onResetPassword: actions.openResetPassword,
33
+ onChangeUsername: actions.renameUserHandler?.({ user }),
34
+ onRequestDelete: actions.requestDelete,
35
+ }, translate, canDelete: actions.canDelete,
36
+ capabilities: actions.rowCapabilities?.(user) ?? actions.canDelete,
37
+ });
38
+ }
39
+ //# sourceMappingURL=UserRowParts.js.map
@@ -44,6 +44,11 @@ import type { Translate } from "@jini-ai/ui/panel-kit";
44
44
  * full-render tests these were extracted underneath (unchanged), plus new
45
45
  * `__tests__/users-components.unit.test.tsx` for direct component-level tests of each piece.
46
46
  *
47
+ * Second complexity pass (lot L182a): `UserRow` was back at cyclomatic 16 and `Users` at 11. The
48
+ * row's cells and its menu-item wiring moved to `UserRowParts.tsx` (`UserNameCell`,
49
+ * `UserStatusCell`, `UserGrantCells`, `userRowMenuItemsFor`), and the Manage panel's controller
50
+ * assembly moved out of `Users`' JSX into `userManageController` below. Markup is unchanged.
51
+ *
47
52
  * Reset-password confirm + reveal (typo-catching pass): `UserResetPasswordDialog` gained a second
48
53
  * field ("Confirm new password") and an independent show/hide toggle per field, both driven by the
49
54
  * new `hooks/use-reset-password-fields.hooks.ts` (confirm-field state and both reveal flags are
@@ -55,13 +60,35 @@ import type { Translate } from "@jini-ai/ui/panel-kit";
55
60
  * is the shared field+toggle shape both new inputs use, following this file's own `GrantSelect`
56
61
  * precedent for a shape used twice.
57
62
  */
58
- export interface UsersProps extends UsersDependencies, UsersOptions {
59
- useUsersHook?: typeof useUsers | undefined;
63
+ /**
64
+ * What a host-supplied `useUsersHook` is called with: `useUsers`' own inputs, except that `translate`
65
+ * may be absent. `Users` reads its copy from the controller the hook returns (`controller.translate`),
66
+ * never from this prop, so a host hook that builds its own controller has no use for it.
67
+ */
68
+ export type UsersHookDependencies = Omit<UsersDependencies, "translate"> & {
69
+ translate?: Translate | undefined;
70
+ };
71
+ /** A host replacement for {@link useUsers} that does not need `translate` (see {@link UsersHookDependencies}). */
72
+ export type UsersHook = (dependencies: UsersHookDependencies, options?: UsersOptions) => UsersController;
73
+ /** Every `Users` prop except the hook/`translate` pair (see {@link UsersProps}). */
74
+ export interface UsersBaseProps extends Omit<UsersDependencies, "translate">, UsersOptions {
60
75
  /** The host owns localization of protocol status/role labels. */
61
76
  renderServerLabel?: ((required: {
62
77
  value: string;
63
78
  }) => ReactNode) | undefined;
64
79
  }
80
+ /**
81
+ * `translate` is only ever forwarded to the hook, so it is required exactly when the default
82
+ * {@link useUsers} (or a hook typed like it) runs, and optional when the host supplies a
83
+ * {@link UsersHook} that does not read it — instead of a dead `translate={key => key}` stub.
84
+ */
85
+ export type UsersProps = UsersBaseProps & ({
86
+ useUsersHook?: typeof useUsers | undefined;
87
+ translate: Translate;
88
+ } | {
89
+ useUsersHook: UsersHook;
90
+ translate?: Translate | undefined;
91
+ });
65
92
  /** The subset of `AdminRole`/`AdminPolicy` a grant `<select>` actually renders. Named rather than
66
93
  * taking the full records, so the shared control below cannot come to depend on anything a role has
67
94
  * and a policy does not. */
@@ -139,6 +166,7 @@ export interface UserRowActionsController {
139
166
  }
140
167
  export interface UserRowProps {
141
168
  user: AdminIdentityUser;
169
+ isOwn?: boolean;
142
170
  roleById: ReadonlyMap<string, AdminRole>;
143
171
  policyById: ReadonlyMap<string, AdminPolicy>;
144
172
  /** Whether the Roles/Policies cells render — see {@link UsersTableProps.showGrants}. */
@@ -155,6 +183,7 @@ export interface UserRowProps {
155
183
  }
156
184
  export interface UsersTableProps {
157
185
  users: AdminIdentityUser[];
186
+ ownPrincipalId?: string | undefined;
158
187
  /** The full records, for the Roles/Policies label columns. The grant selects get their own
159
188
  * `options` through `manage`, so neither consumer has to reach into the other's props. */
160
189
  roles: AdminRole[];
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Fragment } from "react";
2
+ import {} 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, grantOptionLabel, userRowMenuItems, isOwnerAccount } from "./rules.js";
6
+ import { grantOptionLabel } from "./rules.js";
7
+ import { UserGrantCells, UserNameCell, UserStatusCell, userRowMenuItemsFor } from "./UserRowParts.js";
7
8
  import { useUsers } from "./hooks/use-users.hooks.js";
8
9
  import { InfoTip } from "@jini-ai/ui/admin-widgets";
9
10
  import { useResetPasswordFields } from "./hooks/use-reset-password-fields.hooks.js";
@@ -31,28 +32,13 @@ export function UserManagePanel({ principalId, manage, t, colSpan = 6 }) {
31
32
  }
32
33
  /** One user's row plus its optional expanded "Manage" row. `key` lives on the `<UserRow>` element at
33
34
  * 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 });
36
- const policyLabel = formatGrantLabel({ ids: user.policyIds, byId: policyById });
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,
42
- handlers: {
43
- onRequestDisable: actions.requestDisable,
44
- onEnable: (u) => void actions.toggleStatus(u),
45
- onManage: actions.toggleExpanded,
46
- onResetPassword: actions.openResetPassword,
47
- onChangeUsername: actions.renameUserHandler?.({ user }),
48
- onRequestDelete: actions.requestDelete,
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] }));
35
+ function UserRow({ user, isOwn, roleById, policyById, showGrants, actions, manage, agentBase, t, translate, renderServerLabel }) {
36
+ const expanded = actions.expandedId === user.principalId;
37
+ return (_jsxs(_Fragment, { children: [_jsxs("tr", { "aria-current": isOwn ? "true" : undefined, children: [_jsx(UserNameCell, { user: user, isOwn: isOwn, expanded: expanded, onToggle: actions.toggleExpanded, agentBase: agentBase, t: t }), _jsx("td", { children: user.email ?? _jsx("span", { className: "muted-cell", children: "\u2014" }) }), _jsx(UserStatusCell, { status: user.status, t: t, renderServerLabel: renderServerLabel }), showGrants ? (_jsx(UserGrantCells, { user: user, roleById: roleById, policyById: policyById, t: t, translate: translate, renderServerLabel: renderServerLabel })) : null, _jsx("td", { children: _jsx(RowMenu, { triggerLabel: `${t("Actions for user")} "${user.username}"`, agentHandle: `${agentBase}-menu`, items: userRowMenuItemsFor({ user, actions, translate }) }) })] }), expanded ? (_jsx(UserManagePanel, { principalId: user.principalId, manage: manage, t: t, colSpan: showGrants ? 6 : 4 })) : null] }));
52
38
  }
53
39
  /** The users table, or the empty state. Builds the role/policy lookup maps once per render rather
54
40
  * than once per row. */
55
- function UsersTable({ users, roles, policies, showGrants, actions, manage, t, translate, renderServerLabel }) {
41
+ function UsersTable({ users, ownPrincipalId, roles, policies, showGrants, actions, manage, t, translate, renderServerLabel }) {
56
42
  if (users.length === 0) {
57
43
  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.") })] }) }));
58
44
  }
@@ -66,7 +52,12 @@ function UsersTable({ users, roles, policies, showGrants, actions, manage, t, tr
66
52
  // Principal ids are stable and unique, so they disambiguate one row's Manage toggle from
67
53
  // another's — same reasoning as every other list on this workstream.
68
54
  const rowHandles = buildAgentListHandles({ prefix: "users-row", ids: users.map((user) => 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))) })] }) }));
55
+ 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: [_jsxs("th", { "data-label": t("Roles"), children: [t("Roles"), _jsx(InfoTip, { triggerLabel: t("About roles"), label: [
56
+ t("• Owner — full control, including users and site keys."),
57
+ t("• Admin — manage content, settings, site keys, and roles within their permissions."),
58
+ t("• Editor — edit, publish, and delete content; upload, edit, and trash media; switch themes."),
59
+ t("• Viewer — read content, changes, and plugin information."),
60
+ ].join("\n") })] }), _jsx("th", { children: t("Policies") })] })) : null, _jsx("th", { children: t("More") })] }) }), _jsx("tbody", { children: users.map((user, index) => (_jsx(UserRow, { user: user, isOwn: user.principalId === ownPrincipalId, roleById: roleById, policyById: policyById, showGrants: showGrants, actions: actions, manage: manage, agentBase: rowHandles[index], t: t, translate: translate, renderServerLabel: renderServerLabel }, user.principalId))) })] }) }));
70
61
  }
71
62
  /** The Disable confirm dialog — extracted from `Users` verbatim. */
72
63
  function UserDisableDialog({ confirmingDisable, setConfirmingDisable, toggleSavingId, confirmDisable, t }) {
@@ -160,15 +151,39 @@ function UsersPageHeader({ formOpen, setFormOpen, t }) {
160
151
  function UsersNotices({ toggleError, notice }) {
161
152
  return (_jsxs(_Fragment, { children: [toggleError ? _jsx("div", { className: "notice error", children: toggleError }) : null, notice ? _jsx("div", { className: "notice", children: notice }) : null] }));
162
153
  }
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 }, {
154
+ /** Assembles the expanded "Manage" panel's {@link UserManageController} from the screen-wide
155
+ * controller — extracted from `Users`' JSX in the complexity-ceiling pass (lot L182a) so its two
156
+ * `canGrant` ternaries count against this function, not `Users`. `roles`/`policies` are the
157
+ * already-loaded (non-null) lists `Users` narrowed before rendering the table. Without `canGrant`
158
+ * both grants are `null`, so the panel renders no grant controls. */
159
+ function userManageController({ controller, roles, policies }) {
160
+ const { canGrant } = controller;
161
+ return {
162
+ error: controller.grantError,
163
+ saving: controller.grantSaving,
164
+ email: { value: controller.editEmail, set: controller.setEditEmail, saving: controller.emailSaving, save: controller.onSaveEmail },
165
+ roleGrant: canGrant
166
+ ? { options: roles, pendingId: controller.pendingRoleId, setPendingId: controller.setPendingRoleId, submit: controller.onAssignRole }
167
+ : null,
168
+ policyGrant: canGrant
169
+ ? { options: policies, pendingId: controller.pendingPolicyId, setPendingId: controller.setPendingPolicyId, submit: controller.onAttachPolicy }
170
+ : null,
171
+ };
172
+ }
173
+ export function Users({ useUsersHook, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice, renderServerLabel }) {
174
+ // Widening `useUsers` to `UsersHook` is sound only through the `UsersProps` union: whenever
175
+ // `useUsersHook` is omitted or typed as `useUsers`, `translate` is required, so the default hook
176
+ // never runs without it. TypeScript cannot correlate the two destructured props itself.
177
+ const useSelectedUsersHook = (useUsersHook ?? useUsers);
178
+ const controller = useSelectedUsersHook({ port, translate: injectedTranslate, queryScope }, {
165
179
  openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice,
166
180
  });
181
+ const { users, roles, policies, canGrant, error, formOpen, setFormOpen, username, setUsername, email, setEmail, password, setPassword, saving, formError, onCreate, expandedId, toggleExpanded, toggleSavingId, toggleError, notice, confirmingDisable, setConfirmingDisable, requestDisable, confirmDisable, onToggleStatus, resetPasswordFor, setResetPasswordFor, newPassword, setNewPassword, passwordSaving, passwordError, setPasswordError, openResetPassword, confirmResetPassword, canManageUserTrash, rowCapabilities, ownProfile, renameUserHandler, changeUsername, confirmingDelete, setConfirmingDelete, requestDelete, deleteSaving, confirmDelete, t, translate, } = controller;
167
182
  if (error)
168
183
  return _jsx("div", { className: "notice error", children: error });
169
184
  if (!users || !roles || !policies)
170
185
  return _jsx("div", { className: "notice", children: t("Loading users…") });
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: {
186
+ 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, ownPrincipalId: ownProfile?.principalId, roles: roles, policies: policies, showGrants: canGrant, actions: {
172
187
  expandedId,
173
188
  toggleExpanded,
174
189
  savingId: toggleSavingId,
@@ -179,16 +194,6 @@ export function Users({ useUsersHook = useUsers, port, translate: injectedTransl
179
194
  canDelete: canManageUserTrash,
180
195
  rowCapabilities,
181
196
  requestDelete,
182
- }, manage: {
183
- error: grantError,
184
- saving: grantSaving,
185
- email: { value: editEmail, set: setEditEmail, saving: emailSaving, save: onSaveEmail },
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] }));
197
+ }, manage: userManageController({ controller, roles, policies }), 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] }));
193
198
  }
194
199
  //# sourceMappingURL=Users.js.map
@@ -27,7 +27,10 @@ import { useChangeUsername } from "./use-change-username.hooks.js";
27
27
  * `onSuccess` is skipped only when a switch actually happened since this call started — otherwise
28
28
  * a stale grant's success would erase a NEW, unrelated selection the operator has since made for
29
29
  * the panel they actually have open now. */
30
- async function runGrantMutation(generationAtStart, toggleGenerationRef, mutate, onSuccess, setGrantSaving, setGrantError, describeError) {
30
+ async function runGrantMutation({ toggleGenerationRef, mutate, onSuccess, setGrantSaving, setGrantError, describeError }) {
31
+ // Read synchronously, before the first `await` — the same instant both callers used to read and
32
+ // pass in themselves, so this is still the generation this call STARTED under.
33
+ const generationAtStart = toggleGenerationRef.current;
31
34
  setGrantSaving(true);
32
35
  setGrantError(null);
33
36
  try {
@@ -68,6 +71,29 @@ async function loadGrantOptions(port) {
68
71
  throw e;
69
72
  }
70
73
  }
74
+ /**
75
+ * The combined read split into the controller's list fields: all `null` (and `canGrant` false)
76
+ * until the read settles, then `[]` roles/policies with `canGrant` false when `loadGrantOptions`
77
+ * reported a 403. Pure so `useUsers` stays a flat wiring list.
78
+ */
79
+ function selectUsersList({ data }) {
80
+ if (!data)
81
+ return { users: null, roles: null, policies: null, canGrant: false };
82
+ const grantOptions = data.grantOptions;
83
+ return { users: data.users, roles: grantOptions?.roles ?? [], policies: grantOptions?.policies ?? [], canGrant: grantOptions != null };
84
+ }
85
+ /**
86
+ * The signed-in caller as the row-level permission checks see it: their own roster row (`null`
87
+ * while `me()` or the roster is unsettled, or when the caller is not in it), whether that row is an
88
+ * owner, and the role lookup both checks share. Computed once per render rather than once per row.
89
+ *
90
+ * @complexity O(users + roles) per render.
91
+ */
92
+ function selectCaller({ users, roles, ownPrincipalId }) {
93
+ const roleMap = new Map((roles ?? []).map(role => [role.id, role]));
94
+ const ownProfile = users?.find(user => user.principalId === ownPrincipalId) ?? null;
95
+ return { roleMap, ownProfile, callerIsOwner: ownProfile !== null && isOwnerAccount({ user: ownProfile, roles: roleMap }) };
96
+ }
71
97
  /**
72
98
  * Everything the Users screen does — full state, effects, and every server write, as one hook so
73
99
  * `Users.tsx` stays a pure render of whatever this returns. See this file's header for the
@@ -77,7 +103,6 @@ async function loadGrantOptions(port) {
77
103
  * @returns The full `UsersController` the view renders from — see that interface for every field.
78
104
  */
79
105
  export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset = false, onOwnPasswordResetClosed, passwordResetNotice, } = {}) {
80
- const boundT = translate;
81
106
  const describeError = (error, fallback) => describeApiError({ error, fallback, translate });
82
107
  // `portRef` (password-banner plan, 2026-09-24 Slice 3 deep-link half): captured once at mount and
83
108
  // never written again, same discipline and same reasoning as `use-assistant-chats.hooks.ts`'s own
@@ -94,10 +119,7 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
94
119
  return { users: u.users, grantOptions };
95
120
  },
96
121
  });
97
- const users = list.data?.users ?? null;
98
- const roles = list.data ? (list.data.grantOptions?.roles ?? []) : null;
99
- const policies = list.data ? (list.data.grantOptions?.policies ?? []) : null;
100
- const canGrant = list.data?.grantOptions != null;
122
+ const { users, roles, policies, canGrant } = selectUsersList({ data: list.data });
101
123
  const error = list.error ? describeError(list.error, translate("failed to load users")) : null;
102
124
  const [formOpen, setFormOpen] = useState(false);
103
125
  const [username, setUsername] = useState("");
@@ -189,27 +211,15 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
189
211
  cancelled = true;
190
212
  };
191
213
  }, []);
192
- /** Guards the one-shot deep-link auto-open effect below so it fires at most once per mount, even
193
- * as `users`/`ownPrincipalId` keep changing identity across the renders it's waiting on (list
194
- * reloads, `me()` settling). Separate from {@link openedViaDeepLinkRef}, which tracks a DIFFERENT
195
- * question ("should closing the dialog navigate away") over the dialog's own open/close lifetime
196
- * — this one is "has the auto-open already been attempted", which must stay true forever once it
197
- * fires, including after the dialog this effect opened has since been closed. */
214
+ const { roleMap, ownProfile, callerIsOwner } = selectCaller({ users, roles, ownPrincipalId });
198
215
  function rowCapabilities(user) {
199
- const roleMap = new Map((roles ?? []).map(role => [role.id, role]));
200
- const ownRow = users?.find(row => row.principalId === ownPrincipalId);
201
- return userAccountCapabilities({ user, callerPrincipalId: ownPrincipalId,
202
- callerIsOwner: ownRow ? isOwnerAccount({ user: ownRow, roles: roleMap }) : false,
203
- canManageUserTrash, roles: roleMap });
216
+ return userAccountCapabilities({ user, callerPrincipalId: ownPrincipalId, callerIsOwner, canManageUserTrash, roles: roleMap });
204
217
  }
205
- const ownProfile = users?.find(user => user.principalId === ownPrincipalId) ?? null;
206
218
  function canRenameUser(user) {
207
219
  if (user.canRename === false)
208
220
  return false;
209
221
  if (!ownPrincipalId || (!effectivePermissions.includes("*") && !effectivePermissions.includes("user.manage")))
210
222
  return false;
211
- const roleMap = new Map((roles ?? []).map(role => [role.id, role]));
212
- const callerIsOwner = ownProfile ? isOwnerAccount({ user: ownProfile, roles: roleMap }) : false;
213
223
  // Server-supplied owner flags remain authoritative when role.manage is unavailable.
214
224
  const targetIsOwner = user.isOwner ?? (user.roleIds.some(id => !roleMap.has(id)) || isOwnerAccount({ user, roles: roleMap }));
215
225
  return !targetIsOwner || callerIsOwner;
@@ -224,6 +234,12 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
224
234
  function renameUserHandler({ user }, _options = {}) {
225
235
  return canRenameUser(user) ? (target) => changeUsername.open({ user: target }) : undefined;
226
236
  }
237
+ /** Guards the one-shot deep-link auto-open effect below so it fires at most once per mount, even
238
+ * as `users`/`ownPrincipalId` keep changing identity across the renders it's waiting on (list
239
+ * reloads, `me()` settling). Separate from {@link openedViaDeepLinkRef}, which tracks a DIFFERENT
240
+ * question ("should closing the dialog navigate away") over the dialog's own open/close lifetime
241
+ * — this one is "has the auto-open already been attempted", which must stay true forever once it
242
+ * fires, including after the dialog this effect opened has since been closed. */
227
243
  const deepLinkAttemptedRef = useRef(false);
228
244
  /** Set true exactly when this effect opens the dialog, cleared by the close-effect below the first
229
245
  * time it observes `resetPasswordFor` go back to `null`. Read there to decide whether to navigate
@@ -231,16 +247,13 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
231
247
  * normal reset, must not also get shoved to `/users`. */
232
248
  const openedViaDeepLinkRef = useRef(false);
233
249
  useEffect(() => {
234
- if (!openOwnPasswordReset || deepLinkAttemptedRef.current)
235
- return;
236
- if (!users || ownPrincipalId === null)
237
- return;
238
- const ownRow = users.find((u) => u.principalId === ownPrincipalId);
239
- if (!ownRow)
250
+ // `ownProfile` is derived from exactly `users`/`ownPrincipalId` (see `selectCaller`), so it is
251
+ // `null` until both have settled and the caller's own row is in the roster.
252
+ if (!openOwnPasswordReset || deepLinkAttemptedRef.current || !ownProfile)
240
253
  return;
241
254
  deepLinkAttemptedRef.current = true;
242
255
  openedViaDeepLinkRef.current = true;
243
- openResetPassword(ownRow);
256
+ openResetPassword(ownProfile);
244
257
  }, [openOwnPasswordReset, users, ownPrincipalId]);
245
258
  useEffect(() => {
246
259
  if (resetPasswordFor !== null || !openedViaDeepLinkRef.current)
@@ -278,12 +291,26 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
278
291
  async function onAssignRole({ principalId }) {
279
292
  if (!pendingRoleId)
280
293
  return;
281
- await runGrantMutation(toggleGenerationRef.current, toggleGenerationRef, () => assignRoleMutation.mutate({ input: { principalId, roleId: pendingRoleId } }), () => setPendingRoleId(""), setGrantSaving, setGrantError, (e) => describeError(e, translate("failed to assign role")));
294
+ await runGrantMutation({
295
+ toggleGenerationRef,
296
+ mutate: () => assignRoleMutation.mutate({ input: { principalId, roleId: pendingRoleId } }),
297
+ onSuccess: () => setPendingRoleId(""),
298
+ setGrantSaving,
299
+ setGrantError,
300
+ describeError: (e) => describeError(e, translate("failed to assign role")),
301
+ });
282
302
  }
283
303
  async function onAttachPolicy({ principalId }) {
284
304
  if (!pendingPolicyId)
285
305
  return;
286
- await runGrantMutation(toggleGenerationRef.current, toggleGenerationRef, () => attachPolicyMutation.mutate({ input: { principalId, policyId: pendingPolicyId } }), () => setPendingPolicyId(""), setGrantSaving, setGrantError, (e) => describeError(e, translate("failed to attach policy")));
306
+ await runGrantMutation({
307
+ toggleGenerationRef,
308
+ mutate: () => attachPolicyMutation.mutate({ input: { principalId, policyId: pendingPolicyId } }),
309
+ onSuccess: () => setPendingPolicyId(""),
310
+ setGrantSaving,
311
+ setGrantError,
312
+ describeError: (e) => describeError(e, translate("failed to attach policy")),
313
+ });
287
314
  }
288
315
  // `onSaveEmail` was left out of the `runGrantMutation` fold above on purpose: it tracks its OWN
289
316
  // `emailSaving` flag rather than the shared `grantSaving` `onAssignRole`/`onAttachPolicy` use, so
@@ -479,7 +506,7 @@ export function useUsers({ port, translate, queryScope }, { openOwnPasswordReset
479
506
  requestDelete,
480
507
  deleteSaving: deleteUserMutation.status === "pending",
481
508
  confirmDelete,
482
- t: boundT,
509
+ t: translate,
483
510
  translate,
484
511
  };
485
512
  }
@@ -194,16 +194,14 @@ export async function updateUser(required, optional = {}) {
194
194
  }
195
195
  /** Validate and reserve a normalized name inside the caller's identity transaction.
196
196
  * Permission/owner gates run first, so conflicts never disclose accounts to unauthorized callers.
197
+ * Check order (pinned in `__tests__/admin-crud-service.pins.test.ts`): type, blank, port wiring,
198
+ * the target's Trash state, host support (actual changes only), then uniqueness.
197
199
  * @complexity O(n) normalization for n username characters; one indexed lookup and up to two Trash reads.
198
200
  * Throws validation or conflict errors; performs no writes.
199
201
  */
200
- async function resolveUpdatedUsername(required, _optional = {}) {
202
+ async function resolveUpdatedUsername(required) {
201
203
  const { deps, input, target } = required;
202
- if (typeof input.username !== "string")
203
- throw new IdentityValidationError({ message: "username must be a string" });
204
- const username = normalizeUsername({ raw: input.username });
205
- if (!username)
206
- throw new IdentityValidationError({ message: "username must not be blank" });
204
+ const username = normalizeRequestedUsername({ raw: input.username });
207
205
  // Missing host ports are internal wiring faults, not user input errors (HTTP 500, not 400).
208
206
  if (!deps.usernameChanges)
209
207
  throw new Error("username changes are not configured");
@@ -213,15 +211,29 @@ async function resolveUpdatedUsername(required, _optional = {}) {
213
211
  if (username !== target.username) {
214
212
  await deps.usernameChanges.assertSupported?.({ workspaceId: input.workspaceId, principalId: input.principalId });
215
213
  }
214
+ await assertUsernameAvailable({ deps, usernameChanges: deps.usernameChanges, workspaceId: input.workspaceId, target, username });
215
+ return username;
216
+ }
217
+ /** A requested username must be a string that is not blank once normalized. */
218
+ function normalizeRequestedUsername(required) {
219
+ if (typeof required.raw !== "string")
220
+ throw new IdentityValidationError({ message: "username must be a string" });
221
+ const username = normalizeUsername({ raw: required.raw });
222
+ if (!username)
223
+ throw new IdentityValidationError({ message: "username must not be blank" });
224
+ return username;
225
+ }
226
+ /** Refuses a normalized name held by ANY other user, naming Trash as the reason when the holder is there. */
227
+ async function assertUsernameAvailable(required) {
228
+ const { deps, usernameChanges, workspaceId, target, username } = required;
216
229
  // Trash keeps credential rows until purge, so the unfiltered lookup reserves those names too.
217
- const existing = await deps.repos.users.findByUsername({ workspaceId: input.workspaceId, username });
218
- if (existing && existing.principalId !== target.principalId) {
219
- if (await deps.usernameChanges.isInTrash({ workspaceId: input.workspaceId, principalId: existing.principalId })) {
220
- throw new IdentityConflictError({ message: "a user with this username is in the Trash; restore or delete them permanently first", code: "USERNAME_IN_TRASH" });
221
- }
222
- throw new IdentityConflictError({ message: `username '${username}' is already in use`, code: "RESOURCE_CONFLICT" });
230
+ const existing = await deps.repos.users.findByUsername({ workspaceId, username });
231
+ if (!existing || existing.principalId === target.principalId)
232
+ return;
233
+ if (await usernameChanges.isInTrash({ workspaceId, principalId: existing.principalId })) {
234
+ throw new IdentityConflictError({ message: "a user with this username is in the Trash; restore or delete them permanently first", code: "USERNAME_IN_TRASH" });
223
235
  }
224
- return username;
236
+ throw new IdentityConflictError({ message: `username '${username}' is already in use`, code: "RESOURCE_CONFLICT" });
225
237
  }
226
238
  /**
227
239
  * `RESET_USER_PASSWORD` (feature.spec.md ). Gated by **`user.manage`** only