@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.
- package/CHANGELOG.md +43 -0
- package/dist/admin/people.rules.js +2 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/principal-kind-policy.d.ts +30 -0
- package/dist/core/principal-kind-policy.js +28 -0
- package/dist/core/types.d.ts +7 -2
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.js +2 -0
- package/dist/react/members/Members.d.ts +28 -1
- package/dist/react/members/Members.js +14 -10
- package/dist/react/members/hooks/use-members.hooks.d.ts +43 -1
- package/dist/react/members/hooks/use-members.hooks.js +37 -9
- package/dist/react/members/rules.d.ts +31 -4
- package/dist/react/members/rules.js +17 -5
- package/dist/react/models.d.ts +3 -0
- package/dist/react/users/Users.d.ts +105 -6
- package/dist/react/users/Users.js +105 -43
- package/dist/react/users/hooks/use-reset-password-fields.hooks.d.ts +33 -1
- package/dist/react/users/hooks/use-reset-password-fields.hooks.js +16 -1
- package/dist/react/users/hooks/use-users.hooks.d.ts +120 -3
- package/dist/react/users/hooks/use-users.hooks.js +192 -23
- package/dist/react/users/rules.d.ts +115 -8
- package/dist/react/users/rules.js +109 -6
- package/dist/server/admin-crud-service.js +4 -1
- package/dist/server/authorize.d.ts +9 -6
- package/dist/server/authorize.js +11 -3
- package/dist/server/permission-migrations.d.ts +35 -15
- package/dist/server/permission-migrations.js +16 -16
- package/dist/server/seed.js +6 -1
- package/package.json +15 -10
- package/dist/react/roles/Roles.d.ts +0 -100
- package/dist/react/roles/Roles.hooks.d.ts +0 -17
- package/dist/react/roles/Roles.hooks.js +0 -40
- package/dist/react/roles/Roles.js +0 -150
- package/dist/react/roles/hooks/use-roles.hooks.d.ts +0 -80
- package/dist/react/roles/hooks/use-roles.hooks.js +0 -303
- package/dist/react/roles/rules.d.ts +0 -35
- package/dist/react/roles/rules.js +0 -31
- package/dist/react/roles/text.d.ts +0 -27
- 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
|
-
|
|
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
|
}
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
|
@@ -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
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
22
|
-
|
|
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
|
/**
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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
|
package/dist/react/index.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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(
|
|
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
|
|
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
|
|
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 :
|
|
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:
|
|
71
|
+
patchRowState(member.id, { disabling: false, notice: translate("Member disabled.") });
|
|
53
72
|
}
|
|
54
73
|
catch (e) {
|
|
55
|
-
patchRowState(member.id, { disabling: false, error:
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
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",
|
package/dist/react/models.d.ts
CHANGED
|
@@ -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
|
}
|