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
@@ -4,7 +4,14 @@
4
4
  * via `useParams()` (a `:uuid` → edit, load first; else add). Closes via `navigate(-1)`;
5
5
  * on success toasts + calls the layout's `onRefresh`.
6
6
  *
7
- * Fields: Username (required) + Email. On edit, Status is also editable.
7
+ * Fields: Username (required) + Email + optional Sign-in name (`loginName`, auth capabilities
8
+ * R1.3, R1.7, R1.10). On edit, Status is also editable.
9
+ *
10
+ * Sign-in name: checked with the core rule (`loginName.js`) before saving; the server stays
11
+ * authoritative. It is saved in its own `updateUser({ loginName })` call after the other fields
12
+ * (on add: right after the user is created) so a 409 is known to be the name clash and shows the
13
+ * server's fixed text, "That sign-in name is already in use." (frontend-core's `createUser` sends
14
+ * only username and email). Clearing the field removes the name (`null`).
8
15
  *
9
16
  * ⛔ The `:uuid` is a route param only — never rendered as visible text.
10
17
  *
@@ -29,6 +36,7 @@ import {
29
36
  SelectValue,
30
37
  } from '@/components/ui/select';
31
38
  import AssignmentsEditor from './AssignmentsEditor';
39
+ import { checkLoginNameInput, loginNamePatch, loginNameSaveError } from './loginName';
32
40
 
33
41
  export default function UserForm() {
34
42
  const { tokens, identity } = useAuth();
@@ -40,6 +48,8 @@ export default function UserForm() {
40
48
 
41
49
  const [username, setUsername] = useState('');
42
50
  const [email, setEmail] = useState('');
51
+ const [loginName, setLoginName] = useState('');
52
+ const [storedLoginName, setStoredLoginName] = useState(null);
43
53
  const [status, setStatus] = useState('active');
44
54
  const [loading, setLoading] = useState(isEdit);
45
55
  const [busy, setBusy] = useState(false);
@@ -55,6 +65,9 @@ export default function UserForm() {
55
65
  if (r.ok) {
56
66
  setUsername(r.record?.fields?.username || '');
57
67
  setEmail(r.record?.fields?.email || '');
68
+ const stored = r.record?.fields?.loginName;
69
+ setLoginName(typeof stored === 'string' ? stored : '');
70
+ setStoredLoginName(typeof stored === 'string' ? stored : null);
58
71
  setStatus(r.record?.fields?.status || 'active');
59
72
  } else {
60
73
  setError(r.error || 'Could not load the user.');
@@ -75,6 +88,12 @@ export default function UserForm() {
75
88
  setError('Enter a username.');
76
89
  return;
77
90
  }
91
+ const checkedName = checkLoginNameInput(loginName);
92
+ if (!checkedName.ok) {
93
+ setError(checkedName.error);
94
+ return;
95
+ }
96
+ const namePatch = loginNamePatch(isEdit ? storedLoginName : null, checkedName.value);
78
97
  setError('');
79
98
  setBusy(true);
80
99
  const r = isEdit
@@ -84,13 +103,35 @@ export default function UserForm() {
84
103
  status,
85
104
  })
86
105
  : await identity.createUser(token, { username: trimmedUser, email: trimmedEmail });
87
- setBusy(false);
88
106
  if (!r.ok) {
107
+ setBusy(false);
89
108
  setError(r.error || 'Could not save the user.');
90
109
  return;
91
110
  }
92
- toast.success(isEdit ? 'User updated' : 'User created');
111
+ const userUuid = isEdit ? uuid : r.id;
112
+ let nameError = null;
113
+ if (namePatch !== undefined) {
114
+ nameError = userUuid
115
+ ? loginNameSaveError(await identity.updateUser(token, userUuid, { loginName: namePatch }))
116
+ : 'Could not save the sign-in name.';
117
+ }
118
+ setBusy(false);
93
119
  onRefresh?.();
120
+ if (nameError) {
121
+ if (isEdit) {
122
+ // The other fields are saved; keep the form open on the sign-in name.
123
+ setError(`Other changes were saved. ${nameError}`);
124
+ return;
125
+ }
126
+ // The user exists now: continue on its edit form so a retry does not create a second one.
127
+ toast.success('User created');
128
+ toast.error(`${nameError} The user was created without a sign-in name.`);
129
+ if (userUuid) navigate(`/users/edit/${userUuid}`, { replace: true });
130
+ else close();
131
+ return;
132
+ }
133
+ if (namePatch !== undefined) setStoredLoginName(namePatch);
134
+ toast.success(isEdit ? 'User updated' : 'User created');
94
135
  close();
95
136
  };
96
137
 
@@ -136,6 +177,27 @@ export default function UserForm() {
136
177
  />
137
178
  </div>
138
179
 
180
+ <div className="space-y-1.5">
181
+ <Label htmlFor="user-login-name" variant="field">
182
+ Sign-in name (optional)
183
+ </Label>
184
+ <Input
185
+ id="user-login-name"
186
+ maxLength={64}
187
+ autoComplete="off"
188
+ autoCapitalize="none"
189
+ spellCheck={false}
190
+ placeholder="e.g. jsmith"
191
+ value={loginName}
192
+ disabled={loading}
193
+ onChange={(e) => setLoginName(e.target.value)}
194
+ />
195
+ <p className="text-xs text-muted-foreground">
196
+ Lets the user sign in with this name instead of their email, when the app allows it. 3–64
197
+ characters: letters a–z, digits, dot, underscore or hyphen. Leave empty for email only.
198
+ </p>
199
+ </div>
200
+
139
201
  {isEdit && (
140
202
  <div className="space-y-1.5">
141
203
  <Label htmlFor="user-status" variant="field">
@@ -12,11 +12,17 @@
12
12
  * (`row.signInStatus`, computed server-side), with an Invite button for pending users when the
13
13
  * caller holds identity:user:invite (`inviteAction.js`; UX only, the server decides).
14
14
  *
15
+ * 2FA (auth capabilities R11, R13.4, AC-27): a "2FA" column shows `row.mfaStatus` (Enrolled /
16
+ * Required / Grace period / Not required, server-computed), with a menu of Reset 2FA, Unlock and
17
+ * End sessions, each shown only with its own `identity:user:<action>` key and never on the
18
+ * signed-in user's own row (`signInAdmin.js`). Each asks for the admin's own 6-digit code
19
+ * (`SignInActionDialog`); the server decides.
20
+ *
15
21
  * ⛔ No raw UUIDs: columns render username/email/status; `row.id` is a key/route param only.
16
22
  */
17
23
  import { useCallback, useEffect, useMemo, useState } from 'react';
18
24
  import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
19
- import { Send, Users } from 'lucide-react';
25
+ import { KeyRound, LogOut, MoreHorizontal, Send, ShieldOff, Users } from 'lucide-react';
20
26
  import { toast } from 'sonner';
21
27
  import { useAuth, useCan } from '@/auth/AuthContext';
22
28
  import AppShell from '@/shell/AppShell';
@@ -25,7 +31,65 @@ import EntityList, { EntityListControls, EntityListPager } from '@/components/En
25
31
  import ConfirmDialog from '@/components/ConfirmDialog';
26
32
  import { Badge } from '@/components/ui/badge';
27
33
  import { Button } from '@/components/ui/button';
34
+ import {
35
+ DropdownMenu,
36
+ DropdownMenuContent,
37
+ DropdownMenuItem,
38
+ DropdownMenuTrigger,
39
+ } from '@/components/ui/dropdown-menu';
28
40
  import { INVITE_PERMISSION, canInvite, inviteOutcome } from './inviteAction';
41
+ import { mfaStatusLabel, mfaStatusVariant, visibleSignInActions } from './signInAdmin';
42
+ import SignInActionDialog from './SignInActionDialog';
43
+
44
+ const ACTION_ICON = { reset_mfa: ShieldOff, unlock: KeyRound, end_sessions: LogOut };
45
+
46
+ /**
47
+ * The 2FA column: status badge + a menu of the admin sign-in actions the caller may use on this
48
+ * row (none on their own row). Not sortable (computed server-side, not a stored field).
49
+ */
50
+ function mfaColumn(actionsFor, onAction) {
51
+ return {
52
+ key: 'mfaStatus',
53
+ label: '2FA',
54
+ sortable: false,
55
+ render: (row) => {
56
+ const actions = actionsFor(row);
57
+ const name = row.fields?.username || 'this user';
58
+ return (
59
+ <div className="flex items-center gap-2">
60
+ <Badge variant={mfaStatusVariant(row.mfaStatus)}>{mfaStatusLabel(row.mfaStatus)}</Badge>
61
+ {actions.length > 0 && (
62
+ <DropdownMenu>
63
+ <DropdownMenuTrigger asChild>
64
+ <button
65
+ type="button"
66
+ aria-label={`Sign-in actions for ${name}`}
67
+ className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
68
+ >
69
+ <MoreHorizontal className="size-4" />
70
+ </button>
71
+ </DropdownMenuTrigger>
72
+ <DropdownMenuContent align="end" className="w-44">
73
+ {actions.map((a) => {
74
+ const Icon = ACTION_ICON[a.key];
75
+ return (
76
+ <DropdownMenuItem
77
+ key={a.key}
78
+ variant={a.destructive ? 'destructive' : 'default'}
79
+ onClick={() => onAction(a, row)}
80
+ >
81
+ <Icon className="size-4" /> {a.label}
82
+ </DropdownMenuItem>
83
+ );
84
+ })}
85
+ </DropdownMenuContent>
86
+ </DropdownMenu>
87
+ )}
88
+ </div>
89
+ );
90
+ },
91
+ };
92
+ }
29
93
 
30
94
  /** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
31
95
  function formatDate(v) {
@@ -133,7 +197,7 @@ function readQueryFromParams(sp) {
133
197
  }
134
198
 
135
199
  export default function UsersPage() {
136
- const { tokens, identity } = useAuth();
200
+ const { tokens, identity, can, session, profile } = useAuth();
137
201
  const token = tokens?.accessToken;
138
202
  const navigate = useNavigate();
139
203
  const [searchParams, setSearchParams] = useSearchParams();
@@ -162,6 +226,16 @@ export default function UsersPage() {
162
226
  const holdsInvite = useCan(INVITE_PERMISSION);
163
227
  const [invitingId, setInvitingId] = useState(null);
164
228
 
229
+ // Admin sign-in actions (R11): `{ action, user }` while the code dialog is open.
230
+ const [signInAction, setSignInAction] = useState(null);
231
+ const selfId = session?.principalId ?? null;
232
+ const profileId = profile?.id ?? null;
233
+ const actionsFor = useCallback(
234
+ (row) => visibleSignInActions(row, { can, selfIds: [selfId, profileId] }),
235
+ [can, selfId, profileId],
236
+ );
237
+ const onSignInAction = useCallback((action, user) => setSignInAction({ action, user }), []);
238
+
165
239
  const [deleteTarget, setDeleteTarget] = useState(null);
166
240
  const [deleteBusy, setDeleteBusy] = useState(false);
167
241
 
@@ -224,8 +298,9 @@ export default function UsersPage() {
224
298
  const columns = useMemo(() => {
225
299
  const out = [...COLUMNS];
226
300
  out.splice(3, 0, signInColumn(holdsInvite, invitingId, onInvite));
301
+ out.splice(4, 0, mfaColumn(actionsFor, onSignInAction));
227
302
  return out;
228
- }, [holdsInvite, invitingId, onInvite]);
303
+ }, [holdsInvite, invitingId, onInvite, actionsFor, onSignInAction]);
229
304
 
230
305
  const onConfirmDelete = async () => {
231
306
  if (!deleteTarget) return;
@@ -286,6 +361,19 @@ export default function UsersPage() {
286
361
 
287
362
  <Outlet context={{ onRefresh: load }} />
288
363
 
364
+ {signInAction && (
365
+ <SignInActionDialog
366
+ key={`${signInAction.action.key}:${signInAction.user.id}`}
367
+ action={signInAction.action}
368
+ user={signInAction.user}
369
+ onClose={() => setSignInAction(null)}
370
+ onDone={() => {
371
+ setSignInAction(null);
372
+ load();
373
+ }}
374
+ />
375
+ )}
376
+
289
377
  <ConfirmDialog
290
378
  open={!!deleteTarget}
291
379
  title={
@@ -0,0 +1,108 @@
1
+ // loginName.js — the Users form's optional sign-in name (auth capabilities R1.3, R1.7, R1.10).
2
+ // Pure logic, tested by loginName.test.mjs. The check mirrors core's rule
3
+ // (`@astram/core` normalizeLoginName / isValidLoginName) so the admin sees a mistake before
4
+ // saving; the server stays authoritative and answers 400 / 409 itself.
5
+
6
+ /** The stored form: lower-case `[a-z0-9._-]`, 3–64 characters (so never `@`). */
7
+ const LOGIN_NAME_PATTERN = /^[a-z0-9._-]{3,64}$/;
8
+
9
+ /** The server's fixed 409 for a sign-in name another user holds (no tenant named). */
10
+ export const LOGIN_NAME_TAKEN = 'That sign-in name is already in use.';
11
+
12
+ /** The rule, as the server words its 400. */
13
+ export const LOGIN_NAME_INVALID =
14
+ 'Enter a valid sign-in name: 3–64 characters, letters a–z, digits, dot, underscore or hyphen.';
15
+
16
+ const LOGIN_NAME_HAS_AT =
17
+ 'A sign-in name cannot contain @. Use the email field for an email address.';
18
+
19
+ /**
20
+ * Copy of core's `LOGIN_NAME_CASES` (packages/core/test/login-name.test.ts). In the framework repo
21
+ * loginName.test.mjs checks this copy still equals the core table.
22
+ */
23
+ export const LOGIN_NAME_CASES = [
24
+ { input: 'sonja', normalized: 'sonja', valid: true },
25
+ { input: 'SONJA', normalized: 'sonja', valid: true },
26
+ { input: ' Sonja ', normalized: 'sonja', valid: true },
27
+ { input: 'sonja.strydom', normalized: 'sonja.strydom', valid: true },
28
+ { input: 'thabo_m-2', normalized: 'thabo_m-2', valid: true },
29
+ { input: 'abc', normalized: 'abc', valid: true },
30
+ { input: 'a'.repeat(64), normalized: 'a'.repeat(64), valid: true },
31
+ { input: '007', normalized: '007', valid: true },
32
+ { input: 'ab', normalized: 'ab', valid: false },
33
+ { input: ' ab ', normalized: 'ab', valid: false },
34
+ { input: 'a'.repeat(65), normalized: 'a'.repeat(65), valid: false },
35
+ { input: '', normalized: '', valid: false },
36
+ { input: ' ', normalized: '', valid: false },
37
+ { input: 'sonja@example.com', normalized: 'sonja@example.com', valid: false },
38
+ { input: 'son@ja', normalized: 'son@ja', valid: false },
39
+ { input: 'son ja', normalized: 'son ja', valid: false },
40
+ { input: 'sonja!', normalized: 'sonja!', valid: false },
41
+ { input: 'sönja', normalized: 'sönja', valid: false },
42
+ { input: 'sonja\t', normalized: 'sonja', valid: true },
43
+ { input: 'so\nnja', normalized: 'so\nnja', valid: false },
44
+ { input: 'sonja+1', normalized: 'sonja+1', valid: false },
45
+ { input: 'SONJA', normalized: 'SONJA', valid: false },
46
+ { input: 'SONJA\u212A', normalized: 'SONJA\u212A', valid: false },
47
+ { input: '\u212Aim', normalized: '\u212Aim', valid: false },
48
+ { input: '\u0130van', normalized: '\u0130van', valid: false },
49
+ { input: ' sonja\u3000', normalized: ' sonja\u3000', valid: false },
50
+ ];
51
+
52
+ /** Any character outside 7-bit ASCII (astral characters are surrogate pairs, in range). */
53
+ const NON_ASCII = /[\u0080-\uffff]/;
54
+
55
+ /**
56
+ * Trim and lower-case typed ASCII input; nothing else (as core). Non-ASCII input comes back
57
+ * unchanged and is never valid: Unicode case mapping can land on ASCII (U+212A → `k`).
58
+ */
59
+ export function normalizeLoginName(input) {
60
+ const typed = String(input);
61
+ if (NON_ASCII.test(typed)) return typed;
62
+ return typed.trim().toLowerCase();
63
+ }
64
+
65
+ /** True when `value` (already normalized) is an allowed sign-in name. Never throws. */
66
+ export function isValidLoginName(value) {
67
+ return typeof value === 'string' && LOGIN_NAME_PATTERN.test(value);
68
+ }
69
+
70
+ /**
71
+ * Check the form field. Empty means "no sign-in name" (`value: null`).
72
+ * @param {string | undefined | null} text
73
+ * @returns {{ ok: true, value: string | null } | { ok: false, error: string }}
74
+ */
75
+ export function checkLoginNameInput(text) {
76
+ const name = normalizeLoginName(text ?? '');
77
+ if (name === '') return { ok: true, value: null };
78
+ if (name.includes('@')) return { ok: false, error: LOGIN_NAME_HAS_AT };
79
+ if (!isValidLoginName(name)) return { ok: false, error: LOGIN_NAME_INVALID };
80
+ return { ok: true, value: name };
81
+ }
82
+
83
+ /**
84
+ * What to send for `loginName`: `undefined` when unchanged (not sent), `null` to clear, else the
85
+ * new name.
86
+ * @param {string | null | undefined} stored the user's current sign-in name
87
+ * @param {string | null} next the checked form value
88
+ */
89
+ export function loginNamePatch(stored, next) {
90
+ const before =
91
+ typeof stored === 'string' && stored.trim() !== '' ? normalizeLoginName(stored) : null;
92
+ if (before === next) return undefined;
93
+ return next;
94
+ }
95
+
96
+ /**
97
+ * The form error for a sign-in name save. frontend-core maps every non-403 failure of a user
98
+ * write to a generic text, so the fixed server texts are restored here by status: this call sends
99
+ * only `loginName`, so a 409 is the name clash and a 400 is the rule.
100
+ * @param {{ ok: boolean, status?: number, error?: string }} result
101
+ * @returns {string | null}
102
+ */
103
+ export function loginNameSaveError(result) {
104
+ if (result && result.ok) return null;
105
+ if (result?.status === 409) return LOGIN_NAME_TAKEN;
106
+ if (result?.status === 400) return LOGIN_NAME_INVALID;
107
+ return (result && result.error) || 'Could not save the sign-in name.';
108
+ }
@@ -0,0 +1,133 @@
1
+ // loginName.test.mjs — auth capabilities R1.3, R1.10, AC-4 (form): the Users form's optional
2
+ // sign-in name. The client check mirrors core's rule; the server stays authoritative.
3
+ //
4
+ // node --test src/pages/users/loginName.test.mjs
5
+ import { test, describe } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { URL, fileURLToPath } from 'node:url';
9
+ import {
10
+ LOGIN_NAME_CASES,
11
+ LOGIN_NAME_INVALID,
12
+ LOGIN_NAME_TAKEN,
13
+ checkLoginNameInput,
14
+ isValidLoginName,
15
+ loginNamePatch,
16
+ loginNameSaveError,
17
+ normalizeLoginName,
18
+ } from './loginName.js';
19
+
20
+ describe('login name rule (mirror of core LOGIN_NAME_CASES)', () => {
21
+ for (const { input, normalized, valid } of LOGIN_NAME_CASES) {
22
+ test(`${JSON.stringify(input)} → ${JSON.stringify(normalized)} ${valid ? 'valid' : 'invalid'}`, () => {
23
+ assert.equal(normalizeLoginName(input), normalized);
24
+ assert.equal(isValidLoginName(normalizeLoginName(input)), valid);
25
+ });
26
+ }
27
+
28
+ test('validates the stored form only', () => {
29
+ assert.equal(isValidLoginName('Sonja'), false);
30
+ assert.equal(isValidLoginName(' sonja'), false);
31
+ assert.equal(isValidLoginName(undefined), false);
32
+ assert.equal(isValidLoginName(42), false);
33
+ });
34
+
35
+ test('a valid name never contains @', () => {
36
+ for (const c of LOGIN_NAME_CASES) {
37
+ if (isValidLoginName(c.normalized)) assert.ok(!c.normalized.includes('@'));
38
+ }
39
+ });
40
+
41
+ // In this repo the core table sits at packages/core/test/login-name.test.ts; a scaffolded app
42
+ // has no such file, so the parity check is skipped there.
43
+ const coreTable = fileURLToPath(
44
+ new URL('../../../../../../packages/core/test/login-name.test.ts', import.meta.url),
45
+ );
46
+ test('the copy equals core LOGIN_NAME_CASES', { skip: !existsSync(coreTable) }, () => {
47
+ const src = readFileSync(coreTable, 'utf8');
48
+ const m = src.match(/export const LOGIN_NAME_CASES[^=]*=\s*(\[[\s\S]*?\n\]);/);
49
+ assert.ok(m, 'LOGIN_NAME_CASES literal not found in the core test');
50
+ // The literal holds only object literals, strings and 'a'.repeat(n): plain JS.
51
+ const coreCases = new Function(`return ${m[1]};`)();
52
+ assert.deepEqual(LOGIN_NAME_CASES, coreCases);
53
+ });
54
+ });
55
+
56
+ describe('checkLoginNameInput', () => {
57
+ test('empty or blank means "no sign-in name"', () => {
58
+ assert.deepEqual(checkLoginNameInput(''), { ok: true, value: null });
59
+ assert.deepEqual(checkLoginNameInput(' '), { ok: true, value: null });
60
+ assert.deepEqual(checkLoginNameInput(undefined), { ok: true, value: null });
61
+ });
62
+
63
+ test('a valid name comes back normalized', () => {
64
+ assert.deepEqual(checkLoginNameInput(' Sonja.S '), { ok: true, value: 'sonja.s' });
65
+ });
66
+
67
+ test('an email form gets its own message', () => {
68
+ const r = checkLoginNameInput('sonja@example.com');
69
+ assert.equal(r.ok, false);
70
+ assert.match(r.error, /cannot contain @/);
71
+ });
72
+
73
+ test('anything else outside the rule gets the rule text', () => {
74
+ for (const bad of ['ab', 'son ja', 'sönja', 'a'.repeat(65)]) {
75
+ assert.deepEqual(checkLoginNameInput(bad), { ok: false, error: LOGIN_NAME_INVALID });
76
+ }
77
+ });
78
+ });
79
+
80
+ describe('loginNamePatch', () => {
81
+ test('unchanged → undefined (not sent)', () => {
82
+ assert.equal(loginNamePatch('sonja', 'sonja'), undefined);
83
+ assert.equal(loginNamePatch(null, null), undefined);
84
+ assert.equal(loginNamePatch(undefined, null), undefined);
85
+ assert.equal(loginNamePatch('', null), undefined);
86
+ });
87
+
88
+ test('set or changed → the new name; cleared → null', () => {
89
+ assert.equal(loginNamePatch(null, 'sonja'), 'sonja');
90
+ assert.equal(loginNamePatch('sonja', 'sonja2'), 'sonja2');
91
+ assert.equal(loginNamePatch('sonja', null), null);
92
+ });
93
+
94
+ test('a stored name in another case is compared normalized', () => {
95
+ assert.equal(loginNamePatch('Sonja', 'sonja'), undefined);
96
+ });
97
+ });
98
+
99
+ describe('loginNameSaveError', () => {
100
+ test('409 → the server fixed text', () => {
101
+ assert.equal(LOGIN_NAME_TAKEN, 'That sign-in name is already in use.');
102
+ assert.equal(
103
+ loginNameSaveError({ ok: false, status: 409, error: 'Could not update the user.' }),
104
+ LOGIN_NAME_TAKEN,
105
+ );
106
+ });
107
+
108
+ test('400 → the rule text (the server refused the value)', () => {
109
+ assert.equal(
110
+ loginNameSaveError({ ok: false, status: 400, error: 'Could not update the user.' }),
111
+ LOGIN_NAME_INVALID,
112
+ );
113
+ });
114
+
115
+ test('anything else → the service message, or a generic one', () => {
116
+ assert.equal(
117
+ loginNameSaveError({
118
+ ok: false,
119
+ status: 403,
120
+ error: 'You need platform-admin access to do this.',
121
+ }),
122
+ 'You need platform-admin access to do this.',
123
+ );
124
+ assert.equal(
125
+ loginNameSaveError({ ok: false, status: 500 }),
126
+ 'Could not save the sign-in name.',
127
+ );
128
+ });
129
+
130
+ test('success → null', () => {
131
+ assert.equal(loginNameSaveError({ ok: true }), null);
132
+ });
133
+ });
@@ -0,0 +1,122 @@
1
+ // signInAdmin.js — the Users page "2FA" column and admin sign-in actions (auth capabilities
2
+ // R11.1–R11.3, R13.4, AC-27). Pure logic, tested by signInAdmin.test.mjs; UsersPage renders from
3
+ // it. Showing an action is UX only: the server checks the permission, the tenant wall, dominance,
4
+ // self and the admin's own code on every call.
5
+
6
+ /** `mfaStatus` (server-computed) → column label. */
7
+ const MFA_STATUS_LABEL = {
8
+ enrolled: 'Enrolled',
9
+ required: 'Required',
10
+ grace: 'Grace period',
11
+ not_required: 'Not required',
12
+ };
13
+
14
+ const MFA_STATUS_VARIANT = {
15
+ enrolled: 'success',
16
+ required: 'destructive',
17
+ grace: 'warning',
18
+ not_required: 'secondary',
19
+ };
20
+
21
+ /** The 2FA column text; '—' when the server did not compute a status. */
22
+ export function mfaStatusLabel(status) {
23
+ return Object.hasOwn(MFA_STATUS_LABEL, status) ? MFA_STATUS_LABEL[status] : '—';
24
+ }
25
+
26
+ /** The badge variant for a status. */
27
+ export function mfaStatusVariant(status) {
28
+ return Object.hasOwn(MFA_STATUS_VARIANT, status) ? MFA_STATUS_VARIANT[status] : 'secondary';
29
+ }
30
+
31
+ /**
32
+ * The three admin actions: permission key, `identityService` method, menu label, dialog copy and
33
+ * the success toast.
34
+ */
35
+ export const SIGN_IN_ACTIONS = [
36
+ {
37
+ key: 'reset_mfa',
38
+ permission: 'identity:user:reset_mfa',
39
+ method: 'resetUserMfa',
40
+ label: 'Reset 2FA',
41
+ title: (name) => `Reset 2FA for ${name}?`,
42
+ description:
43
+ 'This removes their authenticator and recovery codes and signs them out everywhere. ' +
44
+ 'If a policy requires 2FA, they set it up again at their next sign-in.',
45
+ confirmLabel: 'Reset 2FA',
46
+ destructive: true,
47
+ success: (name) =>
48
+ `2FA reset for ${name}. They will set it up again at their next sign-in if required.`,
49
+ },
50
+ {
51
+ key: 'unlock',
52
+ permission: 'identity:user:unlock',
53
+ method: 'unlockUser',
54
+ label: 'Unlock',
55
+ title: (name) => `Unlock ${name}?`,
56
+ description: 'This clears their failed sign-in and code attempts so they can try again now.',
57
+ confirmLabel: 'Unlock',
58
+ destructive: false,
59
+ success: (name) => `${name} can sign in again.`,
60
+ },
61
+ {
62
+ key: 'end_sessions',
63
+ permission: 'identity:user:end_sessions',
64
+ method: 'endUserSessions',
65
+ label: 'End sessions',
66
+ title: (name) => `End all sessions for ${name}?`,
67
+ description: 'They are signed out on every device and must sign in again.',
68
+ confirmLabel: 'End sessions',
69
+ destructive: true,
70
+ success: (name) => `All of ${name}’s sessions were ended.`,
71
+ },
72
+ ];
73
+
74
+ /**
75
+ * The actions to offer on a row: each needs its own permission, and none on the caller's own row
76
+ * (the server refuses self for all three).
77
+ * @param {{ id?: string } | null | undefined} row
78
+ * @param {{ can: (key: string) => boolean, selfIds: Array<string | null | undefined> }} opts
79
+ */
80
+ export function visibleSignInActions(row, { can, selfIds }) {
81
+ if (!row || !row.id) return [];
82
+ if ((selfIds || []).some((id) => typeof id === 'string' && id !== '' && id === row.id)) return [];
83
+ return SIGN_IN_ACTIONS.filter((a) => can(a.permission));
84
+ }
85
+
86
+ /** Drop spaces from a typed code. */
87
+ export function normalizeAdminCode(text) {
88
+ return String(text ?? '').replace(/\s+/g, '');
89
+ }
90
+
91
+ /** Exactly six digits. */
92
+ export function isAdminCode(text) {
93
+ return /^\d{6}$/.test(normalizeAdminCode(text));
94
+ }
95
+
96
+ /** Failure reason → [text, close the dialog?]. `null` text = use the service's fixed message. */
97
+ const FAILURE = {
98
+ code_required: ['Enter the 6-digit code from your authenticator app.', false],
99
+ invalid_code: ['That code is not valid. Enter a new code from your authenticator app.', false],
100
+ admin_needs_mfa: ['Set up two-step verification on your own account first.', true],
101
+ partially_applied: ['Partly done — try again.', false],
102
+ rate_limited: ['Too many code attempts. Wait a few minutes and try again.', true],
103
+ forbidden: ['You cannot do this for this user.', true],
104
+ not_found: ['This user no longer exists.', true],
105
+ not_authenticated: [null, true],
106
+ unavailable: [null, false],
107
+ unreachable: [null, false],
108
+ };
109
+
110
+ /**
111
+ * The outcome of `identity.resetUserMfa` / `unlockUser` / `endUserSessions`.
112
+ * @param {(typeof SIGN_IN_ACTIONS)[number]} action
113
+ * @param {{ ok: boolean, reason?: string, error?: string }} result
114
+ * @param {string} [name] the user's display name
115
+ * @returns {{ kind: 'success' | 'error', text: string, close: boolean }}
116
+ */
117
+ export function signInActionOutcome(action, result, name = 'this user') {
118
+ if (result && result.ok) return { kind: 'success', text: action.success(name), close: true };
119
+ const entry = Object.hasOwn(FAILURE, result?.reason) ? FAILURE[result.reason] : [null, false];
120
+ const text = entry[0] ?? ((result && result.error) || 'The action failed. Please try again.');
121
+ return { kind: 'error', text, close: entry[1] };
122
+ }