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.
@@ -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/email at once. Only sends the
8
- * fields that actually changed (partial edit). All display values are NAMES (never
9
- * UUIDs) — the profile the server returns is UUID-free by contract. (Logic lifted
10
- * verbatim from the former single-page Account.jsx ProfileCard.)
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 dirty = name.trim() !== initialName || email.trim() !== initialEmail;
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
- if (!name.trim()) {
45
- setError('Please enter your name.');
46
- return;
47
- }
48
- const changes = {};
49
- if (name.trim() !== initialName) changes.name = name.trim();
50
- if (email.trim() !== initialEmail) changes.email = email.trim();
51
- if (Object.keys(changes).length === 0) {
52
- setError('Nothing to update.');
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
- if (r.ok) {
61
- await refreshProfile();
62
- toast.success('Profile updated');
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
- setError(r.error || 'Could not update your profile.');
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 name = newName.trim();
419
- if (!name) {
420
- setAddError('Enter a name for this device.');
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)),