@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,33 +1,141 @@
|
|
|
1
1
|
import type { AdminIdentityUser } from "../models.js";
|
|
2
|
-
import type { Translate } from "@jini-ai/ui/panel-kit";
|
|
2
|
+
import type { Translate, QueryKey } from "@jini-ai/ui/panel-kit";
|
|
3
3
|
import type { RowMenuItem } from "@jini-ai/admin/react";
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* @file Pure logic for the `users` feature — everything that computes a value rather than
|
|
6
|
+
* rendering one. Follows the `posts/rules.ts` convention: no React import, no hooks, directly
|
|
7
|
+
* testable.
|
|
8
|
+
*
|
|
9
|
+
* Moved here from `Users.tsx`: the screen's own `describeApiError` override (a branch per server
|
|
10
|
+
* error `code`), the row-menu item builder (a branch on `user.status` plus the confirm-vs-immediate
|
|
11
|
+
* split for Disable/Enable), and the role/policy grant label formatter (a branch on whether the
|
|
12
|
+
* user holds any grants of that kind). All three were previously closures or free functions inside
|
|
13
|
+
* the component, reachable only by rendering the full screen.
|
|
14
|
+
*
|
|
15
|
+
* `KEYS` (fetch-query migration, 2026-08-12): one identity for the combined users+roles+policies
|
|
16
|
+
* read — matches the pre-migration `reload()`, which always refetched all three together.
|
|
17
|
+
*/
|
|
5
18
|
export declare const KEYS: {
|
|
6
19
|
list: QueryKey;
|
|
7
20
|
};
|
|
8
|
-
|
|
21
|
+
/** Server error `code` -> a plain-language prefix (SPEC-006 errors.spec.md §2), layered on the
|
|
22
|
+
* shared default (`lib/api.ts`'s `describeApiError`) — this screen's `RESOURCE_CONFLICT` means
|
|
23
|
+
* "username already in use", a different meaning than `Roles.tsx`'s "still referenced" or
|
|
24
|
+
* `Workspace.tsx`'s "slug already taken" for the same code (audit cross-cutting finding #2 —
|
|
25
|
+
* deliberately not unified into one table). `VALIDATION_ERROR` stays a dedicated branch rather than
|
|
26
|
+
* joining the table above: its message comes from the error itself (`e.message`), not a fixed
|
|
27
|
+
* string, so it isn't a value a plain lookup can hold.
|
|
28
|
+
*
|
|
29
|
+
* `locale` (C4 fix, 2026-09-20): every branch here used to return its English literal directly,
|
|
30
|
+
* leaking English into every non-`en` locale regardless of the caller's own translated fallback —
|
|
31
|
+
* the fallback was already threaded through `t()` at each call site, but these overrides were not.
|
|
32
|
+
* Each literal is now also a key into `users-i18n.ts`'s `USERS_DICT`. */
|
|
33
|
+
export declare function describeUsersError({ error, fallback, translate }: {
|
|
9
34
|
error: unknown;
|
|
10
35
|
fallback: string;
|
|
11
36
|
translate: Translate;
|
|
12
|
-
}): string;
|
|
37
|
+
}, _optional?: Record<string, never>): string;
|
|
38
|
+
export { describeUsersError as describeApiError };
|
|
39
|
+
/** The callbacks a row menu needs. Passed in rather than imported, so this module stays free of
|
|
40
|
+
* state, and so a test can assert exactly which one a given row wires up (same convention as
|
|
41
|
+
* `posts/rules.ts`'s `PostRowMenuHandlers`). */
|
|
13
42
|
export interface UserRowMenuHandlers {
|
|
43
|
+
/** Asked only when `user.status === "active"` — opens the confirm dialog rather than acting. */
|
|
14
44
|
onRequestDisable: (user: AdminIdentityUser) => void;
|
|
45
|
+
/** Asked only when `user.status !== "active"` — fires immediately, no confirm (Enable was never
|
|
46
|
+
* confirm-gated). */
|
|
15
47
|
onEnable: (user: AdminIdentityUser) => void;
|
|
16
48
|
onManage: (user: AdminIdentityUser) => void;
|
|
17
49
|
onResetPassword: (user: AdminIdentityUser) => void;
|
|
50
|
+
onChangeUsername?: ((user: AdminIdentityUser) => void) | undefined;
|
|
51
|
+
/** Delete-user plan v2 (2026-09-24) — always opens the confirm dialog, never fires immediately:
|
|
52
|
+
* unlike Enable, "Delete" moves the user to the Trash, a destructive-looking action even though
|
|
53
|
+
* it is recoverable there for 60 days. Only asked for when `userRowMenuItems`'s own `canDelete`
|
|
54
|
+
* is true — see that function's doc comment. */
|
|
18
55
|
onRequestDelete: (user: AdminIdentityUser) => void;
|
|
19
56
|
}
|
|
20
|
-
export
|
|
57
|
+
export interface UserAccountCapabilities {
|
|
58
|
+
canDelete: boolean;
|
|
59
|
+
canDisable: boolean;
|
|
60
|
+
canEnable: boolean;
|
|
61
|
+
canResetPassword: boolean;
|
|
62
|
+
}
|
|
63
|
+
/** Courtesy mirror of the server target rule. Unknown identity/roles fail closed. */
|
|
64
|
+
export declare function userAccountCapabilities(required: {
|
|
65
|
+
user: AdminIdentityUser;
|
|
66
|
+
callerPrincipalId: string | null;
|
|
67
|
+
callerIsOwner: boolean;
|
|
68
|
+
canManageUserTrash: boolean;
|
|
69
|
+
roles: ReadonlyMap<string, {
|
|
70
|
+
name: string;
|
|
71
|
+
isBuiltin: boolean;
|
|
72
|
+
}>;
|
|
73
|
+
}, _optional?: Record<string, never>): UserAccountCapabilities;
|
|
74
|
+
export declare function isOwnerAccount(required: {
|
|
75
|
+
user: AdminIdentityUser;
|
|
76
|
+
roles: ReadonlyMap<string, {
|
|
77
|
+
name: string;
|
|
78
|
+
isBuiltin: boolean;
|
|
79
|
+
}>;
|
|
80
|
+
}, _optional?: Record<string, never>): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* `RowMenu` items for one user row — matches `Posts.tsx`/`Pages.tsx`'s three-dot menu shape:
|
|
83
|
+
* Disable/Enable, Manage, Reset password, and — delete-user plan v2 (2026-09-24) — Delete, appended
|
|
84
|
+
* only when `canDelete` is true. The server gate (owner or the built-in `admin` role,
|
|
85
|
+
* `delete-user-service.ts`'s `callerMayManageUserTrash`) is the real boundary; `canDelete` is this
|
|
86
|
+
* row's own affordance-hiding mirror of it, computed once per render from `/auth/me` by
|
|
87
|
+
* `use-users.hooks.ts` — see that hook's `canManageUserTrash` state for where it comes from.
|
|
88
|
+
*
|
|
89
|
+
* Disable/Enable share a single "toggle" item (label follows status, same shape as
|
|
90
|
+
* `Redirects.tsx`'s own toggle item) — Disable confirms via the modal below; Enable fires
|
|
91
|
+
* immediately, matching this screen's existing behavior (Enable was never confirm-gated). The
|
|
92
|
+
* `toggleSaving` guard reproduces the original inline check (`if (toggleSavingId) return`) against
|
|
93
|
+
* whatever row action is currently in flight, read fresh at click time.
|
|
94
|
+
*
|
|
95
|
+
* "Manage" always reads "Manage", never "Close": the item still toggles the expanded panel
|
|
96
|
+
* (`onManage` — closing it again by selecting "Manage" a second time still works exactly as it did
|
|
97
|
+
* as a standalone button), but a `RowMenu` item disappears the instant it is selected, so a label
|
|
98
|
+
* that flips to "Close" is never actually visible mid-interaction — it would only ever describe a
|
|
99
|
+
* state the operator cannot see while the menu that shows it is open. A static label sidesteps that
|
|
100
|
+
* without losing any capability.
|
|
101
|
+
*
|
|
102
|
+
* "Delete" always opens the confirm dialog (`onRequestDelete`), matching Disable's shape rather than
|
|
103
|
+
* Enable's immediate-fire one — see `UserRowMenuHandlers.onRequestDelete`'s own doc comment.
|
|
104
|
+
*
|
|
105
|
+
* @complexity Time/space: O(1) — at most four entries, no iteration.
|
|
106
|
+
*/
|
|
107
|
+
export declare function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete, capabilities }: {
|
|
21
108
|
user: AdminIdentityUser;
|
|
22
109
|
toggleSaving: boolean;
|
|
23
110
|
handlers: UserRowMenuHandlers;
|
|
24
111
|
translate: Translate;
|
|
25
112
|
canDelete: boolean;
|
|
26
|
-
|
|
27
|
-
|
|
113
|
+
capabilities?: boolean | UserAccountCapabilities | undefined;
|
|
114
|
+
}, _optional?: Record<string, never>): RowMenuItem[];
|
|
115
|
+
/**
|
|
116
|
+
* The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
|
|
117
|
+
* user holds none, which the caller renders as the `muted-cell` "none" fallback (a plain ternary on
|
|
118
|
+
* this already-computed value, per this feature's extraction rule).
|
|
119
|
+
*
|
|
120
|
+
* A grant id with no matching entry in `byId` (a role/policy deleted out from under a still-held
|
|
121
|
+
* grant) falls back to rendering the raw id rather than dropping it silently — the operator sees
|
|
122
|
+
* something is wrong instead of an undercount with no explanation.
|
|
123
|
+
*
|
|
124
|
+
* @complexity Time: O(n) in the number of granted ids; space: O(n) for the joined string.
|
|
125
|
+
*/
|
|
126
|
+
export declare function grantOptionLabel(required: {
|
|
127
|
+
option: {
|
|
128
|
+
name: string;
|
|
129
|
+
isBuiltin: boolean;
|
|
130
|
+
};
|
|
131
|
+
translate: (key: string) => string;
|
|
132
|
+
}, _optional?: Record<string, never>): string;
|
|
133
|
+
export declare function formatGrantLabel({ ids, byId, translate }: {
|
|
28
134
|
ids: readonly string[];
|
|
29
135
|
byId: ReadonlyMap<string, {
|
|
30
136
|
name: string;
|
|
137
|
+
isBuiltin?: boolean;
|
|
31
138
|
}>;
|
|
32
|
-
|
|
139
|
+
translate?: Translate | undefined;
|
|
140
|
+
}, _optional?: Record<string, never>): string | null;
|
|
33
141
|
//# sourceMappingURL=rules.d.ts.map
|
|
@@ -1,20 +1,101 @@
|
|
|
1
1
|
import { describeIdentityError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* @file Pure logic for the `users` feature — everything that computes a value rather than
|
|
4
|
+
* rendering one. Follows the `posts/rules.ts` convention: no React import, no hooks, directly
|
|
5
|
+
* testable.
|
|
6
|
+
*
|
|
7
|
+
* Moved here from `Users.tsx`: the screen's own `describeApiError` override (a branch per server
|
|
8
|
+
* error `code`), the row-menu item builder (a branch on `user.status` plus the confirm-vs-immediate
|
|
9
|
+
* split for Disable/Enable), and the role/policy grant label formatter (a branch on whether the
|
|
10
|
+
* user holds any grants of that kind). All three were previously closures or free functions inside
|
|
11
|
+
* the component, reachable only by rendering the full screen.
|
|
12
|
+
*
|
|
13
|
+
* `KEYS` (fetch-query migration, 2026-08-12): one identity for the combined users+roles+policies
|
|
14
|
+
* read — matches the pre-migration `reload()`, which always refetched all three together.
|
|
15
|
+
*/
|
|
2
16
|
export const KEYS = {
|
|
3
17
|
list: ["users-roles-policies"],
|
|
4
18
|
};
|
|
19
|
+
/** Server error `code` -> a plain-language prefix, for every code on this screen whose message is
|
|
20
|
+
* a fixed string. Keyed by the same closed set of `ApiError` codes the old if-chain checked, in a
|
|
21
|
+
* flat lookup rather than sequential branches — this is what actually lowered the function's
|
|
22
|
+
* cognitive score (11 -> under the ceiling): the codes are a closed set of *literal string* keys,
|
|
23
|
+
* not a discriminated union, so there is no TypeScript exhaustiveness to lose by using a table
|
|
24
|
+
* (contrast `translateRunAgentPayload`'s `switch`, which stays a `switch` for exactly that reason).
|
|
25
|
+
* Values are the English source strings — `t()`'s own keys — not yet localized; `describeApiError`
|
|
26
|
+
* below is what translates them. */
|
|
5
27
|
const STATIC_ERROR_MESSAGES = {
|
|
6
28
|
GRANT_EXCEEDS_ISSUER: "You cannot grant a permission you do not hold.",
|
|
7
29
|
FORBIDDEN: "You do not have permission to do that.",
|
|
8
30
|
RESOURCE_CONFLICT: "That username is already in use.",
|
|
9
31
|
OWNER_REQUIRED: "The workspace must keep at least one active owner.",
|
|
32
|
+
// Delete-user plan v2 (2026-09-24), Slice 3/decision 7 — the DELETE route's own 409s.
|
|
10
33
|
SELF_DELETE: "You cannot delete your own account.",
|
|
11
34
|
USER_IN_TRASH: "This user is in the Trash; restore them first.",
|
|
12
35
|
USERNAME_IN_TRASH: "A user with this username is in the Trash; restore or delete them permanently first.",
|
|
13
36
|
};
|
|
14
|
-
|
|
37
|
+
/** Server error `code` -> a plain-language prefix (SPEC-006 errors.spec.md §2), layered on the
|
|
38
|
+
* shared default (`lib/api.ts`'s `describeApiError`) — this screen's `RESOURCE_CONFLICT` means
|
|
39
|
+
* "username already in use", a different meaning than `Roles.tsx`'s "still referenced" or
|
|
40
|
+
* `Workspace.tsx`'s "slug already taken" for the same code (audit cross-cutting finding #2 —
|
|
41
|
+
* deliberately not unified into one table). `VALIDATION_ERROR` stays a dedicated branch rather than
|
|
42
|
+
* joining the table above: its message comes from the error itself (`e.message`), not a fixed
|
|
43
|
+
* string, so it isn't a value a plain lookup can hold.
|
|
44
|
+
*
|
|
45
|
+
* `locale` (C4 fix, 2026-09-20): every branch here used to return its English literal directly,
|
|
46
|
+
* leaking English into every non-`en` locale regardless of the caller's own translated fallback —
|
|
47
|
+
* the fallback was already threaded through `t()` at each call site, but these overrides were not.
|
|
48
|
+
* Each literal is now also a key into `users-i18n.ts`'s `USERS_DICT`. */
|
|
49
|
+
export function describeUsersError({ error, fallback, translate }, _optional = {}) {
|
|
15
50
|
return describeIdentityError({ error, fallback, translate }, { messages: STATIC_ERROR_MESSAGES });
|
|
16
51
|
}
|
|
17
|
-
export
|
|
52
|
+
export { describeUsersError as describeApiError };
|
|
53
|
+
/** Courtesy mirror of the server target rule. Unknown identity/roles fail closed. */
|
|
54
|
+
export function userAccountCapabilities(required, _optional = {}) {
|
|
55
|
+
const { user, callerPrincipalId, callerIsOwner, canManageUserTrash, roles } = required;
|
|
56
|
+
const known = callerPrincipalId !== null;
|
|
57
|
+
const protectedAccount = user.isProtectedAccount ?? (user.roleIds.some(id => {
|
|
58
|
+
const role = roles.get(id);
|
|
59
|
+
return !role || (role.isBuiltin && (role.name === "admin" || role.name === "owner"));
|
|
60
|
+
}));
|
|
61
|
+
const mayAct = known && (callerIsOwner || !protectedAccount);
|
|
62
|
+
const destructive = mayAct && callerPrincipalId !== user.principalId;
|
|
63
|
+
return { canDelete: destructive && canManageUserTrash, canDisable: destructive,
|
|
64
|
+
canEnable: mayAct, canResetPassword: mayAct };
|
|
65
|
+
}
|
|
66
|
+
export function isOwnerAccount(required, _optional = {}) {
|
|
67
|
+
return required.user.isOwner ?? required.user.roleIds.some(id => {
|
|
68
|
+
const role = required.roles.get(id);
|
|
69
|
+
return role?.isBuiltin === true && role.name === "owner";
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `RowMenu` items for one user row — matches `Posts.tsx`/`Pages.tsx`'s three-dot menu shape:
|
|
74
|
+
* Disable/Enable, Manage, Reset password, and — delete-user plan v2 (2026-09-24) — Delete, appended
|
|
75
|
+
* only when `canDelete` is true. The server gate (owner or the built-in `admin` role,
|
|
76
|
+
* `delete-user-service.ts`'s `callerMayManageUserTrash`) is the real boundary; `canDelete` is this
|
|
77
|
+
* row's own affordance-hiding mirror of it, computed once per render from `/auth/me` by
|
|
78
|
+
* `use-users.hooks.ts` — see that hook's `canManageUserTrash` state for where it comes from.
|
|
79
|
+
*
|
|
80
|
+
* Disable/Enable share a single "toggle" item (label follows status, same shape as
|
|
81
|
+
* `Redirects.tsx`'s own toggle item) — Disable confirms via the modal below; Enable fires
|
|
82
|
+
* immediately, matching this screen's existing behavior (Enable was never confirm-gated). The
|
|
83
|
+
* `toggleSaving` guard reproduces the original inline check (`if (toggleSavingId) return`) against
|
|
84
|
+
* whatever row action is currently in flight, read fresh at click time.
|
|
85
|
+
*
|
|
86
|
+
* "Manage" always reads "Manage", never "Close": the item still toggles the expanded panel
|
|
87
|
+
* (`onManage` — closing it again by selecting "Manage" a second time still works exactly as it did
|
|
88
|
+
* as a standalone button), but a `RowMenu` item disappears the instant it is selected, so a label
|
|
89
|
+
* that flips to "Close" is never actually visible mid-interaction — it would only ever describe a
|
|
90
|
+
* state the operator cannot see while the menu that shows it is open. A static label sidesteps that
|
|
91
|
+
* without losing any capability.
|
|
92
|
+
*
|
|
93
|
+
* "Delete" always opens the confirm dialog (`onRequestDelete`), matching Disable's shape rather than
|
|
94
|
+
* Enable's immediate-fire one — see `UserRowMenuHandlers.onRequestDelete`'s own doc comment.
|
|
95
|
+
*
|
|
96
|
+
* @complexity Time/space: O(1) — at most four entries, no iteration.
|
|
97
|
+
*/
|
|
98
|
+
export function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete, capabilities = canDelete }, _optional = {}) {
|
|
18
99
|
const items = [
|
|
19
100
|
{
|
|
20
101
|
key: "toggle",
|
|
@@ -43,7 +124,13 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
|
|
|
43
124
|
onSelect: () => handlers.onResetPassword(user),
|
|
44
125
|
},
|
|
45
126
|
];
|
|
46
|
-
|
|
127
|
+
const permissions = typeof capabilities === "boolean"
|
|
128
|
+
? { canDelete: capabilities, canDisable: true, canEnable: true, canResetPassword: true }
|
|
129
|
+
: capabilities;
|
|
130
|
+
if (handlers.onChangeUsername) {
|
|
131
|
+
items.push({ key: "change-username", label: translate("Change username"), onSelect: () => handlers.onChangeUsername?.(user) });
|
|
132
|
+
}
|
|
133
|
+
if (permissions.canDelete) {
|
|
47
134
|
items.push({
|
|
48
135
|
key: "delete",
|
|
49
136
|
label: translate("Delete"),
|
|
@@ -51,11 +138,30 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
|
|
|
51
138
|
onSelect: () => handlers.onRequestDelete(user),
|
|
52
139
|
});
|
|
53
140
|
}
|
|
54
|
-
return items
|
|
141
|
+
return items.filter(item => item.key === "toggle"
|
|
142
|
+
? (user.status === "active" ? permissions.canDisable : permissions.canEnable)
|
|
143
|
+
: item.key !== "reset-password" || permissions.canResetPassword);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
|
|
147
|
+
* user holds none, which the caller renders as the `muted-cell` "none" fallback (a plain ternary on
|
|
148
|
+
* this already-computed value, per this feature's extraction rule).
|
|
149
|
+
*
|
|
150
|
+
* A grant id with no matching entry in `byId` (a role/policy deleted out from under a still-held
|
|
151
|
+
* grant) falls back to rendering the raw id rather than dropping it silently — the operator sees
|
|
152
|
+
* something is wrong instead of an undercount with no explanation.
|
|
153
|
+
*
|
|
154
|
+
* @complexity Time: O(n) in the number of granted ids; space: O(n) for the joined string.
|
|
155
|
+
*/
|
|
156
|
+
export function grantOptionLabel(required, _optional = {}) {
|
|
157
|
+
return required.option.isBuiltin && required.option.name === "owner" ? required.translate("Owner") : required.option.name;
|
|
55
158
|
}
|
|
56
|
-
export function formatGrantLabel({ ids, byId }) {
|
|
159
|
+
export function formatGrantLabel({ ids, byId, translate = (key) => key }, _optional = {}) {
|
|
57
160
|
if (ids.length === 0)
|
|
58
161
|
return null;
|
|
59
|
-
return ids.map(
|
|
162
|
+
return ids.map(id => {
|
|
163
|
+
const role = byId.get(id);
|
|
164
|
+
return role?.isBuiltin && role.name === "owner" ? translate("Owner") : role?.name ?? id;
|
|
165
|
+
}).join(", ");
|
|
60
166
|
}
|
|
61
167
|
//# sourceMappingURL=rules.js.map
|
|
@@ -75,12 +75,13 @@ export declare function enablePrincipal(required: {
|
|
|
75
75
|
}>;
|
|
76
76
|
/**
|
|
77
77
|
* `UPDATE_USER` (feature.spec.md ). Gated by `user.manage`; existing self-profile edits also
|
|
78
|
-
* accept `member.manage`.
|
|
79
|
-
*
|
|
78
|
+
* accept `member.manage`. Username changes require `user.manage`, including self edits.
|
|
79
|
+
* Only owners may edit owner targets. Password is not this transition's concern.
|
|
80
|
+
* An absent or empty-string `email`
|
|
80
81
|
* clears the stored value to `undefined` ( — no distinct "blank but present" state).
|
|
81
82
|
*
|
|
82
|
-
* @complexity O(
|
|
83
|
-
*
|
|
83
|
+
* @complexity O(n) normalization for n username characters; fixed repo/Trash reads, one user save
|
|
84
|
+
* and one audit append for an actual rename, all inside the shared identity transaction.
|
|
84
85
|
* See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
|
|
85
86
|
*/
|
|
86
87
|
export declare function updateUser(required: {
|
|
@@ -92,6 +93,7 @@ export declare function updateUser(required: {
|
|
|
92
93
|
};
|
|
93
94
|
}, optional?: {
|
|
94
95
|
email?: string | undefined;
|
|
96
|
+
username?: string | undefined;
|
|
95
97
|
}): Promise<{
|
|
96
98
|
user: UserRecord;
|
|
97
99
|
}>;
|
|
@@ -2,6 +2,7 @@ import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
|
|
|
2
2
|
import { inIdentityTransaction } from "./identity-transaction.js";
|
|
3
3
|
import { assertCallerHasAnyPermission, assertGrantClamp, assertOwnerTargetMayBeModified, principalHoldsOwnerWildcard } from "./grant-service.js";
|
|
4
4
|
import { validatePasswordPolicy } from "./password-policy.js";
|
|
5
|
+
import { normalizeUsername } from "./username.js";
|
|
5
6
|
import { isKnownPermission } from "../core/permissions.js";
|
|
6
7
|
import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
|
|
7
8
|
import { IdentityConflictError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, PermissionUnknownError, } from "../core/types.js";
|
|
@@ -138,12 +139,13 @@ export async function enablePrincipal(required) {
|
|
|
138
139
|
}
|
|
139
140
|
/**
|
|
140
141
|
* `UPDATE_USER` (feature.spec.md ). Gated by `user.manage`; existing self-profile edits also
|
|
141
|
-
* accept `member.manage`.
|
|
142
|
-
*
|
|
142
|
+
* accept `member.manage`. Username changes require `user.manage`, including self edits.
|
|
143
|
+
* Only owners may edit owner targets. Password is not this transition's concern.
|
|
144
|
+
* An absent or empty-string `email`
|
|
143
145
|
* clears the stored value to `undefined` ( — no distinct "blank but present" state).
|
|
144
146
|
*
|
|
145
|
-
* @complexity O(
|
|
146
|
-
*
|
|
147
|
+
* @complexity O(n) normalization for n username characters; fixed repo/Trash reads, one user save
|
|
148
|
+
* and one audit append for an actual rename, all inside the shared identity transaction.
|
|
147
149
|
* See docs/decisions/DR-002-grant-authority-and-admin-safety.md.
|
|
148
150
|
*/
|
|
149
151
|
export async function updateUser(required, optional = {}) {
|
|
@@ -158,7 +160,7 @@ export async function updateUser(required, optional = {}) {
|
|
|
158
160
|
workspaceId: input.workspaceId,
|
|
159
161
|
callerPrincipalId: input.callerPrincipalId,
|
|
160
162
|
// Preserve existing self-profile access without letting member.manage edit other operators.
|
|
161
|
-
permissions: input.callerPrincipalId === input.principalId
|
|
163
|
+
permissions: input.username === undefined && input.callerPrincipalId === input.principalId
|
|
162
164
|
? ["user.manage", "member.manage"]
|
|
163
165
|
: ["user.manage"],
|
|
164
166
|
});
|
|
@@ -174,12 +176,53 @@ export async function updateUser(required, optional = {}) {
|
|
|
174
176
|
callerPrincipalId: input.callerPrincipalId,
|
|
175
177
|
principalId: input.principalId,
|
|
176
178
|
});
|
|
177
|
-
const
|
|
179
|
+
const username = input.username === undefined ? target.username : await resolveUpdatedUsername({ deps, input, target });
|
|
180
|
+
// Preserve the old email setter contract, while a username-only call retains email.
|
|
181
|
+
const email = input.username !== undefined && !Object.hasOwn(optional, "email")
|
|
182
|
+
? target.email : (input.email || undefined);
|
|
183
|
+
const updated = { ...target, username, email };
|
|
178
184
|
await deps.repos.users.save(updated);
|
|
185
|
+
if (username !== target.username) {
|
|
186
|
+
// Renaming never changes the principal id or credentials: existing sessions stay valid.
|
|
187
|
+
await deps.usernameChanges.record({ workspaceId: input.workspaceId, principalId: input.principalId,
|
|
188
|
+
callerPrincipalId: input.callerPrincipalId, previousUsername: target.username, username,
|
|
189
|
+
occurredAt: kernelNowIso({ clock: deps.clock }) });
|
|
190
|
+
}
|
|
179
191
|
return { user: updated };
|
|
180
192
|
},
|
|
181
193
|
});
|
|
182
194
|
}
|
|
195
|
+
/** Validate and reserve a normalized name inside the caller's identity transaction.
|
|
196
|
+
* Permission/owner gates run first, so conflicts never disclose accounts to unauthorized callers.
|
|
197
|
+
* @complexity O(n) normalization for n username characters; one indexed lookup and up to two Trash reads.
|
|
198
|
+
* Throws validation or conflict errors; performs no writes.
|
|
199
|
+
*/
|
|
200
|
+
async function resolveUpdatedUsername(required, _optional = {}) {
|
|
201
|
+
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" });
|
|
207
|
+
// Missing host ports are internal wiring faults, not user input errors (HTTP 500, not 400).
|
|
208
|
+
if (!deps.usernameChanges)
|
|
209
|
+
throw new Error("username changes are not configured");
|
|
210
|
+
if (await deps.usernameChanges.isInTrash({ workspaceId: input.workspaceId, principalId: input.principalId })) {
|
|
211
|
+
throw new IdentityConflictError({ message: "this user is in the Trash; restore them first", code: "USER_IN_TRASH" });
|
|
212
|
+
}
|
|
213
|
+
if (username !== target.username) {
|
|
214
|
+
await deps.usernameChanges.assertSupported?.({ workspaceId: input.workspaceId, principalId: input.principalId });
|
|
215
|
+
}
|
|
216
|
+
// 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" });
|
|
223
|
+
}
|
|
224
|
+
return username;
|
|
225
|
+
}
|
|
183
226
|
/**
|
|
184
227
|
* `RESET_USER_PASSWORD` (feature.spec.md ). Gated by **`user.manage`** only
|
|
185
228
|
* (resetting a credential is at least as sensitive as disabling the account, own reasoning). Hashes the new password identically to `CREATE_USER` (argon2id,
|
|
@@ -40,6 +40,7 @@ export interface IdentityToolDeps {
|
|
|
40
40
|
/** Bind the same transaction port as every grant/status writer. */
|
|
41
41
|
transactions: IdentityRepos["transactions"];
|
|
42
42
|
tokens: AuthServiceDeps["tokens"];
|
|
43
|
+
usernameChanges?: AuthServiceDeps["usernameChanges"];
|
|
43
44
|
}
|
|
44
45
|
/**
|
|
45
46
|
* This wiring layer's OWN risk classification, authored from what each handler below actually
|
|
@@ -48,6 +48,7 @@ function identityServiceDepsFrom(deps) {
|
|
|
48
48
|
clock: deps.clock,
|
|
49
49
|
idGen: deps.idGen,
|
|
50
50
|
tokens: deps.tokens,
|
|
51
|
+
usernameChanges: deps.usernameChanges,
|
|
51
52
|
};
|
|
52
53
|
}
|
|
53
54
|
/**
|
|
@@ -64,6 +65,8 @@ export const identityDerivedRisk = new Map([
|
|
|
64
65
|
["identity_user_create", "mutates-durable-state"],
|
|
65
66
|
// -> updateUser (admin-crud-service.ts): users.save, email field only.
|
|
66
67
|
["identity_user_update_email", "mutates-durable-state"],
|
|
68
|
+
// -> updateUser (admin-crud-service.ts): users.save, username field only, plus the transactional audit.
|
|
69
|
+
["identity_user_change_username", "mutates-durable-state"],
|
|
67
70
|
// -> disablePrincipal (admin-crud-service.ts): status flip, guarded by the owner floor. See docs/decisions/DR-007-workspace-and-owner-floors.md.
|
|
68
71
|
["identity_user_disable", "mutates-durable-state"],
|
|
69
72
|
// -> enablePrincipal (admin-crud-service.ts): status flip back to active.
|
|
@@ -283,6 +286,17 @@ export function buildIdentityRegistrations(routeDeps) {
|
|
|
283
286
|
throw new Error(`principal '${input.req("principalId")}' was not found`);
|
|
284
287
|
return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principal.id) }) };
|
|
285
288
|
},
|
|
289
|
+
identity_user_change_username: async (ctx) => {
|
|
290
|
+
const input = identityInput("identity_user_change_username", ctx.input);
|
|
291
|
+
const principalId = input.req("principalId");
|
|
292
|
+
const { user } = await updateUser({ deps: serviceDeps(), input: {
|
|
293
|
+
workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, principalId,
|
|
294
|
+
} }, { username: input.req("username") });
|
|
295
|
+
const principal = await routeDeps.principalRepo.findById({ workspaceId: routeDeps.workspaceId, id: principalId });
|
|
296
|
+
if (!principal)
|
|
297
|
+
throw new Error(`principal '${principalId}' was not found`);
|
|
298
|
+
return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principalId) }) };
|
|
299
|
+
},
|
|
286
300
|
identity_user_disable: async (ctx) => {
|
|
287
301
|
const input = identityInput("identity_user_disable", ctx.input);
|
|
288
302
|
const { principal } = await disablePrincipal({
|
|
@@ -106,6 +106,17 @@ export const identityAgentToolCatalog = [
|
|
|
106
106
|
},
|
|
107
107
|
},
|
|
108
108
|
},
|
|
109
|
+
{
|
|
110
|
+
name: "identity_user_change_username",
|
|
111
|
+
description: "Changes an operator user's login username. Call after finding their principalId with identity_user_list. Returns { user } with the normalized username; email, password and existing sessions stay unchanged. Requires user.manage even for your own profile; only owners may rename owner accounts. Fails for blank names, missing users, users in Trash, or names reserved by any user including Trash. Choose a different name or restore/purge the conflicting trashed user before retrying.",
|
|
112
|
+
sideEffects: "mutates-durable-state",
|
|
113
|
+
authorization: { permission: "user.manage" },
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: "object", additionalProperties: false, required: ["principalId", "username"],
|
|
116
|
+
properties: { principalId: PRINCIPAL_ID_SCHEMA, username: { type: "string", minLength: 1,
|
|
117
|
+
description: "New login name. Trimmed, lowercased and Unicode NFC normalized; unique per workspace, including users in Trash." } },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
109
120
|
{
|
|
110
121
|
name: "identity_user_disable",
|
|
111
122
|
description: "Disables a user, revoking their access. Principals are never hard-deleted because the audit trail references them, so this is how a user is removed. Refuses to disable the seeded owner, and refuses any disable that would leave the workspace with no active owner. Already-disabled is a no-op.",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type UserManagementMessages } from "./messages.js";
|
|
2
|
-
import type { SessionTokenPort } from "../core/runtime-ports.js";
|
|
2
|
+
import type { SessionTokenPort, UsernameChangePort } from "../core/runtime-ports.js";
|
|
3
3
|
import type { Clock, IdGenerator, ISODateTime, UUID } from "@jini-ai/core/primitives";
|
|
4
4
|
import type { IdentityRepos, PasswordHasherPort } from "../core/ports.js";
|
|
5
5
|
import { type PrincipalRecord, type SessionRecord } from "../core/types.js";
|
|
@@ -9,6 +9,8 @@ export interface AuthServiceDeps {
|
|
|
9
9
|
clock: Clock;
|
|
10
10
|
idGen: IdGenerator;
|
|
11
11
|
tokens: SessionTokenPort;
|
|
12
|
+
/** Additive: legacy email updates need no rename lifecycle; renames require it. */
|
|
13
|
+
usernameChanges?: UsernameChangePort | undefined;
|
|
12
14
|
}
|
|
13
15
|
/**
|
|
14
16
|
* Mint and persist a session for a principal the caller has ALREADY identified by some means
|
package/dist/server/seed.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface SeedIdentityInput {
|
|
|
24
24
|
}
|
|
25
25
|
export interface SeedIdentityOptions {
|
|
26
26
|
ownerEmail?: string | undefined;
|
|
27
|
+
/** Stable bootstrap binding persisted by the host. Never derive this from a mutable login/title. */
|
|
28
|
+
seededOwnerPrincipalId?: UUID | undefined;
|
|
27
29
|
}
|
|
28
30
|
export interface SeedIdentityResult {
|
|
29
31
|
ownerPrincipalId: UUID;
|
|
@@ -33,8 +35,8 @@ export interface SeedIdentityResult {
|
|
|
33
35
|
* Seed first-boot identity data.
|
|
34
36
|
*
|
|
35
37
|
* Idempotent in two distinct senses, and both are load-bearing:
|
|
36
|
-
* - A seed that RAN TO COMPLETION is a no-op — the owner
|
|
37
|
-
*
|
|
38
|
+
* - A seed that RAN TO COMPLETION is a no-op — the owner lookup below early-returns after
|
|
39
|
+
* confirming its role binding (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
|
|
38
40
|
* reconcile a built-in policy whose permission list has since grown; that is the host's job — see
|
|
39
41
|
* the host's `applyBuiltinRoleGrants` / `migrateDeprecatedPermissionGrants`, chained onto
|
|
40
42
|
* `identityReady`.)
|
|
@@ -45,8 +47,9 @@ export interface SeedIdentityResult {
|
|
|
45
47
|
* previous attempt already wrote and fills in only what is missing. See the block comment above
|
|
46
48
|
* `ensureBuiltinRole` for why this function must survive being killed halfway.
|
|
47
49
|
*
|
|
48
|
-
* @complexity O(1)
|
|
49
|
-
*
|
|
50
|
+
* @complexity O(1) owner lookup plus the existing principal roster read; fresh seeds make a
|
|
51
|
+
* fixed small number of inserts (4 roles/policies, ~2 dozen policy_permissions,
|
|
52
|
+
* 3 principals, 1 user, 1 role assignment).
|
|
50
53
|
* @overallScore 100
|
|
51
54
|
*/
|
|
52
55
|
export declare function seedIdentity(required: {
|
package/dist/server/seed.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
|
|
2
2
|
import { normalizeUsername } from "./username.js";
|
|
3
|
+
import { IdentityNotFoundError } from "../core/types.js";
|
|
3
4
|
/**
|
|
4
5
|
* @file First-boot identity seed (state.spec `SEED_FIRST_BOOT`).
|
|
5
6
|
*
|
|
@@ -20,7 +21,7 @@ import { normalizeUsername } from "./username.js";
|
|
|
20
21
|
* Seed data lives in one place so both composition roots stay identical
|
|
21
22
|
* (mirrors `server/seed.ts`'s reasoning for workspace/post/presentation seed
|
|
22
23
|
* data). Idempotent: re-seeding an already-seeded workspace is a no-op
|
|
23
|
-
* (checked via the owner username).
|
|
24
|
+
* (checked via a host-supplied stable principal id, or the legacy owner username).
|
|
24
25
|
* See docs/decisions/DR-001-identity-and-session-boundary.md.
|
|
25
26
|
*/
|
|
26
27
|
/** : the literal id the legacy Article VI actor stamp resolves to. See docs/decisions/DR-001-identity-and-session-boundary.md. */
|
|
@@ -303,8 +304,8 @@ async function ensureOwnerRoleBinding(required) {
|
|
|
303
304
|
* Seed first-boot identity data.
|
|
304
305
|
*
|
|
305
306
|
* Idempotent in two distinct senses, and both are load-bearing:
|
|
306
|
-
* - A seed that RAN TO COMPLETION is a no-op — the owner
|
|
307
|
-
*
|
|
307
|
+
* - A seed that RAN TO COMPLETION is a no-op — the owner lookup below early-returns after
|
|
308
|
+
* confirming its role binding (`ensureOwnerRoleBinding`) and writes nothing. (This path does not
|
|
308
309
|
* reconcile a built-in policy whose permission list has since grown; that is the host's job — see
|
|
309
310
|
* the host's `applyBuiltinRoleGrants` / `migrateDeprecatedPermissionGrants`, chained onto
|
|
310
311
|
* `identityReady`.)
|
|
@@ -315,8 +316,9 @@ async function ensureOwnerRoleBinding(required) {
|
|
|
315
316
|
* previous attempt already wrote and fills in only what is missing. See the block comment above
|
|
316
317
|
* `ensureBuiltinRole` for why this function must survive being killed halfway.
|
|
317
318
|
*
|
|
318
|
-
* @complexity O(1)
|
|
319
|
-
*
|
|
319
|
+
* @complexity O(1) owner lookup plus the existing principal roster read; fresh seeds make a
|
|
320
|
+
* fixed small number of inserts (4 roles/policies, ~2 dozen policy_permissions,
|
|
321
|
+
* 3 principals, 1 user, 1 role assignment).
|
|
320
322
|
* @overallScore 100
|
|
321
323
|
*/
|
|
322
324
|
export async function seedIdentity(required, optional = {}) {
|
|
@@ -326,7 +328,14 @@ export async function seedIdentity(required, optional = {}) {
|
|
|
326
328
|
const ownerUsername = normalizeUsername({ raw: input.ownerUsername });
|
|
327
329
|
const ownerPassword = input.ownerPassword;
|
|
328
330
|
const nowIso = kernelNowIso({ clock: deps.clock });
|
|
329
|
-
|
|
331
|
+
// An explicit binding is authoritative, even when somebody has reused its former login name.
|
|
332
|
+
// A missing bound row is an error, never permission to mint a replacement owner.
|
|
333
|
+
const seededOwnerUser = input.seededOwnerPrincipalId !== undefined
|
|
334
|
+
? await deps.repos.users.findByPrincipalId({ workspaceId, principalId: input.seededOwnerPrincipalId }) : null;
|
|
335
|
+
if (input.seededOwnerPrincipalId !== undefined && !seededOwnerUser) {
|
|
336
|
+
throw new IdentityNotFoundError({ message: "the bound seeded owner user was not found; restore the owner identity before booting" });
|
|
337
|
+
}
|
|
338
|
+
const existingOwnerUser = seededOwnerUser ?? await deps.repos.users.findByUsername({
|
|
330
339
|
workspaceId,
|
|
331
340
|
username: ownerUsername,
|
|
332
341
|
});
|