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