create-astram-app 0.11.1 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +98 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/template/frontend/_env.example +7 -0
- package/template/frontend/package.json +2 -2
- package/template/frontend/src/App.jsx +24 -0
- package/template/frontend/src/auth/AuthContext.jsx +73 -30
- package/template/frontend/src/auth/CaptchaWidget.jsx +108 -0
- package/template/frontend/src/auth/captchaProvider.js +23 -0
- package/template/frontend/src/auth/captchaProvider.test.mjs +44 -0
- package/template/frontend/src/auth/captchaRender.js +125 -0
- package/template/frontend/src/auth/captchaRender.test.mjs +182 -0
- package/template/frontend/src/auth/passwordVisibility.js +9 -0
- package/template/frontend/src/auth/passwordVisibility.test.mjs +12 -0
- package/template/frontend/src/auth/reenrollWording.js +29 -0
- package/template/frontend/src/auth/reenrollWording.test.mjs +23 -0
- package/template/frontend/src/auth/routeGate.js +10 -0
- package/template/frontend/src/auth/routeGate.test.mjs +22 -1
- package/template/frontend/src/auth/sessionFlow.js +80 -4
- package/template/frontend/src/auth/sessionFlow.test.mjs +212 -1
- package/template/frontend/src/auth/signInOptions.js +64 -0
- package/template/frontend/src/auth/signInOptions.test.mjs +94 -0
- package/template/frontend/src/auth/useSignInOptions.js +34 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +49 -3
- package/template/frontend/src/pages/Login.jsx +54 -4
- package/template/frontend/src/pages/ResetPassword.jsx +83 -11
- package/template/frontend/src/pages/account/ReenrollPage.jsx +17 -11
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +9 -6
- package/template/frontend/src/pages/audit/AuditPage.jsx +17 -3
- package/template/frontend/src/pages/audit/actorKind.js +17 -0
- package/template/frontend/src/pages/audit/actorKind.test.mjs +23 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +104 -4
- package/template/frontend/src/pages/companies/moveAction.js +61 -0
- package/template/frontend/src/pages/companies/moveAction.test.mjs +91 -0
- package/template/frontend/src/pages/org-structure/OrgStructurePage.jsx +408 -0
- package/template/frontend/src/pages/org-structure/orgStructure.js +140 -0
- package/template/frontend/src/pages/org-structure/orgStructure.test.mjs +199 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +32 -14
- package/template/frontend/src/pages/roles/RolesPage.jsx +167 -42
- package/template/frontend/src/pages/roles/roleRules.js +104 -0
- package/template/frontend/src/pages/roles/roleRules.test.mjs +178 -0
- package/template/frontend/src/pages/security/PolicyFormDialog.jsx +243 -0
- package/template/frontend/src/pages/security/SecuritySettingsPage.jsx +229 -0
- package/template/frontend/src/pages/security/securityPolicies.js +246 -0
- package/template/frontend/src/pages/security/securityPolicies.test.mjs +429 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +5 -2
- package/template/frontend/src/pages/users/PostingRow.jsx +13 -10
- package/template/frontend/src/pages/users/SignInActionDialog.jsx +98 -0
- package/template/frontend/src/pages/users/UserForm.jsx +65 -3
- package/template/frontend/src/pages/users/UsersPage.jsx +91 -3
- package/template/frontend/src/pages/users/loginName.js +108 -0
- package/template/frontend/src/pages/users/loginName.test.mjs +133 -0
- package/template/frontend/src/pages/users/signInAdmin.js +122 -0
- package/template/frontend/src/pages/users/signInAdmin.test.mjs +183 -0
- package/template/frontend/src/platform/httpClient.js +40 -28
- package/template/frontend/src/platform/httpClient.test.mjs +71 -0
- package/template/frontend/src/platform/shieldTransport.js +12 -12
- package/template/frontend/src/shell/AppShell.jsx +4 -0
- package/template/frontend/src/shell/GraceBanner.jsx +28 -0
- package/template/frontend/src/shell/graceDeadline.js +50 -0
- package/template/frontend/src/shell/graceDeadline.test.mjs +59 -0
- package/template/frontend/src/shell/nav.js +30 -1
- package/template/frontend/src/viteConfig.test.mjs +63 -0
- package/template/frontend/vite/captchaCsp.js +99 -0
- package/template/frontend/vite.config.js +14 -3
- package/template/server/_env.example +25 -0
- package/template/server/package.json +9 -6
- package/template/server/src/app.module.ts +17 -6
|
@@ -4,11 +4,26 @@
|
|
|
4
4
|
* /dashboard. An account with MFA enrolled runs a second step: login returns a
|
|
5
5
|
* challengeToken and we prompt for the 6-digit TOTP code, then complete via
|
|
6
6
|
* useAuth().completeMfa() to establish the session exactly as a plain login would.
|
|
7
|
+
*
|
|
8
|
+
* Sign-in options (R13.2, R7.6): on mount the page reads `GET /auth/login-options`. The name
|
|
9
|
+
* field's label follows `signInWith` ("Email" until known). With CAPTCHA on, the widget shows in
|
|
10
|
+
* `always` mode or once a failed attempt answers `captchaRequired`; its token goes with the next
|
|
11
|
+
* attempt and the widget is reset after every attempt (single use). A provider mismatch between
|
|
12
|
+
* the server and this build shows a fixed notice and loads no widget (signInOptions.js).
|
|
7
13
|
*/
|
|
8
14
|
import { useRef, useState } from 'react';
|
|
9
15
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
|
10
16
|
import { AlertCircle, ArrowLeft, Eye, EyeOff, Loader2, LogIn, ShieldCheck } from 'lucide-react';
|
|
11
17
|
import { useAuth } from '@/auth/AuthContext';
|
|
18
|
+
import CaptchaWidget from '@/auth/CaptchaWidget';
|
|
19
|
+
import { useSignInOptions } from '@/auth/useSignInOptions';
|
|
20
|
+
import {
|
|
21
|
+
CAPTCHA_ACTIONS,
|
|
22
|
+
MISCONFIGURED_NOTICE,
|
|
23
|
+
captchaOpts,
|
|
24
|
+
captchaVisible,
|
|
25
|
+
signInLabel,
|
|
26
|
+
} from '@/auth/signInOptions';
|
|
12
27
|
import { loadReturnPath, clearReturnPath } from '@/platform/returnPathStore';
|
|
13
28
|
import { Button } from '@/components/ui/button';
|
|
14
29
|
import { Input } from '@/components/ui/input';
|
|
@@ -19,7 +34,13 @@ import ThemeToggle from '@/theme/ThemeToggle';
|
|
|
19
34
|
import { branding } from '@/config/branding';
|
|
20
35
|
|
|
21
36
|
export default function Login() {
|
|
22
|
-
const { login, completeMfa } = useAuth();
|
|
37
|
+
const { login, completeMfa, auth } = useAuth();
|
|
38
|
+
const signIn = useSignInOptions(auth);
|
|
39
|
+
const nameLabel = signInLabel(signIn.signInWith);
|
|
40
|
+
// Sticky for this page: once the server asks for a CAPTCHA, every later attempt sends one.
|
|
41
|
+
const [captchaRequired, setCaptchaRequired] = useState(false);
|
|
42
|
+
const showCaptcha = captchaVisible(signIn.captcha, captchaRequired);
|
|
43
|
+
const captchaRef = useRef(null);
|
|
23
44
|
const navigate = useNavigate();
|
|
24
45
|
const location = useLocation();
|
|
25
46
|
// Where to land after a successful login: the router's in-memory `from` (set when
|
|
@@ -64,12 +85,23 @@ export default function Login() {
|
|
|
64
85
|
setError('');
|
|
65
86
|
if (busy) return;
|
|
66
87
|
if (!identityHint.trim() || !password) {
|
|
67
|
-
setError(
|
|
88
|
+
setError(`Enter your ${nameLabel.toLowerCase()} and password.`);
|
|
68
89
|
return;
|
|
69
90
|
}
|
|
70
91
|
setBusy(true);
|
|
71
|
-
|
|
92
|
+
let token = '';
|
|
93
|
+
if (showCaptcha) {
|
|
94
|
+
token = (await captchaRef.current?.getToken()) ?? '';
|
|
95
|
+
if (!token) {
|
|
96
|
+
setBusy(false);
|
|
97
|
+
setError('Complete the verification check, then sign in.');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const r = await login(identityHint.trim(), password, captchaOpts(token));
|
|
72
102
|
setBusy(false);
|
|
103
|
+
if (showCaptcha) captchaRef.current?.reset(); // the token is spent either way
|
|
104
|
+
if (r.ok === false && r.captchaRequired) setCaptchaRequired(true);
|
|
73
105
|
if (r.ok === true) goAfterLogin(from);
|
|
74
106
|
else if (r.ok === 'mfa') {
|
|
75
107
|
// MFA enrolled: hold the challenge and move to the code step. The password is
|
|
@@ -201,6 +233,16 @@ export default function Login() {
|
|
|
201
233
|
)}
|
|
202
234
|
</div>
|
|
203
235
|
|
|
236
|
+
{step === 'credentials' && signIn.captcha.status === 'misconfigured' && (
|
|
237
|
+
<div
|
|
238
|
+
role="alert"
|
|
239
|
+
className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
|
|
240
|
+
>
|
|
241
|
+
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
242
|
+
<span>{MISCONFIGURED_NOTICE}</span>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
204
246
|
{error && (
|
|
205
247
|
<div className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
|
|
206
248
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
@@ -316,7 +358,7 @@ export default function Login() {
|
|
|
316
358
|
htmlFor="identityHint"
|
|
317
359
|
className="block text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
318
360
|
>
|
|
319
|
-
|
|
361
|
+
{nameLabel}
|
|
320
362
|
</label>
|
|
321
363
|
<Input
|
|
322
364
|
id="identityHint"
|
|
@@ -359,6 +401,14 @@ export default function Login() {
|
|
|
359
401
|
</div>
|
|
360
402
|
</div>
|
|
361
403
|
|
|
404
|
+
{showCaptcha && (
|
|
405
|
+
<CaptchaWidget
|
|
406
|
+
ref={captchaRef}
|
|
407
|
+
setup={signIn.captcha}
|
|
408
|
+
action={CAPTCHA_ACTIONS.login}
|
|
409
|
+
/>
|
|
410
|
+
)}
|
|
411
|
+
|
|
362
412
|
{/* Remember me + Forgot password, inline below the password. */}
|
|
363
413
|
<div className="flex items-center justify-between pt-0.5">
|
|
364
414
|
<label
|
|
@@ -6,12 +6,25 @@
|
|
|
6
6
|
* /login (the reset revokes ALL sessions server-side, so the user re-signs-in with the
|
|
7
7
|
* new password). On 400 → an "invalid or expired link" message with a way back to
|
|
8
8
|
* request a fresh link.
|
|
9
|
+
*
|
|
10
|
+
* Both password fields have a show/hide toggle (R13.5, AC-22). CAPTCHA (R7.6): the widget shows
|
|
11
|
+
* in `always` mode, or once an attempt answers `captchaRequired` (that 400 is not a dead link);
|
|
12
|
+
* its token goes with the next attempt and the widget is reset after every attempt.
|
|
9
13
|
*/
|
|
10
|
-
import { useState } from 'react';
|
|
14
|
+
import { useRef, useState } from 'react';
|
|
11
15
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
|
12
16
|
import { toast } from 'sonner';
|
|
13
|
-
import { AlertCircle, ArrowLeft, KeyRound, Loader2 } from 'lucide-react';
|
|
17
|
+
import { AlertCircle, ArrowLeft, Eye, EyeOff, KeyRound, Loader2 } from 'lucide-react';
|
|
14
18
|
import { useAuth } from '@/auth/AuthContext';
|
|
19
|
+
import CaptchaWidget from '@/auth/CaptchaWidget';
|
|
20
|
+
import { useSignInOptions } from '@/auth/useSignInOptions';
|
|
21
|
+
import { passwordVisibility } from '@/auth/passwordVisibility';
|
|
22
|
+
import {
|
|
23
|
+
CAPTCHA_ACTIONS,
|
|
24
|
+
MISCONFIGURED_NOTICE,
|
|
25
|
+
captchaOpts,
|
|
26
|
+
captchaVisible,
|
|
27
|
+
} from '@/auth/signInOptions';
|
|
15
28
|
import { Button } from '@/components/ui/button';
|
|
16
29
|
import { Input } from '@/components/ui/input';
|
|
17
30
|
import { Label } from '@/components/ui/label';
|
|
@@ -22,6 +35,34 @@ import { resetWording } from '@/auth/resetWording';
|
|
|
22
35
|
|
|
23
36
|
const MIN_PASSWORD_LEN = 8;
|
|
24
37
|
|
|
38
|
+
/** A password input with the show/hide toggle (same style and labels as the login page). */
|
|
39
|
+
function PasswordInput({ id, value, onChange, autoFocus }) {
|
|
40
|
+
const [shown, setShown] = useState(false);
|
|
41
|
+
const { type, toggleLabel } = passwordVisibility(shown);
|
|
42
|
+
return (
|
|
43
|
+
<div className="relative">
|
|
44
|
+
<Input
|
|
45
|
+
id={id}
|
|
46
|
+
type={type}
|
|
47
|
+
autoComplete="new-password"
|
|
48
|
+
autoFocus={autoFocus}
|
|
49
|
+
value={value}
|
|
50
|
+
onChange={onChange}
|
|
51
|
+
placeholder="••••••••"
|
|
52
|
+
className="pr-10"
|
|
53
|
+
/>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => setShown((s) => !s)}
|
|
57
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
|
|
58
|
+
aria-label={toggleLabel}
|
|
59
|
+
>
|
|
60
|
+
{shown ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
export default function ResetPassword() {
|
|
26
67
|
const { auth } = useAuth();
|
|
27
68
|
const navigate = useNavigate();
|
|
@@ -36,6 +77,10 @@ export default function ResetPassword() {
|
|
|
36
77
|
// When true, the link itself is invalid/expired (400) → offer a fresh request.
|
|
37
78
|
const [linkDead, setLinkDead] = useState(false);
|
|
38
79
|
const [busy, setBusy] = useState(false);
|
|
80
|
+
const signIn = useSignInOptions(auth);
|
|
81
|
+
const [captchaRequired, setCaptchaRequired] = useState(false);
|
|
82
|
+
const showCaptcha = captchaVisible(signIn.captcha, captchaRequired);
|
|
83
|
+
const captchaRef = useRef(null);
|
|
39
84
|
|
|
40
85
|
const onSubmit = async (e) => {
|
|
41
86
|
e.preventDefault();
|
|
@@ -58,14 +103,30 @@ export default function ResetPassword() {
|
|
|
58
103
|
}
|
|
59
104
|
|
|
60
105
|
setBusy(true);
|
|
61
|
-
|
|
106
|
+
let captchaToken = '';
|
|
107
|
+
if (showCaptcha) {
|
|
108
|
+
captchaToken = (await captchaRef.current?.getToken()) ?? '';
|
|
109
|
+
if (!captchaToken) {
|
|
110
|
+
setBusy(false);
|
|
111
|
+
setError('Complete the verification check, then try again.');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const r = await auth.resetPassword(token, next, captchaOpts(captchaToken));
|
|
62
116
|
setBusy(false);
|
|
117
|
+
if (showCaptcha) captchaRef.current?.reset(); // single-use token
|
|
63
118
|
|
|
64
119
|
if (r.ok) {
|
|
65
120
|
toast.success(words.success);
|
|
66
121
|
navigate('/login', { replace: true });
|
|
67
122
|
return;
|
|
68
123
|
}
|
|
124
|
+
// A 400 asking for a CAPTCHA is not a dead link: show the widget and let the user retry.
|
|
125
|
+
if (r.captchaRequired) {
|
|
126
|
+
setCaptchaRequired(true);
|
|
127
|
+
setError('Complete the verification check, then try again.');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
69
130
|
// 400 → the link is invalid/expired; surface a path to request a new one.
|
|
70
131
|
if (r.status === 400) setLinkDead(true);
|
|
71
132
|
setError(r.error);
|
|
@@ -97,6 +158,16 @@ export default function ResetPassword() {
|
|
|
97
158
|
</div>
|
|
98
159
|
</div>
|
|
99
160
|
|
|
161
|
+
{!linkDead && signIn.captcha.status === 'misconfigured' && (
|
|
162
|
+
<div
|
|
163
|
+
role="alert"
|
|
164
|
+
className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
|
|
165
|
+
>
|
|
166
|
+
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
167
|
+
<span>{MISCONFIGURED_NOTICE}</span>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
|
|
100
171
|
{error && (
|
|
101
172
|
<div className="mb-4 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive">
|
|
102
173
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
@@ -121,27 +192,28 @@ export default function ResetPassword() {
|
|
|
121
192
|
<form onSubmit={onSubmit} className="space-y-4" noValidate>
|
|
122
193
|
<div className="space-y-1.5">
|
|
123
194
|
<Label htmlFor="rp-new">New password</Label>
|
|
124
|
-
<
|
|
195
|
+
<PasswordInput
|
|
125
196
|
id="rp-new"
|
|
126
|
-
type="password"
|
|
127
|
-
autoComplete="new-password"
|
|
128
197
|
autoFocus
|
|
129
198
|
value={next}
|
|
130
199
|
onChange={(e) => setNext(e.target.value)}
|
|
131
|
-
placeholder="••••••••"
|
|
132
200
|
/>
|
|
133
201
|
</div>
|
|
134
202
|
<div className="space-y-1.5">
|
|
135
203
|
<Label htmlFor="rp-confirm">Confirm new password</Label>
|
|
136
|
-
<
|
|
204
|
+
<PasswordInput
|
|
137
205
|
id="rp-confirm"
|
|
138
|
-
type="password"
|
|
139
|
-
autoComplete="new-password"
|
|
140
206
|
value={confirm}
|
|
141
207
|
onChange={(e) => setConfirm(e.target.value)}
|
|
142
|
-
placeholder="••••••••"
|
|
143
208
|
/>
|
|
144
209
|
</div>
|
|
210
|
+
{showCaptcha && (
|
|
211
|
+
<CaptchaWidget
|
|
212
|
+
ref={captchaRef}
|
|
213
|
+
setup={signIn.captcha}
|
|
214
|
+
action={CAPTCHA_ACTIONS.reset}
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
145
217
|
<Button type="submit" disabled={busy} className="w-full">
|
|
146
218
|
{busy ? (
|
|
147
219
|
<Loader2 className="size-4 animate-spin" />
|
|
@@ -9,27 +9,35 @@
|
|
|
9
9
|
* adds the framing + the "you're done" transition.
|
|
10
10
|
*
|
|
11
11
|
* When a device confirm succeeds, the server clears the gate on the SAME token (§4.3) —
|
|
12
|
-
* the next request is unrestricted.
|
|
13
|
-
* (so the user is no longer bounced
|
|
12
|
+
* the next request is unrestricted. The auth facts and profile are then refetched and the
|
|
13
|
+
* local re-enroll flag cleared (useAuth().deviceConfirmed, so the user is no longer bounced and
|
|
14
|
+
* lands on a loaded app) while keeping them on-screen to save any one-time
|
|
14
15
|
* recovery codes, then offer an explicit "Continue" back into the app.
|
|
16
|
+
*
|
|
17
|
+
* The same screen serves a session restricted by a 2FA policy (R2.3, 403
|
|
18
|
+
* `mfa_enrollment_required`): only the framing differs, per `restrictionReason`
|
|
19
|
+
* (reenrollWording.js).
|
|
15
20
|
*/
|
|
16
21
|
import { useState } from 'react';
|
|
17
22
|
import { useNavigate } from 'react-router-dom';
|
|
18
23
|
import { ArrowRight, LogOut, ShieldAlert, ShieldCheck } from 'lucide-react';
|
|
19
24
|
import { useAuth } from '@/auth/AuthContext';
|
|
25
|
+
import { reenrollWording } from '@/auth/reenrollWording';
|
|
20
26
|
import { Button } from '@/components/ui/button';
|
|
21
27
|
import TwoFactorPage from './TwoFactorPage';
|
|
22
28
|
|
|
23
29
|
export default function ReenrollPage() {
|
|
24
|
-
const {
|
|
30
|
+
const { logout, restrictionReason } = useAuth();
|
|
31
|
+
// Captured at mount: clearing the restriction on success must not swap the wording.
|
|
32
|
+
const [words] = useState(() => reenrollWording(restrictionReason));
|
|
25
33
|
const navigate = useNavigate();
|
|
26
34
|
const [secured, setSecured] = useState(false);
|
|
27
35
|
|
|
28
|
-
// A device confirm succeeded
|
|
29
|
-
//
|
|
36
|
+
// A device confirm succeeded: TwoFactorPage has already refetched the auth facts and the
|
|
37
|
+
// profile and lifted the UI gate (useAuth().deviceConfirmed), so the user can leave once
|
|
38
|
+
// they've saved their codes.
|
|
30
39
|
const onDeviceConfirmed = () => {
|
|
31
40
|
setSecured(true);
|
|
32
|
-
clearReenroll();
|
|
33
41
|
};
|
|
34
42
|
|
|
35
43
|
const onSignOut = async () => {
|
|
@@ -55,18 +63,16 @@ export default function ReenrollPage() {
|
|
|
55
63
|
)}
|
|
56
64
|
<div className="min-w-0">
|
|
57
65
|
<p className="text-sm font-semibold text-foreground">
|
|
58
|
-
{secured ?
|
|
66
|
+
{secured ? words.securedTitle : words.title}
|
|
59
67
|
</p>
|
|
60
68
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
|
61
|
-
{secured
|
|
62
|
-
? 'A new authenticator device is active. Save any recovery codes shown below, then continue.'
|
|
63
|
-
: 'You signed in with a recovery code. Add a new authenticator device below to re-secure your account — the rest of the app stays locked until you do.'}
|
|
69
|
+
{secured ? words.securedBody : words.body}
|
|
64
70
|
</p>
|
|
65
71
|
</div>
|
|
66
72
|
</div>
|
|
67
73
|
|
|
68
74
|
{/* The re-enroll work — the exact device-add + regenerate-codes flow from the
|
|
69
|
-
account 2FA page, reused as-is
|
|
75
|
+
account 2FA page, reused as-is; it lifts the gate on success, then calls onDeviceConfirmed.
|
|
70
76
|
reenrollMode hides Rename/Remove on existing devices (those routes aren't on
|
|
71
77
|
the server's re-enroll allowlist → they'd only 403 here). */}
|
|
72
78
|
<TwoFactorPage onDeviceConfirmed={onDeviceConfirmed} reenrollMode />
|
|
@@ -270,15 +270,16 @@ function DeviceRow({ device, onRename, onRemove, disabled, reenrollMode = false
|
|
|
270
270
|
/**
|
|
271
271
|
* @param {object} props
|
|
272
272
|
* @param {(() => void)} [props.onDeviceConfirmed] Called after a device confirm
|
|
273
|
-
* succeeds
|
|
274
|
-
*
|
|
273
|
+
* succeeds, once the auth facts and profile are refetched and the restriction lifted
|
|
274
|
+
* (useAuth().deviceConfirmed). Undefined in the normal account context; the
|
|
275
|
+
* forced-re-enroll screen passes it to switch to its "secured" framing.
|
|
275
276
|
* @param {boolean} [props.reenrollMode] When true (the forced-re-enroll screen),
|
|
276
277
|
* hides Rename/Remove on existing devices — those routes aren't on the server's
|
|
277
278
|
* re-enroll allowlist, so offering them would only 403. Add-device + regenerate
|
|
278
279
|
* (both allowlisted) stay usable. Normal /account/2fa omits this → actions shown.
|
|
279
280
|
*/
|
|
280
281
|
export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false } = {}) {
|
|
281
|
-
const { tokens, account } = useAuth();
|
|
282
|
+
const { tokens, account, deviceConfirmed } = useAuth();
|
|
282
283
|
const token = tokens?.accessToken;
|
|
283
284
|
|
|
284
285
|
// List state.
|
|
@@ -476,9 +477,11 @@ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false
|
|
|
476
477
|
setRecoveryCodes(codes);
|
|
477
478
|
setShowRecovery(true);
|
|
478
479
|
}
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
480
|
+
// The server has lifted any restriction and grace deadline on this token: refetch the auth
|
|
481
|
+
// facts and the profile, then lift the local restriction (sessionFlow.js
|
|
482
|
+
// afterDeviceConfirmed), so the nav, gated pages and grace banner are current. Runs after
|
|
483
|
+
// the one-time recovery-codes panel is shown; the parent decides when to leave.
|
|
484
|
+
await deviceConfirmed();
|
|
482
485
|
onDeviceConfirmed?.();
|
|
483
486
|
};
|
|
484
487
|
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* instead (no new reusable component — same primitives EntityList itself imports).
|
|
13
13
|
*
|
|
14
14
|
* ⛔ No raw UUIDs: rows carry NONE (the backend already strips ids — `AuditReadService`
|
|
15
|
-
* server-side). Only `actorLabel` / `
|
|
16
|
-
* ever rendered.
|
|
15
|
+
* server-side). Only `actorLabel` / `actorKind` (as a badge, `actorKind.js`) / `tenantLabel` /
|
|
16
|
+
* `action` / `occurredAt` / `detail` are ever rendered.
|
|
17
17
|
*
|
|
18
18
|
* Tenant column: audit rows always carry a `tenantLabel` (the server auto-scopes a non-
|
|
19
19
|
* root to their own tenant already), but the column is only USEFUL for a
|
|
@@ -37,6 +37,7 @@ import EntityList from '@/components/EntityList';
|
|
|
37
37
|
import { Badge } from '@/components/ui/badge';
|
|
38
38
|
import { Button } from '@/components/ui/button';
|
|
39
39
|
import { Input } from '@/components/ui/input';
|
|
40
|
+
import { actorKindBadge } from './actorKind';
|
|
40
41
|
|
|
41
42
|
/** Readable absolute date-time — e.g. "13 Jul 2026, 4:05 PM"; '—' when missing/invalid. */
|
|
42
43
|
function formatDateTime(ms) {
|
|
@@ -167,7 +168,20 @@ export default function AuditPage() {
|
|
|
167
168
|
{
|
|
168
169
|
key: 'actorLabel',
|
|
169
170
|
label: 'Actor',
|
|
170
|
-
render: (row) =>
|
|
171
|
+
render: (row) => {
|
|
172
|
+
// KI #140: an operation or a service account is badged so it never reads as a person.
|
|
173
|
+
const badge = actorKindBadge(row.actorKind);
|
|
174
|
+
return (
|
|
175
|
+
<span className="inline-flex items-center gap-1.5 text-sm text-foreground">
|
|
176
|
+
{row.actorLabel || '—'}
|
|
177
|
+
{badge ? (
|
|
178
|
+
<Badge variant="outline" className="font-normal text-xs">
|
|
179
|
+
{badge}
|
|
180
|
+
</Badge>
|
|
181
|
+
) : null}
|
|
182
|
+
</span>
|
|
183
|
+
);
|
|
184
|
+
},
|
|
171
185
|
},
|
|
172
186
|
{
|
|
173
187
|
key: 'action',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// actorKind.js — the Audit page's actor badge (KI #140). Pure logic, tested by actorKind.test.mjs;
|
|
2
|
+
// AuditPage renders from it. The server sends `actorKind` with every row ('user', 'service_account',
|
|
3
|
+
// 'operation', 'unknown'); an older server sends none, which reads as a user.
|
|
4
|
+
|
|
5
|
+
const BADGES = Object.freeze({
|
|
6
|
+
operation: 'System',
|
|
7
|
+
service_account: 'Service account',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The badge text for a row's `actorKind`, or null for users, unknown actors and missing kinds.
|
|
12
|
+
* @param {string | null | undefined} kind
|
|
13
|
+
* @returns {string | null}
|
|
14
|
+
*/
|
|
15
|
+
export function actorKindBadge(kind) {
|
|
16
|
+
return typeof kind === 'string' && Object.hasOwn(BADGES, kind) ? BADGES[kind] : null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// actorKind.test.mjs — KI #140: the Audit page badges actors that are not users (an org bootstrap or
|
|
2
|
+
// audit-prune operation, a service account) so they never read as a person; users get no badge.
|
|
3
|
+
//
|
|
4
|
+
// node --test src/pages/audit/actorKind.test.mjs
|
|
5
|
+
import { test, describe } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { actorKindBadge } from './actorKind.js';
|
|
8
|
+
|
|
9
|
+
describe('actorKindBadge', () => {
|
|
10
|
+
test('a framework operation is badged "System"', () => {
|
|
11
|
+
assert.equal(actorKindBadge('operation'), 'System');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('a service account is badged "Service account"', () => {
|
|
15
|
+
assert.equal(actorKindBadge('service_account'), 'Service account');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('users, unknown actors and older servers (no actorKind) get no badge', () => {
|
|
19
|
+
for (const kind of ['user', 'unknown', undefined, null, '', 'OPERATION', 'other']) {
|
|
20
|
+
assert.equal(actorKindBadge(kind), null);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -12,17 +12,30 @@
|
|
|
12
12
|
*
|
|
13
13
|
* ⛔ No raw UUIDs: columns render `fields.name` / `fields.status`; the row uuid (`row.id`)
|
|
14
14
|
* is used only as a key and for routing to the edit page.
|
|
15
|
+
*
|
|
16
|
+
* "Move under…" (G-011) re-parents a company through `identity.moveCompany`; it shows only to
|
|
17
|
+
* callers holding identity:company:update (`moveAction.js`; UX only, the server decides).
|
|
15
18
|
*/
|
|
16
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
19
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
17
20
|
import { Outlet, useNavigate, useSearchParams } from 'react-router-dom';
|
|
18
|
-
import { Building2 } from 'lucide-react';
|
|
21
|
+
import { Building2, FolderTree } from 'lucide-react';
|
|
19
22
|
import { toast } from 'sonner';
|
|
20
|
-
import { useAuth } from '@/auth/AuthContext';
|
|
23
|
+
import { useAuth, useCan } from '@/auth/AuthContext';
|
|
21
24
|
import AppShell from '@/shell/AppShell';
|
|
22
25
|
import PageHeader from '@/components/PageHeader';
|
|
23
26
|
import EntityList, { EntityListControls, EntityListPager } from '@/components/EntityList';
|
|
24
27
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
|
25
28
|
import { Badge } from '@/components/ui/badge';
|
|
29
|
+
import { Button } from '@/components/ui/button';
|
|
30
|
+
import { Label } from '@/components/ui/label';
|
|
31
|
+
import {
|
|
32
|
+
Select,
|
|
33
|
+
SelectContent,
|
|
34
|
+
SelectItem,
|
|
35
|
+
SelectTrigger,
|
|
36
|
+
SelectValue,
|
|
37
|
+
} from '@/components/ui/select';
|
|
38
|
+
import { MOVE_PERMISSION, canMove, moveOutcome, parentOptions, parentUuidOf } from './moveAction';
|
|
26
39
|
|
|
27
40
|
/** Compact absolute date — e.g. "13 Jul 2026"; '—' when missing/invalid. */
|
|
28
41
|
function formatDate(v) {
|
|
@@ -70,6 +83,28 @@ const COLUMNS = [
|
|
|
70
83
|
},
|
|
71
84
|
];
|
|
72
85
|
|
|
86
|
+
/** The row action column: "Move under…" for callers holding the update key (G-011). */
|
|
87
|
+
function moveColumn(holdsUpdate, onMove) {
|
|
88
|
+
return {
|
|
89
|
+
key: 'move',
|
|
90
|
+
label: 'Parent',
|
|
91
|
+
sortable: false,
|
|
92
|
+
render: (row) =>
|
|
93
|
+
canMove(row, holdsUpdate) ? (
|
|
94
|
+
<Button
|
|
95
|
+
type="button"
|
|
96
|
+
variant="ghost"
|
|
97
|
+
size="sm"
|
|
98
|
+
onClick={() => onMove(row)}
|
|
99
|
+
aria-label={`Move ${row.fields?.name || 'this company'} under another company`}
|
|
100
|
+
>
|
|
101
|
+
<FolderTree className="size-4" />
|
|
102
|
+
Move under…
|
|
103
|
+
</Button>
|
|
104
|
+
) : null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
73
108
|
const DEFAULT_LIMIT = 25;
|
|
74
109
|
|
|
75
110
|
/**
|
|
@@ -118,6 +153,12 @@ export default function CompaniesPage() {
|
|
|
118
153
|
setSearchParams(p, { replace: true });
|
|
119
154
|
}, [query, setSearchParams]);
|
|
120
155
|
|
|
156
|
+
// Move dialog state (G-011): the company being moved and the chosen parent option.
|
|
157
|
+
const holdsUpdate = useCan(MOVE_PERMISSION);
|
|
158
|
+
const [moveTarget, setMoveTarget] = useState(null);
|
|
159
|
+
const [moveParent, setMoveParent] = useState('');
|
|
160
|
+
const [moveBusy, setMoveBusy] = useState(false);
|
|
161
|
+
|
|
121
162
|
// Delete confirm state (ConfirmDialog — the API fires ONLY from onConfirm).
|
|
122
163
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
123
164
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
@@ -151,6 +192,39 @@ export default function CompaniesPage() {
|
|
|
151
192
|
const onPageChange = useCallback((page) => setQuery((q) => ({ ...q, page })), []);
|
|
152
193
|
const onLimitChange = useCallback((limit) => setQuery((q) => ({ ...q, limit, page: 1 })), []);
|
|
153
194
|
|
|
195
|
+
const onMove = useCallback((row) => {
|
|
196
|
+
setMoveTarget(row);
|
|
197
|
+
setMoveParent('');
|
|
198
|
+
}, []);
|
|
199
|
+
const columns = useMemo(
|
|
200
|
+
() => [...COLUMNS, moveColumn(holdsUpdate, onMove)],
|
|
201
|
+
[holdsUpdate, onMove],
|
|
202
|
+
);
|
|
203
|
+
const moveOptions = useMemo(
|
|
204
|
+
() => (moveTarget ? parentOptions(rows, moveTarget.id) : []),
|
|
205
|
+
[rows, moveTarget],
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const onConfirmMove = async () => {
|
|
209
|
+
if (!moveTarget) return;
|
|
210
|
+
if (!moveParent) {
|
|
211
|
+
toast.error('Choose the company to move it under.');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
setMoveBusy(true);
|
|
215
|
+
const outcome = moveOutcome(
|
|
216
|
+
await identity.moveCompany(token, moveTarget.id, parentUuidOf(moveParent)),
|
|
217
|
+
);
|
|
218
|
+
setMoveBusy(false);
|
|
219
|
+
if (outcome.kind === 'error') {
|
|
220
|
+
toast.error(outcome.text);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
setMoveTarget(null);
|
|
224
|
+
toast.success(outcome.text);
|
|
225
|
+
await load();
|
|
226
|
+
};
|
|
227
|
+
|
|
154
228
|
const onConfirmDelete = async () => {
|
|
155
229
|
if (!deleteTarget) return;
|
|
156
230
|
setDeleteBusy(true);
|
|
@@ -184,7 +258,7 @@ export default function CompaniesPage() {
|
|
|
184
258
|
/>
|
|
185
259
|
|
|
186
260
|
<EntityList
|
|
187
|
-
columns={
|
|
261
|
+
columns={columns}
|
|
188
262
|
rows={rows}
|
|
189
263
|
search={query.search}
|
|
190
264
|
sortBy={query.sortBy}
|
|
@@ -211,6 +285,32 @@ export default function CompaniesPage() {
|
|
|
211
285
|
{/* Add / Edit render here as a routed overlay; onRefresh re-fetches the list. */}
|
|
212
286
|
<Outlet context={{ onRefresh: load }} />
|
|
213
287
|
|
|
288
|
+
<ConfirmDialog
|
|
289
|
+
open={!!moveTarget}
|
|
290
|
+
title={moveTarget ? `Move “${moveTarget.fields?.name || 'this company'}”` : 'Move company'}
|
|
291
|
+
message="Choose the company it moves under. Its own companies move with it."
|
|
292
|
+
confirmLabel="Move"
|
|
293
|
+
busy={moveBusy}
|
|
294
|
+
onConfirm={onConfirmMove}
|
|
295
|
+
onCancel={() => setMoveTarget(null)}
|
|
296
|
+
>
|
|
297
|
+
<div className="grid gap-2">
|
|
298
|
+
<Label htmlFor="move-parent">New parent</Label>
|
|
299
|
+
<Select value={moveParent} onValueChange={setMoveParent}>
|
|
300
|
+
<SelectTrigger id="move-parent" className="w-full">
|
|
301
|
+
<SelectValue placeholder="Select a company" />
|
|
302
|
+
</SelectTrigger>
|
|
303
|
+
<SelectContent>
|
|
304
|
+
{moveOptions.map((o) => (
|
|
305
|
+
<SelectItem key={o.value} value={o.value}>
|
|
306
|
+
{o.label}
|
|
307
|
+
</SelectItem>
|
|
308
|
+
))}
|
|
309
|
+
</SelectContent>
|
|
310
|
+
</Select>
|
|
311
|
+
</div>
|
|
312
|
+
</ConfirmDialog>
|
|
313
|
+
|
|
214
314
|
<ConfirmDialog
|
|
215
315
|
open={!!deleteTarget}
|
|
216
316
|
title={
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// moveAction.js — the Companies page's "Move under…" action (G-011). Pure logic, tested by
|
|
2
|
+
// moveAction.test.mjs; CompaniesPage renders from it. Showing or hiding the action is UX only: the
|
|
3
|
+
// server decides (`PUT /identity/companies/:uuid/parent` needs identity:company:update, both
|
|
4
|
+
// companies inside the caller's subtree, and no cycle).
|
|
5
|
+
|
|
6
|
+
/** The permission key the move route is gated on. */
|
|
7
|
+
export const MOVE_PERMISSION = 'identity:company:update';
|
|
8
|
+
|
|
9
|
+
/** The select value standing for "no parent" (Radix Select refuses an empty value). */
|
|
10
|
+
export const TOP_LEVEL = '__top_level__';
|
|
11
|
+
|
|
12
|
+
/** The label of the "no parent" option: only a platform admin (root) may move a company there. */
|
|
13
|
+
export const TOP_LEVEL_LABEL = 'Top level (platform admins only)';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Show "Move under…" for this row? Only when the caller holds {@link MOVE_PERMISSION}.
|
|
17
|
+
* @param {{ id?: string } | null | undefined} row
|
|
18
|
+
* @param {boolean} holdsUpdate
|
|
19
|
+
*/
|
|
20
|
+
export function canMove(row, holdsUpdate) {
|
|
21
|
+
return Boolean(holdsUpdate && row && row.id);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The parents offered for `movingUuid`: "Top level" first, then every visible company except the
|
|
26
|
+
* one being moved, by name. The frontend cannot tell a root caller (`/auth/me` carries no root
|
|
27
|
+
* flag), so "Top level" is always offered and labelled for platform admins; the server refuses it
|
|
28
|
+
* for anyone else, and refuses a cycle or an out-of-reach parent. Labels are names, never ids.
|
|
29
|
+
* @param {Array<{ id: string, fields?: { name?: string } }>} rows
|
|
30
|
+
* @param {string} movingUuid
|
|
31
|
+
* @returns {Array<{ value: string, label: string }>}
|
|
32
|
+
*/
|
|
33
|
+
export function parentOptions(rows, movingUuid) {
|
|
34
|
+
const out = (rows || [])
|
|
35
|
+
.filter((r) => r && r.id && r.id !== movingUuid)
|
|
36
|
+
.map((r) => ({ value: r.id, label: r.fields?.name || '(unnamed)' }))
|
|
37
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
38
|
+
out.unshift({ value: TOP_LEVEL, label: TOP_LEVEL_LABEL });
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `parentUuid` to send for a selected option value (`null` for "Top level").
|
|
44
|
+
* @param {string} value
|
|
45
|
+
* @returns {string | null}
|
|
46
|
+
*/
|
|
47
|
+
export function parentUuidOf(value) {
|
|
48
|
+
return value === TOP_LEVEL ? null : value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The toast for an `identity.moveCompany` result.
|
|
53
|
+
* @param {{ ok: boolean, error?: string }} result
|
|
54
|
+
* @returns {{ kind: 'success' | 'error', text: string }}
|
|
55
|
+
*/
|
|
56
|
+
export function moveOutcome(result) {
|
|
57
|
+
if (!result || !result.ok) {
|
|
58
|
+
return { kind: 'error', text: (result && result.error) || 'Could not move the company.' };
|
|
59
|
+
}
|
|
60
|
+
return { kind: 'success', text: 'Company moved' };
|
|
61
|
+
}
|