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,104 @@
|
|
|
1
|
+
// roleRules.js — the Roles page's pure logic (G-013): permission keys, the role name rule, the
|
|
2
|
+
// company and role option mapping (names only, never ids) and the toast text. Tested by
|
|
3
|
+
// roleRules.test.mjs; RolesPage, RoleForm and the posting editor's role picker render from it.
|
|
4
|
+
// Showing, hiding or disabling a button is UX only: the server decides every write (permission,
|
|
5
|
+
// subtree wall, duplicate name in the company, role owned by the posting's company).
|
|
6
|
+
|
|
7
|
+
/** Permission keys of the role routes (`identity:role:<action>`). */
|
|
8
|
+
export const ROLE_PERMISSIONS = {
|
|
9
|
+
read: 'identity:role:read',
|
|
10
|
+
create: 'identity:role:create',
|
|
11
|
+
update: 'identity:role:update',
|
|
12
|
+
delete: 'identity:role:delete',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** The longest role name the server stores (spec §10, the G-012 name rule). */
|
|
16
|
+
export const MAX_ROLE_NAME_LENGTH = 150;
|
|
17
|
+
|
|
18
|
+
/** The list's empty state for a company with no roles. */
|
|
19
|
+
export const EMPTY_ROLES_LABEL = 'No roles yet';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What the caller may do with roles, from the auth-facts `can(key)`. UX only.
|
|
23
|
+
* @param {(key: string) => boolean} can
|
|
24
|
+
* @returns {{ read: boolean, create: boolean, rename: boolean, remove: boolean }}
|
|
25
|
+
*/
|
|
26
|
+
export function canManageRoles(can) {
|
|
27
|
+
if (typeof can !== 'function')
|
|
28
|
+
return { read: false, create: false, rename: false, remove: false };
|
|
29
|
+
return {
|
|
30
|
+
read: can(ROLE_PERMISSIONS.read) === true,
|
|
31
|
+
create: can(ROLE_PERMISSIONS.create) === true,
|
|
32
|
+
rename: can(ROLE_PERMISSIONS.update) === true,
|
|
33
|
+
remove: can(ROLE_PERMISSIONS.delete) === true,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// U+0000–U+001F and U+007F–U+009F.
|
|
38
|
+
// eslint-disable-next-line no-control-regex
|
|
39
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The server's role name rule (spec §10), checked before sending so the user sees the problem at
|
|
43
|
+
* once, in the same order as the server: NFC, trim and collapse whitespace, refuse control
|
|
44
|
+
* characters and an empty result, 1–150 characters. The server applies the same rule and
|
|
45
|
+
* decides; `value` is what it will store.
|
|
46
|
+
* @param {unknown} name
|
|
47
|
+
* @returns {{ ok: true, value: string } | { ok: false, error: string }}
|
|
48
|
+
*/
|
|
49
|
+
export function validateRoleName(name) {
|
|
50
|
+
const value = String(name ?? '')
|
|
51
|
+
.normalize('NFC')
|
|
52
|
+
.trim()
|
|
53
|
+
.replace(/\s+/g, ' ');
|
|
54
|
+
if (CONTROL.test(value)) {
|
|
55
|
+
return { ok: false, error: 'The role name cannot contain control characters.' };
|
|
56
|
+
}
|
|
57
|
+
if (value.length === 0) return { ok: false, error: 'Enter a role name.' };
|
|
58
|
+
if ([...value].length > MAX_ROLE_NAME_LENGTH) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `The role name can be at most ${MAX_ROLE_NAME_LENGTH} characters.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, value };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Engine RecordView rows (`{ id, fields: { name } }`, from `listCompanies` or `listCompanyRoles`)
|
|
69
|
+
* → SearchableSelect options. The id is the option value only; the label is the name, or
|
|
70
|
+
* "(unnamed)", never the id. Rows without an id are dropped.
|
|
71
|
+
* @param {Array<{ id?: string, fields?: { name?: string } }> | null | undefined} rows
|
|
72
|
+
* @returns {Array<{ value: string, label: string }>}
|
|
73
|
+
*/
|
|
74
|
+
export function nameOptions(rows) {
|
|
75
|
+
return (rows || [])
|
|
76
|
+
.filter((row) => row && typeof row.id === 'string' && row.id.length > 0)
|
|
77
|
+
.map((row) => ({
|
|
78
|
+
value: row.id,
|
|
79
|
+
label:
|
|
80
|
+
typeof row.fields?.name === 'string' && row.fields.name.length > 0
|
|
81
|
+
? row.fields.name
|
|
82
|
+
: '(unnamed)',
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DONE = { create: 'created', rename: 'renamed', delete: 'deleted' };
|
|
87
|
+
const VERB = { create: 'create', rename: 'rename', delete: 'delete' };
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The toast for a create / rename / delete result: success text, or the server's message on a
|
|
91
|
+
* refusal (duplicate name in this company, bad name, out of reach), else a fixed fallback.
|
|
92
|
+
* @param {{ ok: boolean, error?: string } | null | undefined} result
|
|
93
|
+
* @param {'create' | 'rename' | 'delete'} action
|
|
94
|
+
* @returns {{ kind: 'success' | 'error', text: string }}
|
|
95
|
+
*/
|
|
96
|
+
export function roleOutcome(result, action) {
|
|
97
|
+
if (!result || !result.ok) {
|
|
98
|
+
return {
|
|
99
|
+
kind: 'error',
|
|
100
|
+
text: (result && result.error) || `Could not ${VERB[action] ?? 'save'} the role.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { kind: 'success', text: `Role ${DONE[action] ?? 'saved'}` };
|
|
104
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// roleRules.test.mjs — G-013: the Roles page's permission keys, role name rule (spec §10), option
|
|
2
|
+
// mapping (names only, never ids) and toast text.
|
|
3
|
+
//
|
|
4
|
+
// node --test src/pages/roles/roleRules.test.mjs
|
|
5
|
+
import { test, describe } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import {
|
|
8
|
+
EMPTY_ROLES_LABEL,
|
|
9
|
+
MAX_ROLE_NAME_LENGTH,
|
|
10
|
+
ROLE_PERMISSIONS,
|
|
11
|
+
canManageRoles,
|
|
12
|
+
nameOptions,
|
|
13
|
+
roleOutcome,
|
|
14
|
+
validateRoleName,
|
|
15
|
+
} from './roleRules.js';
|
|
16
|
+
|
|
17
|
+
describe('permission keys', () => {
|
|
18
|
+
test('match the server role routes', () => {
|
|
19
|
+
assert.deepEqual(ROLE_PERMISSIONS, {
|
|
20
|
+
read: 'identity:role:read',
|
|
21
|
+
create: 'identity:role:create',
|
|
22
|
+
update: 'identity:role:update',
|
|
23
|
+
delete: 'identity:role:delete',
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('canManageRoles', () => {
|
|
29
|
+
const holding =
|
|
30
|
+
(...keys) =>
|
|
31
|
+
(k) =>
|
|
32
|
+
keys.includes(k);
|
|
33
|
+
|
|
34
|
+
test('read only: no write actions', () => {
|
|
35
|
+
assert.deepEqual(canManageRoles(holding('identity:role:read')), {
|
|
36
|
+
read: true,
|
|
37
|
+
create: false,
|
|
38
|
+
rename: false,
|
|
39
|
+
remove: false,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('every key: every action', () => {
|
|
44
|
+
assert.deepEqual(canManageRoles(holding(...Object.values(ROLE_PERMISSIONS))), {
|
|
45
|
+
read: true,
|
|
46
|
+
create: true,
|
|
47
|
+
rename: true,
|
|
48
|
+
remove: true,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a non-boolean answer or no can() denies', () => {
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
canManageRoles(() => 'yes'),
|
|
55
|
+
{
|
|
56
|
+
read: false,
|
|
57
|
+
create: false,
|
|
58
|
+
rename: false,
|
|
59
|
+
remove: false,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
assert.deepEqual(canManageRoles(undefined), {
|
|
63
|
+
read: false,
|
|
64
|
+
create: false,
|
|
65
|
+
rename: false,
|
|
66
|
+
remove: false,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('validateRoleName', () => {
|
|
72
|
+
test('trims, collapses whitespace and returns the stored value', () => {
|
|
73
|
+
assert.deepEqual(validateRoleName(' Shift \t Lead '), { ok: true, value: 'Shift Lead' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('normalises to NFC before measuring', () => {
|
|
77
|
+
const decomposed = 'Café';
|
|
78
|
+
const r = validateRoleName(decomposed);
|
|
79
|
+
assert.equal(r.ok, true);
|
|
80
|
+
assert.equal(r.value, 'Café');
|
|
81
|
+
assert.equal(r.value.length, 4);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('refuses empty and whitespace-only names', () => {
|
|
85
|
+
for (const v of ['', ' ', null, undefined]) {
|
|
86
|
+
assert.deepEqual(validateRoleName(v), { ok: false, error: 'Enter a role name.' });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('refuses C0 and C1 control characters (not whitespace)', () => {
|
|
91
|
+
for (const ch of ['\u0000', '\u0007', '\u001b', '\u007f', '\u0085', '\u009f']) {
|
|
92
|
+
assert.deepEqual(validateRoleName(`Man${ch}ager`), {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: 'The role name cannot contain control characters.',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('whitespace controls (tab, newline) are collapsed before the control check', () => {
|
|
100
|
+
assert.deepEqual(validateRoleName('Shift\nLead'), { ok: true, value: 'Shift Lead' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('allows 150 characters, refuses 151 (code points, not UTF-16 units)', () => {
|
|
104
|
+
assert.equal(MAX_ROLE_NAME_LENGTH, 150);
|
|
105
|
+
assert.equal(validateRoleName('a'.repeat(150)).ok, true);
|
|
106
|
+
assert.deepEqual(validateRoleName('a'.repeat(151)), {
|
|
107
|
+
ok: false,
|
|
108
|
+
error: 'The role name can be at most 150 characters.',
|
|
109
|
+
});
|
|
110
|
+
assert.equal(validateRoleName('\u{1F600}'.repeat(150)).ok, true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('nameOptions', () => {
|
|
115
|
+
test('maps rows to { value: id, label: name }, never showing the id', () => {
|
|
116
|
+
const rows = [
|
|
117
|
+
{ id: 'r-1', fields: { uuid: 'r-1', name: 'Manager', ownerCompanyUuid: 'co-1' } },
|
|
118
|
+
{ id: 'r-2', fields: { uuid: 'r-2', name: '' } },
|
|
119
|
+
{ id: 'r-3' },
|
|
120
|
+
{ fields: { name: 'No id' } },
|
|
121
|
+
null,
|
|
122
|
+
];
|
|
123
|
+
const options = nameOptions(rows);
|
|
124
|
+
assert.deepEqual(options, [
|
|
125
|
+
{ value: 'r-1', label: 'Manager' },
|
|
126
|
+
{ value: 'r-2', label: '(unnamed)' },
|
|
127
|
+
{ value: 'r-3', label: '(unnamed)' },
|
|
128
|
+
]);
|
|
129
|
+
for (const o of options) assert.notEqual(o.label, o.value);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('no rows ⇒ no options', () => {
|
|
133
|
+
assert.deepEqual(nameOptions(undefined), []);
|
|
134
|
+
assert.deepEqual(nameOptions([]), []);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('roleOutcome', () => {
|
|
139
|
+
test('success texts', () => {
|
|
140
|
+
assert.deepEqual(roleOutcome({ ok: true }, 'create'), {
|
|
141
|
+
kind: 'success',
|
|
142
|
+
text: 'Role created',
|
|
143
|
+
});
|
|
144
|
+
assert.deepEqual(roleOutcome({ ok: true }, 'rename'), {
|
|
145
|
+
kind: 'success',
|
|
146
|
+
text: 'Role renamed',
|
|
147
|
+
});
|
|
148
|
+
assert.deepEqual(roleOutcome({ ok: true }, 'delete'), {
|
|
149
|
+
kind: 'success',
|
|
150
|
+
text: 'Role deleted',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('a refusal shows the server message', () => {
|
|
155
|
+
const error = 'A role named "Manager" already exists in this company.';
|
|
156
|
+
assert.deepEqual(roleOutcome({ ok: false, status: 409, error }, 'create'), {
|
|
157
|
+
kind: 'error',
|
|
158
|
+
text: error,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('no message ⇒ the fixed fallback', () => {
|
|
163
|
+
assert.deepEqual(roleOutcome({ ok: false, status: 0 }, 'create'), {
|
|
164
|
+
kind: 'error',
|
|
165
|
+
text: 'Could not create the role.',
|
|
166
|
+
});
|
|
167
|
+
assert.deepEqual(roleOutcome(null, 'delete'), {
|
|
168
|
+
kind: 'error',
|
|
169
|
+
text: 'Could not delete the role.',
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('empty state', () => {
|
|
175
|
+
test('reads "No roles yet"', () => {
|
|
176
|
+
assert.equal(EMPTY_ROLES_LABEL, 'No roles yet');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PolicyFormDialog — add or edit one mandatory-2FA policy (auth capabilities R2.1, R2.2, R2.4).
|
|
3
|
+
*
|
|
4
|
+
* Add: organisation (a company you can see, or the platform minimum), who it covers (everyone /
|
|
5
|
+
* a role / a permission key), and the grace period. Edit: only the role or permission and the
|
|
6
|
+
* grace period change; the organisation and kind are fixed at create (a different one is a delete
|
|
7
|
+
* plus an add), so they are shown as text.
|
|
8
|
+
*
|
|
9
|
+
* Validation lives in `securityPolicies.js`; the server stays authoritative (R2.6: lowering a
|
|
10
|
+
* policy needs more access than tightening it, the platform minimum is root only) and its fixed
|
|
11
|
+
* refusal is shown as the form error. Ids are picker values only; the user sees names.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useState } from 'react';
|
|
14
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
15
|
+
import EntityFormModal from '@/components/EntityFormModal';
|
|
16
|
+
import { Input } from '@/components/ui/input';
|
|
17
|
+
import { Label } from '@/components/ui/label';
|
|
18
|
+
import { SearchableSelect } from '@/components/ui/searchable-select';
|
|
19
|
+
import {
|
|
20
|
+
Select,
|
|
21
|
+
SelectContent,
|
|
22
|
+
SelectItem,
|
|
23
|
+
SelectTrigger,
|
|
24
|
+
SelectValue,
|
|
25
|
+
} from '@/components/ui/select';
|
|
26
|
+
import {
|
|
27
|
+
KIND_OPTIONS,
|
|
28
|
+
MAX_GRACE_DAYS,
|
|
29
|
+
PLATFORM_LABEL,
|
|
30
|
+
PLATFORM_SCOPE,
|
|
31
|
+
describePolicy,
|
|
32
|
+
policyFailureText,
|
|
33
|
+
policyFormFromRow,
|
|
34
|
+
validatePolicyForm,
|
|
35
|
+
} from './securityPolicies';
|
|
36
|
+
|
|
37
|
+
const PLATFORM_OPTION = {
|
|
38
|
+
value: PLATFORM_SCOPE,
|
|
39
|
+
label: `${PLATFORM_LABEL} (platform administrators only)`,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default function PolicyFormDialog({ policy, labels, onClose, onSaved }) {
|
|
43
|
+
const { tokens, identity, profile, securityPolicy } = useAuth();
|
|
44
|
+
const token = tokens?.accessToken;
|
|
45
|
+
const isEdit = Boolean(policy);
|
|
46
|
+
const shown = isEdit ? describePolicy(policy, labels) : null;
|
|
47
|
+
|
|
48
|
+
const [form, setForm] = useState(() =>
|
|
49
|
+
isEdit
|
|
50
|
+
? policyFormFromRow(policy)
|
|
51
|
+
: {
|
|
52
|
+
scope: profile?.company?.id ?? null,
|
|
53
|
+
kind: 'tenant',
|
|
54
|
+
role: null,
|
|
55
|
+
permission: '',
|
|
56
|
+
graceDays: '0',
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
const [scopeLabel, setScopeLabel] = useState(() => profile?.company?.name ?? null);
|
|
60
|
+
const [roleLabel, setRoleLabel] = useState(() => (isEdit ? shown.target : null));
|
|
61
|
+
const [busy, setBusy] = useState(false);
|
|
62
|
+
const [error, setError] = useState('');
|
|
63
|
+
|
|
64
|
+
const set = (patch) => setForm((f) => ({ ...f, ...patch }));
|
|
65
|
+
|
|
66
|
+
const fetchScopes = useCallback(
|
|
67
|
+
async ({ search, page, limit }) => {
|
|
68
|
+
const r = await identity.listCompanies(token, {
|
|
69
|
+
page,
|
|
70
|
+
limit,
|
|
71
|
+
search,
|
|
72
|
+
sortBy: 'name',
|
|
73
|
+
sortOrder: 'asc',
|
|
74
|
+
});
|
|
75
|
+
const companies = r.ok
|
|
76
|
+
? (r.rows || []).map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' }))
|
|
77
|
+
: [];
|
|
78
|
+
// The platform minimum is offered at the top of the first unfiltered page. Only a root
|
|
79
|
+
// account may save it; the server refuses anyone else with a fixed message.
|
|
80
|
+
const withFloor = page === 1 && !search ? [PLATFORM_OPTION, ...companies] : companies;
|
|
81
|
+
return { options: withFloor, total: r.ok ? (r.total ?? 0) : 0, page: r.page ?? page };
|
|
82
|
+
},
|
|
83
|
+
[identity, token],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const fetchRoles = useCallback(
|
|
87
|
+
async ({ search, page, limit }) => {
|
|
88
|
+
const r = await identity.listRoles(token, {
|
|
89
|
+
page,
|
|
90
|
+
limit,
|
|
91
|
+
search,
|
|
92
|
+
sortBy: 'name',
|
|
93
|
+
sortOrder: 'asc',
|
|
94
|
+
});
|
|
95
|
+
if (!r.ok) return { options: [], total: 0, page };
|
|
96
|
+
return {
|
|
97
|
+
options: (r.rows || []).map((row) => ({
|
|
98
|
+
value: row.id,
|
|
99
|
+
label: row.fields?.name || '(unnamed)',
|
|
100
|
+
})),
|
|
101
|
+
total: r.total ?? 0,
|
|
102
|
+
page: r.page ?? page,
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
[identity, token],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const onSubmit = async () => {
|
|
109
|
+
const checked = validatePolicyForm(form, { isEdit });
|
|
110
|
+
if (!checked.ok) {
|
|
111
|
+
setError(checked.error);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!securityPolicy) {
|
|
115
|
+
setError('Security policies are not available in this app.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
setError('');
|
|
119
|
+
setBusy(true);
|
|
120
|
+
const r = isEdit
|
|
121
|
+
? await securityPolicy.update(token, policy.id, checked.body)
|
|
122
|
+
: await securityPolicy.create(token, checked.body);
|
|
123
|
+
setBusy(false);
|
|
124
|
+
if (!r.ok) {
|
|
125
|
+
setError(policyFailureText(r, 'save'));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
onSaved(isEdit ? 'Policy updated' : 'Policy added');
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<EntityFormModal
|
|
133
|
+
title={isEdit ? 'Edit 2FA policy' : 'Add 2FA policy'}
|
|
134
|
+
description="Users this policy covers must set up two-step verification to sign in."
|
|
135
|
+
open
|
|
136
|
+
onClose={onClose}
|
|
137
|
+
onSubmit={onSubmit}
|
|
138
|
+
submitLabel={isEdit ? 'Save changes' : 'Add policy'}
|
|
139
|
+
busy={busy}
|
|
140
|
+
error={error}
|
|
141
|
+
expandable={false}
|
|
142
|
+
>
|
|
143
|
+
<div className="space-y-1.5">
|
|
144
|
+
<Label variant="field">Organisation</Label>
|
|
145
|
+
{isEdit ? (
|
|
146
|
+
<p className="text-sm">{shown.scope}</p>
|
|
147
|
+
) : (
|
|
148
|
+
<SearchableSelect
|
|
149
|
+
value={form.scope}
|
|
150
|
+
selectedLabel={scopeLabel}
|
|
151
|
+
onChange={(scope) => set({ scope })}
|
|
152
|
+
onLabelChange={setScopeLabel}
|
|
153
|
+
fetchPage={fetchScopes}
|
|
154
|
+
placeholder="Select an organisation"
|
|
155
|
+
searchPlaceholder="Search organisations"
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
{!isEdit && form.scope !== PLATFORM_SCOPE && (
|
|
159
|
+
<p className="text-xs text-muted-foreground">
|
|
160
|
+
Applies to this organisation and every organisation under it.
|
|
161
|
+
</p>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div className="space-y-1.5">
|
|
166
|
+
<Label htmlFor="policy-kind" variant="field">
|
|
167
|
+
Who must use 2FA
|
|
168
|
+
</Label>
|
|
169
|
+
{isEdit ? (
|
|
170
|
+
<p className="text-sm">{shown.kind}</p>
|
|
171
|
+
) : (
|
|
172
|
+
<Select value={form.kind} onValueChange={(kind) => set({ kind })}>
|
|
173
|
+
<SelectTrigger id="policy-kind">
|
|
174
|
+
<SelectValue />
|
|
175
|
+
</SelectTrigger>
|
|
176
|
+
<SelectContent>
|
|
177
|
+
{KIND_OPTIONS.map((k) => (
|
|
178
|
+
<SelectItem key={k.value} value={k.value}>
|
|
179
|
+
{k.label}
|
|
180
|
+
</SelectItem>
|
|
181
|
+
))}
|
|
182
|
+
</SelectContent>
|
|
183
|
+
</Select>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{form.kind === 'role' && (
|
|
188
|
+
<div className="space-y-1.5">
|
|
189
|
+
<Label variant="field">Role</Label>
|
|
190
|
+
<SearchableSelect
|
|
191
|
+
value={form.role}
|
|
192
|
+
selectedLabel={roleLabel}
|
|
193
|
+
onChange={(role) => set({ role })}
|
|
194
|
+
onLabelChange={setRoleLabel}
|
|
195
|
+
fetchPage={fetchRoles}
|
|
196
|
+
placeholder="Select a role"
|
|
197
|
+
searchPlaceholder="Search roles"
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{form.kind === 'permission' && (
|
|
203
|
+
<div className="space-y-1.5">
|
|
204
|
+
<Label htmlFor="policy-permission" variant="field">
|
|
205
|
+
Permission
|
|
206
|
+
</Label>
|
|
207
|
+
<Input
|
|
208
|
+
id="policy-permission"
|
|
209
|
+
maxLength={255}
|
|
210
|
+
autoComplete="off"
|
|
211
|
+
spellCheck={false}
|
|
212
|
+
placeholder="e.g. identity:user:update or identity:*"
|
|
213
|
+
value={form.permission}
|
|
214
|
+
onChange={(e) => set({ permission: e.target.value })}
|
|
215
|
+
/>
|
|
216
|
+
<p className="text-xs text-muted-foreground">
|
|
217
|
+
Anyone holding this permission, or any permission a wildcard covers.
|
|
218
|
+
</p>
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
<div className="space-y-1.5">
|
|
223
|
+
<Label htmlFor="policy-grace" variant="field">
|
|
224
|
+
Grace period (days)
|
|
225
|
+
</Label>
|
|
226
|
+
<Input
|
|
227
|
+
id="policy-grace"
|
|
228
|
+
type="number"
|
|
229
|
+
inputMode="numeric"
|
|
230
|
+
min={0}
|
|
231
|
+
max={MAX_GRACE_DAYS}
|
|
232
|
+
step={1}
|
|
233
|
+
value={form.graceDays}
|
|
234
|
+
onChange={(e) => set({ graceDays: e.target.value })}
|
|
235
|
+
/>
|
|
236
|
+
<p className="text-xs text-muted-foreground">
|
|
237
|
+
0 to {MAX_GRACE_DAYS}. Each user gets this many days to set up 2FA from when the policy
|
|
238
|
+
first applies to them. 0 means they must set it up at their next sign-in.
|
|
239
|
+
</p>
|
|
240
|
+
</div>
|
|
241
|
+
</EntityFormModal>
|
|
242
|
+
);
|
|
243
|
+
}
|