@jini-ai/user-management 0.3.3 → 0.6.10
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 +15 -1
- package/dist/admin/adapters/memory.d.ts +3 -5
- package/dist/admin/adapters/memory.js +27 -28
- package/dist/admin/conformance/index.js +1 -0
- package/dist/admin/controllers/auth.controller.js +8 -6
- package/dist/admin/controllers/members.controller.js +9 -6
- package/dist/admin/controllers/roles.controller.js +99 -105
- package/dist/admin/controllers/users.controller.js +56 -36
- package/dist/admin/error-copy.d.ts +11 -0
- package/dist/admin/error-copy.js +10 -0
- package/dist/admin/people.rules.js +37 -31
- package/dist/admin/react/components/MemberRow.d.ts +1 -1
- package/dist/admin/react/components/MemberRow.js +12 -2
- package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +6 -21
- package/dist/admin/react/hooks/AuthBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/AuthScope.hooks.d.ts +22 -0
- package/dist/admin/react/hooks/AuthScope.hooks.js +10 -0
- package/dist/admin/react/hooks/LoginPage.hooks.d.ts +1 -1
- package/dist/admin/react/hooks/LoginPage.hooks.js +1 -1
- package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +6 -17
- package/dist/admin/react/hooks/MembersBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/MembersPage.hooks.d.ts +1 -1
- package/dist/admin/react/hooks/MembersPage.hooks.js +1 -1
- package/dist/admin/react/hooks/MembersScope.hooks.d.ts +17 -0
- package/dist/admin/react/hooks/MembersScope.hooks.js +10 -0
- package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +7 -21
- package/dist/admin/react/hooks/RolesBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/RolesController.hooks.js +1 -1
- package/dist/admin/react/hooks/RolesPage.hooks.d.ts +2 -2
- package/dist/admin/react/hooks/RolesPage.hooks.js +22 -8
- package/dist/admin/react/hooks/RolesScope.hooks.d.ts +21 -0
- package/dist/admin/react/hooks/RolesScope.hooks.js +10 -0
- package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +6 -18
- package/dist/admin/react/hooks/UsersBinding.hooks.js +3 -8
- package/dist/admin/react/hooks/UsersPage.hooks.d.ts +1 -1
- package/dist/admin/react/hooks/UsersPage.hooks.js +1 -1
- package/dist/admin/react/hooks/UsersScope.hooks.d.ts +19 -0
- package/dist/admin/react/hooks/UsersScope.hooks.js +10 -0
- package/dist/admin/react/hooks/UsersTab.hooks.d.ts +6 -0
- package/dist/admin/react/hooks/UsersTab.hooks.js +34 -22
- package/dist/admin/react/index.d.ts +4 -4
- package/dist/admin/react/pages/LoginPage.d.ts +1 -1
- package/dist/admin/react/pages/MembersPage.d.ts +1 -1
- package/dist/admin/react/pages/RolesPage.d.ts +2 -2
- package/dist/admin/react/pages/RolesPage.js +6 -2
- package/dist/admin/react/pages/UsersPage.d.ts +2 -2
- package/dist/admin/react/pages/UsersPage.js +6 -2
- package/dist/admin/rules.js +14 -12
- package/dist/react/errors.js +16 -9
- package/dist/react/index.d.ts +1 -1
- package/dist/react/members/hooks/use-members.hooks.js +21 -12
- package/dist/react/testing.js +4 -6
- package/dist/react/users/UserRowParts.d.ts +53 -0
- package/dist/react/users/UserRowParts.js +39 -0
- package/dist/react/users/Users.d.ts +31 -2
- package/dist/react/users/Users.js +41 -36
- package/dist/react/users/hooks/use-users.hooks.js +57 -30
- package/dist/server/admin-crud-service.js +25 -13
- package/dist/server/agent-tool-input.js +62 -36
- package/dist/server/agent-tool-registrations.d.ts +46 -0
- package/dist/server/agent-tool-registrations.js +28 -20
- package/dist/server/auth-service.js +35 -20
- package/dist/server/authorize.js +7 -9
- package/dist/server/permission-migrations.js +35 -25
- package/dist/server/repo.memory-transactions.js +5 -6
- package/dist/server/repo.memory.js +35 -81
- package/dist/server/seed.js +100 -84
- package/package.json +12 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.10
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 82ed2d3: Members requires FetchQueryProvider. Await the authoritative roster refresh before reporting a completed disable, and restore settlement rationale beside the keyed panel/loading logic.
|
|
8
|
+
- ea8d883: Preserve successful member-disable state and detail when the follow-up roster read fails.
|
|
9
|
+
- Updated dependencies [22f0619]
|
|
10
|
+
- Updated dependencies [82ed2d3]
|
|
11
|
+
- Updated dependencies [5a3de86]
|
|
12
|
+
- @jini-ai/core@0.6.10
|
|
13
|
+
|
|
14
|
+
## 0.3.4 — 2026-10-22
|
|
15
|
+
|
|
16
|
+
- 2026-10-22: Named-argument identity guards, optional translate with a custom Users hook, cancellation kept across confirm-to-run, coverage.
|
|
17
|
+
|
|
3
18
|
## 0.3.3 — 2026-10-13
|
|
4
19
|
|
|
5
20
|
- Accept UI ^0.5.0 for the chart contract extraction.
|
|
@@ -73,7 +88,6 @@
|
|
|
73
88
|
- Add `sessionTtlMs` and replaceable authentication messages options; preserve the 30-day default. Invalid lifetimes raise RangeError before persistence. Sessions retain required workspace scope because repositories require it.
|
|
74
89
|
- Merge the job-specific test configuration into the existing config and rename its setup file.
|
|
75
90
|
|
|
76
|
-
|
|
77
91
|
- Remove specification identifiers from admin safety errors and direct-grant tool text; authorization and ownership checks retain their behavior.
|
|
78
92
|
|
|
79
93
|
- Reject reserved wildcard registration with a typed validation error and converge
|
|
@@ -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:
|
|
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(
|
|
9
|
-
|
|
10
|
-
|
|
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 (!
|
|
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 =>
|
|
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
|
|
126
|
-
const roles = (options.roles
|
|
127
|
-
const policies = (options.policies
|
|
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 =>
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
|
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 (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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; },
|