create-astram-app 0.11.1 → 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 +1 -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,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SecuritySettingsPage — the tenant Security settings page (auth capabilities R11.4, R13.4,
|
|
3
|
+
* AC-26). Phase 1 covers mandatory-2FA policies (R2): list, add, edit, delete. Later phases add
|
|
4
|
+
* more sections here.
|
|
5
|
+
*
|
|
6
|
+
* Gating (UX only; `/identity/security-policies` is `@Classified` per action and the server
|
|
7
|
+
* applies the R2.6 authority rules): the route and nav need `identity:security_policy:read`; Add,
|
|
8
|
+
* Edit and Delete each need their own key. A refusal the UI could not predict (lowering a policy
|
|
9
|
+
* without enough access, the platform minimum for a non-root admin) shows the server's fixed
|
|
10
|
+
* message.
|
|
11
|
+
*
|
|
12
|
+
* Platform-minimum rows are visible to root only; everyone else sees a note that one may also
|
|
13
|
+
* apply.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ No raw ids: the organisation, role and author of each policy are resolved to names
|
|
16
|
+
* (`resolvePolicyLabels`) with "Deleted company" / "Deleted role" / "Unknown user" fallbacks;
|
|
17
|
+
* `row.id` is a key only.
|
|
18
|
+
*/
|
|
19
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
20
|
+
import { Info, Lock } from 'lucide-react';
|
|
21
|
+
import { toast } from 'sonner';
|
|
22
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
23
|
+
import AppShell from '@/shell/AppShell';
|
|
24
|
+
import PageHeader from '@/components/PageHeader';
|
|
25
|
+
import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
|
|
26
|
+
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
27
|
+
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
28
|
+
import { Badge } from '@/components/ui/badge';
|
|
29
|
+
import PolicyFormDialog from './PolicyFormDialog';
|
|
30
|
+
import {
|
|
31
|
+
PLATFORM_LABEL,
|
|
32
|
+
describePolicy,
|
|
33
|
+
policyActions,
|
|
34
|
+
policyFailureText,
|
|
35
|
+
resolvePolicyLabels,
|
|
36
|
+
showFloorNote,
|
|
37
|
+
} from './securityPolicies';
|
|
38
|
+
|
|
39
|
+
const DEFAULT_LIMIT = 25;
|
|
40
|
+
|
|
41
|
+
function columns(labels) {
|
|
42
|
+
const d = (row) => describePolicy(row, labels);
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
key: 'scope',
|
|
46
|
+
label: 'Organisation',
|
|
47
|
+
render: (row) => {
|
|
48
|
+
const scope = d(row).scope;
|
|
49
|
+
return scope === PLATFORM_LABEL ? <Badge variant="info">{scope}</Badge> : scope;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{ key: 'kind', label: 'Who must use 2FA', render: (row) => d(row).kind },
|
|
53
|
+
{
|
|
54
|
+
key: 'target',
|
|
55
|
+
label: 'Role or permission',
|
|
56
|
+
render: (row) =>
|
|
57
|
+
row.fields?.targetKind === 'permission' ? (
|
|
58
|
+
<code className="text-xs">{d(row).target}</code>
|
|
59
|
+
) : (
|
|
60
|
+
d(row).target
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
{ key: 'grace', label: 'Grace period', render: (row) => d(row).grace },
|
|
64
|
+
{ key: 'createdBy', label: 'Added by', render: (row) => d(row).createdBy },
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default function SecuritySettingsPage() {
|
|
69
|
+
const { tokens, identity, securityPolicy, can } = useAuth();
|
|
70
|
+
const token = tokens?.accessToken;
|
|
71
|
+
const actions = policyActions(can);
|
|
72
|
+
|
|
73
|
+
const [rows, setRows] = useState([]);
|
|
74
|
+
const [total, setTotal] = useState(0);
|
|
75
|
+
const [page, setPage] = useState(1);
|
|
76
|
+
const [limit, setLimit] = useState(DEFAULT_LIMIT);
|
|
77
|
+
const [labels, setLabels] = useState(undefined);
|
|
78
|
+
const [loading, setLoading] = useState(true);
|
|
79
|
+
const [error, setError] = useState('');
|
|
80
|
+
|
|
81
|
+
// `null` = closed, `{ policy: null }` = add, `{ policy: row }` = edit.
|
|
82
|
+
const [dialog, setDialog] = useState(null);
|
|
83
|
+
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
84
|
+
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
85
|
+
|
|
86
|
+
const load = useCallback(async () => {
|
|
87
|
+
if (!token) return;
|
|
88
|
+
if (!securityPolicy) {
|
|
89
|
+
setError('Security policies are not available in this app.');
|
|
90
|
+
setLoading(false);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setLoading(true);
|
|
94
|
+
const r = await securityPolicy.list(token, { page, limit });
|
|
95
|
+
if (!r.ok) {
|
|
96
|
+
setRows([]);
|
|
97
|
+
setTotal(0);
|
|
98
|
+
setError(policyFailureText(r, 'load'));
|
|
99
|
+
setLoading(false);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setRows(r.rows);
|
|
103
|
+
setTotal(r.total);
|
|
104
|
+
setError('');
|
|
105
|
+
setLabels(undefined);
|
|
106
|
+
setLoading(false);
|
|
107
|
+
// Names for the ids on this page (one lookup per unique id; fallbacks when gone or walled).
|
|
108
|
+
setLabels(
|
|
109
|
+
await resolvePolicyLabels(r.rows, {
|
|
110
|
+
getCompany: (id) => identity.getCompany(token, id),
|
|
111
|
+
getRole: (id) => identity.getRole(token, id),
|
|
112
|
+
getUser: (id) => identity.getUser(token, id),
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
}, [securityPolicy, identity, token, page, limit]);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
load();
|
|
119
|
+
}, [load]);
|
|
120
|
+
|
|
121
|
+
const cols = useMemo(() => columns(labels), [labels]);
|
|
122
|
+
|
|
123
|
+
const onSaved = (message) => {
|
|
124
|
+
setDialog(null);
|
|
125
|
+
toast.success(message);
|
|
126
|
+
load();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const onConfirmDelete = async () => {
|
|
130
|
+
if (!deleteTarget || !securityPolicy) return;
|
|
131
|
+
setDeleteBusy(true);
|
|
132
|
+
const r = await securityPolicy.remove(token, deleteTarget.id);
|
|
133
|
+
setDeleteBusy(false);
|
|
134
|
+
if (!r.ok) {
|
|
135
|
+
toast.error(policyFailureText(r, 'delete'));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setDeleteTarget(null);
|
|
139
|
+
toast.success('Policy deleted');
|
|
140
|
+
await load();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const deleteShown = deleteTarget ? describePolicy(deleteTarget, labels) : null;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<AppShell section="Settings" title="Security">
|
|
147
|
+
<div className="space-y-4 p-4">
|
|
148
|
+
<PageHeader
|
|
149
|
+
section="Settings"
|
|
150
|
+
title="Security"
|
|
151
|
+
icon={Lock}
|
|
152
|
+
actions={
|
|
153
|
+
<EntityListControls
|
|
154
|
+
addLabel="Add 2FA policy"
|
|
155
|
+
onAdd={actions.add ? () => setDialog({ policy: null }) : undefined}
|
|
156
|
+
/>
|
|
157
|
+
}
|
|
158
|
+
/>
|
|
159
|
+
|
|
160
|
+
<div className="space-y-1">
|
|
161
|
+
<h2 className="text-sm font-semibold">Two-step verification policies</h2>
|
|
162
|
+
<p className="text-sm text-muted-foreground">
|
|
163
|
+
Require two-step verification (2FA) for everyone in an organisation, for holders of a
|
|
164
|
+
role, or for holders of a permission. Users without 2FA can only set it up until they
|
|
165
|
+
do, once their grace period ends.
|
|
166
|
+
</p>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{!loading && !error && showFloorNote(rows) && (
|
|
170
|
+
<Alert>
|
|
171
|
+
<Info className="size-4" />
|
|
172
|
+
<AlertDescription>
|
|
173
|
+
A platform-wide minimum set by the platform administrator may also apply. It is not
|
|
174
|
+
listed here.
|
|
175
|
+
</AlertDescription>
|
|
176
|
+
</Alert>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
<EntityList
|
|
180
|
+
columns={cols}
|
|
181
|
+
rows={rows}
|
|
182
|
+
loading={loading}
|
|
183
|
+
error={error}
|
|
184
|
+
emptyLabel="No 2FA policies yet. Users choose for themselves whether to use 2FA."
|
|
185
|
+
onEdit={actions.edit ? (row) => setDialog({ policy: row }) : undefined}
|
|
186
|
+
onDelete={actions.remove ? (row) => setDeleteTarget(row) : undefined}
|
|
187
|
+
/>
|
|
188
|
+
|
|
189
|
+
<EntityListPager
|
|
190
|
+
total={total}
|
|
191
|
+
page={page}
|
|
192
|
+
limit={limit}
|
|
193
|
+
loading={loading}
|
|
194
|
+
error={error}
|
|
195
|
+
onPageChange={setPage}
|
|
196
|
+
onLimitChange={(l) => {
|
|
197
|
+
setLimit(l);
|
|
198
|
+
setPage(1);
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{dialog && (
|
|
204
|
+
<PolicyFormDialog
|
|
205
|
+
key={dialog.policy?.id ?? 'new'}
|
|
206
|
+
policy={dialog.policy}
|
|
207
|
+
labels={labels}
|
|
208
|
+
onClose={() => setDialog(null)}
|
|
209
|
+
onSaved={onSaved}
|
|
210
|
+
/>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
<ConfirmDialog
|
|
214
|
+
open={!!deleteTarget}
|
|
215
|
+
title="Delete this 2FA policy?"
|
|
216
|
+
message={
|
|
217
|
+
deleteShown
|
|
218
|
+
? `${deleteShown.kind} (${deleteShown.scope}) will no longer need 2FA because of this policy. Other policies still apply. This cannot be undone.`
|
|
219
|
+
: ''
|
|
220
|
+
}
|
|
221
|
+
confirmLabel="Delete"
|
|
222
|
+
destructive
|
|
223
|
+
busy={deleteBusy}
|
|
224
|
+
onConfirm={onConfirmDelete}
|
|
225
|
+
onCancel={() => setDeleteTarget(null)}
|
|
226
|
+
/>
|
|
227
|
+
</AppShell>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// securityPolicies.js — the Security settings page's mandatory-2FA policies (auth capabilities
|
|
2
|
+
// R2.1, R2.2, R2.6, R2.9, R11.4, R13.4, AC-26). Pure logic, tested by securityPolicies.test.mjs;
|
|
3
|
+
// SecuritySettingsPage renders from it.
|
|
4
|
+
//
|
|
5
|
+
// Hiding an action is UX only: `/identity/security-policies` is `@Classified` per action and the
|
|
6
|
+
// service applies the R2.6 authority rules (tighten vs lower, platform floor root only). A policy
|
|
7
|
+
// carries ids (company node, role, author); they are resolved to names here, with a fallback label
|
|
8
|
+
// for a deleted or unreadable record, and never rendered.
|
|
9
|
+
|
|
10
|
+
/** The permission keys the routes are gated on. */
|
|
11
|
+
export const SECURITY_POLICY_PERMISSIONS = {
|
|
12
|
+
read: 'identity:security_policy:read',
|
|
13
|
+
create: 'identity:security_policy:create',
|
|
14
|
+
update: 'identity:security_policy:update',
|
|
15
|
+
delete: 'identity:security_policy:delete',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Who a policy covers. */
|
|
19
|
+
export const KIND_OPTIONS = [
|
|
20
|
+
{ value: 'tenant', label: 'Everyone in this organisation' },
|
|
21
|
+
{ value: 'role', label: 'Holders of a role' },
|
|
22
|
+
{ value: 'permission', label: 'Holders of a permission' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** The longest grace period a policy can give (R2.4). */
|
|
26
|
+
export const MAX_GRACE_DAYS = 14;
|
|
27
|
+
|
|
28
|
+
/** The scope picker's value for the root-only platform minimum (not a company id). */
|
|
29
|
+
export const PLATFORM_SCOPE = '__platform__';
|
|
30
|
+
|
|
31
|
+
export const PLATFORM_LABEL = 'Platform minimum';
|
|
32
|
+
|
|
33
|
+
const FALLBACK = {
|
|
34
|
+
company: 'Deleted company',
|
|
35
|
+
role: 'Deleted role',
|
|
36
|
+
user: 'Unknown user',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const PENDING = '…';
|
|
40
|
+
|
|
41
|
+
// Mirror of core `isSecurityPolicyPermissionTarget`: a key `feature:object:action`, or a grant
|
|
42
|
+
// wildcard `feature`, `feature:*`, `feature:object:*`.
|
|
43
|
+
const SEGMENT = '[a-z][a-z0-9_]*';
|
|
44
|
+
const PERMISSION_TARGET = new RegExp(
|
|
45
|
+
`^${SEGMENT}(?::\\*|:${SEGMENT}:\\*|:${SEGMENT}:${SEGMENT})?$`,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
/** True for a permission key or grant wildcard the server accepts as a policy target. */
|
|
49
|
+
export function isPermissionTarget(key) {
|
|
50
|
+
return typeof key === 'string' && key.length <= 255 && PERMISSION_TARGET.test(key);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const KIND_LABEL = Object.fromEntries(KIND_OPTIONS.map((k) => [k.value, k.label]));
|
|
54
|
+
|
|
55
|
+
/** Which actions to show, from `can(permissionKey)`. */
|
|
56
|
+
export function policyActions(can) {
|
|
57
|
+
return {
|
|
58
|
+
add: can(SECURITY_POLICY_PERMISSIONS.create),
|
|
59
|
+
edit: can(SECURITY_POLICY_PERMISSIONS.update),
|
|
60
|
+
remove: can(SECURITY_POLICY_PERMISSIONS.delete),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isPlatform(row) {
|
|
65
|
+
return row?.fields?.platform === true || row?.fields?.platform === 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when a platform-minimum row is listed; the server shows those to root only. */
|
|
69
|
+
export function hasPlatformRow(rows) {
|
|
70
|
+
return Array.isArray(rows) && rows.some(isPlatform);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Show the "a platform minimum may also apply" note (non-root callers cannot see floor rows). */
|
|
74
|
+
export function showFloorNote(rows) {
|
|
75
|
+
return !hasPlatformRow(rows);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The form state for a stored policy (edit). */
|
|
79
|
+
export function policyFormFromRow(row) {
|
|
80
|
+
const f = row?.fields ?? {};
|
|
81
|
+
const kind = typeof f.targetKind === 'string' ? f.targetKind : 'tenant';
|
|
82
|
+
return {
|
|
83
|
+
scope: isPlatform(row) ? PLATFORM_SCOPE : (f.scopeNodeUuid ?? null),
|
|
84
|
+
kind,
|
|
85
|
+
role: kind === 'role' ? (f.targetValue ?? null) : null,
|
|
86
|
+
permission: kind === 'permission' ? (f.targetValue ?? '') : '',
|
|
87
|
+
graceDays: String(Number.isInteger(f.graceDays) ? f.graceDays : 0),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const GRACE_ERROR = `Grace period must be a whole number of days from 0 to ${MAX_GRACE_DAYS}.`;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check the form and build the request body: the create body, or on edit only the fields the
|
|
95
|
+
* server lets change (target value and grace; scope and kind are fixed at create).
|
|
96
|
+
* @param {{ scope: string | null, kind: string, role: string | null, permission: string, graceDays: string }} form
|
|
97
|
+
* @param {{ isEdit: boolean }} opts
|
|
98
|
+
* @returns {{ ok: true, body: object } | { ok: false, error: string }}
|
|
99
|
+
*/
|
|
100
|
+
export function validatePolicyForm(form, { isEdit }) {
|
|
101
|
+
const { scope, kind } = form;
|
|
102
|
+
if (!KIND_LABEL[kind]) return { ok: false, error: 'Choose who the policy covers.' };
|
|
103
|
+
if (!isEdit && !scope) return { ok: false, error: 'Choose an organisation.' };
|
|
104
|
+
|
|
105
|
+
let targetValue;
|
|
106
|
+
if (kind === 'role') {
|
|
107
|
+
if (!form.role) return { ok: false, error: 'Choose a role.' };
|
|
108
|
+
targetValue = form.role;
|
|
109
|
+
} else if (kind === 'permission') {
|
|
110
|
+
const key = String(form.permission ?? '').trim();
|
|
111
|
+
if (!isPermissionTarget(key)) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error:
|
|
115
|
+
'Enter a permission key such as identity:user:update, or a wildcard such as identity:*.',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
targetValue = key;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const graceText = String(form.graceDays ?? '').trim();
|
|
122
|
+
const graceDays = /^\d+$/.test(graceText) ? Number(graceText) : NaN;
|
|
123
|
+
if (!Number.isInteger(graceDays) || graceDays < 0 || graceDays > MAX_GRACE_DAYS) {
|
|
124
|
+
return { ok: false, error: GRACE_ERROR };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (isEdit) {
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
body: { ...(targetValue !== undefined ? { targetValue } : {}), graceDays },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
body: {
|
|
136
|
+
...(scope === PLATFORM_SCOPE ? { platform: true } : { scopeNodeUuid: scope }),
|
|
137
|
+
targetKind: kind,
|
|
138
|
+
...(targetValue !== undefined ? { targetValue } : {}),
|
|
139
|
+
graceDays,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The unique company, role and user ids on these rows (a permission key is not an id). */
|
|
145
|
+
export function collectLabelIds(rows) {
|
|
146
|
+
const companies = new Set();
|
|
147
|
+
const roles = new Set();
|
|
148
|
+
const users = new Set();
|
|
149
|
+
for (const row of rows || []) {
|
|
150
|
+
const f = row?.fields ?? {};
|
|
151
|
+
if (!isPlatform(row) && typeof f.scopeNodeUuid === 'string' && f.scopeNodeUuid) {
|
|
152
|
+
companies.add(f.scopeNodeUuid);
|
|
153
|
+
}
|
|
154
|
+
if (f.targetKind === 'role' && typeof f.targetValue === 'string' && f.targetValue) {
|
|
155
|
+
roles.add(f.targetValue);
|
|
156
|
+
}
|
|
157
|
+
if (typeof f.createdBy === 'string' && f.createdBy) users.add(f.createdBy);
|
|
158
|
+
}
|
|
159
|
+
return { companies, roles, users };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function labelOf(lookup, id, pick, fallback) {
|
|
163
|
+
try {
|
|
164
|
+
const r = await lookup(id);
|
|
165
|
+
const name = r && r.ok && r.record ? pick(r.record.fields ?? {}) : '';
|
|
166
|
+
return typeof name === 'string' && name.trim() !== '' ? name : fallback;
|
|
167
|
+
} catch {
|
|
168
|
+
return fallback;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function resolveAll(ids, lookup, pick, fallback) {
|
|
173
|
+
const out = new Map();
|
|
174
|
+
await Promise.all(
|
|
175
|
+
[...ids].map(async (id) => out.set(id, await labelOf(lookup, id, pick, fallback))),
|
|
176
|
+
);
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve every id on these rows to a name, one lookup per unique id (the page lists at most one
|
|
182
|
+
* page of policies). A record that is gone, walled or unreadable gets the fallback label.
|
|
183
|
+
* @param {Array<{ fields?: object }>} rows
|
|
184
|
+
* @param {{ getCompany: Function, getRole: Function, getUser: Function }} lookups each `(id) =>
|
|
185
|
+
* Promise<{ ok, record }>` (frontend-core `identity.getCompany/getRole/getUser` bound to a token)
|
|
186
|
+
*/
|
|
187
|
+
export async function resolvePolicyLabels(rows, { getCompany, getRole, getUser }) {
|
|
188
|
+
const ids = collectLabelIds(rows);
|
|
189
|
+
const [companies, roles, users] = await Promise.all([
|
|
190
|
+
resolveAll(ids.companies, getCompany, (f) => f.name, FALLBACK.company),
|
|
191
|
+
resolveAll(ids.roles, getRole, (f) => f.name, FALLBACK.role),
|
|
192
|
+
resolveAll(ids.users, getUser, (f) => f.username || f.email, FALLBACK.user),
|
|
193
|
+
]);
|
|
194
|
+
return { companies, roles, users };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function label(map, id, fallback) {
|
|
198
|
+
if (!map) return PENDING;
|
|
199
|
+
if (typeof id !== 'string' || id === '') return fallback;
|
|
200
|
+
return map.get(id) ?? fallback;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function graceText(days) {
|
|
204
|
+
const n = Number(days);
|
|
205
|
+
if (!Number.isInteger(n) || n <= 0) return 'None';
|
|
206
|
+
return n === 1 ? '1 day' : `${n} days`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The display strings for one policy row — names only, never an id.
|
|
211
|
+
* @param {{ fields?: object }} row
|
|
212
|
+
* @param {{ companies: Map, roles: Map, users: Map } | undefined} labels undefined while loading
|
|
213
|
+
*/
|
|
214
|
+
export function describePolicy(row, labels) {
|
|
215
|
+
const f = row?.fields ?? {};
|
|
216
|
+
const kind = f.targetKind;
|
|
217
|
+
let target;
|
|
218
|
+
if (kind === 'tenant') target = 'Everyone';
|
|
219
|
+
else if (kind === 'role') target = label(labels?.roles, f.targetValue, FALLBACK.role);
|
|
220
|
+
else target = typeof f.targetValue === 'string' ? f.targetValue : '—';
|
|
221
|
+
return {
|
|
222
|
+
scope: isPlatform(row)
|
|
223
|
+
? PLATFORM_LABEL
|
|
224
|
+
: label(labels?.companies, f.scopeNodeUuid, FALLBACK.company),
|
|
225
|
+
kind: KIND_LABEL[kind] ?? '—',
|
|
226
|
+
target,
|
|
227
|
+
grace: graceText(f.graceDays),
|
|
228
|
+
createdBy: label(labels?.users, f.createdBy, FALLBACK.user),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const GENERIC = {
|
|
233
|
+
load: 'Could not load security policies.',
|
|
234
|
+
save: 'Could not save the policy.',
|
|
235
|
+
delete: 'Could not delete the policy.',
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The text for a failed service call. The service already passes the server's fixed 400/403/409
|
|
240
|
+
* message through (e.g. "Only a platform administrator can set a platform-wide policy.").
|
|
241
|
+
* @param {{ error?: string } | undefined} result
|
|
242
|
+
* @param {'load' | 'save' | 'delete'} verb
|
|
243
|
+
*/
|
|
244
|
+
export function policyFailureText(result, verb) {
|
|
245
|
+
return (result && result.error) || GENERIC[verb];
|
|
246
|
+
}
|