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,91 @@
1
+ // moveAction.test.mjs — G-011: the Companies page shows "Move under…" only to callers holding
2
+ // identity:company:update (UX only; the server decides), offers the other visible companies by
3
+ // name after "Top level" (labelled for platform admins; the server refuses it for anyone else), and
4
+ // toasts the server's reason on a refusal.
5
+ //
6
+ // node --test src/pages/companies/moveAction.test.mjs
7
+ import { test, describe } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import {
10
+ MOVE_PERMISSION,
11
+ TOP_LEVEL,
12
+ TOP_LEVEL_LABEL,
13
+ canMove,
14
+ parentOptions,
15
+ parentUuidOf,
16
+ moveOutcome,
17
+ } from './moveAction.js';
18
+
19
+ const ROWS = [
20
+ { id: 'c-b', fields: { name: 'Beta' } },
21
+ { id: 'c-a', fields: { name: 'Alpha' } },
22
+ { id: 'c-x', fields: {} },
23
+ ];
24
+
25
+ describe('canMove', () => {
26
+ test('the permission key is identity:company:update', () => {
27
+ assert.equal(MOVE_PERMISSION, 'identity:company:update');
28
+ });
29
+
30
+ test('shown for a row when the caller holds the key', () => {
31
+ assert.equal(canMove({ id: 'c-a' }, true), true);
32
+ });
33
+
34
+ test('hidden without the permission, or without a row', () => {
35
+ assert.equal(canMove({ id: 'c-a' }, false), false);
36
+ assert.equal(canMove(null, true), false);
37
+ assert.equal(canMove({}, true), false);
38
+ });
39
+ });
40
+
41
+ describe('parentOptions', () => {
42
+ test('"Top level" first, then every other visible company by name, sorted; never the moving one', () => {
43
+ assert.deepEqual(parentOptions(ROWS, 'c-b'), [
44
+ { value: TOP_LEVEL, label: 'Top level (platform admins only)' },
45
+ { value: 'c-x', label: '(unnamed)' },
46
+ { value: 'c-a', label: 'Alpha' },
47
+ ]);
48
+ });
49
+
50
+ test('the "Top level" label says who may use it', () => {
51
+ assert.equal(TOP_LEVEL_LABEL, 'Top level (platform admins only)');
52
+ });
53
+
54
+ test('labels are names, never ids', () => {
55
+ for (const o of parentOptions(ROWS, 'none')) assert.notEqual(o.label, o.value);
56
+ });
57
+
58
+ test('no rows → only "Top level"', () => {
59
+ assert.deepEqual(parentOptions(undefined, 'c-a'), [
60
+ { value: TOP_LEVEL, label: TOP_LEVEL_LABEL },
61
+ ]);
62
+ });
63
+ });
64
+
65
+ describe('parentUuidOf', () => {
66
+ test('"Top level" is sent as null; a company as its uuid', () => {
67
+ assert.equal(parentUuidOf(TOP_LEVEL), null);
68
+ assert.equal(parentUuidOf('c-a'), 'c-a');
69
+ });
70
+ });
71
+
72
+ describe('moveOutcome', () => {
73
+ test('success → "Company moved"', () => {
74
+ assert.deepEqual(moveOutcome({ ok: true }), { kind: 'success', text: 'Company moved' });
75
+ });
76
+
77
+ test('a refusal → the server message', () => {
78
+ assert.deepEqual(moveOutcome({ ok: false, error: 'That move would create a cycle.' }), {
79
+ kind: 'error',
80
+ text: 'That move would create a cycle.',
81
+ });
82
+ });
83
+
84
+ test('no message → a fixed fallback', () => {
85
+ assert.deepEqual(moveOutcome({ ok: false }), {
86
+ kind: 'error',
87
+ text: 'Could not move the company.',
88
+ });
89
+ assert.deepEqual(moveOutcome(null), { kind: 'error', text: 'Could not move the company.' });
90
+ });
91
+ });
@@ -0,0 +1,408 @@
1
+ /**
2
+ * OrgStructurePage — a company's departments and branches (G-012). Pick a company from the
3
+ * caller's reachable companies (`identity.listCompanies`), then manage each list: Add, Rename and
4
+ * Delete (themed ConfirmDialog; the API call runs only from its confirm handler).
5
+ *
6
+ * Each company owns its departments and branches; the server walls every route to the caller's
7
+ * subtree (404 outside it) and decides every write. Buttons show only for callers holding the
8
+ * write keys (`orgStructure.js`; UX only). "Used by N assignments" disables Delete as a hint; the
9
+ * server still refuses (409) a delete while any assignment uses the row, and that message is
10
+ * toasted. Names come from the server; no uuid is ever shown.
11
+ */
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
+ import { MapPin, Network, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
14
+ import { toast } from 'sonner';
15
+ import { useAuth } from '@/auth/AuthContext';
16
+ import AppShell from '@/shell/AppShell';
17
+ import PageHeader from '@/components/PageHeader';
18
+ import EntityList from '@/components/EntityList';
19
+ import ConfirmDialog from '@/components/ConfirmDialog';
20
+ import { Button } from '@/components/ui/button';
21
+ import { Input } from '@/components/ui/input';
22
+ import { Label } from '@/components/ui/label';
23
+ import { SearchableSelect } from '@/components/ui/searchable-select';
24
+ import {
25
+ KINDS,
26
+ LABELS,
27
+ canManage,
28
+ deleteHint,
29
+ displayRows,
30
+ outcome,
31
+ usedByLabel,
32
+ validateName,
33
+ } from './orgStructure';
34
+
35
+ /** The identity-service calls per kind. */
36
+ const CALLS = {
37
+ department: {
38
+ list: (identity, ...a) => identity.listDepartments(...a),
39
+ create: (identity, ...a) => identity.createDepartment(...a),
40
+ rename: (identity, ...a) => identity.renameDepartment(...a),
41
+ remove: (identity, ...a) => identity.deleteDepartment(...a),
42
+ },
43
+ branch: {
44
+ list: (identity, ...a) => identity.listBranches(...a),
45
+ create: (identity, ...a) => identity.createBranch(...a),
46
+ rename: (identity, ...a) => identity.renameBranch(...a),
47
+ remove: (identity, ...a) => identity.deleteBranch(...a),
48
+ },
49
+ };
50
+
51
+ const ICONS = { department: Network, branch: MapPin };
52
+
53
+ /**
54
+ * The Add / Rename dialog: one name field, checked with the server's rules before sending.
55
+ * Mounted only while open, so each opening starts from the current name (rename) or empty (add).
56
+ */
57
+ function NameDialog({ dialog, busy, onSubmit, onCancel }) {
58
+ const [name, setName] = useState(dialog.row ? dialog.row.label : '');
59
+ const [error, setError] = useState('');
60
+ const fieldId = `org-${dialog.kind}-name`;
61
+ const one = LABELS[dialog.kind].one;
62
+ const submit = () => {
63
+ const checked = validateName(name);
64
+ if (!checked.ok) {
65
+ setError(checked.error);
66
+ return;
67
+ }
68
+ setError('');
69
+ onSubmit(checked.value);
70
+ };
71
+
72
+ return (
73
+ <ConfirmDialog
74
+ open
75
+ title={dialog.row ? `Rename “${dialog.row.label}”` : `Add a ${one}`}
76
+ message={dialog.row ? `Enter the new name for this ${one}.` : `Name the new ${one}.`}
77
+ confirmLabel={dialog.row ? 'Rename' : 'Add'}
78
+ busy={busy}
79
+ onConfirm={submit}
80
+ onCancel={onCancel}
81
+ >
82
+ <div className="space-y-1.5">
83
+ <Label htmlFor={fieldId} variant="field">
84
+ Name
85
+ </Label>
86
+ <Input
87
+ id={fieldId}
88
+ value={name}
89
+ onChange={(e) => setName(e.target.value)}
90
+ maxLength={300}
91
+ autoFocus
92
+ aria-invalid={!!error}
93
+ aria-describedby={error ? `${fieldId}-error` : undefined}
94
+ />
95
+ {error && (
96
+ <p id={`${fieldId}-error`} className="text-xs text-destructive">
97
+ {error}
98
+ </p>
99
+ )}
100
+ </div>
101
+ </ConfirmDialog>
102
+ );
103
+ }
104
+
105
+ /** One list (Departments or Branches) for the chosen company. */
106
+ function OrgUnitList({ kind, companyUuid, perms, token, identity }) {
107
+ const labels = LABELS[kind];
108
+ const Icon = ICONS[kind];
109
+ const [rows, setRows] = useState([]);
110
+ const [loading, setLoading] = useState(false);
111
+ const [error, setError] = useState('');
112
+ const [dialog, setDialog] = useState(null);
113
+ const [dialogBusy, setDialogBusy] = useState(false);
114
+ const [deleteTarget, setDeleteTarget] = useState(null);
115
+ const [deleteBusy, setDeleteBusy] = useState(false);
116
+ const request = useRef(0);
117
+
118
+ const load = useCallback(async () => {
119
+ if (!token || !companyUuid || !perms.read) return;
120
+ const mine = ++request.current;
121
+ setLoading(true);
122
+ const r = await CALLS[kind].list(identity, token, companyUuid);
123
+ if (mine !== request.current) return; // a newer company or reload superseded this one
124
+ if (r.ok) {
125
+ setRows(displayRows(r.rows));
126
+ setError('');
127
+ } else {
128
+ setRows([]);
129
+ setError(r.error || `Could not load ${labels.title.toLowerCase()}.`);
130
+ }
131
+ setLoading(false);
132
+ }, [identity, token, companyUuid, kind, perms.read, labels.title]);
133
+
134
+ useEffect(() => {
135
+ load();
136
+ }, [load]);
137
+
138
+ const onSubmitName = async (name) => {
139
+ if (!dialog) return;
140
+ setDialogBusy(true);
141
+ const r = dialog.row
142
+ ? await CALLS[kind].rename(identity, token, dialog.row.uuid, name)
143
+ : await CALLS[kind].create(identity, token, companyUuid, name);
144
+ setDialogBusy(false);
145
+ const o = outcome(r, kind, dialog.row ? 'rename' : 'create');
146
+ if (o.kind === 'error') {
147
+ toast.error(o.text);
148
+ return;
149
+ }
150
+ setDialog(null);
151
+ toast.success(o.text);
152
+ await load();
153
+ };
154
+
155
+ const onConfirmDelete = async () => {
156
+ if (!deleteTarget) return;
157
+ setDeleteBusy(true);
158
+ const o = outcome(await CALLS[kind].remove(identity, token, deleteTarget.uuid), kind, 'delete');
159
+ setDeleteBusy(false);
160
+ if (o.kind === 'error') {
161
+ toast.error(o.text);
162
+ return;
163
+ }
164
+ setDeleteTarget(null);
165
+ toast.success(o.text);
166
+ await load();
167
+ };
168
+
169
+ const columns = [
170
+ { key: 'name', label: 'Name', render: (row) => row.fields.name },
171
+ { key: 'usedBy', label: 'Used by', render: (row) => usedByLabel(row.unit) || '—' },
172
+ ];
173
+ if (perms.rename || perms.remove) {
174
+ columns.push({
175
+ key: 'actions',
176
+ label: 'Actions',
177
+ render: (row) => {
178
+ const hint = deleteHint(row.unit);
179
+ return (
180
+ <div className="flex items-center justify-end gap-1">
181
+ {perms.rename && (
182
+ <Button
183
+ type="button"
184
+ variant="ghost"
185
+ size="sm"
186
+ onClick={() => setDialog({ kind, row: row.unit })}
187
+ aria-label={`Rename ${row.unit.label}`}
188
+ >
189
+ <Pencil className="size-4" />
190
+ Rename
191
+ </Button>
192
+ )}
193
+ {perms.remove && (
194
+ <Button
195
+ type="button"
196
+ variant="ghost"
197
+ size="sm"
198
+ className="text-destructive hover:bg-destructive/10 hover:text-destructive"
199
+ disabled={hint.disabled}
200
+ title={hint.hint || undefined}
201
+ onClick={() => setDeleteTarget(row.unit)}
202
+ aria-label={
203
+ hint.disabled
204
+ ? `Delete ${row.unit.label} (${hint.hint})`
205
+ : `Delete ${row.unit.label}`
206
+ }
207
+ >
208
+ <Trash2 className="size-4" />
209
+ Delete
210
+ </Button>
211
+ )}
212
+ </div>
213
+ );
214
+ },
215
+ });
216
+ }
217
+ // EntityList's row shape: `id` is a key only, `fields.name` the label.
218
+ const listRows = rows.map((unit) => ({ id: unit.uuid, fields: { name: unit.label }, unit }));
219
+ const headingId = `org-${kind}-heading`;
220
+
221
+ return (
222
+ <section aria-labelledby={headingId} className="min-w-0 space-y-3">
223
+ <div className="flex items-center justify-between gap-2">
224
+ <h2 id={headingId} className="flex items-center gap-2 text-base font-semibold">
225
+ <Icon className="size-4 text-primary" aria-hidden="true" />
226
+ {labels.title}
227
+ </h2>
228
+ <div className="flex items-center gap-2">
229
+ {error && perms.read && (
230
+ <Button type="button" variant="outline" size="sm" onClick={load} disabled={loading}>
231
+ <RefreshCw className="size-4" />
232
+ Retry
233
+ </Button>
234
+ )}
235
+ {perms.create && perms.read && (
236
+ <Button
237
+ type="button"
238
+ size="sm"
239
+ onClick={() => setDialog({ kind, row: null })}
240
+ disabled={loading || !!error}
241
+ >
242
+ <Plus className="size-4" />
243
+ Add {labels.one}
244
+ </Button>
245
+ )}
246
+ </div>
247
+ </div>
248
+
249
+ {perms.read ? (
250
+ <EntityList
251
+ columns={columns}
252
+ rows={listRows}
253
+ loading={loading}
254
+ error={error}
255
+ emptyLabel={labels.empty}
256
+ />
257
+ ) : (
258
+ <p className="rounded-xl border p-6 text-sm text-muted-foreground">
259
+ You do not have access to {labels.title.toLowerCase()}.
260
+ </p>
261
+ )}
262
+
263
+ {dialog && (
264
+ <NameDialog
265
+ dialog={dialog}
266
+ busy={dialogBusy}
267
+ onSubmit={onSubmitName}
268
+ onCancel={() => setDialog(null)}
269
+ />
270
+ )}
271
+
272
+ <ConfirmDialog
273
+ open={!!deleteTarget}
274
+ title={deleteTarget ? `Delete “${deleteTarget.label}”?` : `Delete ${labels.one}?`}
275
+ message={`This removes the ${labels.one} from this company. It cannot be undone.`}
276
+ confirmLabel="Delete"
277
+ destructive
278
+ busy={deleteBusy}
279
+ onConfirm={onConfirmDelete}
280
+ onCancel={() => setDeleteTarget(null)}
281
+ />
282
+ </section>
283
+ );
284
+ }
285
+
286
+ export default function OrgStructurePage() {
287
+ const { tokens, identity, can } = useAuth();
288
+ const token = tokens?.accessToken;
289
+ // The company whose units are shown: `{ value: uuid, label: name }`; the uuid is never shown.
290
+ const [company, setCompany] = useState(null);
291
+ const [companyError, setCompanyError] = useState('');
292
+ const [companiesLoaded, setCompaniesLoaded] = useState(false);
293
+
294
+ // The caller's reachable companies, searchable and paged by the server, sorted by name.
295
+ const fetchCompanies = useCallback(
296
+ async ({ search, page, limit }) => {
297
+ const r = await identity.listCompanies(token, {
298
+ page,
299
+ limit,
300
+ search,
301
+ sortBy: 'name',
302
+ sortOrder: 'asc',
303
+ });
304
+ if (!r.ok) return { options: [], total: 0, page };
305
+ return {
306
+ options: (r.rows || [])
307
+ .filter((row) => row && row.id)
308
+ .map((row) => ({ value: row.id, label: row.fields?.name || '(unnamed)' })),
309
+ total: r.total ?? 0,
310
+ page: r.page ?? page,
311
+ };
312
+ },
313
+ [identity, token],
314
+ );
315
+
316
+ // Start on the first reachable company by name, so a one-company admin sees it at once.
317
+ const loadFirstCompany = useCallback(async () => {
318
+ if (!token) return;
319
+ const r = await identity.listCompanies(token, {
320
+ page: 1,
321
+ limit: 1,
322
+ sortBy: 'name',
323
+ sortOrder: 'asc',
324
+ });
325
+ setCompaniesLoaded(true);
326
+ if (!r.ok) {
327
+ setCompanyError(r.error || 'Could not load companies.');
328
+ return;
329
+ }
330
+ setCompanyError('');
331
+ const first = (r.rows || []).find((row) => row && row.id);
332
+ if (first) {
333
+ setCompany(
334
+ (current) => current ?? { value: first.id, label: first.fields?.name || '(unnamed)' },
335
+ );
336
+ }
337
+ }, [identity, token]);
338
+
339
+ useEffect(() => {
340
+ loadFirstCompany();
341
+ }, [loadFirstCompany]);
342
+
343
+ const onCompanyChange = (value) => {
344
+ if (!value) {
345
+ setCompany(null);
346
+ return;
347
+ }
348
+ setCompany((current) => (current?.value === value ? current : { value, label: '' }));
349
+ };
350
+ const onCompanyLabel = (label) =>
351
+ setCompany((current) => (current && label ? { ...current, label } : current));
352
+
353
+ return (
354
+ <AppShell section="Identity" title="Org structure">
355
+ <div className="space-y-4 p-4">
356
+ <PageHeader section="Identity" title="Org structure" icon={Network} />
357
+
358
+ <div className="max-w-md space-y-1.5">
359
+ <Label htmlFor="org-company" variant="field">
360
+ Company
361
+ </Label>
362
+ <SearchableSelect
363
+ id="org-company"
364
+ aria-label="Company"
365
+ value={company?.value ?? null}
366
+ selectedLabel={company?.label || null}
367
+ onChange={onCompanyChange}
368
+ onLabelChange={onCompanyLabel}
369
+ fetchPage={fetchCompanies}
370
+ placeholder="Select a company"
371
+ searchPlaceholder="Search companies"
372
+ />
373
+ {companyError && (
374
+ <div className="flex items-center gap-2 text-sm text-destructive">
375
+ <span>{companyError}</span>
376
+ <Button type="button" variant="outline" size="sm" onClick={loadFirstCompany}>
377
+ <RefreshCw className="size-4" />
378
+ Retry
379
+ </Button>
380
+ </div>
381
+ )}
382
+ </div>
383
+
384
+ {company ? (
385
+ <div className="grid gap-6 lg:grid-cols-2">
386
+ {KINDS.map((kind) => (
387
+ <OrgUnitList
388
+ key={`${kind}:${company.value}`}
389
+ kind={kind}
390
+ companyUuid={company.value}
391
+ perms={canManage(can, kind)}
392
+ token={token}
393
+ identity={identity}
394
+ />
395
+ ))}
396
+ </div>
397
+ ) : (
398
+ companiesLoaded &&
399
+ !companyError && (
400
+ <p className="text-sm text-muted-foreground">
401
+ Choose a company to see its departments and branches.
402
+ </p>
403
+ )
404
+ )}
405
+ </div>
406
+ </AppShell>
407
+ );
408
+ }
@@ -0,0 +1,140 @@
1
+ // orgStructure.js — the Org structure page's pure logic (G-012): permission keys, the name rules,
2
+ // the delete hint and the toast text. Tested by orgStructure.test.mjs; OrgStructurePage renders
3
+ // from it. Showing, hiding or disabling a button is UX only: the server decides every write
4
+ // (permission, subtree wall, duplicate name, in-use check).
5
+
6
+ /** The two org-unit kinds the page manages. */
7
+ export const KINDS = ['department', 'branch'];
8
+
9
+ /** Permission keys per kind: the list route's read key and the three write keys. */
10
+ export const PERMISSIONS = {
11
+ department: {
12
+ read: 'identity:company_department:read',
13
+ create: 'identity:department:create',
14
+ update: 'identity:department:update',
15
+ delete: 'identity:department:delete',
16
+ },
17
+ branch: {
18
+ read: 'identity:company_branch:read',
19
+ create: 'identity:branch:create',
20
+ update: 'identity:branch:update',
21
+ delete: 'identity:branch:delete',
22
+ },
23
+ };
24
+
25
+ /** Labels per kind, for headings, buttons, empty states and toasts. */
26
+ export const LABELS = {
27
+ department: { one: 'department', title: 'Departments', empty: 'No departments yet' },
28
+ branch: { one: 'branch', title: 'Branches', empty: 'No branches yet' },
29
+ };
30
+
31
+ /** The longest name the server stores (column width). */
32
+ export const MAX_NAME_LENGTH = 150;
33
+
34
+ /**
35
+ * What the caller may do with one kind, from the auth-facts `can(key)` (the same projection
36
+ * `useCan` reads). UX only.
37
+ * @param {(key: string) => boolean} can
38
+ * @param {'department' | 'branch'} kind
39
+ * @returns {{ read: boolean, create: boolean, rename: boolean, remove: boolean }}
40
+ */
41
+ export function canManage(can, kind) {
42
+ const keys = PERMISSIONS[kind];
43
+ if (!keys || typeof can !== 'function') {
44
+ return { read: false, create: false, rename: false, remove: false };
45
+ }
46
+ return {
47
+ read: can(keys.read) === true,
48
+ create: can(keys.create) === true,
49
+ rename: can(keys.update) === true,
50
+ remove: can(keys.delete) === true,
51
+ };
52
+ }
53
+
54
+ // U+0000–U+001F and U+007F–U+009F.
55
+ // eslint-disable-next-line no-control-regex
56
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
57
+
58
+ /**
59
+ * The server's name rules (spec §10), checked before sending so the user sees the problem at once:
60
+ * NFC, trim and collapse whitespace, refuse control characters and an empty result, 1–150
61
+ * characters. The server applies the same rules and decides; `value` is what it will store.
62
+ * @param {unknown} name
63
+ * @returns {{ ok: true, value: string } | { ok: false, error: string }}
64
+ */
65
+ export function validateName(name) {
66
+ const value = String(name ?? '')
67
+ .normalize('NFC')
68
+ .trim()
69
+ .replace(/\s+/g, ' ');
70
+ if (CONTROL.test(value))
71
+ return { ok: false, error: 'The name cannot contain control characters.' };
72
+ if (value.length === 0) return { ok: false, error: 'Enter a name.' };
73
+ if ([...value].length > MAX_NAME_LENGTH) {
74
+ return { ok: false, error: `The name can be at most ${MAX_NAME_LENGTH} characters.` };
75
+ }
76
+ return { ok: true, value };
77
+ }
78
+
79
+ /**
80
+ * Whether Delete is offered for a row. `usedBy` > 0 ⇒ disabled with the reason; `usedBy` absent
81
+ * (the caller cannot read assignments) ⇒ enabled. Advisory only: `usedBy` counts the assignments
82
+ * the caller can see, and the server refuses (409) a delete while any assignment uses the row.
83
+ * @param {{ usedBy?: number } | null | undefined} row
84
+ * @returns {{ disabled: boolean, hint: string }}
85
+ */
86
+ export function deleteHint(row) {
87
+ const n = row && typeof row.usedBy === 'number' ? row.usedBy : 0;
88
+ if (n > 0) {
89
+ return { disabled: true, hint: `Used by ${n} ${n === 1 ? 'assignment' : 'assignments'}` };
90
+ }
91
+ return { disabled: false, hint: '' };
92
+ }
93
+
94
+ /**
95
+ * The "used by N" text for a row, or '' when the server sent no count.
96
+ * @param {{ usedBy?: number } | null | undefined} row
97
+ */
98
+ export function usedByLabel(row) {
99
+ if (!row || typeof row.usedBy !== 'number') return '';
100
+ return `Used by ${row.usedBy} ${row.usedBy === 1 ? 'assignment' : 'assignments'}`;
101
+ }
102
+
103
+ /**
104
+ * Rows sorted by name for display, labels only (an unnamed row shows "(unnamed)", never its id).
105
+ * @param {Array<{ uuid?: string, name?: string, usedBy?: number }> | null | undefined} rows
106
+ * @returns {Array<{ uuid: string, label: string, usedBy?: number }>}
107
+ */
108
+ export function displayRows(rows) {
109
+ return (rows || [])
110
+ .filter((r) => r && typeof r.uuid === 'string' && r.uuid.length > 0)
111
+ .map((r) => ({
112
+ uuid: r.uuid,
113
+ label: typeof r.name === 'string' && r.name.length > 0 ? r.name : '(unnamed)',
114
+ ...(typeof r.usedBy === 'number' ? { usedBy: r.usedBy } : {}),
115
+ }))
116
+ .sort((a, b) => a.label.localeCompare(b.label));
117
+ }
118
+
119
+ const DONE = { create: 'added', rename: 'renamed', delete: 'deleted' };
120
+ const VERB = { create: 'add', rename: 'rename', delete: 'delete' };
121
+
122
+ /**
123
+ * The toast for a create / rename / delete result: success text, or the server's message on a
124
+ * refusal (duplicate name, still in use, out of reach), else a fixed fallback.
125
+ * @param {{ ok: boolean, error?: string } | null | undefined} result
126
+ * @param {'department' | 'branch'} kind
127
+ * @param {'create' | 'rename' | 'delete'} action
128
+ * @returns {{ kind: 'success' | 'error', text: string }}
129
+ */
130
+ export function outcome(result, kind, action) {
131
+ const one = LABELS[kind]?.one ?? 'item';
132
+ if (!result || !result.ok) {
133
+ return {
134
+ kind: 'error',
135
+ text: (result && result.error) || `Could not ${VERB[action] ?? 'save'} the ${one}.`,
136
+ };
137
+ }
138
+ const title = one.charAt(0).toUpperCase() + one.slice(1);
139
+ return { kind: 'success', text: `${title} ${DONE[action] ?? 'saved'}` };
140
+ }