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.
- package/dist/index.js +98 -4
- package/dist/index.js.map +1 -1
- package/package.json +6 -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
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|