@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/core/principal-kind-policy.d.ts +1 -1
  3. package/dist/core/principal-kind-policy.js +1 -1
  4. package/dist/core/runtime-ports.d.ts +20 -0
  5. package/dist/core/types.d.ts +4 -1
  6. package/dist/core/types.js +5 -1
  7. package/dist/react/index.d.ts +7 -0
  8. package/dist/react/index.js +4 -0
  9. package/dist/react/members/Members.d.ts +28 -1
  10. package/dist/react/members/Members.js +15 -11
  11. package/dist/react/members/hooks/use-members.hooks.d.ts +46 -2
  12. package/dist/react/members/hooks/use-members.hooks.js +82 -54
  13. package/dist/react/members/rules.d.ts +31 -4
  14. package/dist/react/members/rules.js +17 -5
  15. package/dist/react/models.d.ts +5 -0
  16. package/dist/react/ports.d.ts +2 -0
  17. package/dist/react/testing.js +1 -1
  18. package/dist/react/users/ChangeUsernameDialog.d.ts +8 -0
  19. package/dist/react/users/ChangeUsernameDialog.js +8 -0
  20. package/dist/react/users/Users.d.ts +107 -7
  21. package/dist/react/users/Users.js +108 -43
  22. package/dist/react/users/hooks/use-change-username.hooks.d.ts +27 -0
  23. package/dist/react/users/hooks/use-change-username.hooks.js +53 -0
  24. package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +33 -1
  25. package/dist/react/users/hooks/use-reset-password-fields.hooks.js +16 -1
  26. package/dist/react/users/hooks/use-users.hooks.d.ts +129 -3
  27. package/dist/react/users/hooks/use-users.hooks.js +223 -23
  28. package/dist/react/users/rules.d.ts +116 -8
  29. package/dist/react/users/rules.js +112 -6
  30. package/dist/server/admin-crud-service.d.ts +6 -4
  31. package/dist/server/admin-crud-service.js +49 -6
  32. package/dist/server/agent-tool-registrations.d.ts +1 -0
  33. package/dist/server/agent-tool-registrations.js +14 -0
  34. package/dist/server/agent-tools.js +11 -0
  35. package/dist/server/auth-service.d.ts +3 -1
  36. package/dist/server/seed.d.ts +7 -4
  37. package/dist/server/seed.js +15 -6
  38. package/package.json +17 -12
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## 0.3.0 — 2026-10-05
4
8
 
5
9
  ### BREAKING
@@ -6,7 +6,7 @@ import type { PrincipalKind } from "./types.js";
6
6
  * The one place that decides whether a principal's KIND lets it use the operator RBAC axis
7
7
  * (`authorize()`'s permissions). Today the answer is: every kind except `member`. A `member` is a
8
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 (Tovu F3144): site
9
+ * (tiers, gated content), never by operator RBAC. OWNER DECISION 2026-10-04 (host decision F3144): site
10
10
  * members must not be able to use admin features, at least for now.
11
11
  *
12
12
  * Why a function and not a check at each call site:
@@ -5,7 +5,7 @@
5
5
  * The one place that decides whether a principal's KIND lets it use the operator RBAC axis
6
6
  * (`authorize()`'s permissions). Today the answer is: every kind except `member`. A `member` is a
7
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 (Tovu F3144): site
8
+ * (tiers, gated content), never by operator RBAC. OWNER DECISION 2026-10-04 (host decision F3144): site
9
9
  * members must not be able to use admin features, at least for now.
10
10
  *
11
11
  * Why a function and not a check at each call site:
@@ -5,4 +5,24 @@ export interface SessionTokenPort {
5
5
  rawToken: string;
6
6
  }): string;
7
7
  }
8
+ /** Host lifecycle for renames. Audit writes must join the identity transaction. */
9
+ export interface UsernameChangePort {
10
+ /** A host can refuse a rename that its bootstrap identity cannot safely survive. */
11
+ assertSupported?: ((required: {
12
+ workspaceId: string;
13
+ principalId: string;
14
+ }, optional?: Record<string, never>) => Promise<void>) | undefined;
15
+ isInTrash(required: {
16
+ workspaceId: string;
17
+ principalId: string;
18
+ }, optional?: Record<string, never>): Promise<boolean>;
19
+ record(required: {
20
+ workspaceId: string;
21
+ principalId: string;
22
+ callerPrincipalId: string;
23
+ previousUsername: string;
24
+ username: string;
25
+ occurredAt: string;
26
+ }, optional?: Record<string, never>): Promise<void>;
27
+ }
8
28
  //# sourceMappingURL=runtime-ports.d.ts.map
@@ -156,8 +156,11 @@ export declare class IdentityNotFoundError extends Error {
156
156
  * See docs/decisions/DR-001-identity-and-session-boundary.md.
157
157
  */
158
158
  export declare class IdentityConflictError extends Error {
159
- constructor({ message }: {
159
+ /** Rename conflicts carry a precise reason; legacy conflicts retain their existing contract. */
160
+ readonly code?: "RESOURCE_CONFLICT" | "USER_IN_TRASH" | "USERNAME_IN_TRASH";
161
+ constructor({ message, code }: {
160
162
  message: string;
163
+ code?: "RESOURCE_CONFLICT" | "USER_IN_TRASH" | "USERNAME_IN_TRASH";
161
164
  }, optional?: ErrorOptions);
162
165
  }
163
166
  /** Raised by `login` on bad credentials, a disabled principal, or a blank field. See docs/decisions/DR-001-identity-and-session-boundary.md. */
@@ -18,8 +18,12 @@ export class IdentityNotFoundError extends Error {
18
18
  * See docs/decisions/DR-001-identity-and-session-boundary.md.
19
19
  */
20
20
  export class IdentityConflictError extends Error {
21
- constructor({ message }, optional = {}) {
21
+ /** Rename conflicts carry a precise reason; legacy conflicts retain their existing contract. */
22
+ code;
23
+ constructor({ message, code }, optional = {}) {
22
24
  super(message, optional);
25
+ if (code !== undefined)
26
+ this.code = code;
23
27
  }
24
28
  }
25
29
  /** Raised by `login` on bad credentials, a disabled principal, or a blank field. See docs/decisions/DR-001-identity-and-session-boundary.md. */
@@ -15,4 +15,11 @@ 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 { useChangeUsername } from './users/hooks/use-change-username.hooks.js';
19
+ export type { ChangeUsernameController } from './users/hooks/use-change-username.hooks.js';
20
+ export { ChangeUsernameDialog } from './users/ChangeUsernameDialog.js';
21
+ export { userAccountCapabilities, isOwnerAccount, grantOptionLabel, formatGrantLabel, userRowMenuItems, describeUsersError } from './users/rules.js';
22
+ export type { UserAccountCapabilities, UserRowMenuHandlers } from './users/rules.js';
23
+ export { emptyRowState, memberRowMenuItems, describeMembersError } from './members/rules.js';
24
+ export type { RowActionState, MemberRowMenuHandlers } from './members/rules.js';
18
25
  //# sourceMappingURL=index.d.ts.map
@@ -6,4 +6,8 @@ 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 { useChangeUsername } from './users/hooks/use-change-username.hooks.js';
10
+ export { ChangeUsernameDialog } from './users/ChangeUsernameDialog.js';
11
+ export { userAccountCapabilities, isOwnerAccount, grantOptionLabel, formatGrantLabel, userRowMenuItems, describeUsersError } from './users/rules.js';
12
+ export { emptyRowState, memberRowMenuItems, describeMembersError } from './members/rules.js';
9
13
  //# 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, queryScope, 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 }) {
30
- const { members, error, stateFor, onResendSignInLink, expandedId, detailById, detailError, detailLoadingId, onToggleDetail, confirmingDisable, setConfirmingDisable, confirmDisable, t, translate, } = useMembersHook({ port, translate: injectedTranslate }, { refresh });
31
+ export function Members({ useMembersHook = useMembers, port, translate: injectedTranslate, queryScope, 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 }) {
32
+ const { members, error, stateFor, onResendSignInLink, expandedId, detailById, detailError, detailLoadingId, onToggleDetail, confirmingDisable, setConfirmingDisable, confirmDisable, t, translate, } = useMembersHook({ port, translate: injectedTranslate, ...(queryScope === undefined ? {} : { queryScope }) }, { 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,32 +1,76 @@
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;
37
+ /** Same session/workspace scope as Users; isolated providers retain legacy composition. */
38
+ queryScope?: string;
8
39
  translate: Translate;
9
40
  }
10
41
  export interface MembersController {
42
+ /** `null` until the initial load settles — the caller renders a loading state. */
11
43
  members: AdminMember[] | null;
12
44
  error: string | null;
45
+ /** Per-row action state (Disable/Resend in-flight, error, notice), falling back to the empty
46
+ * state for a row with no action taken yet. */
13
47
  stateFor: (required: {
14
48
  id: string;
15
49
  }) => RowActionState;
16
50
  onResendSignInLink: (member: AdminMember) => Promise<void>;
51
+ /** The row whose detail panel is expanded — `null` when every row is collapsed. */
17
52
  expandedId: string | null;
53
+ /** Detail already fetched for an expanded row, keyed by member id — a cache so re-expanding a
54
+ * row already visited this session doesn't re-fetch. */
18
55
  detailById: Record<string, AdminMember>;
19
56
  detailError: string | null;
20
57
  detailLoadingId: string | null;
21
58
  onToggleDetail: (member: AdminMember) => Promise<void>;
59
+ /** The member a `RowMenu` "Disable" selection is asking to confirm; `null` when the dialog is
60
+ * shut. `ConfirmDialog` stays mounted unconditionally in the view (see its own doc comment on
61
+ * why); this is what drives its `open` prop. */
22
62
  confirmingDisable: AdminMember | null;
23
63
  setConfirmingDisable: Dispatch<SetStateAction<AdminMember | null>>;
24
64
  confirmDisable: () => Promise<void>;
65
+ /** Bound translator — `key` already resolved against the caller's locale, so `Members.tsx` never
66
+ * imports `useAdminLocale`/`members-i18n` itself. See this file's header. */
25
67
  t: Translate;
68
+ /** Raw resolved locale — `rules.ts`'s `memberRowMenuItems` takes `locale` directly rather than a
69
+ * bound translator. See this file's header. */
26
70
  translate: Translate;
27
71
  }
28
72
  export interface MembersOptions {
29
73
  refresh?: IdentityRefreshPort | undefined;
30
74
  }
31
- export declare function useMembers({ port, translate }: MembersDependencies, { refresh }?: MembersOptions): MembersController;
75
+ export declare function useMembers({ port, translate, queryScope }: MembersDependencies, { refresh }?: MembersOptions): MembersController;
32
76
  //# sourceMappingURL=use-members.hooks.d.ts.map
@@ -1,41 +1,66 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
2
- import { describeApiError as describeFeatureError, emptyRowState } from "../rules.js";
3
- import { useSettlementGeneration } from "@jini-ai/ui/panel-kit";
4
- export function useMembers({ port, translate }, { refresh } = {}) {
5
- const describeApiError = (error, fallback) => describeFeatureError({ error, fallback, translate });
6
- const t = translate;
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { describeApiError, emptyRowState } from "../rules.js";
3
+ import { useCachedLoader, useFetchMutation, useFetchQuery, useInvalidate } from "@jini-ai/ui/fetch-query";
4
+ export function useMembers({ port, translate, queryScope = "members" }, { refresh } = {}) {
5
+ const scopeRef = useRef(queryScope);
6
+ scopeRef.current = queryScope;
7
7
  const boundT = translate;
8
- const [members, setMembers] = useState(null);
9
- const [error, setError] = useState(null);
8
+ const describeError = (error, fallback) => describeApiError({ error, fallback, translate });
9
+ const listKey = [queryScope, 'members', 'list'];
10
+ const detailRoot = [queryScope, 'members', 'detail'];
11
+ const invalidate = useInvalidate();
12
+ // Mount reads and assistant refreshes can overlap; the shared cache owns list settlement
13
+ // order so a slow earlier success or failure cannot replace a newer roster.
14
+ const list = useFetchQuery({ key: listKey, fetch: () => port.listMembers({}) });
15
+ const members = list.data?.members ?? null;
16
+ const error = list.error ? describeError(list.error, translate("failed to load members")) : null;
17
+ const listLoader = useCachedLoader({ key: listKey, fetch: () => port.listMembers({}) }, { staleTime: 0 });
18
+ const disable = useFetchMutation({ run: ({ input: id }) => port.disableMember({ id }) }, { invalidates: [listKey, detailRoot] });
19
+ // Sending a link changes no list/detail fields; do not refetch the roster on success.
20
+ const resend = useFetchMutation({ run: ({ input: email }) => port.requestMemberMagicLink({ email }) });
10
21
  const [rowState, setRowState] = useState({});
11
22
  const [expandedId, setExpandedId] = useState(null);
12
23
  const [detailById, setDetailById] = useState({});
13
- const [detailError, setDetailError] = useState(null);
14
- const [detailLoadingId, setDetailLoadingId] = useState(null);
24
+ // Disable now confirms via a `RowMenu` item -> `ConfirmDialog` modal (replacing the in-place
25
+ // two-click `ConfirmButton`, which has no menu-item equivalent — same migration Posts.tsx/
26
+ // Redirects.tsx/Users.tsx already made). `null` when the dialog is closed.
15
27
  const [confirmingDisable, setConfirmingDisable] = useState(null);
16
- const settlement = useSettlementGeneration();
28
+ // Which row's detail panel is actually open (C7, plan-access.md §8, N2) — a ref, not just
29
+ // expandedId state, because onToggleDetail reads it after await, when a closure can be stale.
30
+ // Expanding slow/failing A then B must not let A's failure or finally blank B's panel/spinner.
17
31
  const expandedIdRef = useRef(null);
18
- const detailSettlement = useSettlementGeneration();
19
- const load = useCallback(() => {
20
- const generation = settlement.next();
21
- port
22
- .listMembers({})
23
- .then((r) => {
24
- if (!settlement.isCurrent({ generation }))
25
- return;
26
- setMembers(r.members);
27
- setError(null);
28
- })
29
- .catch((e) => {
30
- if (!settlement.isCurrent({ generation }))
31
- return;
32
- setError(e instanceof Error ? e.message : t("failed to load members"));
33
- });
34
- }, [port, settlement, translate]);
32
+ const detailGenerations = useRef({});
33
+ const [detailGeneration, setDetailGeneration] = useState(0);
34
+ // Entity keys own settlement: a late A response cannot paint B's error/spinner. Detail
35
+ // data remains cached on collapse, while successful disables invalidate both resources.
36
+ const detail = useFetchQuery({
37
+ key: [...detailRoot, expandedId ?? '', detailGeneration],
38
+ fetch: () => port.getMember({ id: expandedId }),
39
+ }, { enabled: expandedId !== null, staleTime: Infinity });
40
+ const detailLoader = useCachedLoader({ key: [...detailRoot, expandedId ?? '', detailGeneration], fetch: () => port.getMember({ id: expandedId }) }, { staleTime: Infinity });
35
41
  useEffect(() => {
36
- load();
37
- }, [load]);
38
- useEffect(() => refresh?.subscribe({ onRefresh: load }), [refresh, load]);
42
+ expandedIdRef.current = null;
43
+ detailGenerations.current = {};
44
+ setExpandedId(null);
45
+ setDetailGeneration(0);
46
+ setDetailById({});
47
+ setRowState({});
48
+ setConfirmingDisable(null);
49
+ }, [queryScope]);
50
+ // Only paint the failure onto the panel that is still expanded: the observer's entity key
51
+ // selects the current row, so a late rejection for a collapsed row cannot surface here.
52
+ const detailError = detail.error ? describeError(detail.error, translate("Failed to load member detail.")) : null;
53
+ // A superseded load leaves the spinner to its successor. Derive it from the current keyed
54
+ // observer instead of letting an old request's finally clear the new row's loading state.
55
+ const detailLoadingId = expandedId !== null && !detail.data && detail.isFetching ? expandedId : null;
56
+ useEffect(() => {
57
+ if (expandedId && detail.data)
58
+ setDetailById(current => ({ ...current, [expandedId]: detail.data.member }));
59
+ }, [expandedId, detail.data]);
60
+ useEffect(() => refresh?.subscribe({ onRefresh: () => {
61
+ invalidate({ key: listKey });
62
+ invalidate({ key: detailRoot });
63
+ } }), [refresh, invalidate, queryScope]);
39
64
  function stateFor({ id }) {
40
65
  return rowState[id] ?? emptyRowState({});
41
66
  }
@@ -47,12 +72,13 @@ export function useMembers({ port, translate }, { refresh } = {}) {
47
72
  return;
48
73
  patchRowState(member.id, { disabling: true, error: null, notice: null });
49
74
  try {
50
- const result = await port.disableMember({ id: member.id });
51
- setMembers((current) => (current ? current.map((m) => (m.id === member.id ? result.member : m)) : current));
52
- patchRowState(member.id, { disabling: false, notice: t("Member disabled.") });
75
+ const result = await disable.mutate({ input: member.id });
76
+ await listLoader.load().catch(() => { }); // Await roster settlement; list.error owns a failed read, not the accepted disable.
77
+ setDetailById(current => current[member.id] ? { ...current, [member.id]: result.member } : current);
78
+ patchRowState(member.id, { disabling: false, notice: translate("Member disabled.") });
53
79
  }
54
80
  catch (e) {
55
- patchRowState(member.id, { disabling: false, error: describeApiError(e, t("Failed to disable member.")) });
81
+ patchRowState(member.id, { disabling: false, error: describeError(e, translate("Failed to disable member.")) });
56
82
  }
57
83
  }
58
84
  async function onResendSignInLink(member) {
@@ -60,13 +86,16 @@ export function useMembers({ port, translate }, { refresh } = {}) {
60
86
  return;
61
87
  patchRowState(member.id, { resending: true, error: null, notice: null });
62
88
  try {
63
- await port.requestMemberMagicLink({ email: member.email });
64
- patchRowState(member.id, { resending: false, notice: t("Sign-in link sent.") });
89
+ await resend.mutate({ input: member.email });
90
+ patchRowState(member.id, { resending: false, notice: translate("Sign-in link sent.") });
65
91
  }
66
92
  catch (e) {
67
- patchRowState(member.id, { resending: false, error: describeApiError(e, t("Failed to send sign-in link.")) });
93
+ patchRowState(member.id, { resending: false, error: describeError(e, translate("Failed to send sign-in link.")) });
68
94
  }
69
95
  }
96
+ /** Confirms the Disable that `RowMenu`'s "Disable" item asked about. Closes the dialog either
97
+ * way (matching Posts.tsx/Redirects.tsx/Users.tsx's own Disable/Delete `ConfirmDialog`
98
+ * convention) — a failure surfaces via the row's own `rs.error`, not by leaving the modal open. */
70
99
  async function confirmDisable() {
71
100
  if (!confirmingDisable)
72
101
  return;
@@ -81,24 +110,23 @@ export function useMembers({ port, translate }, { refresh } = {}) {
81
110
  }
82
111
  expandedIdRef.current = member.id;
83
112
  setExpandedId(member.id);
84
- setDetailError(null);
85
- if (detailById[member.id])
86
- return;
87
- setDetailLoadingId(member.id);
88
- const generation = detailSettlement.next();
113
+ // A collapsed unfinished request cannot own a reopened panel. A primitive generation
114
+ // creates a new read fence for that member, preserving the prior latest-wins contract;
115
+ // successful details keep their current generation so reopen reuses the cache.
116
+ // Claimed before await: even two loads of the SAME row must be distinguishable, since a
117
+ // row-id key alone cannot fence an unfinished request after collapse/reopen.
118
+ const generation = detailById[member.id] ? detailGenerations.current[member.id] ?? 1 : (detailGenerations.current[member.id] ?? 0) + 1;
119
+ detailGenerations.current[member.id] = generation;
120
+ setDetailGeneration(generation);
121
+ // Start through the same cache synchronously so imperative callers still await the
122
+ // detail read; the reactive observer joins it when React commits the selected identity.
123
+ const requestedScope = queryScope;
89
124
  try {
90
- const result = await port.getMember({ id: member.id });
91
- setDetailById((current) => ({ ...current, [member.id]: result.member }));
92
- }
93
- catch (e) {
94
- if (detailSettlement.isCurrent({ generation }) && expandedIdRef.current === member.id) {
95
- setDetailError(describeApiError(e, t("Failed to load member detail.")));
96
- }
97
- }
98
- finally {
99
- if (detailSettlement.isCurrent({ generation }))
100
- setDetailLoadingId(null);
125
+ const result = await detailLoader.load({ key: [...detailRoot, member.id, generation], fetch: () => port.getMember({ id: member.id }) });
126
+ if (scopeRef.current === requestedScope && detailGenerations.current[member.id] === generation)
127
+ setDetailById(current => ({ ...current, [member.id]: result.member }));
101
128
  }
129
+ catch { /* The keyed observer owns the failure for the panel that is still expanded. */ }
102
130
  }
103
131
  return {
104
132
  members,
@@ -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,11 @@ 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;
17
+ /** Host refusal for accounts whose bootstrap identity cannot safely follow a rename yet. */
18
+ canRename?: boolean | undefined;
14
19
  roleIds: string[];
15
20
  policyIds: string[];
16
21
  }
@@ -16,6 +16,7 @@ export interface UsersPort {
16
16
  id: string;
17
17
  };
18
18
  canManageUserTrash: boolean;
19
+ effectivePermissions?: string[] | undefined;
19
20
  }>;
20
21
  listRoles(required: Record<string, never>): Promise<{
21
22
  roles: AdminRole[];
@@ -35,6 +36,7 @@ export interface UsersPort {
35
36
  principalId: string;
36
37
  }, options?: {
37
38
  email?: string | undefined;
39
+ username?: string | undefined;
38
40
  }): Promise<{
39
41
  user: AdminIdentityUser;
40
42
  }>;
@@ -67,7 +67,7 @@ export function createFakeUsersPort(_required, options = {}) {
67
67
  if (options.updateUserError)
68
68
  throw options.updateUserError;
69
69
  const current = requireUser(target.principalId);
70
- return { user: replaceUser({ ...current, ...opts }) };
70
+ return { user: replaceUser({ ...current, ...opts, username: opts?.username ?? current.username }) };
71
71
  },
72
72
  async disableUser({ principalId }) {
73
73
  if (options.toggleStatusError)
@@ -0,0 +1,8 @@
1
+ import type { Translate } from "@jini-ai/ui/panel-kit";
2
+ import type { useChangeUsername } from "./hooks/use-change-username.hooks.js";
3
+ /** Markup shared by row actions and the signed-in user's profile; state lives in the hook. */
4
+ export declare function ChangeUsernameDialog({ controller, translate: t }: {
5
+ controller: ReturnType<typeof useChangeUsername>;
6
+ translate: Translate;
7
+ }, _optional?: Record<string, never>): import("react").JSX.Element;
8
+ //# sourceMappingURL=ChangeUsernameDialog.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ConfirmDialog } from "@jini-ai/admin/react";
3
+ import { agentHandle } from "@jini-ai/agentic";
4
+ /** Markup shared by row actions and the signed-in user's profile; state lives in the hook. */
5
+ export function ChangeUsernameDialog({ controller, translate: t }, _optional = {}) {
6
+ return _jsx(ConfirmDialog, { open: controller.target !== null, agentHandle: "users-change-username", title: t("Change username"), cancelLabel: t("Cancel"), confirmLabel: t("Change username"), pending: controller.pending, onConfirm: () => void controller.confirm({}), onCancel: () => controller.cancel({}), body: _jsxs(_Fragment, { children: [_jsx("p", { children: controller.target?.username }), _jsxs("label", { children: [t("New username"), _jsx("input", { value: controller.username, autoComplete: "off", disabled: controller.pending, onChange: event => controller.setUsername(event.target.value), ...agentHandle({ handle: "users-change-username-input" }, { role: "field", label: "New login username" }) })] }), _jsx("p", { children: t("Existing sessions stay signed in. Use the new username the next time you sign in.") }), controller.error ? _jsx("p", { role: "alert", className: "save-error", children: controller.error }) : null] }) });
7
+ }
8
+ //# sourceMappingURL=ChangeUsernameDialog.js.map