@jini-ai/user-management 0.2.0 → 0.3.0

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-10-05
4
+
5
+ ### BREAKING
6
+
7
+ - `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.
8
+ - 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.
9
+
10
+ ### Changed
11
+
12
+ - `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.
13
+
14
+ ### Fixed
15
+
16
+ - `describePeopleError` ignores inherited `Object.prototype` keys (an error code of `"constructor"` or `"toString"` resolved to that member instead of the message or fallback).
17
+ - Internal `@jini-ai/core` and `@jini-ai/ui-kit` dependencies are caret ranges (`workspace:^`) instead of exact pins.
18
+
3
19
  ## 0.2.0 — 2026-10-02
4
20
 
5
21
  ### BREAKING
@@ -9,6 +25,29 @@
9
25
 
10
26
  ## Unreleased
11
27
 
28
+ ### The built-in admin role holds role.manage
29
+
30
+ - `seedIdentity` grants `role.manage` to the built-in admin policy (owner decision, 2026-10-05), so a
31
+ fresh workspace's admin runs Roles & Permissions. `user.manage` stays owner-only. The issuer clamp
32
+ (`GRANT_EXCEEDS_ISSUER`) is unchanged. Already-seeded workspaces are not revisited by the seed; a
33
+ host reconciles them (e.g. a built-in-role grant applied at boot).
34
+
35
+ ### Site members are barred from operator permissions
36
+
37
+ - Add `"member"` to `PrincipalKind` for a host's public-site sign-ups. `authorize()` now denies a
38
+ `member` principal every permission with reason `principal_kind_denied`, ahead of any grant
39
+ (including the owner wildcard). The rule is one exported function,
40
+ `principalKindMayExercisePermission({ kind, permission })`, so a later decision can relax it in one
41
+ place. Owner counting for the last-owner guard skips barred kinds. Existing kinds are unaffected.
42
+
43
+ ### BREAKING: permission migrations are an explicit registry
44
+
45
+ - Remove the module-singleton `registerPermissionMigration`/`listPermissionMigrations`. Create a
46
+ registry with `createPermissionMigrationRegistry({}, { migrations })` at the composition root
47
+ (seeded with the built-in pairs; `register` keeps the overwrite-by-`from` semantics) and pass
48
+ `migrations: registry.list({})` to `migrateDeprecatedPermissionGrants`, which now requires it.
49
+ Which pairs exist no longer depends on module import order.
50
+
12
51
  ### Compilation fixes
13
52
 
14
53
  - Import the consolidated tab-strip barrel (requires the UI peer to export that
@@ -39,7 +39,8 @@ export function describePeopleError({ error, fallback }, _optional = {}) {
39
39
  SELF_DELETE: 'You cannot delete your own account.', USER_IN_TRASH: 'This user is in the Trash; restore them first.',
40
40
  USERNAME_IN_TRASH: 'A user with this username is in the Trash; restore or delete them permanently first.',
41
41
  };
42
- if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && copy[error.code])
42
+ // Own keys only: a server code such as "constructor" must not resolve to an Object.prototype member.
43
+ if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && Object.hasOwn(copy, error.code))
43
44
  return copy[error.code];
44
45
  return error instanceof Error && error.message ? error.message : fallback;
45
46
  }
@@ -3,4 +3,5 @@ export * from "./types.js";
3
3
  export * from "./ports.js";
4
4
  export * from "./runtime-ports.js";
5
5
  export * from "./permissions.js";
6
+ export * from "./principal-kind-policy.js";
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -6,4 +6,5 @@ export * from "./types.js";
6
6
  export * from "./ports.js";
7
7
  export * from "./runtime-ports.js";
8
8
  export * from "./permissions.js";
9
+ export * from "./principal-kind-policy.js";
9
10
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,30 @@
1
+ import type { PrincipalKind } from "./types.js";
2
+ /**
3
+ * @file Which principal kinds may exercise operator permissions at all.
4
+ *
5
+ * Purpose:
6
+ * The one place that decides whether a principal's KIND lets it use the operator RBAC axis
7
+ * (`authorize()`'s permissions). Today the answer is: every kind except `member`. A `member` is a
8
+ * public-site sign-up — a host's front-end account, governed by that host's own entitlement axis
9
+ * (tiers, gated content), never by operator RBAC. OWNER DECISION 2026-10-04 (Tovu 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 (Tovu F3144): site
9
+ * members must not be able to use admin features, at least for now.
10
+ *
11
+ * Why a function and not a check at each call site:
12
+ * `authorize()` calls this before it reads any grant, so a member principal is denied every
13
+ * permission even if a grant row reaches it (a hand-edited database, a future grant path that forgets
14
+ * the `kind='user'` target check in `grant-service.ts`). Owner-counting guards call it too, so a
15
+ * member can never count as the workspace's remaining owner. Allowing members some admin capability
16
+ * later is a change to this one function — `permission` is passed so such a decision can open a
17
+ * specific capability rather than all of them.
18
+ */
19
+ /**
20
+ * Whether a principal of `kind` may exercise `permission` through operator RBAC at all. Returning
21
+ * `true` only means the kind is not barred; the principal still needs a matching grant.
22
+ *
23
+ * @complexity O(1).
24
+ */
25
+ export function principalKindMayExercisePermission(required, _optional = {}) {
26
+ return required.kind !== "member";
27
+ }
28
+ //# sourceMappingURL=principal-kind-policy.js.map
@@ -18,8 +18,13 @@ import type { ISODateTime, UUID } from "@jini-ai/core/primitives";
18
18
  * the full target schema so those transitions are additive later,
19
19
  * not a repaint.
20
20
  */
21
- /** Every actor is one of these. Only `user`/`system` are populated this pass. */
22
- export type PrincipalKind = "user" | "agent" | "api_key" | "system";
21
+ /**
22
+ * Every actor is one of these. `member` is a host's public-site sign-up (a front-end account, not an
23
+ * operator): it may hold a principal row for identity and audit, but `principalKindMayExercisePermission`
24
+ * (`principal-kind-policy.ts`) bars it from every operator permission, so `authorize()` denies it
25
+ * whatever grants reach it.
26
+ */
27
+ export type PrincipalKind = "user" | "agent" | "api_key" | "system" | "member";
23
28
  /** Disable-only lifecycle — there is no hard-delete state. See docs/decisions/DR-001-identity-and-session-boundary.md. */
24
29
  export type PrincipalStatus = "active" | "disabled";
25
30
  /**
@@ -3,6 +3,7 @@ import { inIdentityTransaction } from "./identity-transaction.js";
3
3
  import { assertCallerHasAnyPermission, assertGrantClamp, assertOwnerTargetMayBeModified, principalHoldsOwnerWildcard } from "./grant-service.js";
4
4
  import { validatePasswordPolicy } from "./password-policy.js";
5
5
  import { isKnownPermission } from "../core/permissions.js";
6
+ import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
6
7
  import { IdentityConflictError, IdentityNotFoundError, IdentityValidationError, OwnerRequiredError, PermissionUnknownError, } from "../core/types.js";
7
8
  /**
8
9
  * @file An amendment that completes the users/roles/policies admin CRUD surface
@@ -554,7 +555,9 @@ export async function removePolicyPermission(required) {
554
555
  async function countActiveOwnerWildcardPrincipals(required) {
555
556
  const { deps, workspaceId } = required;
556
557
  const principals = await deps.repos.principals.list({ workspaceId });
557
- const activePrincipals = principals.filter((principal) => principal.status === "active");
558
+ // A kind barred from operator permissions (a site `member`) can never act as an owner, so it is
559
+ // not counted even if a `*` row reaches it — and members never cost a grant read here.
560
+ const activePrincipals = principals.filter((principal) => principal.status === "active" && principalKindMayExercisePermission({ kind: principal.kind, permission: "*" }));
558
561
  const flags = await Promise.all(activePrincipals.map((principal) => principalHoldsOwnerWildcard({ deps, workspaceId, principalId: principal.id })));
559
562
  return flags.filter(Boolean).length;
560
563
  }
@@ -19,8 +19,8 @@ import type { PolicyPermissionRecord } from "../core/types.js";
19
19
  *
20
20
  * Architectural role:
21
21
  * Implements the matcher precedence in behavior.spec §1.1 exactly:
22
- * disabled-principal short-circuit > owner wildcard > exact matching row >
23
- * fail-closed default. Fail-closed extensibility: a non-null
22
+ * disabled-principal short-circuit > principal-kind bar > owner wildcard >
23
+ * exact matching row > fail-closed default. Fail-closed extensibility: a non-null
24
24
  * `constraintJson` is never treated as unconstrained, and a `resourceType`
25
25
  * mismatch/absence is never treated as a global grant.
26
26
  * See docs/decisions/DR-001-identity-and-session-boundary.md.
@@ -34,7 +34,7 @@ export interface AuthorizeContext {
34
34
  /** `authorize`'s result — always fail-closed on `allowed: false`. See docs/decisions/DR-001-identity-and-session-boundary.md. */
35
35
  export interface AuthorizeResult {
36
36
  allowed: boolean;
37
- /** Machine-readable reason: `principal_disabled | no_grant | resource_scope_mismatch | unconstrained_deny | owner_wildcard | matched`. */
37
+ /** Machine-readable reason: `principal_disabled | principal_kind_denied | no_grant | resource_scope_mismatch | unconstrained_deny | owner_wildcard | matched`. */
38
38
  reason: string;
39
39
  }
40
40
  /** Repos `authorize()`/`resolveEffectivePermissions()` read. Not a port bag with alternate adapters — see file header. */
@@ -68,10 +68,13 @@ export declare function resolveEffectivePermissions(required: {
68
68
  *
69
69
  * Precedence (behavior.spec §1.1, highest to lowest):
70
70
  * 1. Disabled principal -> `false`, `principal_disabled` (overrides all grants).
71
- * 2. An unconstrained owner `"*"` row -> `true`, `owner_wildcard`.
72
- * 3. An exact matching row (permission + workspace + resource-scope + no
71
+ * 2. A principal whose kind may not exercise `permission` (today: `member`, see
72
+ * `principal-kind-policy.ts`) -> `false`, `principal_kind_denied` (overrides all grants,
73
+ * including the owner wildcard).
74
+ * 3. An unconstrained owner `"*"` row -> `true`, `owner_wildcard`.
75
+ * 4. An exact matching row (permission + workspace + resource-scope + no
73
76
  * constraint) -> `true`, `matched`.
74
- * 4. Fail-closed default -> `false`, with the most specific diagnostic reason
77
+ * 5. Fail-closed default -> `false`, with the most specific diagnostic reason
75
78
  * available among same-permission candidates (`unconstrained_deny` over
76
79
  * `resource_scope_mismatch` over `no_grant` — an inferred, undocumented-but-
77
80
  * reasonable tie-break; the spec only pins each reason in isolation, see
@@ -1,3 +1,4 @@
1
+ import { principalKindMayExercisePermission } from "../core/principal-kind-policy.js";
1
2
  /**
2
3
  * Resolve a principal's effective permission rows: the union of (a) its
3
4
  * roles' policies' permissions and (b) policies attached directly via
@@ -46,10 +47,13 @@ function matchesRow(row, permission, context) {
46
47
  *
47
48
  * Precedence (behavior.spec §1.1, highest to lowest):
48
49
  * 1. Disabled principal -> `false`, `principal_disabled` (overrides all grants).
49
- * 2. An unconstrained owner `"*"` row -> `true`, `owner_wildcard`.
50
- * 3. An exact matching row (permission + workspace + resource-scope + no
50
+ * 2. A principal whose kind may not exercise `permission` (today: `member`, see
51
+ * `principal-kind-policy.ts`) -> `false`, `principal_kind_denied` (overrides all grants,
52
+ * including the owner wildcard).
53
+ * 3. An unconstrained owner `"*"` row -> `true`, `owner_wildcard`.
54
+ * 4. An exact matching row (permission + workspace + resource-scope + no
51
55
  * constraint) -> `true`, `matched`.
52
- * 4. Fail-closed default -> `false`, with the most specific diagnostic reason
56
+ * 5. Fail-closed default -> `false`, with the most specific diagnostic reason
53
57
  * available among same-permission candidates (`unconstrained_deny` over
54
58
  * `resource_scope_mismatch` over `no_grant` — an inferred, undocumented-but-
55
59
  * reasonable tie-break; the spec only pins each reason in isolation, see
@@ -68,6 +72,10 @@ export async function authorize(required, optional = {}) {
68
72
  if (!principal || principal.status === "disabled") {
69
73
  return { allowed: false, reason: "principal_disabled" };
70
74
  }
75
+ // Ahead of every grant read: a barred kind is denied even when a `*` row reaches it.
76
+ if (!principalKindMayExercisePermission({ kind: principal.kind, permission })) {
77
+ return { allowed: false, reason: "principal_kind_denied" };
78
+ }
71
79
  const effectiveRows = await resolveEffectivePermissions({
72
80
  deps,
73
81
  principalId,
@@ -16,11 +16,17 @@ import type { IdentityTransactionPort, PolicyPermissionRepoPort, PolicyRepoPort
16
16
  * hand-rolling a fourth divergent copy of the same fix.
17
17
  *
18
18
  * How it relates to the project:
19
- * - `registerPermissionMigration`/`listPermissionMigrations` are a small,
20
- * in-process registry — pure metadata, no I/O, mirrors
21
- * `permissions.ts`'s `PermissionCatalog.register` overwrite semantics.
19
+ * - `createPermissionMigrationRegistry` builds a small, in-process registry
20
+ * object — pure metadata, no I/O, mirrors `permissions.ts`'s
21
+ * `PermissionCatalog.register` overwrite semantics. A host creates ONE at its
22
+ * composition root and registers its own pairs on it explicitly; there is no
23
+ * module-scope registry, so which migrations exist never depends on which
24
+ * modules a process happened to import (a module-singleton filled by import
25
+ * side effects once ran a standalone script's boot reconciliation against an
26
+ * empty registry because that script never imported the registering module).
22
27
  * - `migrateDeprecatedPermissionGrants` is the actual grant fan-out: for
23
- * every registered pair, every policy holding `from` gets every string in
28
+ * every pair the caller passes in `deps.migrations` (normally its registry's
29
+ * `list({})`), every policy holding `from` gets every string in
24
30
  * `to` it doesn't already hold. It is deliberately, structurally
25
31
  * additive-only — there is no delete/update call against
26
32
  * `policy_permissions` anywhere in this file.
@@ -42,29 +48,43 @@ export type { PermissionMigration } from '../core/builtin-permissions.js';
42
48
  export interface MigrateDeprecatedPermissionGrantsDeps {
43
49
  /** Bound to the same storage as both repository ports; required before any migration work. */
44
50
  transactions: IdentityTransactionPort;
51
+ /** Every `{from, to}` pair to fan out — normally `createPermissionMigrationRegistry(...).list({})`. */
52
+ migrations: readonly PermissionMigration[];
45
53
  policyPermissions: PolicyPermissionRepoPort;
46
54
  policies: PolicyRepoPort;
47
55
  idGen: IdGenerator;
48
56
  workspaceId: UUID;
49
57
  }
50
58
  export interface MigrateDeprecatedPermissionGrantsResult {
51
- /** Total new `policy_permissions` rows written across every policy and every registered pair. */
59
+ /** Total new `policy_permissions` rows written across every policy and every passed pair. */
52
60
  migratedGrantCount: number;
53
61
  }
62
+ /** A host-owned set of `{from, to, reason}` pairs; see {@link createPermissionMigrationRegistry}. */
63
+ export interface PermissionMigrationRegistry {
64
+ /**
65
+ * Register a `{from, to, reason}` rename/split pair for later fan-out.
66
+ * Idempotent: re-registering the same `from` overwrites the prior entry
67
+ * rather than duplicating it (matches `PermissionCatalog.register`'s
68
+ * existing overwrite semantics, `identity/permissions.ts`).
69
+ *
70
+ * @complexity O(1).
71
+ */
72
+ register(migration: PermissionMigration): void;
73
+ /** Enumerate every registered migration pair, built-ins first, in first-registration order. */
74
+ list(_required: Record<string, never>): PermissionMigration[];
75
+ }
54
76
  /**
55
- * Register a `{from, to, reason}` rename/split pair for later fan-out.
56
- * Idempotent: re-registering the same `from` overwrites the prior entry
57
- * rather than duplicating it (matches `PermissionCatalog.register`'s
58
- * existing overwrite semantics, `identity/permissions.ts`).
77
+ * Create a migration registry seeded with the library's built-in pairs, then `optional.migrations`.
78
+ * Keyed by `from`, so a host pair with a built-in's `from` overwrites it rather than duplicating.
59
79
  *
60
- * @complexity O(1).
80
+ * @complexity O(b + m) where b = built-in pairs and m = `optional.migrations`.
61
81
  * @overallScore 100
62
82
  */
63
- export declare function registerPermissionMigration(migration: PermissionMigration): void;
64
- /** Enumerate every registered migration pair. */
65
- export declare function listPermissionMigrations(_required: Record<string, never>): PermissionMigration[];
83
+ export declare function createPermissionMigrationRegistry(_required?: Record<string, never>, optional?: {
84
+ migrations?: readonly PermissionMigration[];
85
+ }): PermissionMigrationRegistry;
66
86
  /**
67
- * For every registered `{from, to}` pair, for every policy in the workspace
87
+ * For every `{from, to}` pair in `deps.migrations`, for every policy in the workspace
68
88
  * holding `from`, ensure every string in `to` is also granted — adding only
69
89
  * the rows that are missing. Never removes or mutates `from` (or any other
70
90
  * existing row) — additive-only by construction. The entire fan-out runs in
@@ -75,7 +95,7 @@ export declare function listPermissionMigrations(_required: Record<string, never
75
95
  * returns `migratedGrantCount: 0` (mirrors `migrateLegacyPresentationSettings`'s
76
96
  * boot-safety precedent).
77
97
  *
78
- * @complexity O(p * m) where p = policies in the workspace, m = registered
98
+ * @complexity O(p * m) where p = policies in the workspace, m = passed
79
99
  * migration pairs; both are small, bounded collections in this app's shape.
80
100
  * @overallScore 100
81
101
  * See docs/decisions/DR-003-additive-permission-migration.md.
@@ -1,25 +1,25 @@
1
1
  import { inIdentityTransaction } from "./identity-transaction.js";
2
2
  import { BUILTIN_PERMISSION_MIGRATIONS } from '../core/builtin-permissions.js';
3
- /** Module-singleton registry — keyed by `from` so re-registration overwrites (idempotent, matches `PermissionCatalog.register`). */
4
- const registry = new Map(BUILTIN_PERMISSION_MIGRATIONS.map((migration) => [migration.from, migration]));
5
3
  /**
6
- * Register a `{from, to, reason}` rename/split pair for later fan-out.
7
- * Idempotent: re-registering the same `from` overwrites the prior entry
8
- * rather than duplicating it (matches `PermissionCatalog.register`'s
9
- * existing overwrite semantics, `identity/permissions.ts`).
4
+ * Create a migration registry seeded with the library's built-in pairs, then `optional.migrations`.
5
+ * Keyed by `from`, so a host pair with a built-in's `from` overwrites it rather than duplicating.
10
6
  *
11
- * @complexity O(1).
7
+ * @complexity O(b + m) where b = built-in pairs and m = `optional.migrations`.
12
8
  * @overallScore 100
13
9
  */
14
- export function registerPermissionMigration(migration) {
15
- registry.set(migration.from, migration);
16
- }
17
- /** Enumerate every registered migration pair. */
18
- export function listPermissionMigrations(_required) {
19
- return [...registry.values()];
10
+ export function createPermissionMigrationRegistry(_required = {}, optional = {}) {
11
+ const byFrom = new Map();
12
+ const register = (migration) => {
13
+ byFrom.set(migration.from, migration);
14
+ };
15
+ for (const migration of BUILTIN_PERMISSION_MIGRATIONS)
16
+ register(migration);
17
+ for (const migration of optional.migrations ?? [])
18
+ register(migration);
19
+ return { register, list: () => [...byFrom.values()] };
20
20
  }
21
21
  /**
22
- * For every registered `{from, to}` pair, for every policy in the workspace
22
+ * For every `{from, to}` pair in `deps.migrations`, for every policy in the workspace
23
23
  * holding `from`, ensure every string in `to` is also granted — adding only
24
24
  * the rows that are missing. Never removes or mutates `from` (or any other
25
25
  * existing row) — additive-only by construction. The entire fan-out runs in
@@ -30,13 +30,13 @@ export function listPermissionMigrations(_required) {
30
30
  * returns `migratedGrantCount: 0` (mirrors `migrateLegacyPresentationSettings`'s
31
31
  * boot-safety precedent).
32
32
  *
33
- * @complexity O(p * m) where p = policies in the workspace, m = registered
33
+ * @complexity O(p * m) where p = policies in the workspace, m = passed
34
34
  * migration pairs; both are small, bounded collections in this app's shape.
35
35
  * @overallScore 100
36
36
  * See docs/decisions/DR-003-additive-permission-migration.md.
37
37
  */
38
38
  export async function migrateDeprecatedPermissionGrants(deps) {
39
- const migrations = listPermissionMigrations({});
39
+ const { migrations } = deps;
40
40
  if (migrations.length === 0)
41
41
  return { migratedGrantCount: 0 };
42
42
  return inIdentityTransaction({
@@ -126,7 +126,12 @@ const BUILTIN_ADMIN_PERMISSIONS = [
126
126
  // directly at seed — a distinct grant from settings.write, not owner-only (unlike user.manage/
127
127
  // role.manage below).
128
128
  "workspace.manage",
129
- // Owner-only per : "user.manage", "role.manage" are deliberately absent. See docs/decisions/DR-001-identity-and-session-boundary.md.
129
+ // Roles & Permissions: admin holds role.manage (owner decision 2026-10-05, reversing DR-001's
130
+ // owner-only placement for this one string). The grant service's issuer clamp still refuses any
131
+ // grant beyond what the caller holds (GRANT_EXCEEDS_ISSUER), so an admin cannot hand out owner.
132
+ // Hosts reconcile already-seeded workspaces themselves; seedIdentity never revisits them.
133
+ "role.manage",
134
+ // Owner-only per DR-001: "user.manage" is deliberately absent. See docs/decisions/DR-001-identity-and-session-boundary.md.
130
135
  ];
131
136
  const BUILTIN_EDITOR_PERMISSIONS = [
132
137
  "content.read",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/user-management",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Runtime-separated user management contracts and Node authentication, authorization, and administration services.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -127,14 +127,14 @@
127
127
  "@testing-library/user-event": "^14.5.0",
128
128
  "@testing-library/jest-dom": "^6.6.3",
129
129
  "jsdom": "^25.0.1",
130
- "@jini-ai/agentic": "0.4.0",
131
- "@jini-ai/admin": "0.4.0",
132
- "@jini-ai/cms": "0.4.0",
133
- "@jini-ai/ui": "0.4.0"
130
+ "@jini-ai/ui": "0.4.3",
131
+ "@jini-ai/admin": "0.4.2",
132
+ "@jini-ai/agentic": "0.4.1",
133
+ "@jini-ai/cms": "0.5.0"
134
134
  },
135
135
  "dependencies": {
136
- "@jini-ai/core": "0.4.0",
137
- "@jini-ai/ui-kit": "0.1.0"
136
+ "@jini-ai/ui-kit": "^0.1.1",
137
+ "@jini-ai/core": "^0.4.1"
138
138
  },
139
139
  "scripts": {
140
140
  "build": "tsc -p tsconfig.json",
@@ -1,100 +0,0 @@
1
- import type { Translate } from "@jini-ai/ui/panel-kit";
2
- import { type FormEvent } from "react";
3
- import type { AdminPolicy, AdminPolicyPermission, AdminRole } from "../models.js";
4
- import { useRoles, type RolesDependencies, type PendingPermissionRemove } from "./hooks/use-roles.hooks.js";
5
- import { type RolesTabId } from "./Roles.hooks.js";
6
- export interface RolesProps extends RolesDependencies {
7
- onTabChange: (required: {
8
- tabId: RolesTabId;
9
- }) => void;
10
- usersHref: string;
11
- tabId?: string | null | undefined;
12
- useRolesHook?: typeof useRoles | undefined;
13
- }
14
- export interface RoleCreateFormController {
15
- name: string;
16
- setName: (name: string) => void;
17
- saving: boolean;
18
- error: string | null;
19
- submit: (e: FormEvent) => Promise<void>;
20
- }
21
- export interface RoleRowController {
22
- editingId: string | null;
23
- setEditingId: (roleId: string | null) => void;
24
- draftName: string;
25
- setDraftName: (name: string) => void;
26
- startRename: (role: AdminRole) => void;
27
- saveRename: (required: {
28
- roleId: string;
29
- }) => Promise<void>;
30
- savingId: string | null;
31
- requestDelete: (role: AdminRole | null) => void;
32
- }
33
- export interface RolesSectionProps {
34
- roles: AdminRole[];
35
- create: RoleCreateFormController;
36
- row: RoleRowController;
37
- t: (key: string) => string;
38
- translate: Translate;
39
- }
40
- export declare function RolesSection({ roles, create, row, t, translate }: RolesSectionProps): import("react").JSX.Element;
41
- export interface PolicyRowController {
42
- editingId: string | null;
43
- setEditingId: (policyId: string | null) => void;
44
- draftName: string;
45
- setDraftName: (name: string) => void;
46
- draftDescription: string;
47
- setDraftDescription: (description: string) => void;
48
- startRename: (policy: AdminPolicy) => void;
49
- saveRename: (required: {
50
- policyId: string;
51
- }) => Promise<void>;
52
- savingId: string | null;
53
- requestDelete: (policy: AdminPolicy | null) => void;
54
- }
55
- export interface PolicyPermissionController {
56
- openForPolicyId: string | null;
57
- permission: string;
58
- setPermission: (permission: string) => void;
59
- resourceType: string;
60
- setResourceType: (resourceType: string) => void;
61
- toggleForm: (required: {
62
- policyId: string;
63
- }) => void;
64
- write: (required: {
65
- policyId: string;
66
- }) => Promise<void>;
67
- rows: AdminPolicyPermission[];
68
- loading: boolean;
69
- removingId: string | null;
70
- requestRemove: (target: PendingPermissionRemove) => void;
71
- }
72
- export interface PolicyRowProps {
73
- policy: AdminPolicy;
74
- row: PolicyRowController;
75
- permission: PolicyPermissionController;
76
- agentBase: string;
77
- t: (key: string) => string;
78
- translate: Translate;
79
- }
80
- export declare function PolicyRow({ policy, row, permission, agentBase, t, translate }: PolicyRowProps): import("react").JSX.Element;
81
- export interface PolicyCreateFormController {
82
- name: string;
83
- setName: (name: string) => void;
84
- description: string;
85
- setDescription: (description: string) => void;
86
- saving: boolean;
87
- error: string | null;
88
- submit: (e: FormEvent) => Promise<void>;
89
- }
90
- export interface PoliciesSectionProps {
91
- policies: AdminPolicy[];
92
- create: PolicyCreateFormController;
93
- row: PolicyRowController;
94
- permission: PolicyPermissionController;
95
- t: (key: string) => string;
96
- translate: Translate;
97
- }
98
- export declare function PoliciesSection({ policies, create, row, permission, t, translate }: PoliciesSectionProps): import("react").JSX.Element;
99
- export declare function Roles({ tabId, useRolesHook, port, translate: injectedTranslate, queryScope, onTabChange, usersHref }: RolesProps): import("react").JSX.Element;
100
- //# sourceMappingURL=Roles.d.ts.map
@@ -1,17 +0,0 @@
1
- import type { TabBarTab } from "@jini-ai/ui/tab-strip";
2
- import type { Translate } from "@jini-ai/ui/panel-kit";
3
- export declare function RolesIcon({ size }: {
4
- size?: number;
5
- }): import("react").JSX.Element;
6
- export declare function PoliciesIcon({ size }: {
7
- size?: number;
8
- }): import("react").JSX.Element;
9
- export declare const ROLES_TAB_IDS: readonly ["roles", "policies"];
10
- export type RolesTabId = (typeof ROLES_TAB_IDS)[number];
11
- export declare function resolveRolesTabId({ tabId }: {
12
- tabId: string | null | undefined;
13
- }): RolesTabId;
14
- export declare function resolveRolesTabs({ translate: t }: {
15
- translate: Translate;
16
- }): TabBarTab[];
17
- //# sourceMappingURL=Roles.hooks.d.ts.map
@@ -1,40 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { resolveActiveTabId } from "@jini-ai/ui/panel-kit";
3
- const LINE_ICON = {
4
- viewBox: "0 0 24 24",
5
- fill: "none",
6
- stroke: "currentColor",
7
- strokeWidth: 1.5,
8
- strokeLinecap: "round",
9
- strokeLinejoin: "round",
10
- "aria-hidden": true,
11
- };
12
- export function RolesIcon({ size = 16 }) {
13
- return (_jsxs("svg", { ...LINE_ICON, width: size, height: size, children: [_jsx("circle", { cx: "12", cy: "8", r: "3.5" }), _jsx("path", { d: "M5 20c0-3.3 3.1-6 7-6s7 2.7 7 6" })] }));
14
- }
15
- export function PoliciesIcon({ size = 16 }) {
16
- return (_jsxs("svg", { ...LINE_ICON, width: size, height: size, children: [_jsx("path", { d: "M12 3l7 2.5v6c0 4-3 7.4-7 8.5-4-1.1-7-4.5-7-8.5v-6z" }), _jsx("path", { d: "M9.5 11.5h5M9.5 14.5h3" })] }));
17
- }
18
- export const ROLES_TAB_IDS = ["roles", "policies"];
19
- export function resolveRolesTabId({ tabId }) {
20
- return resolveActiveTabId({ tabId, validIds: ROLES_TAB_IDS, defaultId: "roles" });
21
- }
22
- export function resolveRolesTabs({ translate: t }) {
23
- return [
24
- {
25
- id: "roles",
26
- label: t("Roles"),
27
- icon: _jsx(RolesIcon, {}),
28
- handle: "roles-tab-roles",
29
- handleLabel: t("Switch to the Roles tab — list, create, rename and delete the roles a person can be assigned"),
30
- },
31
- {
32
- id: "policies",
33
- label: t("Policies"),
34
- icon: _jsx(PoliciesIcon, {}),
35
- handle: "roles-tab-policies",
36
- handleLabel: t("Switch to the Policies tab — list and create policies, and add or remove the individual permissions on each one"),
37
- },
38
- ];
39
- }
40
- //# sourceMappingURL=Roles.hooks.js.map
@@ -1,150 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { rolesDescriptionParts, roleDeleteBodyParts, policyDeleteBodyParts, permissionRemoveBodyParts } from "./text.js";
3
- import { Fragment } from "react";
4
- import { DataTable, RowMenu, ConfirmDialog } from "@jini-ai/admin/react";
5
- import { agentHandle } from "@jini-ai/agentic";
6
- import { buildAgentListHandles } from "@jini-ai/ui/panel-kit";
7
- import { TabBar } from "@jini-ai/ui/tab-strip";
8
- import { roleMenuItems, policyMenuItems } from "./rules.js";
9
- import { useRoles } from "./hooks/use-roles.hooks.js";
10
- import { resolveRolesTabId, resolveRolesTabs } from "./Roles.hooks.js";
11
- export function RolesSection({ roles, create, row, t, translate }) {
12
- const roleMenuHandles = buildAgentListHandles({ prefix: "roles-row", ids: roles.map((role) => role.id) });
13
- return (_jsxs(_Fragment, { children: [_jsx("h2", { className: "visually-hidden", children: t("Roles") }), _jsxs("form", { onSubmit: create.submit, className: "notice integrations-form form-measure", children: [create.error ? _jsx("span", { className: "save-error", children: create.error }) : null, _jsxs("label", { children: [t("Role name"), _jsx("input", { value: create.name, onChange: (e) => create.setName(e.target.value), required: true, ...agentHandle({ handle: "roles-create-name" }, { role: "field", label: t("New role's name") }) })] }), _jsx("button", { type: "submit", disabled: create.saving || !create.name, ...agentHandle({ handle: "roles-create-submit" }, { role: "button", label: t("Create this new role") }), children: create.saving ? t("Creating…") : t("Create role") })] }), _jsx(DataTable, { rows: roles, rowKey: (role) => role.id, empty: _jsx("div", { className: "card", children: _jsx("div", { className: "empty-state", children: _jsx("p", { children: t("No roles yet.") }) }) }), columns: [
14
- {
15
- key: "name",
16
- header: t("Name"),
17
- cell: (role, index) => row.editingId === role.id ? (_jsx("input", { value: row.draftName, onChange: (e) => row.setDraftName(e.target.value), ...agentHandle({ handle: `${roleMenuHandles[index]}-rename-name` }, { role: "field", label: t("This role's new name") }) })) : (role.name),
18
- },
19
- { key: "type", header: t("Type"), cell: (role) => (role.isBuiltin ? t("Built-in") : t("Custom")) },
20
- {
21
- key: "actions",
22
- header: t("More"),
23
- cell: (role, index) => role.isBuiltin ? (_jsx("span", { className: "muted-cell", children: "\u2014" })) : row.editingId === role.id ? (_jsxs("span", { className: "editor-actions", children: [_jsx("button", { type: "button", disabled: row.savingId === role.id, onClick: () => row.saveRename({ roleId: role.id }), ...agentHandle({ handle: `${roleMenuHandles[index]}-save` }, { role: "button", label: t(`Save this role's new name`) }), children: row.savingId === role.id ? t("Saving…") : t("Save") }), _jsx("button", { type: "button", onClick: () => row.setEditingId(null), ...agentHandle({ handle: `${roleMenuHandles[index]}-cancel` }, { role: "button", label: t("Cancel renaming this role") }), children: t("Cancel") })] })) : (_jsx(RowMenu, { triggerLabel: `${t("Actions for role")} "${role.name}"`, agentHandle: `${roleMenuHandles[index]}-menu`, items: roleMenuItems({ role, handlers: { onRename: row.startRename, onDelete: row.requestDelete }, translate }) })),
24
- },
25
- ] })] }));
26
- }
27
- function PolicyRowActions({ policy, row, permission, agentBase, t, translate }) {
28
- if (policy.isBuiltin || policy.isFrozen)
29
- return _jsx("span", { className: "muted-cell", children: "\u2014" });
30
- if (row.editingId === policy.id) {
31
- return (_jsxs("span", { className: "editor-actions", children: [_jsx("button", { type: "button", disabled: row.savingId === policy.id, onClick: () => row.saveRename({ policyId: policy.id }), ...agentHandle({ handle: `${agentBase}-save` }, { role: "button", label: t("Save this policy's new name and description") }), children: row.savingId === policy.id ? t("Saving…") : t("Save") }), _jsx("button", { type: "button", onClick: () => row.setEditingId(null), ...agentHandle({ handle: `${agentBase}-cancel` }, { role: "button", label: t("Cancel renaming this policy") }), children: t("Cancel") })] }));
32
- }
33
- return (_jsx(RowMenu, { triggerLabel: `${t("Actions for policy")} "${policy.name}"`, agentHandle: `${agentBase}-menu`, items: policyMenuItems({
34
- policy,
35
- permissionPolicyId: permission.openForPolicyId,
36
- handlers: {
37
- onRename: row.startRename,
38
- onTogglePermissionForm: permission.toggleForm,
39
- onDelete: row.requestDelete,
40
- },
41
- translate,
42
- }) }));
43
- }
44
- function PolicyPermissionList({ policyId, permission, t }) {
45
- if (permission.loading)
46
- return _jsx("p", { className: "muted-cell", children: t("Loading permissions…") });
47
- if (permission.rows.length === 0)
48
- return _jsx("p", { className: "muted-cell", children: t("No permissions yet.") });
49
- const removeHandles = buildAgentListHandles({
50
- prefix: `policy-permission-remove-${policyId}`,
51
- ids: permission.rows.map((row) => row.id),
52
- });
53
- return (_jsx("ul", { className: "permission-list", children: permission.rows.map((row, index) => (_jsxs("li", { children: [_jsx("code", { children: row.permission }), row.resourceType ? _jsxs("span", { className: "muted-cell", children: [" (", row.resourceType, ")"] }) : null, _jsx("button", { type: "button", disabled: permission.removingId === row.id, "aria-label": `${t("Remove permission")} ${row.permission}`, onClick: () => permission.requestRemove({ policyId, row }), ...agentHandle({ handle: removeHandles[index] }, { role: "button", label: t(`Remove the "${row.permission}" permission from this policy`) }), children: permission.removingId === row.id ? t("Removing…") : t("Remove") })] }, row.id))) }));
54
- }
55
- function PolicyPermissionForm({ policyId, savingId, permission, t }) {
56
- const formHandleBase = buildAgentListHandles({ prefix: "policy-permission-form", ids: [policyId] })[0];
57
- return (_jsx("tr", { children: _jsx("td", { colSpan: 4, children: _jsxs("div", { className: "notice integrations-form", children: [_jsx("p", { children: t("Current permissions") }), _jsx(PolicyPermissionList, { policyId: policyId, permission: permission, t: t }), _jsxs("label", { children: [t("Permission"), _jsxs("span", { className: "editor-actions permission-form-fields", children: [_jsx("input", { value: permission.permission, onChange: (e) => permission.setPermission(e.target.value), placeholder: t("e.g. content.write"), ...agentHandle({ handle: `${formHandleBase}-permission` }, { role: "field", label: t("Permission string to grant, e.g. content.write") }) }), _jsx("input", { value: permission.resourceType, onChange: (e) => permission.setResourceType(e.target.value), placeholder: t("resource type (optional)"), ...agentHandle({ handle: `${formHandleBase}-resource-type` }, { role: "field", label: t("Optional resource type this permission is scoped to") }) }), _jsx("button", { type: "button", disabled: !permission.permission || savingId === policyId, onClick: () => permission.write({ policyId }), ...agentHandle({ handle: `${formHandleBase}-add` }, { role: "button", label: t("Add this permission to the policy") }), children: savingId === policyId ? t("Saving…") : t("Add") })] })] })] }) }) }));
58
- }
59
- export function PolicyRow({ policy, row, permission, agentBase, t, translate }) {
60
- const renaming = row.editingId === policy.id;
61
- return (_jsxs(_Fragment, { children: [_jsxs("tr", { children: [_jsx("td", { children: renaming ? (_jsx("input", { value: row.draftName, onChange: (e) => row.setDraftName(e.target.value), ...agentHandle({ handle: `${agentBase}-rename-name` }, { role: "field", label: t("This policy's new name") }) })) : (policy.name) }), _jsx("td", { children: renaming ? (_jsx("input", { value: row.draftDescription, onChange: (e) => row.setDraftDescription(e.target.value), ...agentHandle({ handle: `${agentBase}-rename-description` }, { role: "field", label: t("This policy's new description") }) })) : (policy.description ?? _jsx("span", { className: "muted-cell", children: "\u2014" })) }), _jsxs("td", { children: [policy.isBuiltin ? t("Built-in") : t("Custom"), policy.isFrozen ? ` ${t("(frozen)")}` : ""] }), _jsx("td", { children: _jsx(PolicyRowActions, { policy: policy, row: row, permission: permission, agentBase: agentBase, t: t, translate: translate }) })] }), permission.openForPolicyId === policy.id ? (_jsx(PolicyPermissionForm, { policyId: policy.id, savingId: row.savingId, permission: permission, t: t })) : null] }));
62
- }
63
- export function PoliciesSection({ policies, create, row, permission, t, translate }) {
64
- const policyMenuBases = buildAgentListHandles({ prefix: "policies-row", ids: policies.map((policy) => policy.id) });
65
- return (_jsxs(_Fragment, { children: [_jsx("h2", { className: "visually-hidden", children: t("Policies") }), _jsxs("form", { onSubmit: create.submit, className: "notice integrations-form form-measure", children: [create.error ? _jsx("span", { className: "save-error", children: create.error }) : null, _jsxs("label", { children: [t("Policy name"), _jsx("input", { value: create.name, onChange: (e) => create.setName(e.target.value), required: true, ...agentHandle({ handle: "policies-create-name" }, { role: "field", label: t("New policy's name") }) })] }), _jsxs("label", { children: [t("Description (optional)"), _jsx("input", { value: create.description, onChange: (e) => create.setDescription(e.target.value), ...agentHandle({ handle: "policies-create-description" }, { role: "field", label: t("New policy's optional description") }) })] }), _jsx("button", { type: "submit", disabled: create.saving || !create.name, ...agentHandle({ handle: "policies-create-submit" }, { role: "button", label: t("Create this new policy") }), children: create.saving ? t("Creating…") : t("Create policy") })] }), policies.length === 0 ? (_jsx("div", { className: "card", children: _jsx("div", { className: "empty-state", children: _jsx("p", { children: t("No policies yet.") }) }) })) : (_jsx("div", { className: "table-scroll", children: _jsxs("table", { className: "list-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: t("Name") }), _jsx("th", { children: t("Description") }), _jsx("th", { children: t("Type") }), _jsx("th", { children: t("More") })] }) }), _jsx("tbody", { children: policies.map((policy, index) => (_jsx(Fragment, { children: _jsx(PolicyRow, { policy: policy, row: row, permission: permission, agentBase: policyMenuBases[index], t: t, translate: translate }) }, policy.id))) })] }) }))] }));
66
- }
67
- function RoleDeleteDialog({ pendingRoleDelete, setPendingRoleDelete, rowSavingId, onDeleteRole, t, translate }) {
68
- const { prefix, suffix } = roleDeleteBodyParts({ translate });
69
- return (_jsx(ConfirmDialog, { cancelLabel: t("Cancel"), open: pendingRoleDelete !== null, agentHandle: "roles-delete-role", title: t("Delete role?"), body: pendingRoleDelete ? (_jsxs("p", { children: [prefix, pendingRoleDelete.name, suffix] })) : null, confirmLabel: t("Delete"), destructive: true, pending: pendingRoleDelete !== null && rowSavingId === pendingRoleDelete.id, onConfirm: onDeleteRole, onCancel: () => setPendingRoleDelete(null) }));
70
- }
71
- function PolicyDeleteDialog({ pendingPolicyDelete, setPendingPolicyDelete, rowSavingId, onDeletePolicy, t, translate }) {
72
- const { prefix, suffix } = policyDeleteBodyParts({ translate });
73
- return (_jsx(ConfirmDialog, { cancelLabel: t("Cancel"), open: pendingPolicyDelete !== null, agentHandle: "roles-delete-policy", title: t("Delete policy?"), body: pendingPolicyDelete ? (_jsxs("p", { children: [prefix, pendingPolicyDelete.name, suffix] })) : null, confirmLabel: t("Delete"), destructive: true, pending: pendingPolicyDelete !== null && rowSavingId === pendingPolicyDelete.id, onConfirm: onDeletePolicy, onCancel: () => setPendingPolicyDelete(null) }));
74
- }
75
- function PermissionRemoveDialog({ pendingPermissionRemove, setPendingPermissionRemove, removingPermissionId, onConfirmRemovePermission, t, translate, }) {
76
- const { prefix, suffix } = permissionRemoveBodyParts({ translate });
77
- const pending = pendingPermissionRemove;
78
- return (_jsx(ConfirmDialog, { cancelLabel: t("Cancel"), open: pending !== null, agentHandle: "roles-remove-permission", title: t("Remove permission?"), body: pending ? (_jsxs("p", { children: [prefix, _jsx("code", { children: pending.row.permission }), pending.row.resourceType ? ` (${pending.row.resourceType})` : "", suffix] })) : null, confirmLabel: t("Remove"), tone: "warning", pending: pending !== null && removingPermissionId === pending.row.id, onConfirm: onConfirmRemovePermission, onCancel: () => setPendingPermissionRemove(null) }));
79
- }
80
- function RolesTab({ controller }) {
81
- const c = controller;
82
- return (_jsx(RolesSection, { roles: c.roles ?? [], create: {
83
- name: c.roleName,
84
- setName: c.setRoleName,
85
- saving: c.roleSaving,
86
- error: c.roleError,
87
- submit: c.onCreateRole,
88
- }, row: {
89
- editingId: c.editingRoleId,
90
- setEditingId: c.setEditingRoleId,
91
- draftName: c.editingRoleName,
92
- setDraftName: c.setEditingRoleName,
93
- startRename: c.startEditRole,
94
- saveRename: c.onSaveRole,
95
- savingId: c.rowSavingId,
96
- requestDelete: c.setPendingRoleDelete,
97
- }, t: c.t, translate: c.translate }));
98
- }
99
- function PoliciesTab({ controller }) {
100
- const c = controller;
101
- return (_jsx(PoliciesSection, { policies: c.policies ?? [], create: {
102
- name: c.policyName,
103
- setName: c.setPolicyName,
104
- description: c.policyDescription,
105
- setDescription: c.setPolicyDescription,
106
- saving: c.policySaving,
107
- error: c.policyError,
108
- submit: c.onCreatePolicy,
109
- }, row: {
110
- editingId: c.editingPolicyId,
111
- setEditingId: c.setEditingPolicyId,
112
- draftName: c.editingPolicyName,
113
- setDraftName: c.setEditingPolicyName,
114
- draftDescription: c.editingPolicyDescription,
115
- setDraftDescription: c.setEditingPolicyDescription,
116
- startRename: c.startEditPolicy,
117
- saveRename: c.onSavePolicy,
118
- savingId: c.rowSavingId,
119
- requestDelete: c.setPendingPolicyDelete,
120
- }, permission: {
121
- openForPolicyId: c.permissionPolicyId,
122
- permission: c.permissionInput,
123
- setPermission: c.setPermissionInput,
124
- resourceType: c.resourceTypeInput,
125
- setResourceType: c.setResourceTypeInput,
126
- toggleForm: c.togglePermissionForm,
127
- write: c.onWritePermission,
128
- rows: c.permissionRows,
129
- loading: c.permissionsLoading,
130
- removingId: c.removingPermissionId,
131
- requestRemove: c.setPendingPermissionRemove,
132
- }, t: c.t, translate: c.translate }));
133
- }
134
- function rolesTabPanel(activeTabId, controller) {
135
- if (activeTabId === "policies")
136
- return _jsx(PoliciesTab, { controller: controller });
137
- return _jsx(RolesTab, { controller: controller });
138
- }
139
- export function Roles({ tabId, useRolesHook = useRoles, port, translate: injectedTranslate, queryScope, onTabChange, usersHref }) {
140
- const controller = useRolesHook({ port, translate: injectedTranslate, queryScope });
141
- const { roles, policies, error, rowError, rowSavingId, pendingRoleDelete, setPendingRoleDelete, onDeleteRole, pendingPolicyDelete, setPendingPolicyDelete, onDeletePolicy, pendingPermissionRemove, setPendingPermissionRemove, removingPermissionId, onConfirmRemovePermission, t, translate, } = controller;
142
- const { prefix: descriptionPrefix, linkLabel: descriptionLinkLabel, suffix: descriptionSuffix } = rolesDescriptionParts({ translate });
143
- if (error)
144
- return _jsx("div", { className: "notice error", children: error });
145
- if (!roles || !policies)
146
- return _jsx("div", { className: "notice", children: t("Loading roles & permissions…") });
147
- const activeTabId = resolveRolesTabId({ tabId });
148
- 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("Roles & Permissions") }), _jsxs("p", { className: "page-description", children: [descriptionPrefix, _jsx("a", { href: usersHref, children: descriptionLinkLabel }), descriptionSuffix] })] }) }), rowError ? _jsx("div", { className: "notice error", children: rowError }) : null, _jsx(TabBar, { ariaLabel: t("Roles & Permissions"), tabs: resolveRolesTabs({ translate: t }), activeId: activeTabId, onChange: (next) => onTabChange({ tabId: resolveRolesTabId({ tabId: next }) }), containerHandle: "roles-tab-bar" }), rolesTabPanel(activeTabId, controller), _jsx(RoleDeleteDialog, { pendingRoleDelete: pendingRoleDelete, setPendingRoleDelete: setPendingRoleDelete, rowSavingId: rowSavingId, t: t, translate: translate, onDeleteRole: onDeleteRole }), _jsx(PolicyDeleteDialog, { pendingPolicyDelete: pendingPolicyDelete, setPendingPolicyDelete: setPendingPolicyDelete, rowSavingId: rowSavingId, t: t, translate: translate, onDeletePolicy: onDeletePolicy }), _jsx(PermissionRemoveDialog, { pendingPermissionRemove: pendingPermissionRemove, setPendingPermissionRemove: setPendingPermissionRemove, removingPermissionId: removingPermissionId, onConfirmRemovePermission: onConfirmRemovePermission, t: t, translate: translate })] }));
149
- }
150
- //# sourceMappingURL=Roles.js.map
@@ -1,80 +0,0 @@
1
- import type { Translate } from "@jini-ai/ui/panel-kit";
2
- import { type AdminPolicy, type AdminPolicyPermission, type AdminRole } from "../../models.js";
3
- import type { RolesPort } from "../../ports.js";
4
- export interface PendingPermissionRemove {
5
- policyId: string;
6
- row: AdminPolicyPermission;
7
- }
8
- export interface RolesController {
9
- roles: AdminRole[] | null;
10
- policies: AdminPolicy[] | null;
11
- error: string | null;
12
- rowError: string | null;
13
- roleName: string;
14
- setRoleName: (name: string) => void;
15
- roleSaving: boolean;
16
- roleError: string | null;
17
- onCreateRole: (e: React.FormEvent) => Promise<void>;
18
- policyName: string;
19
- setPolicyName: (name: string) => void;
20
- policyDescription: string;
21
- setPolicyDescription: (description: string) => void;
22
- policySaving: boolean;
23
- policyError: string | null;
24
- onCreatePolicy: (e: React.FormEvent) => Promise<void>;
25
- editingRoleId: string | null;
26
- setEditingRoleId: (roleId: string | null) => void;
27
- editingRoleName: string;
28
- setEditingRoleName: (name: string) => void;
29
- startEditRole: (role: AdminRole) => void;
30
- onSaveRole: (required: {
31
- roleId: string;
32
- }) => Promise<void>;
33
- editingPolicyId: string | null;
34
- setEditingPolicyId: (policyId: string | null) => void;
35
- editingPolicyName: string;
36
- setEditingPolicyName: (name: string) => void;
37
- editingPolicyDescription: string;
38
- setEditingPolicyDescription: (description: string) => void;
39
- startEditPolicy: (policy: AdminPolicy) => void;
40
- onSavePolicy: (required: {
41
- policyId: string;
42
- }) => Promise<void>;
43
- rowSavingId: string | null;
44
- permissionPolicyId: string | null;
45
- permissionInput: string;
46
- setPermissionInput: (permission: string) => void;
47
- resourceTypeInput: string;
48
- setResourceTypeInput: (resourceType: string) => void;
49
- togglePermissionForm: (required: {
50
- policyId: string;
51
- }) => void;
52
- onWritePermission: (required: {
53
- policyId: string;
54
- }) => Promise<void>;
55
- permissionRows: AdminPolicyPermission[];
56
- permissionsLoading: boolean;
57
- removingPermissionId: string | null;
58
- onRemovePermission: (required: {
59
- policyId: string;
60
- policyPermissionId: string;
61
- }) => Promise<void>;
62
- pendingPermissionRemove: PendingPermissionRemove | null;
63
- setPendingPermissionRemove: (target: PendingPermissionRemove | null) => void;
64
- onConfirmRemovePermission: () => Promise<void>;
65
- pendingRoleDelete: AdminRole | null;
66
- setPendingRoleDelete: (role: AdminRole | null) => void;
67
- onDeleteRole: () => Promise<void>;
68
- pendingPolicyDelete: AdminPolicy | null;
69
- setPendingPolicyDelete: (policy: AdminPolicy | null) => void;
70
- onDeletePolicy: () => Promise<void>;
71
- t: (key: string) => string;
72
- translate: Translate;
73
- }
74
- export interface RolesDependencies {
75
- port: RolesPort;
76
- translate: Translate;
77
- queryScope: string;
78
- }
79
- export declare function useRoles(deps: RolesDependencies): RolesController;
80
- //# sourceMappingURL=use-roles.hooks.d.ts.map
@@ -1,303 +0,0 @@
1
- import { useRef, useState } from "react";
2
- import {} from "../../models.js";
3
- import { useFetchMutation, useFetchQuery } from "@jini-ai/ui/panel-kit";
4
- import { describeApiError as describeFeatureError, KEYS } from "../rules.js";
5
- async function runRowDelete(id, mutate, setRowSavingId, setRowError, clearPending, describeError) {
6
- setRowSavingId(id);
7
- setRowError(null);
8
- try {
9
- await mutate(id);
10
- }
11
- catch (e) {
12
- setRowError(describeError(e));
13
- }
14
- finally {
15
- setRowSavingId((current) => (current === id ? null : current));
16
- clearPending();
17
- }
18
- }
19
- export function useRoles(deps) {
20
- const { port, translate, queryScope } = deps;
21
- const describeApiError = (error, fallback) => describeFeatureError({ error, fallback, translate });
22
- const t = translate;
23
- const boundT = translate;
24
- const list = useFetchQuery({
25
- key: [queryScope, ...KEYS.list],
26
- fetch: async () => {
27
- const [r, p] = await Promise.all([port.listRoles({}), port.listPolicies({})]);
28
- return { roles: r.roles, policies: p.policies };
29
- },
30
- });
31
- const roles = list.data?.roles ?? null;
32
- const policies = list.data?.policies ?? null;
33
- const error = list.error ? describeApiError(list.error, t("failed to load roles/policies")) : null;
34
- const [rowError, setRowError] = useState(null);
35
- const [roleName, setRoleName] = useState("");
36
- const [policyName, setPolicyName] = useState("");
37
- const [policyDescription, setPolicyDescription] = useState("");
38
- const [editingRoleId, setEditingRoleId] = useState(null);
39
- const [editingRoleName, setEditingRoleName] = useState("");
40
- const [editingPolicyId, setEditingPolicyId] = useState(null);
41
- const [editingPolicyName, setEditingPolicyName] = useState("");
42
- const [editingPolicyDescription, setEditingPolicyDescription] = useState("");
43
- const [rowSavingId, setRowSavingId] = useState(null);
44
- const [permissionPolicyId, setPermissionPolicyId] = useState(null);
45
- const [permissionInput, setPermissionInput] = useState("");
46
- const [resourceTypeInput, setResourceTypeInput] = useState("");
47
- const [permissionRows, setPermissionRows] = useState([]);
48
- const [permissionsLoading, setPermissionsLoading] = useState(false);
49
- const [removingPermissionId, setRemovingPermissionId] = useState(null);
50
- const permissionsGenerationRef = useRef(0);
51
- const permissionPolicyIdRef = useRef(null);
52
- const [pendingRoleDelete, setPendingRoleDelete] = useState(null);
53
- const [pendingPolicyDelete, setPendingPolicyDelete] = useState(null);
54
- const [pendingPermissionRemove, setPendingPermissionRemove] = useState(null);
55
- const createRoleMutation = useFetchMutation({
56
- run: ({ input: name }) => port.createRole({ name: name })
57
- }, {
58
- invalidates: [[queryScope, ...KEYS.list]],
59
- });
60
- const createPolicyMutation = useFetchMutation({
61
- run: ({ input }) => port.createPolicy({ name: input.name }, { description: input.description })
62
- }, {
63
- invalidates: [[queryScope, ...KEYS.list]],
64
- });
65
- const saveRoleMutation = useFetchMutation({
66
- run: ({ input }) => port.updateRole(input)
67
- }, {
68
- invalidates: [[queryScope, ...KEYS.list]],
69
- });
70
- const deleteRoleMutation = useFetchMutation({
71
- run: ({ input: id }) => port.deleteRole({ roleId: id })
72
- }, {
73
- invalidates: [[queryScope, ...KEYS.list]],
74
- });
75
- const savePolicyMutation = useFetchMutation({
76
- run: ({ input }) => port.updatePolicy({ policyId: input.policyId }, { name: input.name, description: input.description })
77
- }, {
78
- invalidates: [[queryScope, ...KEYS.list]],
79
- });
80
- const deletePolicyMutation = useFetchMutation({
81
- run: ({ input: id }) => port.deletePolicy({ policyId: id })
82
- }, {
83
- invalidates: [[queryScope, ...KEYS.list]],
84
- });
85
- const writePermissionMutation = useFetchMutation({
86
- run: ({ input }) => port.writePolicyPermission({ policyId: input.policyId, permission: input.permission }, { resourceType: input.resourceType }),
87
- });
88
- const removePermissionMutation = useFetchMutation({
89
- run: ({ input }) => port.removePolicyPermission(input),
90
- });
91
- async function onCreateRole(e) {
92
- e.preventDefault();
93
- try {
94
- await createRoleMutation.mutate({ input: roleName });
95
- setRoleName("");
96
- }
97
- catch {
98
- }
99
- }
100
- async function onCreatePolicy(e) {
101
- e.preventDefault();
102
- try {
103
- await createPolicyMutation.mutate({ input: { name: policyName, description: policyDescription || undefined } });
104
- setPolicyName("");
105
- setPolicyDescription("");
106
- }
107
- catch {
108
- }
109
- }
110
- function startEditRole(role) {
111
- setRowError(null);
112
- setEditingRoleId(role.id);
113
- setEditingRoleName(role.name);
114
- }
115
- async function onSaveRole({ roleId }) {
116
- setRowSavingId(roleId);
117
- setRowError(null);
118
- try {
119
- await saveRoleMutation.mutate({ input: { roleId, name: editingRoleName } });
120
- setEditingRoleId((current) => (current === roleId ? null : current));
121
- }
122
- catch (e) {
123
- setRowError(describeApiError(e, t("failed to rename role")));
124
- }
125
- finally {
126
- setRowSavingId((current) => (current === roleId ? null : current));
127
- }
128
- }
129
- async function onDeleteRole() {
130
- if (!pendingRoleDelete)
131
- return;
132
- const role = pendingRoleDelete;
133
- await runRowDelete(role.id, (id) => deleteRoleMutation.mutate({ input: id }), setRowSavingId, setRowError, () => setPendingRoleDelete((current) => (current?.id === role.id ? null : current)), (e) => describeApiError(e, t("failed to delete role")));
134
- }
135
- function startEditPolicy(policy) {
136
- setRowError(null);
137
- setEditingPolicyId(policy.id);
138
- setEditingPolicyName(policy.name);
139
- setEditingPolicyDescription(policy.description ?? "");
140
- }
141
- async function onSavePolicy({ policyId }) {
142
- setRowSavingId(policyId);
143
- setRowError(null);
144
- try {
145
- await savePolicyMutation.mutate({ input: { policyId, name: editingPolicyName, description: editingPolicyDescription } });
146
- setEditingPolicyId((current) => (current === policyId ? null : current));
147
- }
148
- catch (e) {
149
- setRowError(describeApiError(e, t("failed to update policy")));
150
- }
151
- finally {
152
- setRowSavingId((current) => (current === policyId ? null : current));
153
- }
154
- }
155
- async function onDeletePolicy() {
156
- if (!pendingPolicyDelete)
157
- return;
158
- const policy = pendingPolicyDelete;
159
- await runRowDelete(policy.id, (id) => deletePolicyMutation.mutate({ input: id }), setRowSavingId, setRowError, () => setPendingPolicyDelete((current) => (current?.id === policy.id ? null : current)), (e) => describeApiError(e, t("failed to delete policy")));
160
- }
161
- async function loadPermissions(policyId) {
162
- const generation = ++permissionsGenerationRef.current;
163
- setPermissionsLoading(true);
164
- try {
165
- const { policyPermissions } = await port.listPolicyPermissions({ policyId: policyId });
166
- if (permissionsGenerationRef.current !== generation)
167
- return;
168
- setPermissionRows(policyPermissions);
169
- }
170
- catch (e) {
171
- if (permissionsGenerationRef.current !== generation)
172
- return;
173
- setPermissionRows([]);
174
- setRowError(describeApiError(e, t("failed to load permissions")));
175
- }
176
- finally {
177
- if (permissionsGenerationRef.current !== generation)
178
- return;
179
- setPermissionsLoading(false);
180
- }
181
- }
182
- function togglePermissionForm({ policyId }) {
183
- setRowError(null);
184
- setPermissionInput("");
185
- setResourceTypeInput("");
186
- const closing = permissionPolicyIdRef.current === policyId;
187
- permissionPolicyIdRef.current = closing ? null : policyId;
188
- setPermissionPolicyId(permissionPolicyIdRef.current);
189
- setPermissionRows([]);
190
- if (!closing)
191
- void loadPermissions(policyId);
192
- }
193
- async function onRemovePermission({ policyId, policyPermissionId }) {
194
- setRemovingPermissionId(policyPermissionId);
195
- setRowError(null);
196
- try {
197
- await removePermissionMutation.mutate({ input: { policyId, policyPermissionId } });
198
- if (permissionPolicyIdRef.current === policyId)
199
- await loadPermissions(policyId);
200
- }
201
- catch (e) {
202
- setRowError(describeApiError(e, t("failed to remove permission")));
203
- }
204
- finally {
205
- setRemovingPermissionId((current) => (current === policyPermissionId ? null : current));
206
- }
207
- }
208
- async function onConfirmRemovePermission() {
209
- if (!pendingPermissionRemove)
210
- return;
211
- const target = pendingPermissionRemove;
212
- try {
213
- await onRemovePermission({ policyId: target.policyId, policyPermissionId: target.row.id });
214
- }
215
- finally {
216
- setPendingPermissionRemove((current) => (current?.row.id === target.row.id ? null : current));
217
- }
218
- }
219
- async function onWritePermission({ policyId }) {
220
- if (!permissionInput)
221
- return;
222
- const generationAtStart = permissionsGenerationRef.current;
223
- setRowSavingId(policyId);
224
- setRowError(null);
225
- try {
226
- await writePermissionMutation.mutate({ input: { policyId, permission: permissionInput, resourceType: resourceTypeInput || undefined } });
227
- if (permissionsGenerationRef.current === generationAtStart) {
228
- setPermissionInput("");
229
- setResourceTypeInput("");
230
- }
231
- if (permissionPolicyIdRef.current === policyId)
232
- await loadPermissions(policyId);
233
- }
234
- catch (e) {
235
- setRowError(describeApiError(e, t("failed to add permission")));
236
- }
237
- finally {
238
- setRowSavingId((current) => (current === policyId ? null : current));
239
- }
240
- }
241
- const roleSaving = createRoleMutation.status === "pending";
242
- const roleError = createRoleMutation.error ? describeApiError(createRoleMutation.error, t("failed to create role")) : null;
243
- const policySaving = createPolicyMutation.status === "pending";
244
- const policyError = createPolicyMutation.error
245
- ? describeApiError(createPolicyMutation.error, t("failed to create policy"))
246
- : null;
247
- return {
248
- roles,
249
- policies,
250
- error,
251
- rowError,
252
- roleName,
253
- setRoleName,
254
- roleSaving,
255
- roleError,
256
- onCreateRole,
257
- policyName,
258
- setPolicyName,
259
- policyDescription,
260
- setPolicyDescription,
261
- policySaving,
262
- policyError,
263
- onCreatePolicy,
264
- editingRoleId,
265
- setEditingRoleId,
266
- editingRoleName,
267
- setEditingRoleName,
268
- startEditRole,
269
- onSaveRole,
270
- editingPolicyId,
271
- setEditingPolicyId,
272
- editingPolicyName,
273
- setEditingPolicyName,
274
- editingPolicyDescription,
275
- setEditingPolicyDescription,
276
- startEditPolicy,
277
- onSavePolicy,
278
- rowSavingId,
279
- permissionPolicyId,
280
- permissionInput,
281
- setPermissionInput,
282
- resourceTypeInput,
283
- setResourceTypeInput,
284
- togglePermissionForm,
285
- onWritePermission,
286
- permissionRows,
287
- permissionsLoading,
288
- removingPermissionId,
289
- onRemovePermission,
290
- pendingPermissionRemove,
291
- setPendingPermissionRemove,
292
- onConfirmRemovePermission,
293
- pendingRoleDelete,
294
- setPendingRoleDelete,
295
- onDeleteRole,
296
- pendingPolicyDelete,
297
- setPendingPolicyDelete,
298
- onDeletePolicy,
299
- t: boundT,
300
- translate,
301
- };
302
- }
303
- //# sourceMappingURL=use-roles.hooks.js.map
@@ -1,35 +0,0 @@
1
- import type { AdminRole, AdminPolicy } from "../models.js";
2
- import type { Translate } from "@jini-ai/ui/panel-kit";
3
- import type { RowMenuItem } from "@jini-ai/admin/react";
4
- import type { QueryKey } from "@jini-ai/ui/panel-kit";
5
- export declare const KEYS: {
6
- list: QueryKey;
7
- };
8
- export declare function describeApiError({ error, fallback, translate }: {
9
- error: unknown;
10
- fallback: string;
11
- translate: Translate;
12
- }): string;
13
- export interface RoleRowMenuHandlers {
14
- onRename: (role: AdminRole) => void;
15
- onDelete: (role: AdminRole) => void;
16
- }
17
- export declare function roleMenuItems({ role, handlers, translate }: {
18
- role: AdminRole;
19
- handlers: RoleRowMenuHandlers;
20
- translate: Translate;
21
- }): RowMenuItem[];
22
- export interface PolicyRowMenuHandlers {
23
- onRename: (policy: AdminPolicy) => void;
24
- onTogglePermissionForm: (required: {
25
- policyId: string;
26
- }) => void;
27
- onDelete: (policy: AdminPolicy) => void;
28
- }
29
- export declare function policyMenuItems({ policy, permissionPolicyId, handlers, translate }: {
30
- policy: AdminPolicy;
31
- permissionPolicyId: string | null;
32
- handlers: PolicyRowMenuHandlers;
33
- translate: Translate;
34
- }): RowMenuItem[];
35
- //# sourceMappingURL=rules.d.ts.map
@@ -1,31 +0,0 @@
1
- import { describeIdentityError } from "../errors.js";
2
- export const KEYS = {
3
- list: ["roles-and-policies"],
4
- };
5
- const STATIC_ERROR_MESSAGES = {
6
- FORBIDDEN: "You do not have permission to do that.",
7
- RESOURCE_CONFLICT: "It is still in use — remove that assignment/attachment first.",
8
- PERMISSION_UNKNOWN: "That permission is not recognized.",
9
- GRANT_EXCEEDS_ISSUER: "You cannot grant a permission you do not hold.",
10
- };
11
- export function describeApiError({ error, fallback, translate }) {
12
- return describeIdentityError({ error, fallback, translate }, { messages: STATIC_ERROR_MESSAGES });
13
- }
14
- export function roleMenuItems({ role, handlers, translate }) {
15
- return [
16
- { key: "rename", label: translate("Rename"), onSelect: () => handlers.onRename(role) },
17
- { key: "delete", label: translate("Delete"), destructive: true, onSelect: () => handlers.onDelete(role) },
18
- ];
19
- }
20
- export function policyMenuItems({ policy, permissionPolicyId, handlers, translate }) {
21
- return [
22
- { key: "rename", label: translate("Rename"), onSelect: () => handlers.onRename(policy) },
23
- {
24
- key: "permission",
25
- label: permissionPolicyId === policy.id ? translate("Close") : translate("Add permission"),
26
- onSelect: () => handlers.onTogglePermissionForm({ policyId: policy.id }),
27
- },
28
- { key: "delete", label: translate("Delete"), destructive: true, onSelect: () => handlers.onDelete(policy) },
29
- ];
30
- }
31
- //# sourceMappingURL=rules.js.map
@@ -1,27 +0,0 @@
1
- import { type Translate } from "@jini-ai/ui/panel-kit";
2
- export declare function rolesDescriptionParts({ translate }: {
3
- translate: Translate;
4
- }): {
5
- prefix: string;
6
- suffix: string;
7
- linkLabel: string;
8
- };
9
- export declare function roleDeleteBodyParts({ translate }: {
10
- translate: Translate;
11
- }): {
12
- prefix: string;
13
- suffix: string;
14
- };
15
- export declare function policyDeleteBodyParts({ translate }: {
16
- translate: Translate;
17
- }): {
18
- prefix: string;
19
- suffix: string;
20
- };
21
- export declare function permissionRemoveBodyParts({ translate }: {
22
- translate: Translate;
23
- }): {
24
- prefix: string;
25
- suffix: string;
26
- };
27
- //# sourceMappingURL=text.d.ts.map
@@ -1,13 +0,0 @@
1
- import { splitOnPlaceholders } from "@jini-ai/ui/panel-kit";
2
- export function rolesDescriptionParts({ translate }) {
3
- const [prefix = "", suffix = ""] = splitOnPlaceholders({ template: translate("Roles and policies grant access to operator users. Assign a role or policy to a specific user from the {users} screen."), tokens: ["{users}"] });
4
- return { prefix, suffix, linkLabel: translate("Users") };
5
- }
6
- function nameParts(translate, template) {
7
- const [prefix = "", suffix = ""] = splitOnPlaceholders({ template: translate(template), tokens: ["{name}"] });
8
- return { prefix, suffix };
9
- }
10
- export function roleDeleteBodyParts({ translate }) { return nameParts(translate, 'Delete role "{name}"?'); }
11
- export function policyDeleteBodyParts({ translate }) { return nameParts(translate, 'Delete policy "{name}"?'); }
12
- export function permissionRemoveBodyParts({ translate }) { return nameParts(translate, 'Remove "{name}" from this policy? Anyone with this policy loses it.'); }
13
- //# sourceMappingURL=text.js.map