create-astram-app 0.11.0 → 0.12.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/dist/index.js +98 -4
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/template/frontend/_env.example +7 -0
- package/template/frontend/package.json +2 -2
- package/template/frontend/src/App.jsx +24 -0
- package/template/frontend/src/auth/AuthContext.jsx +73 -30
- package/template/frontend/src/auth/CaptchaWidget.jsx +108 -0
- package/template/frontend/src/auth/captchaProvider.js +23 -0
- package/template/frontend/src/auth/captchaProvider.test.mjs +44 -0
- package/template/frontend/src/auth/captchaRender.js +125 -0
- package/template/frontend/src/auth/captchaRender.test.mjs +182 -0
- package/template/frontend/src/auth/passwordVisibility.js +9 -0
- package/template/frontend/src/auth/passwordVisibility.test.mjs +12 -0
- package/template/frontend/src/auth/reenrollWording.js +29 -0
- package/template/frontend/src/auth/reenrollWording.test.mjs +23 -0
- package/template/frontend/src/auth/routeGate.js +10 -0
- package/template/frontend/src/auth/routeGate.test.mjs +22 -1
- package/template/frontend/src/auth/sessionFlow.js +80 -4
- package/template/frontend/src/auth/sessionFlow.test.mjs +212 -1
- package/template/frontend/src/auth/signInOptions.js +64 -0
- package/template/frontend/src/auth/signInOptions.test.mjs +94 -0
- package/template/frontend/src/auth/useSignInOptions.js +34 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +49 -3
- package/template/frontend/src/pages/Login.jsx +54 -4
- package/template/frontend/src/pages/ResetPassword.jsx +83 -11
- package/template/frontend/src/pages/account/ReenrollPage.jsx +17 -11
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +9 -6
- package/template/frontend/src/pages/audit/AuditPage.jsx +17 -3
- package/template/frontend/src/pages/audit/actorKind.js +17 -0
- package/template/frontend/src/pages/audit/actorKind.test.mjs +23 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +104 -4
- package/template/frontend/src/pages/companies/moveAction.js +61 -0
- package/template/frontend/src/pages/companies/moveAction.test.mjs +91 -0
- package/template/frontend/src/pages/org-structure/OrgStructurePage.jsx +408 -0
- package/template/frontend/src/pages/org-structure/orgStructure.js +140 -0
- package/template/frontend/src/pages/org-structure/orgStructure.test.mjs +199 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +32 -14
- package/template/frontend/src/pages/roles/RolesPage.jsx +167 -42
- package/template/frontend/src/pages/roles/roleRules.js +104 -0
- package/template/frontend/src/pages/roles/roleRules.test.mjs +178 -0
- package/template/frontend/src/pages/security/PolicyFormDialog.jsx +243 -0
- package/template/frontend/src/pages/security/SecuritySettingsPage.jsx +229 -0
- package/template/frontend/src/pages/security/securityPolicies.js +246 -0
- package/template/frontend/src/pages/security/securityPolicies.test.mjs +429 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +5 -2
- package/template/frontend/src/pages/users/PostingRow.jsx +13 -10
- package/template/frontend/src/pages/users/SignInActionDialog.jsx +98 -0
- package/template/frontend/src/pages/users/UserForm.jsx +65 -3
- package/template/frontend/src/pages/users/UsersPage.jsx +91 -3
- package/template/frontend/src/pages/users/loginName.js +108 -0
- package/template/frontend/src/pages/users/loginName.test.mjs +133 -0
- package/template/frontend/src/pages/users/signInAdmin.js +122 -0
- package/template/frontend/src/pages/users/signInAdmin.test.mjs +183 -0
- package/template/frontend/src/platform/httpClient.js +40 -28
- package/template/frontend/src/platform/httpClient.test.mjs +71 -0
- package/template/frontend/src/platform/shieldTransport.js +12 -12
- package/template/frontend/src/shell/AppShell.jsx +4 -0
- package/template/frontend/src/shell/GraceBanner.jsx +28 -0
- package/template/frontend/src/shell/graceDeadline.js +50 -0
- package/template/frontend/src/shell/graceDeadline.test.mjs +59 -0
- package/template/frontend/src/shell/nav.js +30 -1
- package/template/frontend/src/viteConfig.test.mjs +63 -0
- package/template/frontend/vite/captchaCsp.js +99 -0
- package/template/frontend/vite.config.js +14 -3
- package/template/server/_env.example +25 -0
- package/template/server/package.json +9 -6
- package/template/server/src/app.module.ts +17 -6
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// securityPolicies.test.mjs — auth capabilities R2.1, R2.2, R2.6, R2.9, R11.4, R13.4, AC-26: the
|
|
2
|
+
// Security settings page lists, creates, edits and deletes mandatory-2FA policies. Actions are
|
|
3
|
+
// hidden by permission (UX only) and server refusals are still shown. Every id on a policy
|
|
4
|
+
// (company, role, author) is resolved to a name, with a fallback label; a uuid is never shown.
|
|
5
|
+
//
|
|
6
|
+
// node --test src/pages/security/securityPolicies.test.mjs
|
|
7
|
+
import { test, describe } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import {
|
|
10
|
+
SECURITY_POLICY_PERMISSIONS,
|
|
11
|
+
KIND_OPTIONS,
|
|
12
|
+
MAX_GRACE_DAYS,
|
|
13
|
+
PLATFORM_SCOPE,
|
|
14
|
+
isPermissionTarget,
|
|
15
|
+
policyFormFromRow,
|
|
16
|
+
validatePolicyForm,
|
|
17
|
+
policyActions,
|
|
18
|
+
hasPlatformRow,
|
|
19
|
+
showFloorNote,
|
|
20
|
+
collectLabelIds,
|
|
21
|
+
resolvePolicyLabels,
|
|
22
|
+
describePolicy,
|
|
23
|
+
policyFailureText,
|
|
24
|
+
} from './securityPolicies.js';
|
|
25
|
+
|
|
26
|
+
const COMPANY = '11111111-1111-4111-8111-111111111111';
|
|
27
|
+
const ROLE = '22222222-2222-4222-8222-222222222222';
|
|
28
|
+
const USER = '33333333-3333-4333-8333-333333333333';
|
|
29
|
+
const GONE = '44444444-4444-4444-8444-444444444444';
|
|
30
|
+
|
|
31
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
32
|
+
|
|
33
|
+
describe('constants', () => {
|
|
34
|
+
test('the four permission keys', () => {
|
|
35
|
+
assert.deepEqual(SECURITY_POLICY_PERMISSIONS, {
|
|
36
|
+
read: 'identity:security_policy:read',
|
|
37
|
+
create: 'identity:security_policy:create',
|
|
38
|
+
update: 'identity:security_policy:update',
|
|
39
|
+
delete: 'identity:security_policy:delete',
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the three kinds with plain labels', () => {
|
|
44
|
+
assert.deepEqual(KIND_OPTIONS, [
|
|
45
|
+
{ value: 'tenant', label: 'Everyone in this organisation' },
|
|
46
|
+
{ value: 'role', label: 'Holders of a role' },
|
|
47
|
+
{ value: 'permission', label: 'Holders of a permission' },
|
|
48
|
+
]);
|
|
49
|
+
assert.equal(MAX_GRACE_DAYS, 14);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('isPermissionTarget (mirror of core isSecurityPolicyPermissionTarget)', () => {
|
|
54
|
+
test('keys and grant wildcards', () => {
|
|
55
|
+
for (const ok of [
|
|
56
|
+
'identity:user:update',
|
|
57
|
+
'identity:*',
|
|
58
|
+
'identity:user:*',
|
|
59
|
+
'identity',
|
|
60
|
+
'crm_x:deal_1:approve',
|
|
61
|
+
]) {
|
|
62
|
+
assert.equal(isPermissionTarget(ok), true, ok);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('anything else', () => {
|
|
67
|
+
for (const bad of [
|
|
68
|
+
'*',
|
|
69
|
+
'Identity:user:read',
|
|
70
|
+
'identity:',
|
|
71
|
+
'identity:*:read',
|
|
72
|
+
'a:b:c:d',
|
|
73
|
+
'1x:y:z',
|
|
74
|
+
'identity:user',
|
|
75
|
+
' identity:*',
|
|
76
|
+
'',
|
|
77
|
+
undefined,
|
|
78
|
+
`a:b:${'c'.repeat(260)}`,
|
|
79
|
+
]) {
|
|
80
|
+
assert.equal(isPermissionTarget(bad), false, String(bad));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('validatePolicyForm — create', () => {
|
|
86
|
+
const base = { scope: COMPANY, kind: 'tenant', role: null, permission: '', graceDays: '0' };
|
|
87
|
+
|
|
88
|
+
test('an organisation-wide policy for a chosen company', () => {
|
|
89
|
+
assert.deepEqual(validatePolicyForm(base, { isEdit: false }), {
|
|
90
|
+
ok: true,
|
|
91
|
+
body: { scopeNodeUuid: COMPANY, targetKind: 'tenant', graceDays: 0 },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('the platform minimum sends platform:true and no company', () => {
|
|
96
|
+
assert.deepEqual(
|
|
97
|
+
validatePolicyForm({ ...base, scope: PLATFORM_SCOPE, graceDays: '3' }, { isEdit: false }),
|
|
98
|
+
{
|
|
99
|
+
ok: true,
|
|
100
|
+
body: { platform: true, targetKind: 'tenant', graceDays: 3 },
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a role policy needs a role', () => {
|
|
106
|
+
assert.deepEqual(validatePolicyForm({ ...base, kind: 'role' }, { isEdit: false }), {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: 'Choose a role.',
|
|
109
|
+
});
|
|
110
|
+
assert.deepEqual(validatePolicyForm({ ...base, kind: 'role', role: ROLE }, { isEdit: false }), {
|
|
111
|
+
ok: true,
|
|
112
|
+
body: { scopeNodeUuid: COMPANY, targetKind: 'role', targetValue: ROLE, graceDays: 0 },
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a permission policy needs a well-formed key (trimmed)', () => {
|
|
117
|
+
const bad = validatePolicyForm(
|
|
118
|
+
{ ...base, kind: 'permission', permission: 'Identity:*' },
|
|
119
|
+
{ isEdit: false },
|
|
120
|
+
);
|
|
121
|
+
assert.equal(bad.ok, false);
|
|
122
|
+
assert.match(bad.error, /identity:user:update/);
|
|
123
|
+
assert.deepEqual(
|
|
124
|
+
validatePolicyForm(
|
|
125
|
+
{ ...base, kind: 'permission', permission: ' identity:* ' },
|
|
126
|
+
{ isEdit: false },
|
|
127
|
+
),
|
|
128
|
+
{
|
|
129
|
+
ok: true,
|
|
130
|
+
body: {
|
|
131
|
+
scopeNodeUuid: COMPANY,
|
|
132
|
+
targetKind: 'permission',
|
|
133
|
+
targetValue: 'identity:*',
|
|
134
|
+
graceDays: 0,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('a scope is required', () => {
|
|
141
|
+
assert.deepEqual(validatePolicyForm({ ...base, scope: null }, { isEdit: false }), {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: 'Choose an organisation.',
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('grace days: whole number 0–14', () => {
|
|
148
|
+
for (const g of ['-1', '15', '1.5', 'abc', '']) {
|
|
149
|
+
assert.deepEqual(validatePolicyForm({ ...base, graceDays: g }, { isEdit: false }), {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: 'Grace period must be a whole number of days from 0 to 14.',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
assert.equal(
|
|
155
|
+
validatePolicyForm({ ...base, graceDays: ' 14 ' }, { isEdit: false }).body.graceDays,
|
|
156
|
+
14,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('an unknown kind is refused', () => {
|
|
161
|
+
assert.equal(validatePolicyForm({ ...base, kind: 'everyone' }, { isEdit: false }).ok, false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('validatePolicyForm — edit', () => {
|
|
166
|
+
test('only target value and grace are sent', () => {
|
|
167
|
+
assert.deepEqual(
|
|
168
|
+
validatePolicyForm(
|
|
169
|
+
{ scope: COMPANY, kind: 'role', role: ROLE, permission: '', graceDays: '7' },
|
|
170
|
+
{ isEdit: true },
|
|
171
|
+
),
|
|
172
|
+
{ ok: true, body: { targetValue: ROLE, graceDays: 7 } },
|
|
173
|
+
);
|
|
174
|
+
assert.deepEqual(
|
|
175
|
+
validatePolicyForm(
|
|
176
|
+
{ scope: PLATFORM_SCOPE, kind: 'tenant', role: null, permission: '', graceDays: '2' },
|
|
177
|
+
{ isEdit: true },
|
|
178
|
+
),
|
|
179
|
+
{ ok: true, body: { graceDays: 2 } },
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('policyFormFromRow', () => {
|
|
185
|
+
test('a stored policy pre-fills the form', () => {
|
|
186
|
+
assert.deepEqual(
|
|
187
|
+
policyFormFromRow({
|
|
188
|
+
id: 'p1',
|
|
189
|
+
fields: {
|
|
190
|
+
scopeNodeUuid: COMPANY,
|
|
191
|
+
platform: false,
|
|
192
|
+
targetKind: 'permission',
|
|
193
|
+
targetValue: 'identity:*',
|
|
194
|
+
graceDays: 5,
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
{ scope: COMPANY, kind: 'permission', role: null, permission: 'identity:*', graceDays: '5' },
|
|
198
|
+
);
|
|
199
|
+
assert.deepEqual(
|
|
200
|
+
policyFormFromRow({
|
|
201
|
+
id: 'p2',
|
|
202
|
+
fields: {
|
|
203
|
+
scopeNodeUuid: null,
|
|
204
|
+
platform: true,
|
|
205
|
+
targetKind: 'role',
|
|
206
|
+
targetValue: ROLE,
|
|
207
|
+
graceDays: 0,
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
{ scope: PLATFORM_SCOPE, kind: 'role', role: ROLE, permission: '', graceDays: '0' },
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('policyActions (UX gate)', () => {
|
|
216
|
+
test('add / edit / delete each follow their own permission', () => {
|
|
217
|
+
const only =
|
|
218
|
+
(...keys) =>
|
|
219
|
+
(k) =>
|
|
220
|
+
keys.includes(k);
|
|
221
|
+
assert.deepEqual(policyActions(only()), { add: false, edit: false, remove: false });
|
|
222
|
+
assert.deepEqual(policyActions(only('identity:security_policy:create')), {
|
|
223
|
+
add: true,
|
|
224
|
+
edit: false,
|
|
225
|
+
remove: false,
|
|
226
|
+
});
|
|
227
|
+
assert.deepEqual(
|
|
228
|
+
policyActions(only('identity:security_policy:update', 'identity:security_policy:delete')),
|
|
229
|
+
{
|
|
230
|
+
add: false,
|
|
231
|
+
edit: true,
|
|
232
|
+
remove: true,
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('platform floor rows', () => {
|
|
239
|
+
const tenantRow = {
|
|
240
|
+
id: 'p1',
|
|
241
|
+
fields: { scopeNodeUuid: COMPANY, platform: false, targetKind: 'tenant' },
|
|
242
|
+
};
|
|
243
|
+
const floorRow = {
|
|
244
|
+
id: 'p2',
|
|
245
|
+
fields: { scopeNodeUuid: null, platform: true, targetKind: 'tenant' },
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
test('a listed floor row proves the caller is root (the server shows floors to root only)', () => {
|
|
249
|
+
assert.equal(hasPlatformRow([tenantRow, floorRow]), true);
|
|
250
|
+
assert.equal(hasPlatformRow([tenantRow]), false);
|
|
251
|
+
assert.equal(hasPlatformRow(undefined), false);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('the "platform minimum may also apply" note shows unless floor rows are visible', () => {
|
|
255
|
+
assert.equal(showFloorNote([tenantRow]), true);
|
|
256
|
+
assert.equal(showFloorNote([]), true);
|
|
257
|
+
assert.equal(showFloorNote([floorRow]), false);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('id resolution', () => {
|
|
262
|
+
const rows = [
|
|
263
|
+
{
|
|
264
|
+
id: 'p1',
|
|
265
|
+
fields: {
|
|
266
|
+
scopeNodeUuid: COMPANY,
|
|
267
|
+
platform: false,
|
|
268
|
+
targetKind: 'role',
|
|
269
|
+
targetValue: ROLE,
|
|
270
|
+
createdBy: USER,
|
|
271
|
+
graceDays: 0,
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
id: 'p2',
|
|
276
|
+
fields: {
|
|
277
|
+
scopeNodeUuid: GONE,
|
|
278
|
+
platform: false,
|
|
279
|
+
targetKind: 'role',
|
|
280
|
+
targetValue: GONE,
|
|
281
|
+
createdBy: GONE,
|
|
282
|
+
graceDays: 1,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: 'p3',
|
|
287
|
+
fields: {
|
|
288
|
+
scopeNodeUuid: null,
|
|
289
|
+
platform: true,
|
|
290
|
+
targetKind: 'permission',
|
|
291
|
+
targetValue: 'identity:*',
|
|
292
|
+
createdBy: USER,
|
|
293
|
+
graceDays: 14,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: 'p4',
|
|
298
|
+
fields: {
|
|
299
|
+
scopeNodeUuid: COMPANY,
|
|
300
|
+
platform: false,
|
|
301
|
+
targetKind: 'tenant',
|
|
302
|
+
targetValue: null,
|
|
303
|
+
graceDays: 2,
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
test('collects unique company, role and user ids (permission keys are not ids)', () => {
|
|
309
|
+
const ids = collectLabelIds(rows);
|
|
310
|
+
assert.deepEqual([...ids.companies].sort(), [COMPANY, GONE].sort());
|
|
311
|
+
assert.deepEqual([...ids.roles].sort(), [ROLE, GONE].sort());
|
|
312
|
+
assert.deepEqual([...ids.users].sort(), [USER, GONE].sort());
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const lookups = () => {
|
|
316
|
+
const calls = [];
|
|
317
|
+
const rec = (name) => ({ ok: true, record: { id: 'x', fields: name } });
|
|
318
|
+
return {
|
|
319
|
+
calls,
|
|
320
|
+
getCompany: async (id) => (
|
|
321
|
+
calls.push(['company', id]),
|
|
322
|
+
id === COMPANY ? rec({ name: 'Acme' }) : { ok: true, record: null }
|
|
323
|
+
),
|
|
324
|
+
getRole: async (id) => (
|
|
325
|
+
calls.push(['role', id]),
|
|
326
|
+
id === ROLE ? rec({ name: 'Finance admin' }) : { ok: false, status: 404, error: 'x' }
|
|
327
|
+
),
|
|
328
|
+
getUser: async (id) => (
|
|
329
|
+
calls.push(['user', id]),
|
|
330
|
+
id === USER ? rec({ username: 'sonja' }) : { ok: false, status: 403, error: 'x' }
|
|
331
|
+
),
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
test('resolves each id once, with fallbacks for deleted or unreadable records', async () => {
|
|
336
|
+
const l = lookups();
|
|
337
|
+
const labels = await resolvePolicyLabels(rows, l);
|
|
338
|
+
assert.equal(l.calls.length, 6, 'one lookup per unique id');
|
|
339
|
+
assert.equal(labels.companies.get(COMPANY), 'Acme');
|
|
340
|
+
assert.equal(labels.companies.get(GONE), 'Deleted company');
|
|
341
|
+
assert.equal(labels.roles.get(ROLE), 'Finance admin');
|
|
342
|
+
assert.equal(labels.roles.get(GONE), 'Deleted role');
|
|
343
|
+
assert.equal(labels.users.get(USER), 'sonja');
|
|
344
|
+
assert.equal(labels.users.get(GONE), 'Unknown user');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('a lookup that throws still yields the fallback', async () => {
|
|
348
|
+
const boom = async () => {
|
|
349
|
+
throw new Error('network');
|
|
350
|
+
};
|
|
351
|
+
const labels = await resolvePolicyLabels(rows, {
|
|
352
|
+
getCompany: boom,
|
|
353
|
+
getRole: boom,
|
|
354
|
+
getUser: boom,
|
|
355
|
+
});
|
|
356
|
+
assert.equal(labels.companies.get(COMPANY), 'Deleted company');
|
|
357
|
+
assert.equal(labels.roles.get(ROLE), 'Deleted role');
|
|
358
|
+
assert.equal(labels.users.get(USER), 'Unknown user');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('describePolicy renders names only — never a uuid', async () => {
|
|
362
|
+
const labels = await resolvePolicyLabels(rows, lookups());
|
|
363
|
+
const out = rows.map((r) => describePolicy(r, labels));
|
|
364
|
+
assert.deepEqual(out[0], {
|
|
365
|
+
scope: 'Acme',
|
|
366
|
+
kind: 'Holders of a role',
|
|
367
|
+
target: 'Finance admin',
|
|
368
|
+
grace: 'None',
|
|
369
|
+
createdBy: 'sonja',
|
|
370
|
+
});
|
|
371
|
+
assert.deepEqual(out[1], {
|
|
372
|
+
scope: 'Deleted company',
|
|
373
|
+
kind: 'Holders of a role',
|
|
374
|
+
target: 'Deleted role',
|
|
375
|
+
grace: '1 day',
|
|
376
|
+
createdBy: 'Unknown user',
|
|
377
|
+
});
|
|
378
|
+
assert.deepEqual(out[2], {
|
|
379
|
+
scope: 'Platform minimum',
|
|
380
|
+
kind: 'Holders of a permission',
|
|
381
|
+
target: 'identity:*',
|
|
382
|
+
grace: '14 days',
|
|
383
|
+
createdBy: 'sonja',
|
|
384
|
+
});
|
|
385
|
+
assert.deepEqual(out[3], {
|
|
386
|
+
scope: 'Acme',
|
|
387
|
+
kind: 'Everyone in this organisation',
|
|
388
|
+
target: 'Everyone',
|
|
389
|
+
grace: '2 days',
|
|
390
|
+
createdBy: 'Unknown user',
|
|
391
|
+
});
|
|
392
|
+
for (const d of out) for (const v of Object.values(d)) assert.doesNotMatch(v, UUID_RE);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('labels not yet loaded read as a neutral placeholder, never the id', () => {
|
|
396
|
+
const d = describePolicy(rows[0], undefined);
|
|
397
|
+
assert.equal(d.scope, '…');
|
|
398
|
+
assert.equal(d.target, '…');
|
|
399
|
+
assert.equal(d.createdBy, '…');
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe('policyFailureText', () => {
|
|
404
|
+
test('a 403 shows the server fixed message the service passed through', () => {
|
|
405
|
+
assert.equal(
|
|
406
|
+
policyFailureText(
|
|
407
|
+
{
|
|
408
|
+
ok: false,
|
|
409
|
+
status: 403,
|
|
410
|
+
error: 'Only a platform administrator can set a platform-wide policy.',
|
|
411
|
+
},
|
|
412
|
+
'save',
|
|
413
|
+
),
|
|
414
|
+
'Only a platform administrator can set a platform-wide policy.',
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('empty error → a generic per-verb message', () => {
|
|
419
|
+
assert.equal(
|
|
420
|
+
policyFailureText({ ok: false, status: 500, error: '' }, 'save'),
|
|
421
|
+
'Could not save the policy.',
|
|
422
|
+
);
|
|
423
|
+
assert.equal(
|
|
424
|
+
policyFailureText({ ok: false, status: 500 }, 'delete'),
|
|
425
|
+
'Could not delete the policy.',
|
|
426
|
+
);
|
|
427
|
+
assert.equal(policyFailureText(undefined, 'load'), 'Could not load security policies.');
|
|
428
|
+
});
|
|
429
|
+
});
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Company-scoped dropdowns: this card loads ITS company's department + branch catalogs on
|
|
10
10
|
* mount (the parent may pass a cache to avoid refetching). The Role dropdown is the growable
|
|
11
|
-
* catalog — each PostingRow self-fetches
|
|
12
|
-
*
|
|
11
|
+
* catalog — each PostingRow self-fetches THIS company's roles (`listCompanyRoles`, G-013:
|
|
12
|
+
* a role is posted only in the company that owns it; server-paginated + searchable) via the
|
|
13
|
+
* injected `identity` client and the `companyUuid` passed here.
|
|
13
14
|
*
|
|
14
15
|
* ⛔ No raw UUIDs: the header renders `companyName`; posting dropdowns render names with
|
|
15
16
|
* uuid values. `membershipUuid` / `companyUuid` are used only as keys/params.
|
|
@@ -246,6 +247,7 @@ export default function MembershipCard({
|
|
|
246
247
|
posting={row}
|
|
247
248
|
departments={departments}
|
|
248
249
|
branches={branches}
|
|
250
|
+
companyUuid={companyUuid}
|
|
249
251
|
token={token}
|
|
250
252
|
identity={identity}
|
|
251
253
|
onChange={(patch) => patchRow(row.key, patch)}
|
|
@@ -266,6 +268,7 @@ export default function MembershipCard({
|
|
|
266
268
|
posting={row}
|
|
267
269
|
departments={departments}
|
|
268
270
|
branches={branches}
|
|
271
|
+
companyUuid={companyUuid}
|
|
269
272
|
token={token}
|
|
270
273
|
identity={identity}
|
|
271
274
|
onChange={(patch) => patchRow(row.key, patch)}
|
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
* Props:
|
|
30
30
|
* posting — { roleUuid, roleName, departmentUuid, branchUuid, effectiveFrom, effectiveUntil }
|
|
31
31
|
* departments / branches — [{ uuid, name }] (this company's catalogs; may be empty)
|
|
32
|
+
* companyUuid — the membership's company: the Role dropdown lists only its roles (G-013)
|
|
32
33
|
* token — access token (for the Role dropdown's self-fetch)
|
|
33
|
-
* identity — the shared identity client service (for `
|
|
34
|
+
* identity — the shared identity client service (for `listCompanyRoles`)
|
|
34
35
|
* onChange(patch) — merge a field change into this row
|
|
35
36
|
* onRemove() — drop this row
|
|
36
37
|
* disabled — save in flight
|
|
@@ -43,6 +44,7 @@ import { Badge } from '@/components/ui/badge';
|
|
|
43
44
|
import { Label } from '@/components/ui/label';
|
|
44
45
|
import { DateField } from '@/components/ui/date-field';
|
|
45
46
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
|
47
|
+
import { nameOptions } from '../roles/roleRules';
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
50
|
* Classify a posting's effective window relative to now:
|
|
@@ -88,6 +90,7 @@ export default function PostingRow({
|
|
|
88
90
|
posting,
|
|
89
91
|
departments,
|
|
90
92
|
branches,
|
|
93
|
+
companyUuid,
|
|
91
94
|
token,
|
|
92
95
|
identity,
|
|
93
96
|
onChange,
|
|
@@ -97,20 +100,20 @@ export default function PostingRow({
|
|
|
97
100
|
}) {
|
|
98
101
|
const chip = windowState(posting.effectiveFrom, posting.effectiveUntil);
|
|
99
102
|
|
|
100
|
-
// Role is the growable catalog → server-paginated + searchable via `
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
+
// Role is the growable catalog → server-paginated + searchable via `listCompanyRoles`, for the
|
|
104
|
+
// membership's company only: a role can be posted only in the company that owns it (G-013;
|
|
105
|
+
// the server refuses any other with 400). The row already knows the selected role's NAME
|
|
106
|
+
// (posting.roleName, resolved server-side), so the trigger shows a name even before the first
|
|
107
|
+
// page loads.
|
|
103
108
|
const fetchRoles = useCallback(
|
|
104
109
|
async ({ search, page, limit }) => {
|
|
105
|
-
|
|
110
|
+
if (!companyUuid) return { options: [], total: 0, page };
|
|
111
|
+
const r = await identity.listCompanyRoles(token, companyUuid, { search, page, limit });
|
|
106
112
|
if (!r.ok) return { options: [], total: 0, page };
|
|
107
|
-
const options = (r.rows
|
|
108
|
-
value: row.id,
|
|
109
|
-
label: row.fields?.name || '(unnamed)',
|
|
110
|
-
}));
|
|
113
|
+
const options = nameOptions(r.rows);
|
|
111
114
|
return { options, total: r.total ?? options.length, page: r.page ?? page };
|
|
112
115
|
},
|
|
113
|
-
[identity, token],
|
|
116
|
+
[identity, token, companyUuid],
|
|
114
117
|
);
|
|
115
118
|
|
|
116
119
|
// Department / Branch: small, per-company catalogs (passed in). The SAME control in
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SignInActionDialog — confirm one admin sign-in action (reset 2FA, unlock, end sessions;
|
|
3
|
+
* auth capabilities R11.1, R11.2) with the admin's OWN current 6-digit authenticator code.
|
|
4
|
+
*
|
|
5
|
+
* The code goes to the server with the action (`identity.resetUserMfa` / `unlockUser` /
|
|
6
|
+
* `endUserSessions`); nothing is decided here. The outcome text comes from `signInActionOutcome`:
|
|
7
|
+
* a wrong code or a partly-applied action keeps the dialog open for another try; a final refusal
|
|
8
|
+
* (no 2FA on the admin's account, rate limited, not allowed) closes it with a toast.
|
|
9
|
+
*
|
|
10
|
+
* The user is named by display name only; `user.id` is passed to the service, never shown.
|
|
11
|
+
*/
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
import { toast } from 'sonner';
|
|
14
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
15
|
+
import EntityFormModal from '@/components/EntityFormModal';
|
|
16
|
+
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
|
17
|
+
import { Label } from '@/components/ui/label';
|
|
18
|
+
import { isAdminCode, normalizeAdminCode, signInActionOutcome } from './signInAdmin';
|
|
19
|
+
|
|
20
|
+
export default function SignInActionDialog({ action, user, onClose, onDone }) {
|
|
21
|
+
const { tokens, identity } = useAuth();
|
|
22
|
+
const token = tokens?.accessToken;
|
|
23
|
+
const name = user.fields?.username || user.fields?.email || 'this user';
|
|
24
|
+
|
|
25
|
+
const [code, setCode] = useState('');
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
const [error, setError] = useState('');
|
|
28
|
+
|
|
29
|
+
const submit = async () => {
|
|
30
|
+
if (busy) return;
|
|
31
|
+
if (!isAdminCode(code)) {
|
|
32
|
+
setError('Enter the 6-digit code from your authenticator app.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setError('');
|
|
36
|
+
setBusy(true);
|
|
37
|
+
const result = await identity[action.method](token, user.id, normalizeAdminCode(code));
|
|
38
|
+
setBusy(false);
|
|
39
|
+
const outcome = signInActionOutcome(action, result, name);
|
|
40
|
+
if (outcome.kind === 'success') {
|
|
41
|
+
toast.success(outcome.text);
|
|
42
|
+
onDone();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (outcome.close) {
|
|
46
|
+
toast.error(outcome.text);
|
|
47
|
+
onClose();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
setCode('');
|
|
51
|
+
setError(outcome.text);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<EntityFormModal
|
|
56
|
+
title={action.title(name)}
|
|
57
|
+
description={action.description}
|
|
58
|
+
open
|
|
59
|
+
onClose={onClose}
|
|
60
|
+
onSubmit={submit}
|
|
61
|
+
submitLabel={action.confirmLabel}
|
|
62
|
+
busy={busy}
|
|
63
|
+
error={error}
|
|
64
|
+
expandable={false}
|
|
65
|
+
>
|
|
66
|
+
<div className="space-y-1.5">
|
|
67
|
+
<Label htmlFor="admin-code" variant="field">
|
|
68
|
+
Your authenticator code
|
|
69
|
+
</Label>
|
|
70
|
+
<p className="text-xs text-muted-foreground">
|
|
71
|
+
Enter the current code from your own authenticator app to confirm.
|
|
72
|
+
</p>
|
|
73
|
+
<div className="flex justify-center py-1">
|
|
74
|
+
<InputOTP
|
|
75
|
+
id="admin-code"
|
|
76
|
+
autoFocus
|
|
77
|
+
maxLength={6}
|
|
78
|
+
inputMode="numeric"
|
|
79
|
+
pattern="[0-9]*"
|
|
80
|
+
autoComplete="one-time-code"
|
|
81
|
+
value={code}
|
|
82
|
+
onChange={setCode}
|
|
83
|
+
disabled={busy}
|
|
84
|
+
>
|
|
85
|
+
<InputOTPGroup>
|
|
86
|
+
<InputOTPSlot index={0} />
|
|
87
|
+
<InputOTPSlot index={1} />
|
|
88
|
+
<InputOTPSlot index={2} />
|
|
89
|
+
<InputOTPSlot index={3} />
|
|
90
|
+
<InputOTPSlot index={4} />
|
|
91
|
+
<InputOTPSlot index={5} />
|
|
92
|
+
</InputOTPGroup>
|
|
93
|
+
</InputOTP>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</EntityFormModal>
|
|
97
|
+
);
|
|
98
|
+
}
|