@jini-ai/user-management 0.2.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/admin/people.rules.js +2 -1
  3. package/dist/core/index.d.ts +1 -0
  4. package/dist/core/index.js +1 -0
  5. package/dist/core/principal-kind-policy.d.ts +30 -0
  6. package/dist/core/principal-kind-policy.js +28 -0
  7. package/dist/core/types.d.ts +7 -2
  8. package/dist/react/index.d.ts +4 -0
  9. package/dist/react/index.js +2 -0
  10. package/dist/react/members/Members.d.ts +28 -1
  11. package/dist/react/members/Members.js +14 -10
  12. package/dist/react/members/hooks/use-members.hooks.d.ts +43 -1
  13. package/dist/react/members/hooks/use-members.hooks.js +37 -9
  14. package/dist/react/members/rules.d.ts +31 -4
  15. package/dist/react/members/rules.js +17 -5
  16. package/dist/react/models.d.ts +3 -0
  17. package/dist/react/users/Users.d.ts +105 -6
  18. package/dist/react/users/Users.js +105 -43
  19. package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +33 -1
  20. package/dist/react/users/hooks/use-reset-password-fields.hooks.js +16 -1
  21. package/dist/react/users/hooks/use-users.hooks.d.ts +120 -3
  22. package/dist/react/users/hooks/use-users.hooks.js +192 -23
  23. package/dist/react/users/rules.d.ts +115 -8
  24. package/dist/react/users/rules.js +109 -6
  25. package/dist/server/admin-crud-service.js +4 -1
  26. package/dist/server/authorize.d.ts +9 -6
  27. package/dist/server/authorize.js +11 -3
  28. package/dist/server/permission-migrations.d.ts +35 -15
  29. package/dist/server/permission-migrations.js +16 -16
  30. package/dist/server/seed.js +6 -1
  31. package/package.json +15 -10
  32. package/dist/react/roles/Roles.d.ts +0 -100
  33. package/dist/react/roles/Roles.hooks.d.ts +0 -17
  34. package/dist/react/roles/Roles.hooks.js +0 -40
  35. package/dist/react/roles/Roles.js +0 -150
  36. package/dist/react/roles/hooks/use-roles.hooks.d.ts +0 -80
  37. package/dist/react/roles/hooks/use-roles.hooks.js +0 -303
  38. package/dist/react/roles/rules.d.ts +0 -35
  39. package/dist/react/roles/rules.js +0 -31
  40. package/dist/react/roles/text.d.ts +0 -27
  41. package/dist/react/roles/text.js +0 -13
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 — 2026-10-08
4
+
5
+ - Absorbs Tovu Users/Members owner/self protections, reset handling and host ports (a319c44f).
6
+
7
+ ## 0.3.0 — 2026-10-05
8
+
9
+ ### BREAKING
10
+
11
+ - `PrincipalKind` adds `"member"` (a host's public-site sign-up). New `principalKindMayExercisePermission({ kind, permission })`: `authorize()` checks it right after the disabled-principal check, so a member is denied every operator permission (reason `principal_kind_denied`) even when a `*` grant reaches it, and the last-owner guard never counts a member as the remaining owner. Exhaustive switches over `PrincipalKind` need the new case.
12
+ - Permission migrations are registered explicitly at composition: `registerPermissionMigration`/`listPermissionMigrations` (an import-time module singleton) are replaced by `createPermissionMigrationRegistry({}, { migrations })`, seeded with the built-in pairs, and `migrateDeprecatedPermissionGrants` takes the explicit `deps.migrations` list.
13
+
14
+ ### Changed
15
+
16
+ - `seedIdentity` grants `role.manage` to the built-in admin policy, so an admin runs Roles & Permissions. `user.manage` stays owner-only and the grant-exceeds-issuer clamp is unchanged. Already-seeded workspaces are reconciled by the host.
17
+
18
+ ### Fixed
19
+
20
+ - `describePeopleError` ignores inherited `Object.prototype` keys (an error code of `"constructor"` or `"toString"` resolved to that member instead of the message or fallback).
21
+ - Internal `@jini-ai/core` and `@jini-ai/ui-kit` dependencies are caret ranges (`workspace:^`) instead of exact pins.
22
+
3
23
  ## 0.2.0 — 2026-10-02
4
24
 
5
25
  ### BREAKING
@@ -9,6 +29,29 @@
9
29
 
10
30
  ## Unreleased
11
31
 
32
+ ### The built-in admin role holds role.manage
33
+
34
+ - `seedIdentity` grants `role.manage` to the built-in admin policy (owner decision, 2026-10-05), so a
35
+ fresh workspace's admin runs Roles & Permissions. `user.manage` stays owner-only. The issuer clamp
36
+ (`GRANT_EXCEEDS_ISSUER`) is unchanged. Already-seeded workspaces are not revisited by the seed; a
37
+ host reconciles them (e.g. a built-in-role grant applied at boot).
38
+
39
+ ### Site members are barred from operator permissions
40
+
41
+ - Add `"member"` to `PrincipalKind` for a host's public-site sign-ups. `authorize()` now denies a
42
+ `member` principal every permission with reason `principal_kind_denied`, ahead of any grant
43
+ (including the owner wildcard). The rule is one exported function,
44
+ `principalKindMayExercisePermission({ kind, permission })`, so a later decision can relax it in one
45
+ place. Owner counting for the last-owner guard skips barred kinds. Existing kinds are unaffected.
46
+
47
+ ### BREAKING: permission migrations are an explicit registry
48
+
49
+ - Remove the module-singleton `registerPermissionMigration`/`listPermissionMigrations`. Create a
50
+ registry with `createPermissionMigrationRegistry({}, { migrations })` at the composition root
51
+ (seeded with the built-in pairs; `register` keeps the overwrite-by-`from` semantics) and pass
52
+ `migrations: registry.list({})` to `migrateDeprecatedPermissionGrants`, which now requires it.
53
+ Which pairs exist no longer depends on module import order.
54
+
12
55
  ### Compilation fixes
13
56
 
14
57
  - Import the consolidated tab-strip barrel (requires the UI peer to export that
@@ -39,7 +39,8 @@ export function describePeopleError({ error, fallback }, _optional = {}) {
39
39
  SELF_DELETE: 'You cannot delete your own account.', USER_IN_TRASH: 'This user is in the Trash; restore them first.',
40
40
  USERNAME_IN_TRASH: 'A user with this username is in the Trash; restore or delete them permanently first.',
41
41
  };
42
- if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && copy[error.code])
42
+ // Own keys only: a server code such as "constructor" must not resolve to an Object.prototype member.
43
+ if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && Object.hasOwn(copy, error.code))
43
44
  return copy[error.code];
44
45
  return error instanceof Error && error.message ? error.message : fallback;
45
46
  }
@@ -3,4 +3,5 @@ export * from "./types.js";
3
3
  export * from "./ports.js";
4
4
  export * from "./runtime-ports.js";
5
5
  export * from "./permissions.js";
6
+ export * from "./principal-kind-policy.js";
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -6,4 +6,5 @@ export * from "./types.js";
6
6
  export * from "./ports.js";
7
7
  export * from "./runtime-ports.js";
8
8
  export * from "./permissions.js";
9
+ export * from "./principal-kind-policy.js";
9
10
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,30 @@
1
+ import type { PrincipalKind } from "./types.js";
2
+ /**
3
+ * @file Which principal kinds may exercise operator permissions at all.
4
+ *
5
+ * Purpose:
6
+ * The one place that decides whether a principal's KIND lets it use the operator RBAC axis
7
+ * (`authorize()`'s permissions). Today the answer is: every kind except `member`. A `member` is a
8
+ * public-site sign-up — a host's front-end account, governed by that host's own entitlement axis
9
+ * (tiers, gated content), never by operator RBAC. OWNER DECISION 2026-10-04 (host decision F3144): site
10
+ * members must not be able to use admin features, at least for now.
11
+ *
12
+ * Why a function and not a check at each call site:
13
+ * `authorize()` calls this before it reads any grant, so a member principal is denied every
14
+ * permission even if a grant row reaches it (a hand-edited database, a future grant path that forgets
15
+ * the `kind='user'` target check in `grant-service.ts`). Owner-counting guards call it too, so a
16
+ * member can never count as the workspace's remaining owner. Allowing members some admin capability
17
+ * later is a change to this one function — `permission` is passed so such a decision can open a
18
+ * specific capability rather than all of them.
19
+ */
20
+ /**
21
+ * Whether a principal of `kind` may exercise `permission` through operator RBAC at all. Returning
22
+ * `true` only means the kind is not barred; the principal still needs a matching grant.
23
+ *
24
+ * @complexity O(1).
25
+ */
26
+ export declare function principalKindMayExercisePermission(required: {
27
+ kind: PrincipalKind;
28
+ permission: string;
29
+ }, _optional?: Record<string, never>): boolean;
30
+ //# sourceMappingURL=principal-kind-policy.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file Which principal kinds may exercise operator permissions at all.
3
+ *
4
+ * Purpose:
5
+ * The one place that decides whether a principal's KIND lets it use the operator RBAC axis
6
+ * (`authorize()`'s permissions). Today the answer is: every kind except `member`. A `member` is a
7
+ * public-site sign-up — a host's front-end account, governed by that host's own entitlement axis
8
+ * (tiers, gated content), never by operator RBAC. OWNER DECISION 2026-10-04 (host decision F3144): site
9
+ * members must not be able to use admin features, at least for now.
10
+ *
11
+ * Why a function and not a check at each call site:
12
+ * `authorize()` calls this before it reads any grant, so a member principal is denied every
13
+ * permission even if a grant row reaches it (a hand-edited database, a future grant path that forgets
14
+ * the `kind='user'` target check in `grant-service.ts`). Owner-counting guards call it too, so a
15
+ * member can never count as the workspace's remaining owner. Allowing members some admin capability
16
+ * later is a change to this one function — `permission` is passed so such a decision can open a
17
+ * specific capability rather than all of them.
18
+ */
19
+ /**
20
+ * Whether a principal of `kind` may exercise `permission` through operator RBAC at all. Returning
21
+ * `true` only means the kind is not barred; the principal still needs a matching grant.
22
+ *
23
+ * @complexity O(1).
24
+ */
25
+ export function principalKindMayExercisePermission(required, _optional = {}) {
26
+ return required.kind !== "member";
27
+ }
28
+ //# sourceMappingURL=principal-kind-policy.js.map
@@ -18,8 +18,13 @@ import type { ISODateTime, UUID } from "@jini-ai/core/primitives";
18
18
  * the full target schema so those transitions are additive later,
19
19
  * not a repaint.
20
20
  */
21
- /** Every actor is one of these. Only `user`/`system` are populated this pass. */
22
- export type PrincipalKind = "user" | "agent" | "api_key" | "system";
21
+ /**
22
+ * Every actor is one of these. `member` is a host's public-site sign-up (a front-end account, not an
23
+ * operator): it may hold a principal row for identity and audit, but `principalKindMayExercisePermission`
24
+ * (`principal-kind-policy.ts`) bars it from every operator permission, so `authorize()` denies it
25
+ * whatever grants reach it.
26
+ */
27
+ export type PrincipalKind = "user" | "agent" | "api_key" | "system" | "member";
23
28
  /** Disable-only lifecycle — there is no hard-delete state. See docs/decisions/DR-001-identity-and-session-boundary.md. */
24
29
  export type PrincipalStatus = "active" | "disabled";
25
30
  /**
@@ -15,4 +15,8 @@ export type { MembersProps } from './members/Members.js';
15
15
  export { useMembers } from './members/hooks/use-members.hooks.js';
16
16
  export type { MembersController, MembersDependencies, MembersOptions } from './members/hooks/use-members.hooks.js';
17
17
  export { describeIdentityError } from './errors.js';
18
+ export { userAccountCapabilities, isOwnerAccount, grantOptionLabel, formatGrantLabel, userRowMenuItems, describeUsersError } from './users/rules.js';
19
+ export type { UserAccountCapabilities, UserRowMenuHandlers } from './users/rules.js';
20
+ export { emptyRowState, memberRowMenuItems, describeMembersError } from './members/rules.js';
21
+ export type { RowActionState, MemberRowMenuHandlers } from './members/rules.js';
18
22
  //# sourceMappingURL=index.d.ts.map
@@ -6,4 +6,6 @@ export { useResetPasswordFields } from './users/hooks/use-reset-password-fields.
6
6
  export { Members } from './members/Members.js';
7
7
  export { useMembers } from './members/hooks/use-members.hooks.js';
8
8
  export { describeIdentityError } from './errors.js';
9
+ export { userAccountCapabilities, isOwnerAccount, grantOptionLabel, formatGrantLabel, userRowMenuItems, describeUsersError } from './users/rules.js';
10
+ export { emptyRowState, memberRowMenuItems, describeMembersError } from './members/rules.js';
9
11
  //# sourceMappingURL=index.js.map
@@ -1,6 +1,33 @@
1
1
  import { useMembers, type MembersDependencies, type MembersOptions } from "./hooks/use-members.hooks.js";
2
+ import type { ReactNode } from "react";
3
+ /**
4
+ * @file Admin "Members" screen (ADR-030, ADR-PIPE-013 Decision §7) — markup only.
5
+ *
6
+ * State and API calls live in `hooks/use-members.hooks.ts`; the row-menu logic, per-row action
7
+ * state shape, and the server-error-message override live in `rules.ts`. What stays here is what
8
+ * actually renders: the table and the confirm dialog.
9
+ *
10
+ * Mirrors `features/posts/Posts.tsx`'s fetch/loading/error/table shape. Adds the
11
+ * three row-level actions this remediation wires up (T039): disable, resend
12
+ * sign-in link, and a click-to-expand detail panel — all calling the 3
13
+ * already-existing, already-unused the host admin API client client methods
14
+ * (`disableMember`, `requestMemberMagicLink`, `getMember`). No new backend
15
+ * contract needed. Pagination is explicitly deferred (ADR-PIPE-013 Decision
16
+ * §7) — not part of this screen yet.
17
+ *
18
+ * Per-row in-flight state disables only the clicked control (not the whole
19
+ * table), and errors surface via the existing `notice error` convention
20
+ * (inline per row for actions; a full-width banner for the initial load).
21
+ * Stays a single flat file, matching every other admin section's convention.
22
+ */
2
23
  export interface MembersProps extends MembersDependencies, MembersOptions {
3
24
  useMembersHook?: typeof useMembers | undefined;
25
+ /** Host terminology is copy, not a second members lifecycle. */
26
+ description?: string | undefined;
27
+ emptyDescription?: string | undefined;
28
+ renderServerLabel?: ((required: {
29
+ value: string;
30
+ }) => ReactNode) | undefined;
4
31
  }
5
- export declare function Members({ useMembersHook, port, translate: injectedTranslate, refresh }: MembersProps): import("react").JSX.Element;
32
+ export declare function Members({ useMembersHook, port, translate: injectedTranslate, refresh, description, emptyDescription, renderServerLabel }: MembersProps): import("react").JSX.Element;
6
33
  //# sourceMappingURL=Members.d.ts.map
@@ -6,6 +6,10 @@ import { formatTimestamp } from "@jini-ai/ui/panel-kit";
6
6
  import { buildAgentListHandles } from "@jini-ai/ui/panel-kit";
7
7
  import { memberRowMenuItems } from "./rules.js";
8
8
  import { useMembers } from "./hooks/use-members.hooks.js";
9
+ /** The expanded row's detail panel — one of "loading" / "error" / the fetched fields / nothing
10
+ * yet, extracted out of `MemberRow` so its three-way branch isn't counted in `MemberRow`'s own
11
+ * scope. Same "the panel, not the row, was the actual size" split `Users.tsx`'s
12
+ * `UserRow` -> `UserManagePanel` and `Roles.tsx`'s `PolicyRow` -> `PolicyRowActions` already use. */
9
13
  function MemberDetailPanel({ memberId, detailLoadingId, detailError, detail, t }) {
10
14
  if (detailLoadingId === memberId)
11
15
  return _jsx("div", { className: "notice", children: t("Loading detail…") });
@@ -15,24 +19,24 @@ function MemberDetailPanel({ memberId, detailLoadingId, detailError, detail, t }
15
19
  return null;
16
20
  return (_jsxs("dl", { className: "member-detail", children: [_jsx("dt", { children: t("ID") }), _jsx("dd", { children: detail.id }), _jsx("dt", { children: t("Email verified") }), _jsx("dd", { children: detail.emailVerifiedAt ?? t("not verified") }), _jsx("dt", { children: t("Updated") }), _jsx("dd", { children: detail.updatedAt }), _jsx("dt", { children: t("Version") }), _jsx("dd", { children: detail.version })] }));
17
21
  }
18
- function MemberRow({ member, rowState, isExpanded, detail, detailError, detailLoadingId, onToggleDetail, onResendSignInLink, setConfirmingDisable, agentBase, t, translate, }) {
19
- return (_jsxs(Fragment, { children: [_jsxs("tr", { children: [_jsx("td", { children: _jsx("button", { type: "button", className: "link-button", onClick: () => void onToggleDetail(member), "aria-expanded": isExpanded, ...agentHandle({ handle: `${agentBase}-toggle-detail` }, { role: "button", label: t(`Expand or collapse ${member.email}'s detail panel`) }), children: member.email }) }), _jsx("td", { children: member.name ?? t("—") }), _jsx("td", { children: _jsx("span", { className: `status status-${member.status}`, children: t(member.status) }) }), _jsx("td", { children: formatTimestamp({ iso: member.createdAt }) }), _jsxs("td", { children: [_jsx(RowMenu, { triggerLabel: `${t("Actions for member")} "${member.email}"`, agentHandle: `${agentBase}-menu`, items: memberRowMenuItems({
20
- member,
21
- rs: rowState,
22
- handlers: {
22
+ /** One member's row plus its optional expanded detail row — extracted from `Members`'s
23
+ * `.map()` body verbatim, same convention `Users.tsx`'s `UserRow`/`Roles.tsx`'s `PolicyRow` use.
24
+ * `key` lives on the `<MemberRow>` element at the call site. */
25
+ function MemberRow({ member, rowState, isExpanded, detail, detailError, detailLoadingId, onToggleDetail, onResendSignInLink, setConfirmingDisable, agentBase, t, translate, renderServerLabel, }) {
26
+ return (_jsxs(Fragment, { children: [_jsxs("tr", { children: [_jsx("td", { children: _jsx("button", { type: "button", className: "link-button", onClick: () => void onToggleDetail(member), "aria-expanded": isExpanded, ...agentHandle({ handle: `${agentBase}-toggle-detail` }, { role: "button", label: `Expand or collapse ${member.email}'s detail panel` }), children: member.email }) }), _jsx("td", { children: member.name ?? "—" }), _jsx("td", { children: _jsx("span", { className: `status status-${member.status}`, children: renderServerLabel?.({ value: member.status }) ?? t(member.status) }) }), _jsx("td", { children: formatTimestamp({ iso: member.createdAt }) }), _jsxs("td", { children: [_jsx(RowMenu, { triggerLabel: `${t("Actions for member")} "${member.email}"`, agentHandle: `${agentBase}-menu`, items: memberRowMenuItems({ member, rs: rowState, handlers: {
23
27
  onResendSignInLink: (m) => void onResendSignInLink(m),
24
28
  onRequestDisable: setConfirmingDisable,
25
- },
26
- translate,
27
- }) }), rowState.error ? (_jsx("div", { className: "notice error", role: "alert", children: rowState.error })) : null, rowState.notice ? _jsx("div", { className: "notice", children: rowState.notice }) : null] })] }), isExpanded ? (_jsx("tr", { children: _jsx("td", { colSpan: 5, children: _jsx(MemberDetailPanel, { memberId: member.id, detailLoadingId: detailLoadingId, detailError: detailError, detail: detail, t: t }) }) })) : null] }, member.id));
29
+ }, translate }) }), rowState.error ? (_jsx("div", { className: "notice error", role: "alert", children: rowState.error })) : null, rowState.notice ? _jsx("div", { className: "notice", children: rowState.notice }) : null] })] }), isExpanded ? (_jsx("tr", { children: _jsx("td", { colSpan: 5, children: _jsx(MemberDetailPanel, { memberId: member.id, detailLoadingId: detailLoadingId, detailError: detailError, detail: detail, t: t }) }) })) : null] }, member.id));
28
30
  }
29
- export function Members({ useMembersHook = useMembers, port, translate: injectedTranslate, refresh }) {
31
+ export function Members({ useMembersHook = useMembers, port, translate: injectedTranslate, refresh, description = "People who have registered an account — review status, resend a sign-in link, or disable access.", emptyDescription = "Registered members will show up here.", renderServerLabel }) {
30
32
  const { members, error, stateFor, onResendSignInLink, expandedId, detailById, detailError, detailLoadingId, onToggleDetail, confirmingDisable, setConfirmingDisable, confirmDisable, t, translate, } = useMembersHook({ port, translate: injectedTranslate }, { refresh });
31
33
  if (error)
32
34
  return _jsx("div", { className: "notice error", children: error });
33
35
  if (!members)
34
36
  return _jsx("div", { className: "notice", children: t("Loading members…") });
37
+ // Member ids are stable and unique, so they disambiguate one row's menu from another's — same
38
+ // reasoning as every other list on this workstream.
35
39
  const memberMenuBases = buildAgentListHandles({ prefix: "members-row", ids: members.map((member) => member.id) });
36
- return (_jsxs("div", { className: "page", children: [_jsx("div", { className: "page-header", children: _jsxs("div", { className: "page-header-text", children: [_jsx("p", { className: "page-kicker", children: t("People") }), _jsx("h1", { className: "page-title", children: t("Members") }), _jsx("p", { className: "page-description", children: t("People who have registered an account — review status, resend a sign-in link, or disable access.") })] }) }), members.length === 0 ? (_jsx("div", { className: "card", children: _jsxs("div", { className: "empty-state", children: [_jsx("p", { children: t("No members yet.") }), _jsx("p", { className: "page-description", children: t("Registered members will show up here.") })] }) })) : (_jsx("div", { className: "table-scroll", children: _jsxs("table", { className: "list-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: t("Email") }), _jsx("th", { children: t("Name") }), _jsx("th", { children: t("Status") }), _jsx("th", { children: t("Created") }), _jsx("th", { children: t("Actions") })] }) }), _jsx("tbody", { children: members.map((member, index) => (_jsx(MemberRow, { member: member, rowState: stateFor({ id: member.id }), isExpanded: expandedId === member.id, detail: detailById[member.id], detailError: detailError, detailLoadingId: detailLoadingId, onToggleDetail: onToggleDetail, onResendSignInLink: onResendSignInLink, setConfirmingDisable: setConfirmingDisable, agentBase: memberMenuBases[index], t: t, translate: translate }, member.id))) })] }) })), _jsx(ConfirmDialog, { cancelLabel: t("Cancel"), open: confirmingDisable !== null, agentHandle: "members-disable", title: t("Disable this member?"), body: confirmingDisable ? (_jsxs("p", { children: [t("Disable"), " \"", confirmingDisable.email, "\"? ", t("They will no longer be able to sign in.")] })) : null, confirmLabel: t("Disable"), tone: "warning", pending: confirmingDisable !== null && stateFor({ id: confirmingDisable.id }).disabling, onConfirm: confirmDisable, onCancel: () => setConfirmingDisable(null) })] }));
40
+ return (_jsxs("div", { className: "page", children: [_jsx("div", { className: "page-header", children: _jsxs("div", { className: "page-header-text", children: [_jsx("p", { className: "page-kicker", children: t("People") }), _jsx("h1", { className: "page-title", children: t("Members") }), _jsx("p", { className: "page-description", children: t(description) })] }) }), members.length === 0 ? (_jsx("div", { className: "card", children: _jsxs("div", { className: "empty-state", children: [_jsx("p", { children: t("No members yet.") }), _jsx("p", { className: "page-description", children: t(emptyDescription) })] }) })) : (_jsx("div", { className: "table-scroll", children: _jsxs("table", { className: "list-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: t("Email") }), _jsx("th", { children: t("Name") }), _jsx("th", { children: t("Status") }), _jsx("th", { children: t("Created") }), _jsx("th", { children: t("Actions") })] }) }), _jsx("tbody", { children: members.map((member, index) => (_jsx(MemberRow, { member: member, rowState: stateFor({ id: member.id }), isExpanded: expandedId === member.id, detail: detailById[member.id], detailError: detailError, detailLoadingId: detailLoadingId, onToggleDetail: onToggleDetail, onResendSignInLink: onResendSignInLink, setConfirmingDisable: setConfirmingDisable, agentBase: memberMenuBases[index], t: t, translate: translate, renderServerLabel: renderServerLabel }, member.id))) })] }) })), _jsx(ConfirmDialog, { cancelLabel: t("Cancel"), open: confirmingDisable !== null, agentHandle: "members-disable", title: t("Disable this member?"), body: confirmingDisable ? (_jsxs("p", { children: [t("Disable"), " \"", confirmingDisable.email, "\"? ", t("They will no longer be able to sign in.")] })) : null, confirmLabel: t("Disable"), tone: "warning", pending: confirmingDisable !== null && stateFor({ id: confirmingDisable.id }).disabling, onConfirm: confirmDisable, onCancel: () => setConfirmingDisable(null) })] }));
37
41
  }
38
42
  //# sourceMappingURL=Members.js.map
@@ -1,28 +1,70 @@
1
+ import type { IdentityRefreshPort } from "../../ports.js";
1
2
  import { type Dispatch, type SetStateAction } from "react";
2
3
  import type { AdminMember } from "../../models.js";
3
4
  import { type RowActionState } from "../rules.js";
4
- import type { MembersPort, IdentityRefreshPort } from "../../ports.js";
5
+ import type { MembersPort } from "../../ports.js";
5
6
  import type { Translate } from "@jini-ai/ui/panel-kit";
7
+ /**
8
+ * @file Everything the Members screen does, so `Members.tsx` is only markup.
9
+ *
10
+ * Extracted verbatim — same state, same declaration order, same effect bodies, same error strings.
11
+ * `RowActionState`/`emptyRowState`/`describeApiError` moved to `rules.ts` (they were already
12
+ * module-scope free functions in the original, just private and untested); this hook imports them
13
+ * back for `stateFor` and its own async handlers.
14
+ *
15
+ * Naming follows `hooks/use-settings-slice.hooks.ts` and `posts/hooks/use-posts.hooks.ts`:
16
+ * `use-<thing>.hooks.ts`. Feature-local because nothing outside `features/members` needs it.
17
+ *
18
+ * `deps.port` is injected (see `members-port.hooks.ts`) rather than reaching for `lib/api`'s `api`
19
+ * directly — the same `useX(dependencies)` / `useWiredX()` split `redirects`/`widgets`/`plugins` use.
20
+ *
21
+ * `t`/`locale` (2026-08-11, standing i18n rule — a component with a hook gets a BOUND `t` from that
22
+ * hook, not its own `useAdminLocale()`/dictionary import): this hook already called
23
+ * `useAdminLocale()` for its own error-string translations, so exposing that SAME already-resolved
24
+ * `locale` as a bound `t` (plus the raw value, still needed for `rules.ts`'s `memberRowMenuItems`,
25
+ * which takes `locale` directly) on the return value adds no new fetch — `Members.tsx` used to call
26
+ * `useAdminLocale()` a second time and rebuild its own `translateMembers(locale, key)` closure,
27
+ * entirely redundant with the resolution this hook was already doing internally.
28
+ *
29
+ * `useContentRefreshSubscription` (staleness-bug generalization pass — see that hook's own header):
30
+ * `load` is pulled into a `useCallback` so it can also be handed to that hook, which re-runs it
31
+ * whenever `members_disable` (the host member tool contributor) changes a
32
+ * member's status from an assistant run this screen otherwise has no way to learn about. No draft
33
+ * to protect — every row's own edit state is per-action busy/error tracking, not typed text.
34
+ */
6
35
  export interface MembersDependencies {
7
36
  port: MembersPort;
8
37
  translate: Translate;
9
38
  }
10
39
  export interface MembersController {
40
+ /** `null` until the initial load settles — the caller renders a loading state. */
11
41
  members: AdminMember[] | null;
12
42
  error: string | null;
43
+ /** Per-row action state (Disable/Resend in-flight, error, notice), falling back to the empty
44
+ * state for a row with no action taken yet. */
13
45
  stateFor: (required: {
14
46
  id: string;
15
47
  }) => RowActionState;
16
48
  onResendSignInLink: (member: AdminMember) => Promise<void>;
49
+ /** The row whose detail panel is expanded — `null` when every row is collapsed. */
17
50
  expandedId: string | null;
51
+ /** Detail already fetched for an expanded row, keyed by member id — a cache so re-expanding a
52
+ * row already visited this session doesn't re-fetch. */
18
53
  detailById: Record<string, AdminMember>;
19
54
  detailError: string | null;
20
55
  detailLoadingId: string | null;
21
56
  onToggleDetail: (member: AdminMember) => Promise<void>;
57
+ /** The member a `RowMenu` "Disable" selection is asking to confirm; `null` when the dialog is
58
+ * shut. `ConfirmDialog` stays mounted unconditionally in the view (see its own doc comment on
59
+ * why); this is what drives its `open` prop. */
22
60
  confirmingDisable: AdminMember | null;
23
61
  setConfirmingDisable: Dispatch<SetStateAction<AdminMember | null>>;
24
62
  confirmDisable: () => Promise<void>;
63
+ /** Bound translator — `key` already resolved against the caller's locale, so `Members.tsx` never
64
+ * imports `useAdminLocale`/`members-i18n` itself. See this file's header. */
25
65
  t: Translate;
66
+ /** Raw resolved locale — `rules.ts`'s `memberRowMenuItems` takes `locale` directly rather than a
67
+ * bound translator. See this file's header. */
26
68
  translate: Translate;
27
69
  }
28
70
  export interface MembersOptions {
@@ -1,10 +1,9 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { describeApiError as describeFeatureError, emptyRowState } from "../rules.js";
2
+ import { describeApiError, emptyRowState } from "../rules.js";
3
3
  import { useSettlementGeneration } from "@jini-ai/ui/panel-kit";
4
4
  export function useMembers({ port, translate }, { refresh } = {}) {
5
- const describeApiError = (error, fallback) => describeFeatureError({ error, fallback, translate });
6
- const t = translate;
7
5
  const boundT = translate;
6
+ const describeError = (error, fallback) => describeApiError({ error, fallback, translate });
8
7
  const [members, setMembers] = useState(null);
9
8
  const [error, setError] = useState(null);
10
9
  const [rowState, setRowState] = useState({});
@@ -12,11 +11,27 @@ export function useMembers({ port, translate }, { refresh } = {}) {
12
11
  const [detailById, setDetailById] = useState({});
13
12
  const [detailError, setDetailError] = useState(null);
14
13
  const [detailLoadingId, setDetailLoadingId] = useState(null);
14
+ // Disable now confirms via a `RowMenu` item -> `ConfirmDialog` modal (replacing the in-place
15
+ // two-click `ConfirmButton`, which has no menu-item equivalent — same migration Posts.tsx/
16
+ // Redirects.tsx/Users.tsx already made). `null` when the dialog is closed.
15
17
  const [confirmingDisable, setConfirmingDisable] = useState(null);
16
18
  const settlement = useSettlementGeneration();
19
+ // Which row's detail panel is actually open (C7, plan-access.md §8, N2) — a ref, not just
20
+ // `expandedId` state, because `onToggleDetail`'s `catch`/`finally` read it after an `await`, where
21
+ // a state read would see the closure's stale value. Set by `onToggleDetail` alone. Without this,
22
+ // expanding row A (slow, then failing), then row B, lets A's `finally` blank B's loading indicator
23
+ // and lets A's failure paint onto B's now-open panel — the same "entity-scoped panel, unkeyed
24
+ // settle" shape `use-roles.hooks.ts`'s `permissionPolicyIdRef` fixes for `loadPermissions`.
17
25
  const expandedIdRef = useRef(null);
26
+ // Latest-wins for `onToggleDetail`'s detail loads, a separate instance from `settlement` (which
27
+ // tracks `load()`'s list reads).
18
28
  const detailSettlement = useSettlementGeneration();
19
29
  const load = useCallback(() => {
30
+ // Claim this call's generation BEFORE the request starts — see `useSettlementGeneration`'s own
31
+ // doc for why a synchronous ref bump, not `useState`, is what makes two overlapping calls each
32
+ // see the other's claim. Needed now that a content refresh can fire more than once per run
33
+ // (mid-run tool progress, see `AssistantDock.hooks.tsx`), so two overlapping `load()` calls have
34
+ // no ordering guarantee on their responses.
20
35
  const generation = settlement.next();
21
36
  port
22
37
  .listMembers({})
@@ -29,8 +44,12 @@ export function useMembers({ port, translate }, { refresh } = {}) {
29
44
  .catch((e) => {
30
45
  if (!settlement.isCurrent({ generation }))
31
46
  return;
32
- setError(e instanceof Error ? e.message : t("failed to load members"));
47
+ setError(e instanceof Error ? e.message : translate("failed to load members"));
33
48
  });
49
+ // `port`/`locale`/`settlement` are added — see `use-page-editor.hooks.ts`'s identical note:
50
+ // function-scoped values ESLint's exhaustive-deps rule can see, referentially stable in
51
+ // production, so this changes nothing about when this callback's identity changes.
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
34
53
  }, [port, settlement, translate]);
35
54
  useEffect(() => {
36
55
  load();
@@ -49,10 +68,10 @@ export function useMembers({ port, translate }, { refresh } = {}) {
49
68
  try {
50
69
  const result = await port.disableMember({ id: member.id });
51
70
  setMembers((current) => (current ? current.map((m) => (m.id === member.id ? result.member : m)) : current));
52
- patchRowState(member.id, { disabling: false, notice: t("Member disabled.") });
71
+ patchRowState(member.id, { disabling: false, notice: translate("Member disabled.") });
53
72
  }
54
73
  catch (e) {
55
- patchRowState(member.id, { disabling: false, error: describeApiError(e, t("Failed to disable member.")) });
74
+ patchRowState(member.id, { disabling: false, error: describeError(e, translate("Failed to disable member.")) });
56
75
  }
57
76
  }
58
77
  async function onResendSignInLink(member) {
@@ -61,12 +80,15 @@ export function useMembers({ port, translate }, { refresh } = {}) {
61
80
  patchRowState(member.id, { resending: true, error: null, notice: null });
62
81
  try {
63
82
  await port.requestMemberMagicLink({ email: member.email });
64
- patchRowState(member.id, { resending: false, notice: t("Sign-in link sent.") });
83
+ patchRowState(member.id, { resending: false, notice: translate("Sign-in link sent.") });
65
84
  }
66
85
  catch (e) {
67
- patchRowState(member.id, { resending: false, error: describeApiError(e, t("Failed to send sign-in link.")) });
86
+ patchRowState(member.id, { resending: false, error: describeError(e, translate("Failed to send sign-in link.")) });
68
87
  }
69
88
  }
89
+ /** Confirms the Disable that `RowMenu`'s "Disable" item asked about. Closes the dialog either
90
+ * way (matching Posts.tsx/Redirects.tsx/Users.tsx's own Disable/Delete `ConfirmDialog`
91
+ * convention) — a failure surfaces via the row's own `rs.error`, not by leaving the modal open. */
70
92
  async function confirmDisable() {
71
93
  if (!confirmingDisable)
72
94
  return;
@@ -85,17 +107,23 @@ export function useMembers({ port, translate }, { refresh } = {}) {
85
107
  if (detailById[member.id])
86
108
  return;
87
109
  setDetailLoadingId(member.id);
110
+ // Claimed before the `await`, so a later detail load (another row's, or this same row's after
111
+ // a close and reopen) supersedes this one. The row-id key alone cannot tell two loads of the
112
+ // SAME row apart.
88
113
  const generation = detailSettlement.next();
89
114
  try {
90
115
  const result = await port.getMember({ id: member.id });
91
116
  setDetailById((current) => ({ ...current, [member.id]: result.member }));
92
117
  }
93
118
  catch (e) {
119
+ // Only paint the failure onto the panel this load was actually for — a different row may
120
+ // already be open by the time this settles, or a newer load of this row may be running.
94
121
  if (detailSettlement.isCurrent({ generation }) && expandedIdRef.current === member.id) {
95
- setDetailError(describeApiError(e, t("Failed to load member detail.")));
122
+ setDetailError(describeError(e, translate("Failed to load member detail.")));
96
123
  }
97
124
  }
98
125
  finally {
126
+ // A superseded load leaves the spinner to the load that superseded it.
99
127
  if (detailSettlement.isCurrent({ generation }))
100
128
  setDetailLoadingId(null);
101
129
  }
@@ -1,26 +1,53 @@
1
1
  import type { AdminMember } from "../models.js";
2
2
  import type { Translate } from "@jini-ai/ui/panel-kit";
3
3
  import type { RowMenuItem } from "@jini-ai/admin/react";
4
+ /**
5
+ * @file Pure logic for the `members` 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 `Members.tsx`: `RowActionState`/`emptyRowState` (already module-scope free
10
+ * functions in the original, just not exported or testable), the screen's `describeApiError`
11
+ * override (a `FORBIDDEN`-code branch), and the row-menu item builder (a branch on
12
+ * `member.status !== "disabled"`).
13
+ */
14
+ /** Per-row in-flight/result state for the two row actions (Disable, Resend sign-in link). */
4
15
  export interface RowActionState {
5
16
  disabling: boolean;
6
17
  resending: boolean;
7
18
  error: string | null;
8
19
  notice: string | null;
9
20
  }
10
- export declare function emptyRowState(_required: Record<string, never>): RowActionState;
11
- export declare function describeApiError({ error, fallback, translate }: {
21
+ export declare function emptyRowState(_required: Record<string, never>, _optional?: Record<string, never>): RowActionState;
22
+ /** Overrides layered on the shared default (`lib/api.ts`'s `describeApiError`).
23
+ *
24
+ * `locale` (C4 fix, 2026-09-20): the FORBIDDEN override used to return its English literal
25
+ * directly, leaking English into every non-`en` locale — see `users/rules.ts`'s identical fix for
26
+ * the full reasoning. The literal is now also a key into `members-i18n.ts`'s `MEMBERS_DICT`. */
27
+ export declare function describeMembersError({ error, fallback, translate }: {
12
28
  error: unknown;
13
29
  fallback: string;
14
30
  translate: Translate;
15
- }): string;
31
+ }, _optional?: Record<string, never>): string;
32
+ export { describeMembersError as describeApiError };
33
+ /** The callbacks a row menu needs. Passed in rather than imported, so this module stays free of
34
+ * state (same convention as `posts/rules.ts`'s `PostRowMenuHandlers`). */
16
35
  export interface MemberRowMenuHandlers {
17
36
  onResendSignInLink: (member: AdminMember) => void;
37
+ /** Opens the confirm dialog; only offered when `member.status !== "disabled"`. */
18
38
  onRequestDisable: (member: AdminMember) => void;
19
39
  }
40
+ /** `RowMenu` items for one member row. "Disable" is omitted once the member is already disabled
41
+ * — `RowMenu` has no per-item `disabled`, and `Posts.tsx`'s own precedent (omitting "Disable"
42
+ * entirely for an already-draft row rather than showing it disabled) is to omit an inapplicable
43
+ * action rather than show it as a no-op.
44
+ *
45
+ * @complexity Time/space: O(1) — at most two entries, no iteration.
46
+ */
20
47
  export declare function memberRowMenuItems({ member, rs, handlers, translate }: {
21
48
  member: AdminMember;
22
49
  rs: RowActionState;
23
50
  handlers: MemberRowMenuHandlers;
24
51
  translate: Translate;
25
- }): RowMenuItem[];
52
+ }, _optional?: Record<string, never>): RowMenuItem[];
26
53
  //# sourceMappingURL=rules.d.ts.map
@@ -1,12 +1,24 @@
1
1
  import { describeIdentityError } from "../errors.js";
2
- const STATIC_ERROR_MESSAGES = { FORBIDDEN: "You do not have permission to do that." };
3
- export function emptyRowState(_required) {
2
+ export function emptyRowState(_required, _optional = {}) {
4
3
  return { disabling: false, resending: false, error: null, notice: null };
5
4
  }
6
- export function describeApiError({ error, fallback, translate }) {
7
- return describeIdentityError({ error, fallback, translate }, { messages: STATIC_ERROR_MESSAGES });
5
+ /** Overrides layered on the shared default (`lib/api.ts`'s `describeApiError`).
6
+ *
7
+ * `locale` (C4 fix, 2026-09-20): the FORBIDDEN override used to return its English literal
8
+ * directly, leaking English into every non-`en` locale — see `users/rules.ts`'s identical fix for
9
+ * the full reasoning. The literal is now also a key into `members-i18n.ts`'s `MEMBERS_DICT`. */
10
+ export function describeMembersError({ error, fallback, translate }, _optional = {}) {
11
+ return describeIdentityError({ error, fallback, translate }, { messages: { FORBIDDEN: "You do not have permission to do that." } });
8
12
  }
9
- export function memberRowMenuItems({ member, rs, handlers, translate }) {
13
+ export { describeMembersError as describeApiError };
14
+ /** `RowMenu` items for one member row. "Disable" is omitted once the member is already disabled
15
+ * — `RowMenu` has no per-item `disabled`, and `Posts.tsx`'s own precedent (omitting "Disable"
16
+ * entirely for an already-draft row rather than showing it disabled) is to omit an inapplicable
17
+ * action rather than show it as a no-op.
18
+ *
19
+ * @complexity Time/space: O(1) — at most two entries, no iteration.
20
+ */
21
+ export function memberRowMenuItems({ member, rs, handlers, translate }, _optional = {}) {
10
22
  const items = [
11
23
  {
12
24
  key: "resend",
@@ -11,6 +11,9 @@ export interface AdminIdentityUser {
11
11
  status: "active" | "disabled";
12
12
  createdAt: string;
13
13
  lastLoginAt?: string | undefined;
14
+ /** Server classification covers ownership granted by policy, not only named roles. */
15
+ isOwner?: boolean | undefined;
16
+ isProtectedAccount?: boolean | undefined;
14
17
  roleIds: string[];
15
18
  policyIds: string[];
16
19
  }