@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.
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:
@@ -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
  }
@@ -1,26 +1,100 @@
1
- import type { Translate } from "@jini-ai/ui/panel-kit";
2
1
  import { type Dispatch, type SetStateAction } from "react";
3
2
  import type { AdminIdentityUser, AdminPolicy, AdminRole } from "../models.js";
3
+ import { type UserAccountCapabilities } from "./rules.js";
4
4
  import { useUsers, type UsersDependencies, type UsersOptions } from "./hooks/use-users.hooks.js";
5
+ import type { ReactNode } from "react";
6
+ import type { Translate } from "@jini-ai/ui/panel-kit";
7
+ /**
8
+ * @file Admin "Users" screen (SPEC-006 §3 human grant-writing transitions + 0.6.0 CRUD-completion
9
+ * amendment) — markup only.
10
+ *
11
+ * State and API calls live in `hooks/use-users.hooks.ts`; the row-menu logic and the
12
+ * server-error-message overrides live in `rules.ts`. What stays here is what actually renders: the
13
+ * form, the table, and the two dialogs.
14
+ *
15
+ * Mirrors `features/integrations/Integrations.tsx`'s fetch/loading/error/form/table shape.
16
+ * Lists operator users, creates new ones (`CREATE_USER`), and grants roles/
17
+ * policies to an existing user (`ASSIGN_ROLE`/`ATTACH_POLICY`) via an
18
+ * inline expandable "Manage" row. Role/policy *creation* is out of
19
+ * this screen's UI (no `CREATE_ROLE`/`CREATE_POLICY` form here) — the API
20
+ * client exposes `api.createRole`/`api.createPolicy` for the Roles &
21
+ * Permissions screen to reuse.
22
+ *
23
+ * 0.6.0: the same expandable row also carries `UPDATE_USER` (email),
24
+ * `RESET_USER_PASSWORD`, and a quick `DISABLE_PRINCIPAL`/`ENABLE_PRINCIPAL` toggle.
25
+ *
26
+ * Row actions (audit follow-up): all three of the above — Disable/Enable, Manage (opens the
27
+ * expandable row), Reset password — now live behind a single three-dot `RowMenu`, matching
28
+ * `Posts.tsx`/`Pages.tsx`'s row-action shape rather than a row of separate buttons.
29
+ *
30
+ * Delete-user plan v2 (2026-09-24): a fourth "Delete" item joins the menu, shown only when the
31
+ * signed-in caller may manage the user Trash (owner or the built-in `admin` role — OWNER DECISION
32
+ * 2026-09-24, `use-users.hooks.ts`'s `canManageUserTrash`). It moves the target to the Trash
33
+ * (disabled, sessions revoked, restorable there for 60 days) rather than hard-deleting it — see
34
+ * `UserDeleteDialog`'s own doc comment.
35
+ *
36
+ * Complexity-ceiling pass (2026-08-06): `Users` and its row-map closure both scored over the
37
+ * ceiling (19/16 and 11/11 respectively). Split into four top-level, individually-testable
38
+ * functions below — `NewUserForm`, `UsersTable`/`UserRow`, `UserDisableDialog`/`UserDeleteDialog`,
39
+ * `UserResetPasswordDialog` — following `Taxonomy.tsx`'s existing convention of sibling top-level
40
+ * function components in the same file rather than nested closures (a nested `const` inside
41
+ * `Users` would not have moved any branching out of `Users`' own scope). `Users` itself is now a
42
+ * thin composition of those, none of them exported, so no test does anything `Users.tsx`
43
+ * didn't already support — see `__tests__/Users.unit.test.tsx`/`Users.crud.unit.test.tsx` for the
44
+ * full-render tests these were extracted underneath (unchanged), plus new
45
+ * `__tests__/users-components.unit.test.tsx` for direct component-level tests of each piece.
46
+ *
47
+ * Reset-password confirm + reveal (typo-catching pass): `UserResetPasswordDialog` gained a second
48
+ * field ("Confirm new password") and an independent show/hide toggle per field, both driven by the
49
+ * new `hooks/use-reset-password-fields.hooks.ts` (confirm-field state and both reveal flags are
50
+ * purely local/ephemeral — never sent to the server, so they don't belong in `use-users.hooks.ts`'s
51
+ * screen-wide `UsersController`). The confirm-vs-server-error display conflict is resolved by giving
52
+ * the two states one shared slot with a fixed precedence — a live mismatch always wins over a stale
53
+ * `passwordError` from a previous attempt — rather than trying to show both at once or inventing a
54
+ * second error line; see `UserResetPasswordDialog`'s own body for the exact ternary. `RevealablePasswordField`
55
+ * is the shared field+toggle shape both new inputs use, following this file's own `GrantSelect`
56
+ * precedent for a shape used twice.
57
+ */
5
58
  export interface UsersProps extends UsersDependencies, UsersOptions {
6
59
  useUsersHook?: typeof useUsers | undefined;
7
- openOwnPasswordReset?: boolean | undefined;
60
+ /** The host owns localization of protocol status/role labels. */
61
+ renderServerLabel?: ((required: {
62
+ value: string;
63
+ }) => ReactNode) | undefined;
8
64
  }
65
+ /** The subset of `AdminRole`/`AdminPolicy` a grant `<select>` actually renders. Named rather than
66
+ * taking the full records, so the shared control below cannot come to depend on anything a role has
67
+ * and a policy does not. */
9
68
  export interface GrantOption {
10
69
  id: string;
11
70
  name: string;
12
71
  isBuiltin: boolean;
13
72
  }
73
+ /** One "pick something, then grant it to this user" control. Both grants on the Manage panel are
74
+ * this same shape, which is what lets them share {@link GrantSelect} instead of being two
75
+ * near-identical blocks whose props had to be threaded separately. */
14
76
  export interface GrantSelectController {
15
77
  options: GrantOption[];
78
+ /** The id currently selected in the `<select>`, or `""` for the placeholder row. */
16
79
  pendingId: string;
17
80
  setPendingId: Dispatch<SetStateAction<string>>;
18
81
  submit: (required: {
19
82
  principalId: string;
20
83
  }) => Promise<void>;
21
84
  }
85
+ /**
86
+ * Everything the expanded "Manage" panel needs, as one object.
87
+ *
88
+ * Replaces the fifteen loose state/setter/callback props this panel used to take and `UserRow` used
89
+ * to relay through an `Omit<UserManagePanelProps, "principalId">` rest spread. The grouping is by
90
+ * interaction, not by convenience: the email editor and the two grants are three independent
91
+ * controls that happen to share one error line and one in-flight flag, and saying so in the type is
92
+ * what lets a test build a Manage panel without also standing up a row, a table, and a page.
93
+ */
22
94
  export interface UserManageController {
95
+ /** The one error line the whole panel shares — any of the three writes can set it. */
23
96
  error: string | null;
97
+ /** Shared by both grants: the panel issues one grant at a time. */
24
98
  saving: boolean;
25
99
  email: {
26
100
  value: string;
@@ -30,43 +104,68 @@ export interface UserManageController {
30
104
  principalId: string;
31
105
  }) => Promise<void>;
32
106
  };
33
- roleGrant: GrantSelectController;
34
- policyGrant: GrantSelectController;
107
+ /** `null` when the caller may not read roles/policies (`UsersController.canGrant`): no grant controls. */
108
+ roleGrant: GrantSelectController | null;
109
+ policyGrant: GrantSelectController | null;
35
110
  }
36
111
  export interface UserManagePanelProps {
37
112
  principalId: string;
38
113
  manage: UserManageController;
39
114
  t: (key: string) => string;
115
+ /** The table's column count, so the panel row spans it exactly (fewer without Roles/Policies). */
116
+ colSpan?: number;
40
117
  }
41
- export declare function UserManagePanel({ principalId, manage, t }: UserManagePanelProps): import("react").JSX.Element;
118
+ /** The expanded "Manage" row's contents (email edit, role/policy grant forms).
119
+ *
120
+ * Exported so a test can drive it with a hand-built {@link UserManageController} — the seam only
121
+ * counts as one if something other than `Users` can supply it. */
122
+ export declare function UserManagePanel({ principalId, manage, t, colSpan }: UserManagePanelProps): import("react").JSX.Element;
123
+ /** A user row's own affordances — expand/collapse and the three menu actions. Distinct from
124
+ * {@link UserManageController}, which describes what the expanded panel does once it is open. */
42
125
  export interface UserRowActionsController {
43
126
  expandedId: string | null;
44
127
  toggleExpanded: (user: AdminIdentityUser) => void;
128
+ /** Which row has a status write in flight — drives that row's disabled menu item only. */
45
129
  savingId: string | null;
46
130
  requestDisable: (user: AdminIdentityUser) => void;
47
131
  toggleStatus: (user: AdminIdentityUser) => Promise<void>;
48
132
  openResetPassword: (user: AdminIdentityUser) => void;
133
+ /** Delete-user plan v2 (2026-09-24): whether the row's `RowMenu` should carry a "Delete" item at
134
+ * all — see `use-users.hooks.ts`'s `canManageUserTrash` doc comment for where this comes from. */
49
135
  canDelete: boolean;
136
+ rowCapabilities?: (user: AdminIdentityUser) => UserAccountCapabilities;
50
137
  requestDelete: (user: AdminIdentityUser) => void;
51
138
  }
52
139
  export interface UserRowProps {
53
140
  user: AdminIdentityUser;
54
141
  roleById: ReadonlyMap<string, AdminRole>;
55
142
  policyById: ReadonlyMap<string, AdminPolicy>;
143
+ /** Whether the Roles/Policies cells render — see {@link UsersTableProps.showGrants}. */
144
+ showGrants: boolean;
56
145
  actions: UserRowActionsController;
57
146
  manage: UserManageController;
147
+ /** This row's own distinct handle base — computed once, across every rendered row, by
148
+ * `UsersTable` (via `buildAgentListHandles`); see `Taxonomy.tsx`'s `NewTermForm.agentBase` for
149
+ * why a per-instance uniqueness search does not work here. */
58
150
  agentBase: string;
59
151
  t: (key: string) => string;
60
152
  translate: Translate;
153
+ renderServerLabel?: UsersProps["renderServerLabel"];
61
154
  }
62
155
  export interface UsersTableProps {
63
156
  users: AdminIdentityUser[];
157
+ /** The full records, for the Roles/Policies label columns. The grant selects get their own
158
+ * `options` through `manage`, so neither consumer has to reach into the other's props. */
64
159
  roles: AdminRole[];
65
160
  policies: AdminPolicy[];
161
+ /** `UsersController.canGrant`: without `role.manage` there are no names to resolve grant ids
162
+ * against, so the Roles/Policies columns are left out rather than showing raw ids. */
163
+ showGrants: boolean;
66
164
  actions: UserRowActionsController;
67
165
  manage: UserManageController;
68
166
  t: (key: string) => string;
69
167
  translate: Translate;
168
+ renderServerLabel?: UsersProps["renderServerLabel"];
70
169
  }
71
- export declare function Users({ useUsersHook, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed }: UsersProps): import("react").JSX.Element;
170
+ export declare function Users({ useUsersHook, port, translate: injectedTranslate, queryScope, openOwnPasswordReset, onOwnPasswordResetClosed, passwordResetNotice, renderServerLabel }: UsersProps): import("react").JSX.Element;
72
171
  //# sourceMappingURL=Users.d.ts.map