@jini-ai/user-management 0.3.3 → 0.6.10
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 +15 -1
- package/dist/admin/adapters/memory.d.ts +3 -5
- package/dist/admin/adapters/memory.js +27 -28
- package/dist/admin/conformance/index.js +1 -0
- package/dist/admin/controllers/auth.controller.js +8 -6
- package/dist/admin/controllers/members.controller.js +9 -6
- package/dist/admin/controllers/roles.controller.js +99 -105
- package/dist/admin/controllers/users.controller.js +56 -36
- package/dist/admin/error-copy.d.ts +11 -0
- package/dist/admin/error-copy.js +10 -0
- package/dist/admin/people.rules.js +37 -31
- package/dist/admin/react/components/MemberRow.d.ts +1 -1
- package/dist/admin/react/components/MemberRow.js +12 -2
- package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +6 -21
- package/dist/admin/react/hooks/AuthBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/AuthScope.hooks.d.ts +22 -0
- package/dist/admin/react/hooks/AuthScope.hooks.js +10 -0
- package/dist/admin/react/hooks/LoginPage.hooks.d.ts +1 -1
- package/dist/admin/react/hooks/LoginPage.hooks.js +1 -1
- package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +6 -17
- package/dist/admin/react/hooks/MembersBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/MembersPage.hooks.d.ts +1 -1
- package/dist/admin/react/hooks/MembersPage.hooks.js +1 -1
- package/dist/admin/react/hooks/MembersScope.hooks.d.ts +17 -0
- package/dist/admin/react/hooks/MembersScope.hooks.js +10 -0
- package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +7 -21
- package/dist/admin/react/hooks/RolesBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/RolesController.hooks.js +1 -1
- package/dist/admin/react/hooks/RolesPage.hooks.d.ts +2 -2
- package/dist/admin/react/hooks/RolesPage.hooks.js +22 -8
- package/dist/admin/react/hooks/RolesScope.hooks.d.ts +21 -0
- package/dist/admin/react/hooks/RolesScope.hooks.js +10 -0
- package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +6 -18
- package/dist/admin/react/hooks/UsersBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/UsersPage.hooks.d.ts +1 -1
- package/dist/admin/react/hooks/UsersPage.hooks.js +1 -1
- package/dist/admin/react/hooks/UsersScope.hooks.d.ts +19 -0
- package/dist/admin/react/hooks/UsersScope.hooks.js +10 -0
- package/dist/admin/react/hooks/UsersTab.hooks.d.ts +6 -0
- package/dist/admin/react/hooks/UsersTab.hooks.js +34 -22
- package/dist/admin/react/index.d.ts +4 -4
- package/dist/admin/react/pages/LoginPage.d.ts +1 -1
- package/dist/admin/react/pages/MembersPage.d.ts +1 -1
- package/dist/admin/react/pages/RolesPage.d.ts +2 -2
- package/dist/admin/react/pages/RolesPage.js +6 -2
- package/dist/admin/react/pages/UsersPage.d.ts +2 -2
- package/dist/admin/react/pages/UsersPage.js +6 -2
- package/dist/admin/rules.js +14 -12
- package/dist/react/errors.js +16 -9
- package/dist/react/index.d.ts +1 -1
- package/dist/react/members/hooks/use-members.hooks.js +21 -12
- package/dist/react/testing.js +4 -6
- package/dist/react/users/UserRowParts.d.ts +53 -0
- package/dist/react/users/UserRowParts.js +39 -0
- package/dist/react/users/Users.d.ts +31 -2
- package/dist/react/users/Users.js +41 -36
- package/dist/react/users/hooks/use-users.hooks.js +57 -30
- package/dist/server/admin-crud-service.js +25 -13
- package/dist/server/agent-tool-input.js +62 -36
- package/dist/server/agent-tool-registrations.d.ts +46 -0
- package/dist/server/agent-tool-registrations.js +28 -20
- package/dist/server/auth-service.js +35 -20
- package/dist/server/authorize.js +7 -9
- package/dist/server/permission-migrations.js +35 -25
- package/dist/server/repo.memory-transactions.js +5 -6
- package/dist/server/repo.memory.js +35 -81
- package/dist/server/seed.js +100 -84
- package/package.json +12 -12
|
@@ -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
|
-
|
|
59
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
36
|
-
|
|
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: [
|
|
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
|
-
|
|
164
|
-
|
|
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 }),
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
if (!
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
|
202
|
+
async function resolveUpdatedUsername(required) {
|
|
201
203
|
const { deps, input, target } = required;
|
|
202
|
-
|
|
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
|
|
218
|
-
if (existing
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|