@jini-ai/user-management 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- export function describeApiError({ error, fallback, translate }) {
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 function userRowMenuItems({ user, toggleSaving, handlers, translate, canDelete }) {
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,10 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
43
124
  onSelect: () => handlers.onResetPassword(user),
44
125
  },
45
126
  ];
46
- if (canDelete) {
127
+ const permissions = typeof capabilities === "boolean"
128
+ ? { canDelete: capabilities, canDisable: true, canEnable: true, canResetPassword: true }
129
+ : capabilities;
130
+ if (permissions.canDelete) {
47
131
  items.push({
48
132
  key: "delete",
49
133
  label: translate("Delete"),
@@ -51,11 +135,30 @@ export function userRowMenuItems({ user, toggleSaving, handlers, translate, canD
51
135
  onSelect: () => handlers.onRequestDelete(user),
52
136
  });
53
137
  }
54
- return items;
138
+ return items.filter(item => item.key === "toggle"
139
+ ? (user.status === "active" ? permissions.canDisable : permissions.canEnable)
140
+ : item.key !== "reset-password" || permissions.canResetPassword);
141
+ }
142
+ /**
143
+ * The role or policy names a user holds, for the "Roles"/"Policies" table cells — `null` when the
144
+ * user holds none, which the caller renders as the `muted-cell` "none" fallback (a plain ternary on
145
+ * this already-computed value, per this feature's extraction rule).
146
+ *
147
+ * A grant id with no matching entry in `byId` (a role/policy deleted out from under a still-held
148
+ * grant) falls back to rendering the raw id rather than dropping it silently — the operator sees
149
+ * something is wrong instead of an undercount with no explanation.
150
+ *
151
+ * @complexity Time: O(n) in the number of granted ids; space: O(n) for the joined string.
152
+ */
153
+ export function grantOptionLabel(required, _optional = {}) {
154
+ return required.option.isBuiltin && required.option.name === "owner" ? required.translate("Owner") : required.option.name;
55
155
  }
56
- export function formatGrantLabel({ ids, byId }) {
156
+ export function formatGrantLabel({ ids, byId, translate = (key) => key }, _optional = {}) {
57
157
  if (ids.length === 0)
58
158
  return null;
59
- return ids.map((id) => byId.get(id)?.name ?? id).join(", ");
159
+ return ids.map(id => {
160
+ const role = byId.get(id);
161
+ return role?.isBuiltin && role.name === "owner" ? translate("Owner") : role?.name ?? id;
162
+ }).join(", ");
60
163
  }
61
164
  //# sourceMappingURL=rules.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/user-management",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Runtime-separated user management contracts and Node authentication, authorization, and administration services.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -86,13 +86,17 @@
86
86
  "access": "public",
87
87
  "registry": "https://registry.npmjs.org"
88
88
  },
89
+ "dependencies": {
90
+ "@jini-ai/core": "^0.4.2"
91
+ },
89
92
  "peerDependencies": {
90
93
  "argon2": "^0.44.0",
91
94
  "react": "^18.3.0 || ^19.0.0",
92
95
  "react-dom": "^18.3.0 || ^19.0.0",
93
96
  "@jini-ai/ui": "^0.4.0",
94
97
  "@jini-ai/admin": "^0.4.0",
95
- "@jini-ai/agentic": "^0.4.0"
98
+ "@jini-ai/agentic": "^0.4.0",
99
+ "@jini-ai/ui-kit": "^0.1.1"
96
100
  },
97
101
  "peerDependenciesMeta": {
98
102
  "argon2": {
@@ -112,6 +116,9 @@
112
116
  },
113
117
  "@jini-ai/agentic": {
114
118
  "optional": true
119
+ },
120
+ "@jini-ai/ui-kit": {
121
+ "optional": true
115
122
  }
116
123
  },
117
124
  "devDependencies": {
@@ -127,14 +134,12 @@
127
134
  "@testing-library/user-event": "^14.5.0",
128
135
  "@testing-library/jest-dom": "^6.6.3",
129
136
  "jsdom": "^25.0.1",
130
- "@jini-ai/ui": "0.4.3",
131
- "@jini-ai/admin": "0.4.2",
132
- "@jini-ai/agentic": "0.4.1",
133
- "@jini-ai/cms": "0.5.0"
134
- },
135
- "dependencies": {
136
- "@jini-ai/ui-kit": "^0.1.1",
137
- "@jini-ai/core": "^0.4.1"
137
+ "@jini-ai/ui": "0.4.5",
138
+ "@jini-ai/cms": "0.5.3",
139
+ "@jini-ai/core": "^0.4.2",
140
+ "@jini-ai/ui-kit": "^0.1.2",
141
+ "@jini-ai/agentic": "0.4.3",
142
+ "@jini-ai/admin": "0.4.3"
138
143
  },
139
144
  "scripts": {
140
145
  "build": "tsc -p tsconfig.json",