@jini-ai/user-management 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/admin/adapters/memory.d.ts +3 -5
  3. package/dist/admin/adapters/memory.js +27 -28
  4. package/dist/admin/conformance/index.js +1 -0
  5. package/dist/admin/controllers/auth.controller.js +8 -6
  6. package/dist/admin/controllers/members.controller.js +9 -6
  7. package/dist/admin/controllers/roles.controller.js +99 -105
  8. package/dist/admin/controllers/users.controller.js +56 -36
  9. package/dist/admin/error-copy.d.ts +11 -0
  10. package/dist/admin/error-copy.js +10 -0
  11. package/dist/admin/people.rules.js +37 -31
  12. package/dist/admin/react/components/MemberRow.d.ts +1 -1
  13. package/dist/admin/react/components/MemberRow.js +12 -2
  14. package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +6 -21
  15. package/dist/admin/react/hooks/AuthBinding.hooks.js +3 -8
  16. package/dist/admin/react/hooks/AuthScope.hooks.d.ts +22 -0
  17. package/dist/admin/react/hooks/AuthScope.hooks.js +10 -0
  18. package/dist/admin/react/hooks/LoginPage.hooks.d.ts +1 -1
  19. package/dist/admin/react/hooks/LoginPage.hooks.js +1 -1
  20. package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +6 -17
  21. package/dist/admin/react/hooks/MembersBinding.hooks.js +3 -8
  22. package/dist/admin/react/hooks/MembersPage.hooks.d.ts +1 -1
  23. package/dist/admin/react/hooks/MembersPage.hooks.js +1 -1
  24. package/dist/admin/react/hooks/MembersScope.hooks.d.ts +17 -0
  25. package/dist/admin/react/hooks/MembersScope.hooks.js +10 -0
  26. package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +7 -21
  27. package/dist/admin/react/hooks/RolesBinding.hooks.js +3 -8
  28. package/dist/admin/react/hooks/RolesController.hooks.js +1 -1
  29. package/dist/admin/react/hooks/RolesPage.hooks.d.ts +2 -2
  30. package/dist/admin/react/hooks/RolesPage.hooks.js +22 -8
  31. package/dist/admin/react/hooks/RolesScope.hooks.d.ts +21 -0
  32. package/dist/admin/react/hooks/RolesScope.hooks.js +10 -0
  33. package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +6 -18
  34. package/dist/admin/react/hooks/UsersBinding.hooks.js +3 -8
  35. package/dist/admin/react/hooks/UsersPage.hooks.d.ts +1 -1
  36. package/dist/admin/react/hooks/UsersPage.hooks.js +1 -1
  37. package/dist/admin/react/hooks/UsersScope.hooks.d.ts +19 -0
  38. package/dist/admin/react/hooks/UsersScope.hooks.js +10 -0
  39. package/dist/admin/react/hooks/UsersTab.hooks.d.ts +6 -0
  40. package/dist/admin/react/hooks/UsersTab.hooks.js +34 -22
  41. package/dist/admin/react/index.d.ts +4 -4
  42. package/dist/admin/react/pages/LoginPage.d.ts +1 -1
  43. package/dist/admin/react/pages/MembersPage.d.ts +1 -1
  44. package/dist/admin/react/pages/RolesPage.d.ts +2 -2
  45. package/dist/admin/react/pages/RolesPage.js +6 -2
  46. package/dist/admin/react/pages/UsersPage.d.ts +2 -2
  47. package/dist/admin/react/pages/UsersPage.js +6 -2
  48. package/dist/admin/rules.js +14 -12
  49. package/dist/react/errors.js +16 -9
  50. package/dist/react/index.d.ts +1 -1
  51. package/dist/react/members/hooks/use-members.hooks.js +21 -12
  52. package/dist/react/testing.js +4 -6
  53. package/dist/react/users/UserRowParts.d.ts +53 -0
  54. package/dist/react/users/UserRowParts.js +39 -0
  55. package/dist/react/users/Users.d.ts +31 -2
  56. package/dist/react/users/Users.js +41 -36
  57. package/dist/react/users/hooks/use-users.hooks.js +57 -30
  58. package/dist/server/admin-crud-service.js +25 -13
  59. package/dist/server/agent-tool-input.js +62 -36
  60. package/dist/server/agent-tool-registrations.d.ts +46 -0
  61. package/dist/server/agent-tool-registrations.js +28 -20
  62. package/dist/server/auth-service.js +35 -20
  63. package/dist/server/authorize.js +7 -9
  64. package/dist/server/permission-migrations.js +35 -25
  65. package/dist/server/repo.memory-transactions.js +5 -6
  66. package/dist/server/repo.memory.js +35 -81
  67. package/dist/server/seed.js +100 -84
  68. package/package.json +8 -8
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4 — 2026-10-22
4
+
5
+ - 2026-10-22: Named-argument identity guards, optional translate with a custom Users hook, cancellation kept across confirm-to-run, coverage.
6
+
3
7
  ## 0.3.3 — 2026-10-13
4
8
 
5
9
  - Accept UI ^0.5.0 for the chart contract extraction.
@@ -1,5 +1,5 @@
1
- import type { RolesApiPort } from '../ports.js';
2
- import type { RoleRecord, PolicyRecord, PolicyPermissionRecord } from '../models.js';
1
+ import type { RolesApiPort, UsersApiPort, UsersSafetyPort, MembersApiPort, AuthApiPort } from '../ports.js';
2
+ import type { RoleRecord, PolicyRecord, PolicyPermissionRecord, AdminOperator, AdminMember, AdminAccount, CallerInfo } from '../models.js';
3
3
  export interface MemoryRolesOptions {
4
4
  readonly permissions?: readonly string[];
5
5
  readonly catalog?: readonly string[];
@@ -15,8 +15,6 @@ export interface MemoryRolesOptions {
15
15
  export declare function createMemoryRolesApi({ workspaceId }: {
16
16
  workspaceId: string;
17
17
  }, options?: MemoryRolesOptions): RolesApiPort;
18
- import type { UsersApiPort, UsersSafetyPort, MembersApiPort, AuthApiPort } from '../ports.js';
19
- import type { AdminOperator, AdminMember, CallerInfo } from '../models.js';
20
18
  /** Complete fake ownership data. Production hosts must resolve effective unconstrained wildcard
21
19
  * grants, never role display names. Unknown ids deliberately fail closed. */
22
20
  export declare function createMemoryUsersSafety({ principalIds, ownerPrincipalIds, seededOwnerPrincipalId }: {
@@ -53,7 +51,7 @@ export declare function createMemoryMembersApi({ workspaceId }: {
53
51
  /** Deterministic credential fake; production hashing/session policy stays in the server entry. */
54
52
  export declare function createMemoryAuthApi({ accounts }: {
55
53
  accounts: readonly {
56
- user: import('../models.js').AdminAccount;
54
+ user: AdminAccount;
57
55
  password: string;
58
56
  disabled?: boolean;
59
57
  }[];
@@ -1,20 +1,23 @@
1
- import { RolesApiError } from '../ports.js';
1
+ import { PeopleApiError, RolesApiError } from '../ports.js';
2
2
  import { BUILTIN_PERMISSIONS } from '../../core/builtin-permissions.js';
3
3
  import { canManageRoles } from '../rules.js';
4
+ import { hasAdminGrant, userActionAllowed } from '../people.rules.js';
5
+ /** Seed records that belong to this fake's workspace; every other workspace is invisible. */
6
+ function inWorkspace(records, workspaceId) {
7
+ return (records ?? []).filter(r => r.workspaceId === workspaceId);
8
+ }
9
+ function check(o) { o.signal?.throwIfAborted(); }
10
+ /** Unknown ids deliberately fail closed: they are neither owner nor operator. */
11
+ function ownership({ known, owners, principalId }) {
12
+ return known.has(principalId) ? owners.has(principalId) ? 'owner' : 'operator' : 'unknown';
13
+ }
4
14
  /** Isolated fake of the server wire contract, including immutable and issuer guards.
5
15
  * It is not a production authorizer or a persistent identity repository. */
6
16
  export function createMemoryRolesApi({ workspaceId }, options = {}) {
7
17
  const grants = Object.freeze([...(options.permissions ?? [])]), catalog = new Set(options.catalog ?? BUILTIN_PERMISSIONS.map(p => p.id));
8
- const roles = new Map(), policies = new Map(), permissions = new Map();
9
- for (const r of options.roles ?? [])
10
- if (r.workspaceId === workspaceId)
11
- roles.set(r.id, { ...r });
12
- for (const p of options.policies ?? [])
13
- if (p.workspaceId === workspaceId)
14
- policies.set(p.id, { ...p });
15
- for (const p of options.policyPermissions ?? [])
16
- if (p.workspaceId === workspaceId)
17
- permissions.set(p.id, { ...p });
18
+ const roles = new Map(inWorkspace(options.roles, workspaceId).map(r => [r.id, { ...r }]));
19
+ const policies = new Map(inWorkspace(options.policies, workspaceId).map(p => [p.id, { ...p }]));
20
+ const permissions = new Map(inWorkspace(options.policyPermissions, workspaceId).map(p => [p.id, { ...p }]));
18
21
  let sequence = 0;
19
22
  const id = () => {
20
23
  let next;
@@ -24,7 +27,6 @@ export function createMemoryRolesApi({ workspaceId }, options = {}) {
24
27
  return next;
25
28
  };
26
29
  const fail = (code, message) => { throw new RolesApiError({ code, message }); };
27
- const check = (o) => { o.signal?.throwIfAborted(); };
28
30
  const write = (o) => {
29
31
  check(o);
30
32
  if (!canManageRoles({ permissions: grants }))
@@ -94,7 +96,7 @@ export function createMemoryRolesApi({ workspaceId }, options = {}) {
94
96
  editablePolicy(r.policyId);
95
97
  if (!catalog.has(r.permission))
96
98
  fail('PERMISSION_UNKNOWN', 'Unrecognized permission.');
97
- if (!grants.includes('*') && !grants.includes(r.permission))
99
+ if (!hasAdminGrant({ permissions: grants, permission: r.permission }))
98
100
  fail('GRANT_EXCEEDS_ISSUER', 'Grant exceeds issuer.');
99
101
  const resourceType = o.resourceType || null;
100
102
  const policyPermission = { id: id(), workspaceId, policyId: r.policyId, permission: r.permission, resourceType, constraintJson: null };
@@ -111,27 +113,24 @@ export function createMemoryRolesApi({ workspaceId }, options = {}) {
111
113
  },
112
114
  };
113
115
  }
114
- import { PeopleApiError } from '../ports.js';
115
- import { hasAdminGrant, userActionAllowed } from '../people.rules.js';
116
116
  /** Complete fake ownership data. Production hosts must resolve effective unconstrained wildcard
117
117
  * grants, never role display names. Unknown ids deliberately fail closed. */
118
118
  export function createMemoryUsersSafety({ principalIds, ownerPrincipalIds, seededOwnerPrincipalId }, _optional = {}) {
119
119
  const known = new Set(principalIds), owners = new Set(ownerPrincipalIds);
120
- return { seededOwnerPrincipalId, ownerStatus: r => known.has(r.principalId) ? owners.has(r.principalId) ? 'owner' : 'operator' : 'unknown' };
120
+ return { seededOwnerPrincipalId, ownerStatus: r => ownership({ known, owners, principalId: r.principalId }) };
121
121
  }
122
122
  /** Fake wire backend only: no persistent password storage, real sessions, audit or Trash service. */
123
123
  export function createMemoryUsersApi({ workspaceId }, options = {}) {
124
124
  const grants = Object.freeze([...(options.permissions ?? [])]);
125
- const users = new Map((options.users ?? []).filter(u => u.workspaceId === workspaceId).map(u => [u.principalId, clone(u)]));
126
- const roles = (options.roles ?? []).filter(r => r.workspaceId === workspaceId).map(r => ({ ...r }));
127
- const policies = (options.policies ?? []).filter(p => p.workspaceId === workspaceId).map(p => ({ ...p }));
125
+ const users = new Map(inWorkspace(options.users, workspaceId).map(u => [u.principalId, clone(u)]));
126
+ const roles = inWorkspace(options.roles, workspaceId).map(r => ({ ...r }));
127
+ const policies = inWorkspace(options.policies, workspaceId).map(p => ({ ...p }));
128
128
  const owners = new Set(options.ownerPrincipalIds ?? []), trash = new Set();
129
129
  const caller = options.caller ? structuredClone(options.caller) : { user: { id: 'memory-caller', username: 'caller' }, canManageUserTrash: false };
130
- const safety = { seededOwnerPrincipalId: options.seededOwnerPrincipalId ?? null, ownerStatus: r => users.has(r.principalId) ? owners.has(r.principalId) ? 'owner' : 'operator' : 'unknown' };
130
+ const safety = { seededOwnerPrincipalId: options.seededOwnerPrincipalId ?? null, ownerStatus: r => ownership({ known: users, owners, principalId: r.principalId }) };
131
131
  let sequence = 0;
132
132
  function clone(u) { return { ...u, roleIds: [...u.roleIds], policyIds: [...u.policyIds] }; }
133
133
  const fail = (code, message) => { throw new PeopleApiError({ code, message }); };
134
- const check = (o) => o.signal?.throwIfAborted();
135
134
  const get = (id) => users.get(id) ?? fail('RESOURCE_NOT_FOUND', 'User not found.');
136
135
  function write(id, action, o) {
137
136
  check(o);
@@ -146,6 +145,9 @@ export function createMemoryUsersApi({ workspaceId }, options = {}) {
146
145
  }
147
146
  const password = (value) => { if (!value || [...value].length > 512)
148
147
  fail('VALIDATION_ERROR', 'Password must be present and no more than 512 characters.'); };
148
+ /** Disabling or deleting an owner must leave another active owner behind. */
149
+ function keepAnotherActiveOwner(u) { if (owners.has(u.principalId) && [...users.values()].filter(v => v.status === 'active' && owners.has(v.principalId)).length <= 1)
150
+ fail('OWNER_REQUIRED', 'The workspace must keep at least one active owner.'); }
149
151
  function clamp(contents) { if (grants.includes('*'))
150
152
  return; if (!contents || contents.some(p => !grants.includes(p)))
151
153
  fail('GRANT_EXCEEDS_ISSUER', 'Grant exceeds issuer.'); }
@@ -176,8 +178,7 @@ export function createMemoryUsersApi({ workspaceId }, options = {}) {
176
178
  async updateUser(r, o = {}) { const u = write(r.principalId, 'email', o); const user = { ...u, ...(o.email !== undefined ? { email: o.email } : {}) }; users.set(u.principalId, user); return { user: clone(user) }; },
177
179
  async disableUser(r, o = {}) {
178
180
  const u = write(r.principalId, 'disable', o);
179
- if (owners.has(u.principalId) && [...users.values()].filter(v => v.status === 'active' && owners.has(v.principalId)).length <= 1)
180
- fail('OWNER_REQUIRED', 'The workspace must keep at least one active owner.');
181
+ keepAnotherActiveOwner(u);
181
182
  const user = { ...u, status: 'disabled' };
182
183
  users.set(u.principalId, user);
183
184
  return { user: clone(user) };
@@ -188,14 +189,12 @@ export function createMemoryUsersApi({ workspaceId }, options = {}) {
188
189
  fail('RESOURCE_NOT_FOUND', 'Role not found.'); clamp(options.rolePermissions?.[r.roleId]); users.set(u.principalId, { ...u, roleIds: [...new Set([...u.roleIds, r.roleId])] }); return { assignment: { principalId: u.principalId, roleId: r.roleId } }; },
189
190
  async attachPolicy(r, o = {}) { const u = write(r.principalId, 'grants', o); if (!policies.some(v => v.id === r.policyId))
190
191
  fail('RESOURCE_NOT_FOUND', 'Policy not found.'); clamp(options.policyPermissions?.[r.policyId]); users.set(u.principalId, { ...u, policyIds: [...new Set([...u.policyIds, r.policyId])] }); return { attachment: { principalId: u.principalId, policyId: r.policyId } }; },
191
- async deleteUser(r, o = {}) { const u = write(r.principalId, 'delete', o); if (owners.has(u.principalId) && [...users.values()].filter(v => v.status === 'active' && owners.has(v.principalId)).length <= 1)
192
- fail('OWNER_REQUIRED', 'The workspace must keep at least one active owner.'); users.set(u.principalId, { ...u, status: 'disabled' }); trash.add(u.principalId); },
192
+ async deleteUser(r, o = {}) { const u = write(r.principalId, 'delete', o); keepAnotherActiveOwner(u); users.set(u.principalId, { ...u, status: 'disabled' }); trash.add(u.principalId); },
193
193
  };
194
194
  }
195
195
  export function createMemoryMembersApi({ workspaceId }, { members = [], permissions = [] } = {}) {
196
196
  const grants = Object.freeze([...permissions]);
197
- const rows = new Map(members.filter(m => m.workspaceId === workspaceId).map(m => [m.id, { ...m }]));
198
- const check = (o) => o.signal?.throwIfAborted();
197
+ const rows = new Map(inWorkspace(members, workspaceId).map(m => [m.id, { ...m }]));
199
198
  const fail = (code, message) => { throw new PeopleApiError({ code, message }); };
200
199
  const gate = (o) => { check(o); if (!hasAdminGrant({ permissions: grants, permission: 'member.manage' }))
201
200
  fail('FORBIDDEN', 'Missing member.manage.'); };
@@ -211,7 +210,7 @@ export function createMemoryMembersApi({ workspaceId }, { members = [], permissi
211
210
  /** Deterministic credential fake; production hashing/session policy stays in the server entry. */
212
211
  export function createMemoryAuthApi({ accounts }, _optional = {}) {
213
212
  const rows = accounts.map(a => ({ ...a, user: { ...a.user } }));
214
- return { async login(r, o = {}) { o.signal?.throwIfAborted(); const found = rows.find(a => a.user.username.normalize('NFC').toLowerCase() === r.username.trim().normalize('NFC').toLowerCase()); if (!r.password || !found || found.disabled || found.password !== r.password)
213
+ return { async login(r, o = {}) { check(o); const found = rows.find(a => a.user.username.normalize('NFC').toLowerCase() === r.username.trim().normalize('NFC').toLowerCase()); if (!r.password || !found || found.disabled || found.password !== r.password)
215
214
  throw new PeopleApiError({ code: 'UNAUTHENTICATED', message: 'invalid username or password' }); return { user: { ...found.user } }; } };
216
215
  }
217
216
  //# sourceMappingURL=memory.js.map
@@ -85,6 +85,7 @@ export async function runUsersApiConformance({ api }, { prefix = `users-${Date.n
85
85
  await check('snapshot isolation', async () => { const u = (await api.listUsers({})).users.find(u => u.principalId === principalId); u.roleIds.push('foreign'); assert(!(await api.listUsers({})).users.find(u => u.principalId === principalId).roleIds.includes('foreign'), 'mutable role storage exposed'); });
86
86
  await check('email patch preserves omitted email', async () => { await api.updateUser({ principalId: principalId }, { email: 'after@example.test' }); assert((await api.updateUser({ principalId: principalId })).user.email === 'after@example.test', 'omitted email cleared'); });
87
87
  await check('disable and enable operator', async () => { assert((await api.disableUser({ principalId: principalId })).user.status === 'disabled', 'disable missing'); assert((await api.enableUser({ principalId: principalId })).user.status === 'active', 'enable missing'); });
88
+ // eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Disposable-user conformance fixture for an explicitly isolated authenticated host; this is not an application credential.
88
89
  await check('reset password', async () => { await api.resetUserPassword({ principalId: principalId, password: 'replacement' }); });
89
90
  if (roleId)
90
91
  await check('assign role', async () => { await api.assignRole({ principalId: principalId, roleId }); assert((await api.listUsers({})).users.find(u => u.principalId === principalId)?.roleIds.includes(roleId), 'role missing'); });
@@ -12,21 +12,23 @@ export function createAuthController({ api, onLogin }, { initialUsername = 'admi
12
12
  if (!store.active({}) || s.busy)
13
13
  return false;
14
14
  set({ busy: true, error: null });
15
+ // One return after the try statement, so no unreachable continuation follows the catch.
16
+ let loggedIn = false;
15
17
  try {
16
18
  const { user } = await api.login({ username: s.username, password: s.password }, store.call);
17
- if (!store.active({}))
18
- return false;
19
- set({ password: '' });
20
- onLogin({ user });
21
- return true;
19
+ if (store.active({})) {
20
+ set({ password: '' });
21
+ onLogin({ user });
22
+ loggedIn = true;
23
+ }
22
24
  }
23
25
  catch (error) {
24
26
  set({ error: error instanceof Error ? error.message : 'login failed' });
25
- return false;
26
27
  }
27
28
  finally {
28
29
  set({ busy: false });
29
30
  }
31
+ return loggedIn;
30
32
  },
31
33
  };
32
34
  }
@@ -9,7 +9,8 @@ export function createMembersController({ api }, { permissions = [], refresh } =
9
9
  const read = store.getSnapshot, call = store.call, set = (patch) => store.set({ patch });
10
10
  let listGeneration = 0, detailGeneration = 0;
11
11
  const report = (error, fallback) => describePeopleError({ error, fallback });
12
- const row = (id) => read({}).members?.find(m => m.id === id);
12
+ const list = () => read({}).members ?? [];
13
+ const row = (id) => list().find(m => m.id === id);
13
14
  function stateFor({ id }, _optional = {}) { return read({}).rows[id] ?? emptyRow; }
14
15
  function patchRow(id, patch) { set({ rows: Object.freeze({ ...read({}).rows, [id]: Object.freeze({ ...stateFor({ id }), ...patch }) }) }); }
15
16
  const gate = () => store.active({}) && hasAdminGrant({ permissions: grants, permission: 'member.manage' });
@@ -66,18 +67,19 @@ export function createMembersController({ api }, { permissions = [], refresh } =
66
67
  if (!member || !gate() || stateFor({ id }).resending)
67
68
  return false;
68
69
  patchRow(id, { resending: true, error: null, notice: null });
70
+ let sent = false;
69
71
  try {
70
72
  await api.requestMemberMagicLink({ email: member.email }, call);
71
73
  patchRow(id, { notice: 'Sign-in link sent.' });
72
- return store.active({});
74
+ sent = store.active({});
73
75
  }
74
76
  catch (error) {
75
77
  patchRow(id, { error: report(error, 'Failed to send sign-in link.') });
76
- return false;
77
78
  }
78
79
  finally {
79
80
  patchRow(id, { resending: false });
80
81
  }
82
+ return sent;
81
83
  }
82
84
  async function confirmDestructive(_required, _optional = {}) {
83
85
  const target = read({}).pending;
@@ -85,19 +87,19 @@ export function createMembersController({ api }, { permissions = [], refresh } =
85
87
  return false;
86
88
  set({ confirming: true });
87
89
  patchRow(target.id, { disabling: true, error: null, notice: null });
90
+ let disabled = false;
88
91
  try {
89
92
  const { member } = await api.disableMember({ id: target.id }, call);
90
93
  // Invalidate older reads before applying the write response, including the cached detail.
91
94
  ++listGeneration;
92
95
  ++detailGeneration;
93
- set({ members: Object.freeze((read({}).members ?? []).map(m => m.id === member.id ? Object.freeze({ ...member }) : m)),
96
+ set({ members: Object.freeze(list().map(m => m.id === member.id ? Object.freeze({ ...member }) : m)),
94
97
  detailById: Object.freeze({ ...read({}).detailById, [member.id]: Object.freeze({ ...member }) }), detailLoadingId: null });
95
98
  patchRow(target.id, { notice: 'Member disabled.' });
96
- return store.active({});
99
+ disabled = store.active({});
97
100
  }
98
101
  catch (error) {
99
102
  patchRow(target.id, { error: report(error, 'Failed to disable member.') });
100
- return false;
101
103
  }
102
104
  finally {
103
105
  patchRow(target.id, { disabling: false });
@@ -105,6 +107,7 @@ export function createMembersController({ api }, { permissions = [], refresh } =
105
107
  set({ pending: null });
106
108
  set({ confirming: false });
107
109
  }
110
+ return disabled;
108
111
  }
109
112
  const unsubscribe = refresh?.subscribe({ onRefresh: () => { void load({}); } });
110
113
  return {
@@ -39,6 +39,40 @@ export function createRolesController({ api }, { permissions = [] } = {}) {
39
39
  const roleGate = (r) => gate() && !!r && (mutableRole({ role: r }) || (set({ rowError: m.immutable }), false));
40
40
  const policyGate = (p) => gate() && !!p && (mutablePolicy({ policy: p }) || (set({ rowError: m.immutable }), false));
41
41
  const rows = (items) => Object.freeze(items.map(r => Object.freeze({ ...r })));
42
+ /** Shared settle path for the two create forms: each owns its own busy flag and error slot. */
43
+ async function formWrite({ saving, error: errorKey, fallback, run }) {
44
+ set({ [saving]: true, [errorKey]: null });
45
+ let saved = false;
46
+ try {
47
+ await run();
48
+ await load({});
49
+ saved = !disposed;
50
+ }
51
+ catch (error) {
52
+ set({ [errorKey]: report(error, fallback) });
53
+ }
54
+ finally {
55
+ set({ [saving]: false });
56
+ }
57
+ return saved;
58
+ }
59
+ /** Shared settle path for row writes (rename, policy edit, permission grant). */
60
+ async function rowWrite({ id, fallback, run }) {
61
+ set({ rowSavingId: id, rowError: null });
62
+ let saved = false;
63
+ try {
64
+ await run();
65
+ saved = !disposed;
66
+ }
67
+ catch (error) {
68
+ set({ rowError: report(error, fallback) });
69
+ }
70
+ finally {
71
+ if (state.rowSavingId === id)
72
+ set({ rowSavingId: null });
73
+ }
74
+ return saved;
75
+ }
42
76
  async function load(_required, _optional = {}) {
43
77
  if (disposed)
44
78
  return;
@@ -89,41 +123,21 @@ export function createRolesController({ api }, { permissions = [] } = {}) {
89
123
  if (!gate() || state.roleSaving)
90
124
  return false;
91
125
  const name = state.roleName;
92
- set({ roleSaving: true, roleError: null });
93
- try {
94
- await api.createRole({ name }, call);
95
- if (state.roleName === name)
96
- set({ roleName: '' });
97
- await load({});
98
- return !disposed;
99
- }
100
- catch (error) {
101
- set({ roleError: report(error, 'Failed to create role.') });
102
- return false;
103
- }
104
- finally {
105
- set({ roleSaving: false });
106
- }
126
+ return formWrite({ saving: 'roleSaving', error: 'roleError', fallback: 'Failed to create role.', run: async () => {
127
+ await api.createRole({ name }, call);
128
+ if (state.roleName === name)
129
+ set({ roleName: '' });
130
+ } });
107
131
  }
108
132
  async function createPolicy(_required, _optional = {}) {
109
133
  if (!gate() || state.policySaving)
110
134
  return false;
111
135
  const name = state.policyName, description = state.policyDescription;
112
- set({ policySaving: true, policyError: null });
113
- try {
114
- await api.createPolicy({ name }, { ...call, ...(description ? { description } : {}) });
115
- if (state.policyName === name && state.policyDescription === description)
116
- set({ policyName: '', policyDescription: '' });
117
- await load({});
118
- return !disposed;
119
- }
120
- catch (error) {
121
- set({ policyError: report(error, 'Failed to create policy.') });
122
- return false;
123
- }
124
- finally {
125
- set({ policySaving: false });
126
- }
136
+ return formWrite({ saving: 'policySaving', error: 'policyError', fallback: 'Failed to create policy.', run: async () => {
137
+ await api.createPolicy({ name }, { ...call, ...(description ? { description } : {}) });
138
+ if (state.policyName === name && state.policyDescription === description)
139
+ set({ policyName: '', policyDescription: '' });
140
+ } });
127
141
  }
128
142
  function editRole({ role: r }, _optional = {}) {
129
143
  if (roleGate(role(r.id)))
@@ -137,75 +151,52 @@ export function createRolesController({ api }, { permissions = [] } = {}) {
137
151
  const id = state.editingRoleId, name = state.editingRoleName;
138
152
  if (!id || !roleGate(role(id)) || state.rowSavingId)
139
153
  return false;
140
- set({ rowSavingId: id, rowError: null });
141
- try {
142
- await api.updateRole({ roleId: id, name }, call);
143
- // A late save must not close another row's newer edit.
144
- if (state.editingRoleId === id && state.editingRoleName === name)
145
- set({ editingRoleId: null });
146
- await load({});
147
- return !disposed;
148
- }
149
- catch (error) {
150
- set({ rowError: report(error, 'Failed to rename role.') });
151
- return false;
152
- }
153
- finally {
154
- if (state.rowSavingId === id)
155
- set({ rowSavingId: null });
156
- }
154
+ return rowWrite({ id, fallback: 'Failed to rename role.', run: async () => {
155
+ await api.updateRole({ roleId: id, name }, call);
156
+ // A late save must not close another row's newer edit.
157
+ if (state.editingRoleId === id && state.editingRoleName === name)
158
+ set({ editingRoleId: null });
159
+ await load({});
160
+ } });
157
161
  }
158
162
  async function savePolicy(_required, _optional = {}) {
159
163
  const id = state.editingPolicyId, name = state.editingPolicyName, description = state.editingPolicyDescription;
160
164
  if (!id || !policyGate(policy(id)) || state.rowSavingId)
161
165
  return false;
162
- set({ rowSavingId: id, rowError: null });
163
- try {
164
- await api.updatePolicy({ policyId: id }, { ...call, name, description });
165
- if (state.editingPolicyId === id && state.editingPolicyName === name && state.editingPolicyDescription === description)
166
- set({ editingPolicyId: null });
167
- await load({});
168
- return !disposed;
169
- }
170
- catch (error) {
171
- set({ rowError: report(error, 'Failed to update policy.') });
172
- return false;
173
- }
174
- finally {
175
- if (state.rowSavingId === id)
176
- set({ rowSavingId: null });
177
- }
166
+ return rowWrite({ id, fallback: 'Failed to update policy.', run: async () => {
167
+ await api.updatePolicy({ policyId: id }, { ...call, name, description });
168
+ if (state.editingPolicyId === id && state.editingPolicyName === name && state.editingPolicyDescription === description)
169
+ set({ editingPolicyId: null });
170
+ await load({});
171
+ } });
178
172
  }
179
173
  async function writePermission(_required, _optional = {}) {
180
174
  const id = state.permissionPolicyId, permission = state.permissionInput, resourceType = state.resourceTypeInput;
181
175
  if (!id || !permission || !policyGate(policy(id)) || state.rowSavingId)
182
176
  return false;
183
177
  const generation = panelGeneration;
184
- set({ rowSavingId: id, rowError: null });
185
- try {
186
- await api.writePolicyPermission({ policyId: id, permission }, { ...call, ...(resourceType ? { resourceType } : {}) });
187
- // Ordering alone cannot protect a trailing refresh: it always mints the newest generation.
188
- // Check the CURRENT panel identity, and preserve a newer draft even on the same panel.
189
- if (generation === panelGeneration && state.permissionInput === permission && state.resourceTypeInput === resourceType)
190
- set({ permissionInput: '', resourceTypeInput: '' });
191
- if (state.permissionPolicyId === id)
192
- await loadPermissions({ policyId: id });
193
- return !disposed;
194
- }
195
- catch (error) {
196
- set({ rowError: report(error, 'Failed to add permission.') });
197
- return false;
198
- }
199
- finally {
200
- if (state.rowSavingId === id)
201
- set({ rowSavingId: null });
202
- }
178
+ return rowWrite({ id, fallback: 'Failed to add permission.', run: async () => {
179
+ await api.writePolicyPermission({ policyId: id, permission }, { ...call, ...(resourceType ? { resourceType } : {}) });
180
+ // Ordering alone cannot protect a trailing refresh: it always mints the newest generation.
181
+ // Check the CURRENT panel identity, and preserve a newer draft even on the same panel.
182
+ if (generation === panelGeneration && state.permissionInput === permission && state.resourceTypeInput === resourceType)
183
+ set({ permissionInput: '', resourceTypeInput: '' });
184
+ if (state.permissionPolicyId === id)
185
+ await loadPermissions({ policyId: id });
186
+ } });
203
187
  }
204
188
  function targetGate(target) {
205
189
  if (target.kind === 'role')
206
190
  return roleGate(role(target.role.id));
207
191
  return policyGate(policy(target.kind === 'policy' ? target.policy.id : target.policyId));
208
192
  }
193
+ function capture(target) {
194
+ if (target.kind === 'role')
195
+ return Object.freeze({ kind: 'role', role: Object.freeze({ ...target.role }) });
196
+ if (target.kind === 'policy')
197
+ return Object.freeze({ kind: 'policy', policy: Object.freeze({ ...target.policy }) });
198
+ return Object.freeze({ kind: 'permission', policyId: target.policyId, row: Object.freeze({ ...target.row }) });
199
+ }
209
200
  function requestDestructive({ target }, _optional = {}) {
210
201
  if (state.confirming || !targetGate(target))
211
202
  return;
@@ -213,49 +204,52 @@ export function createRolesController({ api }, { permissions = [] } = {}) {
213
204
  return;
214
205
  // The menu closes after one click; shell confirmation survives that closure. Revoke also
215
206
  // needs confirmation: removing from an attached policy immediately changes live authority.
216
- const captured = target.kind === 'role' ? Object.freeze({ kind: 'role', role: Object.freeze({ ...target.role }) }) : target.kind === 'policy' ? Object.freeze({ kind: 'policy', policy: Object.freeze({ ...target.policy }) }) : Object.freeze({ kind: 'permission', policyId: target.policyId, row: Object.freeze({ ...target.row }) });
217
- set({ pending: captured, rowError: null });
207
+ set({ pending: capture(target), rowError: null });
218
208
  }
219
209
  function cancelDestructive(_required, _optional = {}) {
220
210
  if (!state.confirming)
221
211
  set({ pending: null });
222
212
  }
213
+ async function destroy(target) {
214
+ if (target.kind === 'role') {
215
+ await api.deleteRole({ roleId: target.role.id }, call);
216
+ await load({});
217
+ }
218
+ else if (target.kind === 'policy') {
219
+ await api.deletePolicy({ policyId: target.policy.id }, call);
220
+ if (state.permissionPolicyId === target.policy.id) {
221
+ ++permissionGeneration;
222
+ ++panelGeneration;
223
+ set({ permissionPolicyId: null, permissionRows: [], permissionsLoading: false });
224
+ }
225
+ await load({});
226
+ }
227
+ else {
228
+ set({ removingPermissionId: target.row.id });
229
+ await api.removePolicyPermission({ policyId: target.policyId, policyPermissionId: target.row.id }, call);
230
+ if (state.permissionPolicyId === target.policyId)
231
+ await loadPermissions({ policyId: target.policyId });
232
+ }
233
+ }
223
234
  async function confirmDestructive(_required, _optional = {}) {
224
235
  const target = state.pending;
225
236
  if (!target || state.confirming || state.rowSavingId || !targetGate(target))
226
237
  return false;
227
238
  set({ confirming: true, rowError: null });
239
+ let destroyed = false;
228
240
  try {
229
- if (target.kind === 'role') {
230
- await api.deleteRole({ roleId: target.role.id }, call);
231
- await load({});
232
- }
233
- else if (target.kind === 'policy') {
234
- await api.deletePolicy({ policyId: target.policy.id }, call);
235
- if (state.permissionPolicyId === target.policy.id) {
236
- ++permissionGeneration;
237
- ++panelGeneration;
238
- set({ permissionPolicyId: null, permissionRows: [], permissionsLoading: false });
239
- }
240
- await load({});
241
- }
242
- else {
243
- set({ removingPermissionId: target.row.id });
244
- await api.removePolicyPermission({ policyId: target.policyId, policyPermissionId: target.row.id }, call);
245
- if (state.permissionPolicyId === target.policyId)
246
- await loadPermissions({ policyId: target.policyId });
247
- }
248
- return !disposed;
241
+ await destroy(target);
242
+ destroyed = !disposed;
249
243
  }
250
244
  catch (error) {
251
245
  set({ rowError: report(error, target.kind === 'permission' ? 'Failed to remove permission.' : `Failed to delete ${target.kind}.`) });
252
- return false;
253
246
  }
254
247
  finally {
255
248
  if (state.pending === target)
256
249
  set({ pending: null });
257
250
  set({ confirming: false, removingPermissionId: null });
258
251
  }
252
+ return destroyed;
259
253
  }
260
254
  return {
261
255
  getSnapshot(_required = {}, _optional = {}) { return state; },