create-astram-app 0.7.0 → 0.9.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/README.md +155 -6
- package/package.json +1 -1
- package/template/CLAUDE.md +2 -2
- package/template/README.md +4 -3
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/App.jsx +7 -1
- package/template/frontend/src/auth/confirmEmailLink.js +51 -0
- package/template/frontend/src/auth/confirmEmailLink.test.mjs +108 -0
- package/template/frontend/src/auth/deviceEnrollment.js +33 -0
- package/template/frontend/src/auth/deviceEnrollment.test.mjs +56 -0
- package/template/frontend/src/auth/profileEdit.js +58 -0
- package/template/frontend/src/auth/profileEdit.test.mjs +135 -0
- package/template/frontend/src/pages/ConfirmEmail.jsx +123 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +102 -20
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +39 -4
- package/template/frontend/vite.config.js +21 -1
- package/template/server/eslint.config.mjs +150 -36
- package/template/server/package.json +7 -6
- package/template/server/scripts/check-wall.mjs +66 -0
- package/template/server/src/app.module.ts +17 -23
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// profileEdit.test.mjs — KI #14: changing the email needs the current password (and an MFA
|
|
2
|
+
// code when the account has MFA) and only takes effect from the link sent to the new address.
|
|
3
|
+
//
|
|
4
|
+
// node --test src/auth/profileEdit.test.mjs
|
|
5
|
+
import { test, describe } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { planProfileEdit, interpretProfileResult, PENDING_EMAIL_MESSAGE } from './profileEdit.js';
|
|
8
|
+
|
|
9
|
+
const initial = { name: 'Jane', email: 'old@x.test' };
|
|
10
|
+
const pw = 'p'.repeat(12);
|
|
11
|
+
const form = (over = {}) => ({
|
|
12
|
+
name: 'Jane',
|
|
13
|
+
email: 'old@x.test',
|
|
14
|
+
currentPassword: '',
|
|
15
|
+
code: '',
|
|
16
|
+
codeRequired: false,
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('planProfileEdit', () => {
|
|
21
|
+
test('name only → no password asked, only the name is sent', () => {
|
|
22
|
+
assert.deepEqual(planProfileEdit(form({ name: ' New ' }), initial), {
|
|
23
|
+
changes: { name: 'New' },
|
|
24
|
+
emailChanging: false,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a password typed for a name-only edit is not sent', () => {
|
|
29
|
+
const plan = planProfileEdit(form({ name: 'New', currentPassword: pw }), initial);
|
|
30
|
+
assert.deepEqual(plan.changes, { name: 'New' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('empty name → error', () => {
|
|
34
|
+
assert.deepEqual(planProfileEdit(form({ name: ' ' }), initial), {
|
|
35
|
+
error: 'Please enter your name.',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('nothing changed → error', () => {
|
|
40
|
+
assert.deepEqual(planProfileEdit(form(), initial), { error: 'Nothing to update.' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('email changed without a current password → error, nothing sent', () => {
|
|
44
|
+
assert.deepEqual(planProfileEdit(form({ email: 'new@x.test' }), initial), {
|
|
45
|
+
error: 'Enter your current password to change your email.',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('email changed with the password → email + currentPassword sent', () => {
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
planProfileEdit(form({ email: ' new@x.test ', currentPassword: pw }), initial),
|
|
52
|
+
{ changes: { email: 'new@x.test', currentPassword: pw }, emailChanging: true },
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('MFA account: a code is required and sent with the email change', () => {
|
|
57
|
+
assert.deepEqual(
|
|
58
|
+
planProfileEdit(
|
|
59
|
+
form({ email: 'new@x.test', currentPassword: pw, codeRequired: true }),
|
|
60
|
+
initial,
|
|
61
|
+
),
|
|
62
|
+
{ error: 'Enter a code from your authenticator app or a recovery code.' },
|
|
63
|
+
);
|
|
64
|
+
assert.deepEqual(
|
|
65
|
+
planProfileEdit(
|
|
66
|
+
form({ email: 'new@x.test', currentPassword: pw, codeRequired: true, code: ' 123456 ' }),
|
|
67
|
+
initial,
|
|
68
|
+
),
|
|
69
|
+
{
|
|
70
|
+
changes: { email: 'new@x.test', currentPassword: pw, code: '123456' },
|
|
71
|
+
emailChanging: true,
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('name and email together → both sent, plus the proof', () => {
|
|
77
|
+
const plan = planProfileEdit(
|
|
78
|
+
form({ name: 'New', email: 'new@x.test', currentPassword: pw }),
|
|
79
|
+
initial,
|
|
80
|
+
);
|
|
81
|
+
assert.deepEqual(plan.changes, { name: 'New', email: 'new@x.test', currentPassword: pw });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the password is never trimmed', () => {
|
|
85
|
+
const plan = planProfileEdit(
|
|
86
|
+
form({ email: 'new@x.test', currentPassword: ` ${pw} ` }),
|
|
87
|
+
initial,
|
|
88
|
+
);
|
|
89
|
+
assert.equal(plan.changes.currentPassword, ` ${pw} `);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('interpretProfileResult', () => {
|
|
94
|
+
test('200 → saved', () => {
|
|
95
|
+
assert.deepEqual(interpretProfileResult({ ok: true, profile: {} }), { kind: 'saved' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('202 → pending, with the "check your new inbox" message', () => {
|
|
99
|
+
assert.deepEqual(interpretProfileResult({ ok: true, pendingEmail: true, profile: {} }), {
|
|
100
|
+
kind: 'pending',
|
|
101
|
+
message: PENDING_EMAIL_MESSAGE,
|
|
102
|
+
});
|
|
103
|
+
assert.equal(
|
|
104
|
+
PENDING_EMAIL_MESSAGE,
|
|
105
|
+
'We sent a confirmation link to the new address. Your email changes when you open it.',
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('mfa_required / invalid_code → error that shows the code field', () => {
|
|
110
|
+
for (const reason of ['mfa_required', 'invalid_code']) {
|
|
111
|
+
assert.deepEqual(interpretProfileResult({ ok: false, status: 400, reason, error: 'E' }), {
|
|
112
|
+
kind: 'error',
|
|
113
|
+
error: 'E',
|
|
114
|
+
showCode: true,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('other failures → error, code field unchanged', () => {
|
|
120
|
+
assert.deepEqual(
|
|
121
|
+
interpretProfileResult({
|
|
122
|
+
ok: false,
|
|
123
|
+
status: 400,
|
|
124
|
+
reason: 'invalid_current_password',
|
|
125
|
+
error: 'Current password is incorrect.',
|
|
126
|
+
}),
|
|
127
|
+
{ kind: 'error', error: 'Current password is incorrect.', showCode: false },
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(interpretProfileResult({ ok: false, status: 500 }), {
|
|
130
|
+
kind: 'error',
|
|
131
|
+
error: 'Could not update your profile.',
|
|
132
|
+
showCode: false,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConfirmEmail — the page the email-change link opens (`/confirm-email#t=…`, KI #14).
|
|
3
|
+
*
|
|
4
|
+
* Public: the link may be opened on a device with no session, and a signed-in user is not
|
|
5
|
+
* bounced away (that would drop the fragment). On mount it reads the token from the URL
|
|
6
|
+
* fragment, removes the fragment from the address bar and history (`history.replaceState`),
|
|
7
|
+
* and sets `<meta name="referrer" content="no-referrer">` while mounted. It then POSTs the
|
|
8
|
+
* token once through `authService.confirmEmail` (no bearer). The token is never logged,
|
|
9
|
+
* stored or put in a query string. Fragment handling lives in `auth/confirmEmailLink.js`.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
12
|
+
import { Link } from 'react-router-dom';
|
|
13
|
+
import { AlertCircle, ArrowLeft, CheckCircle2, Loader2 } from 'lucide-react';
|
|
14
|
+
import { useAuth } from '@/auth/AuthContext';
|
|
15
|
+
import { applyNoReferrer, readConfirmToken, scrubConfirmToken } from '@/auth/confirmEmailLink';
|
|
16
|
+
import { Card, CardContent } from '@/components/ui/card';
|
|
17
|
+
import ThemeToggle from '@/theme/ThemeToggle';
|
|
18
|
+
import { branding } from '@/config/branding';
|
|
19
|
+
|
|
20
|
+
const MISSING_TOKEN = { ok: false, status: 400, error: 'This link is invalid or has expired.' };
|
|
21
|
+
|
|
22
|
+
export default function ConfirmEmail() {
|
|
23
|
+
const { auth, isAuthenticated, refreshProfile } = useAuth();
|
|
24
|
+
// Read only (pure, so StrictMode's double call sees the same value); scrubbed below.
|
|
25
|
+
const [token] = useState(() => readConfirmToken(window.location.hash));
|
|
26
|
+
const [result, setResult] = useState(token ? null : MISSING_TOKEN);
|
|
27
|
+
// The token is single-use: post it once, even when StrictMode re-runs the effect.
|
|
28
|
+
const postedRef = useRef(false);
|
|
29
|
+
|
|
30
|
+
// Before paint: drop the fragment from the URL and history, and send no Referer.
|
|
31
|
+
useLayoutEffect(() => {
|
|
32
|
+
scrubConfirmToken(window);
|
|
33
|
+
return applyNoReferrer(document);
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!token || postedRef.current) return;
|
|
38
|
+
postedRef.current = true;
|
|
39
|
+
auth.confirmEmail(token).then((r) => {
|
|
40
|
+
setResult(r);
|
|
41
|
+
// Best effort: the header shows the new address at once (a revoked session just
|
|
42
|
+
// signs out on its next request).
|
|
43
|
+
if (r.ok && isAuthenticated) refreshProfile();
|
|
44
|
+
});
|
|
45
|
+
}, [auth, token, isAuthenticated, refreshProfile]);
|
|
46
|
+
|
|
47
|
+
const back = isAuthenticated
|
|
48
|
+
? { to: '/account/profile', label: 'Back to your profile' }
|
|
49
|
+
: { to: '/login', label: 'Sign in' };
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="grid-bg relative flex min-h-screen items-center justify-center bg-background p-6">
|
|
53
|
+
<div className="absolute right-4 top-4">
|
|
54
|
+
<ThemeToggle compact />
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<Card className="w-full max-w-md shadow-xl">
|
|
58
|
+
<CardContent className="p-8">
|
|
59
|
+
<div className="mb-7 flex flex-col items-center gap-4">
|
|
60
|
+
<img
|
|
61
|
+
src={branding.logo.light}
|
|
62
|
+
alt={branding.appName}
|
|
63
|
+
className="h-9 w-auto dark:hidden"
|
|
64
|
+
/>
|
|
65
|
+
<img
|
|
66
|
+
src={branding.logo.dark}
|
|
67
|
+
alt={branding.appName}
|
|
68
|
+
className="hidden h-9 w-auto dark:block"
|
|
69
|
+
/>
|
|
70
|
+
<h1 className="text-xl font-bold tracking-tight">Confirm your new email</h1>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{result === null && (
|
|
74
|
+
<div
|
|
75
|
+
role="status"
|
|
76
|
+
aria-live="polite"
|
|
77
|
+
className="flex items-center justify-center gap-2 text-sm text-muted-foreground"
|
|
78
|
+
>
|
|
79
|
+
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
|
80
|
+
Confirming…
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{result?.ok === true && (
|
|
85
|
+
<div
|
|
86
|
+
role="status"
|
|
87
|
+
className="flex items-start gap-2 rounded-lg border border-primary/30 bg-primary/10 px-3 py-2.5 text-sm"
|
|
88
|
+
>
|
|
89
|
+
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-primary" />
|
|
90
|
+
<span>
|
|
91
|
+
Your email address has been changed. For your security, your other sessions were
|
|
92
|
+
signed out.
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{result?.ok === false && (
|
|
98
|
+
<div
|
|
99
|
+
role="alert"
|
|
100
|
+
className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-sm text-destructive"
|
|
101
|
+
>
|
|
102
|
+
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
103
|
+
<span>
|
|
104
|
+
{result.error}
|
|
105
|
+
{result.status === 400 && ' Request the change again from your profile.'}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<div className="mt-6 text-center">
|
|
111
|
+
<Link
|
|
112
|
+
to={back.to}
|
|
113
|
+
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
|
114
|
+
>
|
|
115
|
+
<ArrowLeft className="size-4" />
|
|
116
|
+
{back.label}
|
|
117
|
+
</Link>
|
|
118
|
+
</div>
|
|
119
|
+
</CardContent>
|
|
120
|
+
</Card>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -4,15 +4,22 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Pre-fills from the /auth/me profile (via useAuth().profile); on save it PATCHes
|
|
6
6
|
* /account/profile through the shared accountService, then calls refreshProfile() so
|
|
7
|
-
* the header/sidebar user card reflects the new name
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* the header/sidebar user card reflects the new name at once. Only sends the fields that
|
|
8
|
+
* actually changed (partial edit). All display values are NAMES (never UUIDs).
|
|
9
|
+
*
|
|
10
|
+
* Email change (KI #14): the server verifies it. Changing the email asks for the current
|
|
11
|
+
* password, and for a TOTP / recovery code when the account has MFA (detected up front from
|
|
12
|
+
* the device list, and always when the server answers `mfa_required`). The server answers 202
|
|
13
|
+
* and mails a link to the NEW address; the email only changes when that link is opened (the
|
|
14
|
+
* /confirm-email page), so the page keeps showing the old one. The password and code live in
|
|
15
|
+
* component state for one request and are cleared after it. The form logic is in
|
|
16
|
+
* `auth/profileEdit.js`.
|
|
11
17
|
*/
|
|
12
18
|
import { useEffect, useState } from 'react';
|
|
13
19
|
import { toast } from 'sonner';
|
|
14
|
-
import { Loader2, ShieldCheck, UserRound } from 'lucide-react';
|
|
20
|
+
import { Loader2, MailCheck, ShieldCheck, UserRound } from 'lucide-react';
|
|
15
21
|
import { useAuth } from '@/auth/AuthContext';
|
|
22
|
+
import { planProfileEdit, interpretProfileResult } from '@/auth/profileEdit';
|
|
16
23
|
import { Button } from '@/components/ui/button';
|
|
17
24
|
import { Input } from '@/components/ui/input';
|
|
18
25
|
import { Label } from '@/components/ui/label';
|
|
@@ -22,12 +29,18 @@ export default function ProfilePage() {
|
|
|
22
29
|
const { tokens, account, profile, refreshProfile } = useAuth();
|
|
23
30
|
const [name, setName] = useState(profile?.name ?? '');
|
|
24
31
|
const [email, setEmail] = useState(profile?.email ?? '');
|
|
32
|
+
const [currentPassword, setCurrentPassword] = useState('');
|
|
33
|
+
const [code, setCode] = useState('');
|
|
34
|
+
// True when the account has MFA (from the device list) or the server asked for a code.
|
|
35
|
+
const [codeRequired, setCodeRequired] = useState(false);
|
|
36
|
+
const [pendingNotice, setPendingNotice] = useState('');
|
|
25
37
|
const [error, setError] = useState('');
|
|
26
38
|
const [submitting, setSubmitting] = useState(false);
|
|
27
39
|
|
|
28
40
|
const initialName = profile?.name ?? '';
|
|
29
41
|
const initialEmail = profile?.email ?? '';
|
|
30
|
-
const
|
|
42
|
+
const emailDirty = email.trim() !== initialEmail;
|
|
43
|
+
const dirty = name.trim() !== initialName || emailDirty;
|
|
31
44
|
|
|
32
45
|
// The profile may still be loading (null) when this first mounts (e.g. a hard page
|
|
33
46
|
// refresh). Seed the fields once it arrives / changes; a server-side refresh after
|
|
@@ -37,32 +50,58 @@ export default function ProfilePage() {
|
|
|
37
50
|
setEmail(profile?.email ?? '');
|
|
38
51
|
}, [profile?.name, profile?.email]);
|
|
39
52
|
|
|
53
|
+
// Best effort: ask for the MFA code up front when the account has a device. If this
|
|
54
|
+
// fails, the server's `mfa_required` answer shows the field instead.
|
|
55
|
+
const accessToken = tokens?.accessToken;
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!accessToken) return;
|
|
58
|
+
let active = true;
|
|
59
|
+
account.listMfaDevices(accessToken).then((r) => {
|
|
60
|
+
if (active && r.ok && r.mfaEnabled) setCodeRequired(true);
|
|
61
|
+
});
|
|
62
|
+
return () => {
|
|
63
|
+
active = false;
|
|
64
|
+
};
|
|
65
|
+
}, [account, accessToken]);
|
|
66
|
+
|
|
40
67
|
const onSubmit = async (e) => {
|
|
41
68
|
e.preventDefault();
|
|
42
69
|
setError('');
|
|
43
70
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
setError(
|
|
71
|
+
const plan = planProfileEdit(
|
|
72
|
+
{ name, email, currentPassword, code, codeRequired },
|
|
73
|
+
{
|
|
74
|
+
name: initialName,
|
|
75
|
+
email: initialEmail,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
if (plan.error) {
|
|
79
|
+
setError(plan.error);
|
|
53
80
|
return;
|
|
54
81
|
}
|
|
55
82
|
|
|
56
83
|
setSubmitting(true);
|
|
57
|
-
const r = await account.updateProfile(tokens?.accessToken, changes);
|
|
84
|
+
const r = await account.updateProfile(tokens?.accessToken, plan.changes);
|
|
85
|
+
// The password and code were for this one request only.
|
|
86
|
+
setCurrentPassword('');
|
|
87
|
+
setCode('');
|
|
58
88
|
setSubmitting(false);
|
|
59
89
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
90
|
+
const outcome = interpretProfileResult(r);
|
|
91
|
+
if (outcome.kind === 'error') {
|
|
92
|
+
if (outcome.showCode) setCodeRequired(true);
|
|
93
|
+
setError(outcome.error);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await refreshProfile();
|
|
97
|
+
if (outcome.kind === 'pending') {
|
|
98
|
+
// The email is unchanged until the link is opened: show the current one again.
|
|
99
|
+
setEmail(initialEmail);
|
|
100
|
+
setPendingNotice(outcome.message);
|
|
101
|
+
toast.success('Check your new inbox');
|
|
63
102
|
return;
|
|
64
103
|
}
|
|
65
|
-
|
|
104
|
+
toast.success('Profile updated');
|
|
66
105
|
};
|
|
67
106
|
|
|
68
107
|
return (
|
|
@@ -99,6 +138,49 @@ export default function ProfilePage() {
|
|
|
99
138
|
/>
|
|
100
139
|
</div>
|
|
101
140
|
|
|
141
|
+
{emailDirty && (
|
|
142
|
+
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
|
|
143
|
+
<p className="text-sm text-muted-foreground">
|
|
144
|
+
To change your email, confirm it is you. We will send a link to the new address;
|
|
145
|
+
your email changes when you open it.
|
|
146
|
+
</p>
|
|
147
|
+
<div className="space-y-1.5">
|
|
148
|
+
<Label htmlFor="pf-current-password">Current password</Label>
|
|
149
|
+
<Input
|
|
150
|
+
id="pf-current-password"
|
|
151
|
+
type="password"
|
|
152
|
+
autoComplete="current-password"
|
|
153
|
+
value={currentPassword}
|
|
154
|
+
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
{codeRequired && (
|
|
158
|
+
<div className="space-y-1.5">
|
|
159
|
+
<Label htmlFor="pf-code">Authenticator or recovery code</Label>
|
|
160
|
+
<Input
|
|
161
|
+
id="pf-code"
|
|
162
|
+
type="text"
|
|
163
|
+
inputMode="text"
|
|
164
|
+
autoComplete="one-time-code"
|
|
165
|
+
value={code}
|
|
166
|
+
onChange={(e) => setCode(e.target.value)}
|
|
167
|
+
placeholder="123456"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{pendingNotice && (
|
|
175
|
+
<div
|
|
176
|
+
role="status"
|
|
177
|
+
className="flex items-start gap-2 rounded-lg border border-primary/30 bg-primary/10 px-3 py-2 text-sm"
|
|
178
|
+
>
|
|
179
|
+
<MailCheck className="mt-0.5 size-4 shrink-0 text-primary" />
|
|
180
|
+
<span>{pendingNotice}</span>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
|
|
102
184
|
{error && (
|
|
103
185
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
104
186
|
{error}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
X,
|
|
38
38
|
} from 'lucide-react';
|
|
39
39
|
import { useAuth } from '@/auth/AuthContext';
|
|
40
|
+
import { addDeviceNeedsCode, planBeginDevice } from '@/auth/deviceEnrollment';
|
|
40
41
|
import { Button } from '@/components/ui/button';
|
|
41
42
|
import { Input } from '@/components/ui/input';
|
|
42
43
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
|
@@ -296,6 +297,14 @@ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false
|
|
|
296
297
|
const [code, setCode] = useState('');
|
|
297
298
|
const [addError, setAddError] = useState('');
|
|
298
299
|
const [addBusy, setAddBusy] = useState(false);
|
|
300
|
+
// With MFA already on, adding a device needs a code from an existing one (or a recovery code).
|
|
301
|
+
const [stepUpCode, setStepUpCode] = useState('');
|
|
302
|
+
const [serverAskedCode, setServerAskedCode] = useState(false);
|
|
303
|
+
const stepUpRequired = addDeviceNeedsCode({
|
|
304
|
+
deviceCount: devices.length,
|
|
305
|
+
reenrollMode,
|
|
306
|
+
serverAsked: serverAskedCode,
|
|
307
|
+
});
|
|
299
308
|
// The boxed-OTP input (input-otp forwards its ref to the real <input>). Focused when the
|
|
300
309
|
// enroll step opens so the user can type the code straight away without clicking a box.
|
|
301
310
|
const codeInputRef = useRef(null);
|
|
@@ -399,12 +408,15 @@ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false
|
|
|
399
408
|
const openAdd = () => {
|
|
400
409
|
setAddError('');
|
|
401
410
|
setNewName('');
|
|
411
|
+
setStepUpCode('');
|
|
402
412
|
setAddPhase('naming');
|
|
403
413
|
};
|
|
404
414
|
|
|
405
415
|
const resetAdd = () => {
|
|
406
416
|
setAddPhase('idle');
|
|
407
417
|
setNewName('');
|
|
418
|
+
setStepUpCode('');
|
|
419
|
+
setServerAskedCode(false);
|
|
408
420
|
setDeviceId('');
|
|
409
421
|
setSecret('');
|
|
410
422
|
setOtpauthUri('');
|
|
@@ -415,18 +427,26 @@ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false
|
|
|
415
427
|
const onBegin = async (e) => {
|
|
416
428
|
e.preventDefault();
|
|
417
429
|
setAddError('');
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
430
|
+
const plan = planBeginDevice({
|
|
431
|
+
name: newName,
|
|
432
|
+
code: stepUpCode,
|
|
433
|
+
codeRequired: stepUpRequired,
|
|
434
|
+
});
|
|
435
|
+
if (plan.error) {
|
|
436
|
+
setAddError(plan.error);
|
|
421
437
|
return;
|
|
422
438
|
}
|
|
423
439
|
setAddBusy(true);
|
|
424
|
-
const r = await account.beginMfaDevice(token, name);
|
|
440
|
+
const r = await account.beginMfaDevice(token, plan.name, plan.code);
|
|
425
441
|
setAddBusy(false);
|
|
426
442
|
if (!r.ok) {
|
|
443
|
+
if (r.reason === 'mfa_required') setServerAskedCode(true);
|
|
444
|
+
// A spent or wrong code cannot be retried: clear it.
|
|
445
|
+
if (r.reason === 'invalid_code') setStepUpCode('');
|
|
427
446
|
setAddError(r.error || 'Could not start two-factor setup.');
|
|
428
447
|
return;
|
|
429
448
|
}
|
|
449
|
+
setStepUpCode('');
|
|
430
450
|
setDeviceId(r.deviceId || '');
|
|
431
451
|
setSecret(r.secret || '');
|
|
432
452
|
setOtpauthUri(r.otpauthUri || '');
|
|
@@ -606,6 +626,21 @@ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false
|
|
|
606
626
|
A friendly label so you can tell your devices apart.
|
|
607
627
|
</p>
|
|
608
628
|
</div>
|
|
629
|
+
{stepUpRequired && (
|
|
630
|
+
<div className="space-y-1.5">
|
|
631
|
+
<Label htmlFor="mfa-stepup-code">Current code</Label>
|
|
632
|
+
<Input
|
|
633
|
+
id="mfa-stepup-code"
|
|
634
|
+
autoComplete="one-time-code"
|
|
635
|
+
placeholder="6-digit code or recovery code"
|
|
636
|
+
value={stepUpCode}
|
|
637
|
+
onChange={(e) => setStepUpCode(e.target.value)}
|
|
638
|
+
/>
|
|
639
|
+
<p className="text-xs text-muted-foreground">
|
|
640
|
+
From one of your existing authenticator apps, or a recovery code.
|
|
641
|
+
</p>
|
|
642
|
+
</div>
|
|
643
|
+
)}
|
|
609
644
|
<div className="flex items-center gap-2">
|
|
610
645
|
<Button type="submit" disabled={addBusy}>
|
|
611
646
|
{addBusy ? (
|
|
@@ -21,6 +21,26 @@ function devCsp() {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* `Referrer-Policy: no-referrer` on the email-change confirmation page (KI #14), for
|
|
26
|
+
* `npm run dev` and `npm run preview`. The page also sets the same policy with a <meta> tag
|
|
27
|
+
* while it is mounted, and its token is in the URL fragment (never sent in a Referer). In
|
|
28
|
+
* production, send this header for `/confirm-email` from whatever serves the built app.
|
|
29
|
+
*/
|
|
30
|
+
function confirmEmailNoReferrer() {
|
|
31
|
+
const setHeader = (req, res, next) => {
|
|
32
|
+
if ((req.url ?? '').split(/[?#]/)[0] === '/confirm-email') {
|
|
33
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
34
|
+
}
|
|
35
|
+
next();
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
name: 'astram-confirm-email-no-referrer',
|
|
39
|
+
configureServer: (server) => server.middlewares.use(setHeader),
|
|
40
|
+
configurePreviewServer: (server) => server.middlewares.use(setHeader),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
/**
|
|
25
45
|
* __APP_NAME__ frontend — Vite config (shadcn/ui + Tailwind v4).
|
|
26
46
|
*
|
|
@@ -29,7 +49,7 @@ function devCsp() {
|
|
|
29
49
|
* `/auth/login`. `@` aliases `src/` (the shadcn import convention).
|
|
30
50
|
*/
|
|
31
51
|
export default defineConfig({
|
|
32
|
-
plugins: [react(), tailwindcss(), devCsp()],
|
|
52
|
+
plugins: [react(), tailwindcss(), devCsp(), confirmEmailNoReferrer()],
|
|
33
53
|
resolve: {
|
|
34
54
|
alias: {
|
|
35
55
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|