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,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
+ }