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.
Files changed (68) hide show
  1. package/dist/index.js +98 -4
  2. package/dist/index.js.map +1 -1
  3. package/package.json +6 -1
  4. package/template/frontend/_env.example +7 -0
  5. package/template/frontend/package.json +2 -2
  6. package/template/frontend/src/App.jsx +24 -0
  7. package/template/frontend/src/auth/AuthContext.jsx +73 -30
  8. package/template/frontend/src/auth/CaptchaWidget.jsx +108 -0
  9. package/template/frontend/src/auth/captchaProvider.js +23 -0
  10. package/template/frontend/src/auth/captchaProvider.test.mjs +44 -0
  11. package/template/frontend/src/auth/captchaRender.js +125 -0
  12. package/template/frontend/src/auth/captchaRender.test.mjs +182 -0
  13. package/template/frontend/src/auth/passwordVisibility.js +9 -0
  14. package/template/frontend/src/auth/passwordVisibility.test.mjs +12 -0
  15. package/template/frontend/src/auth/reenrollWording.js +29 -0
  16. package/template/frontend/src/auth/reenrollWording.test.mjs +23 -0
  17. package/template/frontend/src/auth/routeGate.js +10 -0
  18. package/template/frontend/src/auth/routeGate.test.mjs +22 -1
  19. package/template/frontend/src/auth/sessionFlow.js +80 -4
  20. package/template/frontend/src/auth/sessionFlow.test.mjs +212 -1
  21. package/template/frontend/src/auth/signInOptions.js +64 -0
  22. package/template/frontend/src/auth/signInOptions.test.mjs +94 -0
  23. package/template/frontend/src/auth/useSignInOptions.js +34 -0
  24. package/template/frontend/src/pages/ForgotPassword.jsx +49 -3
  25. package/template/frontend/src/pages/Login.jsx +54 -4
  26. package/template/frontend/src/pages/ResetPassword.jsx +83 -11
  27. package/template/frontend/src/pages/account/ReenrollPage.jsx +17 -11
  28. package/template/frontend/src/pages/account/TwoFactorPage.jsx +9 -6
  29. package/template/frontend/src/pages/audit/AuditPage.jsx +17 -3
  30. package/template/frontend/src/pages/audit/actorKind.js +17 -0
  31. package/template/frontend/src/pages/audit/actorKind.test.mjs +23 -0
  32. package/template/frontend/src/pages/companies/CompaniesPage.jsx +104 -4
  33. package/template/frontend/src/pages/companies/moveAction.js +61 -0
  34. package/template/frontend/src/pages/companies/moveAction.test.mjs +91 -0
  35. package/template/frontend/src/pages/org-structure/OrgStructurePage.jsx +408 -0
  36. package/template/frontend/src/pages/org-structure/orgStructure.js +140 -0
  37. package/template/frontend/src/pages/org-structure/orgStructure.test.mjs +199 -0
  38. package/template/frontend/src/pages/roles/RoleForm.jsx +32 -14
  39. package/template/frontend/src/pages/roles/RolesPage.jsx +167 -42
  40. package/template/frontend/src/pages/roles/roleRules.js +104 -0
  41. package/template/frontend/src/pages/roles/roleRules.test.mjs +178 -0
  42. package/template/frontend/src/pages/security/PolicyFormDialog.jsx +243 -0
  43. package/template/frontend/src/pages/security/SecuritySettingsPage.jsx +229 -0
  44. package/template/frontend/src/pages/security/securityPolicies.js +246 -0
  45. package/template/frontend/src/pages/security/securityPolicies.test.mjs +429 -0
  46. package/template/frontend/src/pages/users/MembershipCard.jsx +5 -2
  47. package/template/frontend/src/pages/users/PostingRow.jsx +13 -10
  48. package/template/frontend/src/pages/users/SignInActionDialog.jsx +98 -0
  49. package/template/frontend/src/pages/users/UserForm.jsx +65 -3
  50. package/template/frontend/src/pages/users/UsersPage.jsx +91 -3
  51. package/template/frontend/src/pages/users/loginName.js +108 -0
  52. package/template/frontend/src/pages/users/loginName.test.mjs +133 -0
  53. package/template/frontend/src/pages/users/signInAdmin.js +122 -0
  54. package/template/frontend/src/pages/users/signInAdmin.test.mjs +183 -0
  55. package/template/frontend/src/platform/httpClient.js +40 -28
  56. package/template/frontend/src/platform/httpClient.test.mjs +71 -0
  57. package/template/frontend/src/platform/shieldTransport.js +12 -12
  58. package/template/frontend/src/shell/AppShell.jsx +4 -0
  59. package/template/frontend/src/shell/GraceBanner.jsx +28 -0
  60. package/template/frontend/src/shell/graceDeadline.js +50 -0
  61. package/template/frontend/src/shell/graceDeadline.test.mjs +59 -0
  62. package/template/frontend/src/shell/nav.js +30 -1
  63. package/template/frontend/src/viteConfig.test.mjs +63 -0
  64. package/template/frontend/vite/captchaCsp.js +99 -0
  65. package/template/frontend/vite.config.js +14 -3
  66. package/template/server/_env.example +25 -0
  67. package/template/server/package.json +9 -6
  68. 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 it (server-paginated + searchable) via the injected
12
- * `identity` client, so no global role list is threaded through here.
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 `listRoles`)
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 `listRoles`. The row
101
- // already knows the selected role's NAME (posting.roleName, resolved server-side), so the
102
- // trigger shows a name even before the first page loads.
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
- const r = await identity.listRoles(token, { search, page, limit });
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 || []).map((row) => ({
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
+ }