create-nextblock 0.13.7 → 0.13.8
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/package.json +1 -1
- package/templates/nextblock-template/app/(auth-pages)/two-factor/actions.ts +21 -1
- package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +34 -10
- package/templates/nextblock-template/app/actions/email.ts +78 -7
- package/templates/nextblock-template/app/actions/feedback.ts +57 -14
- package/templates/nextblock-template/app/actions/interactions.test.ts +3 -0
- package/templates/nextblock-template/app/actions.ts +17 -4
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/bot-protection/actions.ts +9 -6
- package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +1 -5
- package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +9 -6
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +1 -5
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +18 -8
- package/templates/nextblock-template/app/cms/settings/email/actions.ts +59 -29
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +5 -1
- package/templates/nextblock-template/app/cms/settings/global-css/actions.ts +6 -5
- package/templates/nextblock-template/app/cms/settings/global-css/components/GlobalCssForm.tsx +2 -1
- package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +18 -7
- package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +16 -7
- package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/registration/actions.ts +18 -7
- package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/security/actions.ts +227 -131
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +134 -18
- package/templates/nextblock-template/lib/auth/twoFactor.test.ts +254 -0
- package/templates/nextblock-template/lib/auth/twoFactor.ts +56 -13
- package/templates/nextblock-template/lib/cms/action-result.ts +12 -0
- package/templates/nextblock-template/lib/config/email-settings.ts +40 -3
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useRef, useState, useTransition } from 'react';
|
|
3
|
+
import { useEffect, useRef, useState, useTransition } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
4
5
|
import { useRouter } from 'next/navigation';
|
|
6
|
+
import { MailWarning } from 'lucide-react';
|
|
5
7
|
import {
|
|
6
8
|
Alert,
|
|
7
9
|
AlertDescription,
|
|
10
|
+
AlertTitle,
|
|
8
11
|
Badge,
|
|
9
12
|
Button,
|
|
10
13
|
Card,
|
|
@@ -32,11 +35,19 @@ import {
|
|
|
32
35
|
updateGlobalSecuritySettings,
|
|
33
36
|
verifyEmailEnrollment,
|
|
34
37
|
verifyTotpEnrollment,
|
|
38
|
+
type ActionResult,
|
|
35
39
|
type SecurityPanelData,
|
|
36
40
|
} from '../actions';
|
|
37
41
|
|
|
38
42
|
type EnrollMode = 'idle' | 'totp' | 'email';
|
|
39
43
|
|
|
44
|
+
/**
|
|
45
|
+
* How long the resend button stays parked after a successful send. Relays queue for
|
|
46
|
+
* seconds-to-minutes, and hammering resend is what leaves a user holding several codes
|
|
47
|
+
* with no idea which to type — they all work now, but the wait is still the real fix.
|
|
48
|
+
*/
|
|
49
|
+
const RESEND_COOLDOWN_SECONDS = 30;
|
|
50
|
+
|
|
40
51
|
function formatDate(value: string): string {
|
|
41
52
|
try {
|
|
42
53
|
return new Date(value).toLocaleDateString(undefined, {
|
|
@@ -60,16 +71,37 @@ export default function SecurityPanel({ data }: { data: SecurityPanelData }) {
|
|
|
60
71
|
);
|
|
61
72
|
const [emailSent, setEmailSent] = useState(false);
|
|
62
73
|
const [code, setCode] = useState('');
|
|
74
|
+
const [cooldown, setCooldown] = useState(0);
|
|
75
|
+
// Server-reported: catches SMTP breaking between page load and the click, when the
|
|
76
|
+
// server-rendered `data.emailConfigured` is already stale.
|
|
77
|
+
const [smtpUnavailable, setSmtpUnavailable] = useState(false);
|
|
78
|
+
const emailAvailable = data.emailConfigured && !smtpUnavailable;
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (cooldown <= 0) return;
|
|
82
|
+
const timer = setTimeout(() => setCooldown((seconds) => seconds - 1), 1000);
|
|
83
|
+
return () => clearTimeout(timer);
|
|
84
|
+
}, [cooldown]);
|
|
63
85
|
|
|
64
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Actions report failure by returning it, so a failed one must not run `onSuccess` — a
|
|
88
|
+
* rejected code has to leave the QR / code entry on screen for another attempt.
|
|
89
|
+
*/
|
|
90
|
+
const run = (fn: () => Promise<ActionResult>, onSuccess?: () => void) => {
|
|
65
91
|
setMessage(null);
|
|
66
92
|
startTransition(async () => {
|
|
67
93
|
try {
|
|
68
|
-
const result =
|
|
69
|
-
if (result
|
|
70
|
-
|
|
94
|
+
const result = await fn();
|
|
95
|
+
if (!result.ok) {
|
|
96
|
+
if (result.needsSmtp) setSmtpUnavailable(true);
|
|
97
|
+
setMessage({ error: result.error });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setMessage({ success: result.message });
|
|
101
|
+
onSuccess?.();
|
|
71
102
|
router.refresh();
|
|
72
103
|
} catch (error) {
|
|
104
|
+
// Transport-level only (the actions themselves no longer throw).
|
|
73
105
|
setMessage({
|
|
74
106
|
error: error instanceof Error ? error.message : 'Something went wrong.',
|
|
75
107
|
});
|
|
@@ -109,13 +141,45 @@ export default function SecurityPanel({ data }: { data: SecurityPanelData }) {
|
|
|
109
141
|
run(() => verifyTotpEnrollment(formData), resetEnrollment);
|
|
110
142
|
};
|
|
111
143
|
|
|
112
|
-
|
|
113
|
-
|
|
144
|
+
/** Used for both the initial "Secure Email Code" click and the Resend button. */
|
|
145
|
+
const requestEmailCode = () => {
|
|
146
|
+
if (!emailAvailable) return;
|
|
114
147
|
setTotp(null);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
148
|
+
setMessage(null);
|
|
149
|
+
if (mode !== 'email') {
|
|
150
|
+
setMode('email');
|
|
151
|
+
setCode('');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (cooldown > 0) {
|
|
155
|
+
setMessage({
|
|
156
|
+
success: `A code is already on its way to ${data.email}. You can request another in ${cooldown}s.`,
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
startTransition(async () => {
|
|
162
|
+
try {
|
|
163
|
+
const result = await sendEmailEnrollmentCode();
|
|
164
|
+
if (result.ok) {
|
|
165
|
+
setEmailSent(true);
|
|
166
|
+
setCooldown(RESEND_COOLDOWN_SECONDS);
|
|
167
|
+
setMessage({ success: result.message });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (result.needsSmtp) setSmtpUnavailable(true);
|
|
171
|
+
// Never leave the panel stuck on "Sending…" — drop back to the chooser so the
|
|
172
|
+
// failure and the fix (configure SMTP) are the only things on screen.
|
|
173
|
+
setEmailSent(false);
|
|
174
|
+
setMode('idle');
|
|
175
|
+
setMessage({ error: result.error });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
setEmailSent(false);
|
|
178
|
+
setMode('idle');
|
|
179
|
+
setMessage({
|
|
180
|
+
error: error instanceof Error ? error.message : 'Could not send a code.',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
119
183
|
});
|
|
120
184
|
};
|
|
121
185
|
|
|
@@ -185,6 +249,8 @@ export default function SecurityPanel({ data }: { data: SecurityPanelData }) {
|
|
|
185
249
|
</div>
|
|
186
250
|
) : (
|
|
187
251
|
<div className="space-y-4">
|
|
252
|
+
{!emailAvailable && <SmtpRequiredNotice isAdmin={data.isAdmin} />}
|
|
253
|
+
|
|
188
254
|
{/* Method chooser */}
|
|
189
255
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
190
256
|
<button
|
|
@@ -201,14 +267,25 @@ export default function SecurityPanel({ data }: { data: SecurityPanelData }) {
|
|
|
201
267
|
</button>
|
|
202
268
|
<button
|
|
203
269
|
type="button"
|
|
204
|
-
onClick={
|
|
205
|
-
|
|
270
|
+
onClick={requestEmailCode}
|
|
271
|
+
disabled={!emailAvailable}
|
|
272
|
+
aria-describedby={!emailAvailable ? 'smtp-required-notice' : undefined}
|
|
273
|
+
className={`rounded-lg border p-4 text-left transition enabled:hover:border-primary disabled:cursor-not-allowed disabled:opacity-60 ${
|
|
206
274
|
mode === 'email' ? 'border-primary ring-1 ring-primary' : ''
|
|
207
275
|
}`}
|
|
208
276
|
>
|
|
209
|
-
<p className="font-medium text-sm">
|
|
277
|
+
<p className="font-medium text-sm">
|
|
278
|
+
Secure Email Code
|
|
279
|
+
{!emailAvailable && (
|
|
280
|
+
<Badge variant="secondary" className="ml-2 align-middle text-[10px]">
|
|
281
|
+
Needs SMTP
|
|
282
|
+
</Badge>
|
|
283
|
+
)}
|
|
284
|
+
</p>
|
|
210
285
|
<p className="text-xs text-slate-500">
|
|
211
|
-
|
|
286
|
+
{emailAvailable
|
|
287
|
+
? 'Receive a 6-digit code at your account email each time you sign in.'
|
|
288
|
+
: 'Unavailable until an email (SMTP) server is configured for this site.'}
|
|
212
289
|
</p>
|
|
213
290
|
</button>
|
|
214
291
|
</div>
|
|
@@ -298,16 +375,23 @@ export default function SecurityPanel({ data }: { data: SecurityPanelData }) {
|
|
|
298
375
|
</Button>
|
|
299
376
|
<Button
|
|
300
377
|
variant="ghost"
|
|
301
|
-
onClick={
|
|
302
|
-
disabled={isPending}
|
|
378
|
+
onClick={requestEmailCode}
|
|
379
|
+
disabled={isPending || cooldown > 0}
|
|
303
380
|
title="Send a new code"
|
|
304
381
|
>
|
|
305
|
-
Resend
|
|
382
|
+
{cooldown > 0 ? `Resend in ${cooldown}s` : 'Resend'}
|
|
306
383
|
</Button>
|
|
307
384
|
<Button variant="ghost" onClick={resetEnrollment} disabled={isPending}>
|
|
308
385
|
Cancel
|
|
309
386
|
</Button>
|
|
310
387
|
</div>
|
|
388
|
+
{emailSent && (
|
|
389
|
+
<p className="text-xs text-slate-500">
|
|
390
|
+
Delivery depends on your mail provider and can take a minute. If you
|
|
391
|
+
request another code, the earlier ones keep working for 5 minutes — enter
|
|
392
|
+
whichever arrives first.
|
|
393
|
+
</p>
|
|
394
|
+
)}
|
|
311
395
|
</div>
|
|
312
396
|
)}
|
|
313
397
|
</div>
|
|
@@ -383,6 +467,38 @@ export default function SecurityPanel({ data }: { data: SecurityPanelData }) {
|
|
|
383
467
|
);
|
|
384
468
|
}
|
|
385
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Shown when no SMTP transport resolves. Without one the email factor is a dead end — the
|
|
472
|
+
* code is minted and the panel claims success, but nothing is ever delivered — so the tile
|
|
473
|
+
* is disabled and this points at the fix instead.
|
|
474
|
+
*/
|
|
475
|
+
function SmtpRequiredNotice({ isAdmin }: { isAdmin: boolean }) {
|
|
476
|
+
return (
|
|
477
|
+
<Alert variant="warning" id="smtp-required-notice">
|
|
478
|
+
<MailWarning className="h-4 w-4" />
|
|
479
|
+
<AlertTitle>Email codes need an SMTP server</AlertTitle>
|
|
480
|
+
<AlertDescription className="space-y-2">
|
|
481
|
+
<p>
|
|
482
|
+
This site has no outgoing mail server configured, so a verification code cannot
|
|
483
|
+
reach your inbox. Use an authenticator app instead, or set up SMTP first.
|
|
484
|
+
</p>
|
|
485
|
+
{isAdmin ? (
|
|
486
|
+
<Link
|
|
487
|
+
href="/cms/settings/email"
|
|
488
|
+
className="inline-flex items-center font-medium underline underline-offset-4"
|
|
489
|
+
>
|
|
490
|
+
Configure email (SMTP) →
|
|
491
|
+
</Link>
|
|
492
|
+
) : (
|
|
493
|
+
<p className="font-medium">
|
|
494
|
+
Ask an administrator to configure it under Settings → Email.
|
|
495
|
+
</p>
|
|
496
|
+
)}
|
|
497
|
+
</AlertDescription>
|
|
498
|
+
</Alert>
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
386
502
|
function SignupPolicyCard({
|
|
387
503
|
initial,
|
|
388
504
|
isPending,
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const dbServerMocks = vi.hoisted(() => ({
|
|
4
|
+
createClient: vi.fn(),
|
|
5
|
+
getServiceRoleSupabaseClient: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('@nextblock-cms/db/server', () => dbServerMocks);
|
|
9
|
+
vi.mock('./cookies', () => ({
|
|
10
|
+
TWO_FACTOR_COOKIE: 'nb_2fa',
|
|
11
|
+
clearCookie: vi.fn(),
|
|
12
|
+
getCookieValue: vi.fn(),
|
|
13
|
+
setSecureCookie: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('./trustedDevices', () => ({ hasValidTrustedDevice: vi.fn() }));
|
|
16
|
+
vi.mock('../privacy/settings', () => ({ getSecuritySettings: vi.fn() }));
|
|
17
|
+
vi.mock('server-only', () => ({}));
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createEmailChallenge,
|
|
21
|
+
getEmailResendCooldownSeconds,
|
|
22
|
+
hasPendingEmailChallenge,
|
|
23
|
+
verifyEmailChallenge,
|
|
24
|
+
} from './twoFactor';
|
|
25
|
+
|
|
26
|
+
type ChallengeRow = {
|
|
27
|
+
id: string;
|
|
28
|
+
user_id: string;
|
|
29
|
+
token_hash: string;
|
|
30
|
+
expires_at: string;
|
|
31
|
+
consumed_at: string | null;
|
|
32
|
+
created_at: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Minimal in-memory stand-in for the `email_2fa_challenges` table, supporting exactly the
|
|
37
|
+
* PostgREST chains twoFactor.ts builds. Rows get strictly increasing `created_at` values so
|
|
38
|
+
* the "newest N codes" window is deterministic rather than clock-resolution dependent.
|
|
39
|
+
*/
|
|
40
|
+
function createFakeDb() {
|
|
41
|
+
const rows: ChallengeRow[] = [];
|
|
42
|
+
let sequence = 0;
|
|
43
|
+
|
|
44
|
+
function makeQuery(kind: 'select' | 'update', patch?: Partial<ChallengeRow>) {
|
|
45
|
+
const filters: Array<(row: ChallengeRow) => boolean> = [];
|
|
46
|
+
let newestFirst = false;
|
|
47
|
+
let max = Number.POSITIVE_INFINITY;
|
|
48
|
+
|
|
49
|
+
const run = () => {
|
|
50
|
+
let matched = rows.filter((row) => filters.every((keep) => keep(row)));
|
|
51
|
+
if (newestFirst) {
|
|
52
|
+
matched = [...matched].sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
53
|
+
}
|
|
54
|
+
matched = matched.slice(0, max);
|
|
55
|
+
if (kind === 'update') {
|
|
56
|
+
matched.forEach((row) => Object.assign(row, patch));
|
|
57
|
+
}
|
|
58
|
+
return { data: matched.map((row) => ({ ...row })), error: null };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const query = {
|
|
62
|
+
eq(column: keyof ChallengeRow, value: unknown) {
|
|
63
|
+
filters.push((row) => row[column] === value);
|
|
64
|
+
return query;
|
|
65
|
+
},
|
|
66
|
+
is(column: keyof ChallengeRow, value: unknown) {
|
|
67
|
+
filters.push((row) => row[column] === value);
|
|
68
|
+
return query;
|
|
69
|
+
},
|
|
70
|
+
gt(column: keyof ChallengeRow, value: string) {
|
|
71
|
+
filters.push((row) => String(row[column]) > value);
|
|
72
|
+
return query;
|
|
73
|
+
},
|
|
74
|
+
order(_column: string, options?: { ascending?: boolean }) {
|
|
75
|
+
newestFirst = options?.ascending === false;
|
|
76
|
+
return query;
|
|
77
|
+
},
|
|
78
|
+
limit(count: number) {
|
|
79
|
+
max = count;
|
|
80
|
+
return query;
|
|
81
|
+
},
|
|
82
|
+
then<TResult>(
|
|
83
|
+
onFulfilled: (value: ReturnType<typeof run>) => TResult,
|
|
84
|
+
onRejected?: (reason: unknown) => TResult,
|
|
85
|
+
) {
|
|
86
|
+
return Promise.resolve(run()).then(onFulfilled, onRejected);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
return query;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const client = {
|
|
93
|
+
from(table: string) {
|
|
94
|
+
if (table !== 'email_2fa_challenges') {
|
|
95
|
+
throw new Error(`Unexpected table in test: ${table}`);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
insert(values: Omit<ChallengeRow, 'id' | 'consumed_at' | 'created_at'>) {
|
|
99
|
+
sequence += 1;
|
|
100
|
+
rows.push({
|
|
101
|
+
id: `row-${sequence}`,
|
|
102
|
+
consumed_at: null,
|
|
103
|
+
// Distinct, ordered timestamps regardless of how fast the test runs.
|
|
104
|
+
created_at: new Date(Date.UTC(2026, 0, 1) + sequence).toISOString(),
|
|
105
|
+
...values,
|
|
106
|
+
});
|
|
107
|
+
return Promise.resolve({ data: null, error: null });
|
|
108
|
+
},
|
|
109
|
+
select: () => makeQuery('select'),
|
|
110
|
+
update: (patch: Partial<ChallengeRow>) => makeQuery('update', patch),
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return { client, rows };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const USER = 'user-1';
|
|
119
|
+
|
|
120
|
+
describe('email 2FA challenges', () => {
|
|
121
|
+
let db: ReturnType<typeof createFakeDb>;
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
vi.clearAllMocks();
|
|
125
|
+
process.env.NB_2FA_SECRET = 'test-secret';
|
|
126
|
+
db = createFakeDb();
|
|
127
|
+
dbServerMocks.getServiceRoleSupabaseClient.mockReturnValue(db.client);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('keeps an earlier code valid after a resend', async () => {
|
|
131
|
+
// The regression: relays deliver out of order, so the code a user receives first is
|
|
132
|
+
// often the one requested first. Issuing a new code must not kill it.
|
|
133
|
+
const first = await createEmailChallenge(USER);
|
|
134
|
+
const second = await createEmailChallenge(USER);
|
|
135
|
+
expect(first).not.toBe(second);
|
|
136
|
+
|
|
137
|
+
await expect(verifyEmailChallenge(USER, first)).resolves.toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('accepts the newest code too', async () => {
|
|
141
|
+
await createEmailChallenge(USER);
|
|
142
|
+
const second = await createEmailChallenge(USER);
|
|
143
|
+
|
|
144
|
+
await expect(verifyEmailChallenge(USER, second)).resolves.toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('burns every live sibling once one code succeeds', async () => {
|
|
148
|
+
const first = await createEmailChallenge(USER);
|
|
149
|
+
const second = await createEmailChallenge(USER);
|
|
150
|
+
|
|
151
|
+
await expect(verifyEmailChallenge(USER, first)).resolves.toBe(true);
|
|
152
|
+
// The unused sibling must not stay redeemable after the factor is satisfied.
|
|
153
|
+
await expect(verifyEmailChallenge(USER, second)).resolves.toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('drops the fourth-oldest code out of the usable window', async () => {
|
|
157
|
+
const oldest = await createEmailChallenge(USER);
|
|
158
|
+
await createEmailChallenge(USER);
|
|
159
|
+
await createEmailChallenge(USER);
|
|
160
|
+
await createEmailChallenge(USER);
|
|
161
|
+
|
|
162
|
+
// Unreachable even though its row has not expired — the window caps the guess surface.
|
|
163
|
+
await expect(verifyEmailChallenge(USER, oldest)).resolves.toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('keeps all three in-window codes usable', async () => {
|
|
167
|
+
await createEmailChallenge(USER);
|
|
168
|
+
const second = await createEmailChallenge(USER);
|
|
169
|
+
await createEmailChallenge(USER);
|
|
170
|
+
await createEmailChallenge(USER);
|
|
171
|
+
|
|
172
|
+
// Second-oldest of four is the boundary of the three-code window.
|
|
173
|
+
await expect(verifyEmailChallenge(USER, second)).resolves.toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rejects expired codes', async () => {
|
|
177
|
+
const code = await createEmailChallenge(USER);
|
|
178
|
+
db.rows.forEach((row) => {
|
|
179
|
+
row.expires_at = new Date(Date.now() - 1000).toISOString();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await expect(verifyEmailChallenge(USER, code)).resolves.toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('rejects malformed codes without touching the database', async () => {
|
|
186
|
+
await createEmailChallenge(USER);
|
|
187
|
+
const fromSpy = vi.spyOn(db.client, 'from');
|
|
188
|
+
|
|
189
|
+
await expect(verifyEmailChallenge(USER, 'abc')).resolves.toBe(false);
|
|
190
|
+
await expect(verifyEmailChallenge(USER, '12345')).resolves.toBe(false);
|
|
191
|
+
expect(fromSpy).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('does not leak codes across users', async () => {
|
|
195
|
+
const code = await createEmailChallenge(USER);
|
|
196
|
+
|
|
197
|
+
await expect(verifyEmailChallenge('user-2', code)).resolves.toBe(false);
|
|
198
|
+
await expect(verifyEmailChallenge(USER, code)).resolves.toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('resend throttle', () => {
|
|
202
|
+
/** The fake stamps rows at a fixed epoch; move the newest one to a chosen age. */
|
|
203
|
+
const ageNewestBy = (ms: number) => {
|
|
204
|
+
const newest = [...db.rows].sort((a, b) => b.created_at.localeCompare(a.created_at))[0];
|
|
205
|
+
newest.created_at = new Date(Date.now() - ms).toISOString();
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
it('allows the first send', async () => {
|
|
209
|
+
await expect(getEmailResendCooldownSeconds(USER)).resolves.toBe(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('blocks a send immediately after one', async () => {
|
|
213
|
+
await createEmailChallenge(USER);
|
|
214
|
+
ageNewestBy(0);
|
|
215
|
+
|
|
216
|
+
const wait = await getEmailResendCooldownSeconds(USER);
|
|
217
|
+
expect(wait).toBeGreaterThan(0);
|
|
218
|
+
expect(wait).toBeLessThanOrEqual(20);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('allows another send once the window passes', async () => {
|
|
222
|
+
await createEmailChallenge(USER);
|
|
223
|
+
ageNewestBy(25_000);
|
|
224
|
+
|
|
225
|
+
await expect(getEmailResendCooldownSeconds(USER)).resolves.toBe(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('measures time since the last send, not the last live code', async () => {
|
|
229
|
+
const code = await createEmailChallenge(USER);
|
|
230
|
+
await verifyEmailChallenge(USER, code); // consumes it
|
|
231
|
+
ageNewestBy(0);
|
|
232
|
+
|
|
233
|
+
// A consumed row still represents an email that just went out.
|
|
234
|
+
await expect(getEmailResendCooldownSeconds(USER)).resolves.toBeGreaterThan(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('throttles per user', async () => {
|
|
238
|
+
await createEmailChallenge(USER);
|
|
239
|
+
ageNewestBy(0);
|
|
240
|
+
|
|
241
|
+
await expect(getEmailResendCooldownSeconds('user-2')).resolves.toBe(0);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('reports a pending challenge only while one is live', async () => {
|
|
246
|
+
await expect(hasPendingEmailChallenge(USER)).resolves.toBe(false);
|
|
247
|
+
|
|
248
|
+
const code = await createEmailChallenge(USER);
|
|
249
|
+
await expect(hasPendingEmailChallenge(USER)).resolves.toBe(true);
|
|
250
|
+
|
|
251
|
+
await verifyEmailChallenge(USER, code);
|
|
252
|
+
await expect(hasPendingEmailChallenge(USER)).resolves.toBe(false);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -16,20 +16,32 @@ const EMAIL_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
16
16
|
const TWO_FACTOR_SESSION_TTL_SECONDS = 12 * 60 * 60; // 12 hours
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* How many of a user's most recent codes stay usable at once.
|
|
20
|
+
*
|
|
21
|
+
* Relays (SMTP2GO, SES, …) do not preserve send order and can take minutes to deliver, so
|
|
22
|
+
* a user who clicks "resend" often receives the ORIGINAL code first. Hard-invalidating on
|
|
23
|
+
* every request made that first-arriving code fail — the classic "the code you emailed me
|
|
24
|
+
* doesn't work" loop. Keeping a short window of concurrently valid codes removes the race;
|
|
25
|
+
* the guess surface stays bounded because only this many are ever checked and all of them
|
|
26
|
+
* die on first successful use (and after EMAIL_CODE_TTL_MS regardless).
|
|
27
|
+
*/
|
|
28
|
+
const MAX_LIVE_EMAIL_CODES = 3;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Minimum gap between user-requested codes. Deliberately shorter than the UI countdown so a
|
|
32
|
+
* normal user never sees the server refusal — the client timer is a courtesy that a crafted
|
|
33
|
+
* request ignores, and this is what actually bounds how fast one session can drive the mailer.
|
|
34
|
+
*/
|
|
35
|
+
const RESEND_COOLDOWN_MS = 20_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create and persist a hashed 6-digit email code and return the RAW code so the caller can
|
|
39
|
+
* email it. Earlier codes are deliberately left alive — see MAX_LIVE_EMAIL_CODES. Kept to a
|
|
40
|
+
* single round trip: this sits directly in front of the SMTP handoff on a click path.
|
|
21
41
|
*/
|
|
22
42
|
export async function createEmailChallenge(userId: string): Promise<string> {
|
|
23
43
|
const code = generateNumericCode(6);
|
|
24
44
|
const svc = getServiceRoleSupabaseClient();
|
|
25
|
-
const nowIso = new Date().toISOString();
|
|
26
|
-
|
|
27
|
-
// Invalidate any earlier pending codes so only the newest one works.
|
|
28
|
-
await svc
|
|
29
|
-
.from('email_2fa_challenges')
|
|
30
|
-
.update({ consumed_at: nowIso })
|
|
31
|
-
.eq('user_id', userId)
|
|
32
|
-
.is('consumed_at', null);
|
|
33
45
|
|
|
34
46
|
await svc.from('email_2fa_challenges').insert({
|
|
35
47
|
user_id: userId,
|
|
@@ -40,6 +52,27 @@ export async function createEmailChallenge(userId: string): Promise<string> {
|
|
|
40
52
|
return code;
|
|
41
53
|
}
|
|
42
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Seconds the caller must wait before another code may be requested, or 0 when a send is
|
|
57
|
+
* allowed. Considers consumed rows too — this measures time since the last SEND, not since
|
|
58
|
+
* the last still-usable code.
|
|
59
|
+
*/
|
|
60
|
+
export async function getEmailResendCooldownSeconds(userId: string): Promise<number> {
|
|
61
|
+
const svc = getServiceRoleSupabaseClient();
|
|
62
|
+
const { data } = await svc
|
|
63
|
+
.from('email_2fa_challenges')
|
|
64
|
+
.select('created_at')
|
|
65
|
+
.eq('user_id', userId)
|
|
66
|
+
.order('created_at', { ascending: false })
|
|
67
|
+
.limit(1);
|
|
68
|
+
|
|
69
|
+
const latest = data?.[0]?.created_at;
|
|
70
|
+
if (!latest) return 0;
|
|
71
|
+
const elapsed = Date.now() - new Date(latest).getTime();
|
|
72
|
+
if (!Number.isFinite(elapsed) || elapsed >= RESEND_COOLDOWN_MS) return 0;
|
|
73
|
+
return Math.ceil((RESEND_COOLDOWN_MS - elapsed) / 1000);
|
|
74
|
+
}
|
|
75
|
+
|
|
43
76
|
/** True when the user has an unconsumed, unexpired email code awaiting entry. */
|
|
44
77
|
export async function hasPendingEmailChallenge(userId: string): Promise<boolean> {
|
|
45
78
|
const svc = getServiceRoleSupabaseClient();
|
|
@@ -53,7 +86,11 @@ export async function hasPendingEmailChallenge(userId: string): Promise<boolean>
|
|
|
53
86
|
return Boolean(data && data.length > 0);
|
|
54
87
|
}
|
|
55
88
|
|
|
56
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Verify a submitted email code against the user's live challenges. Any of the newest
|
|
91
|
+
* MAX_LIVE_EMAIL_CODES is accepted, so a code that arrives out of order still works;
|
|
92
|
+
* anything older than that window is unreachable even though its row lingers until expiry.
|
|
93
|
+
*/
|
|
57
94
|
export async function verifyEmailChallenge(userId: string, code: string): Promise<boolean> {
|
|
58
95
|
const trimmed = (code || '').trim();
|
|
59
96
|
if (!/^\d{6}$/.test(trimmed)) return false;
|
|
@@ -67,14 +104,20 @@ export async function verifyEmailChallenge(userId: string, code: string): Promis
|
|
|
67
104
|
.is('consumed_at', null)
|
|
68
105
|
.gt('expires_at', nowIso)
|
|
69
106
|
.order('created_at', { ascending: false })
|
|
70
|
-
.limit(
|
|
107
|
+
.limit(MAX_LIVE_EMAIL_CODES);
|
|
71
108
|
|
|
72
109
|
if (!data || data.length === 0) return false;
|
|
73
110
|
const candidateHash = sha256Hex(trimmed);
|
|
74
111
|
const match = data.find((row) => safeEqual(row.token_hash, candidateHash));
|
|
75
112
|
if (!match) return false;
|
|
76
113
|
|
|
77
|
-
|
|
114
|
+
// Burn every live code for this user, not just the matched row: the siblings from a
|
|
115
|
+
// resend have served their purpose and must not stay redeemable after a success.
|
|
116
|
+
await svc
|
|
117
|
+
.from('email_2fa_challenges')
|
|
118
|
+
.update({ consumed_at: nowIso })
|
|
119
|
+
.eq('user_id', userId)
|
|
120
|
+
.is('consumed_at', null);
|
|
78
121
|
return true;
|
|
79
122
|
}
|
|
80
123
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard result shape for CMS settings server actions.
|
|
3
|
+
*
|
|
4
|
+
* Next replaces the message of an uncaught Server Action error with a generic string in
|
|
5
|
+
* production builds, so anything the operator has to be able to read — a permission
|
|
6
|
+
* refusal, a relay's rejection, a validation complaint — must come back as data rather
|
|
7
|
+
* than as a throw. Actions that only ever fail in ways nobody needs to read (pure data
|
|
8
|
+
* readers rendered by a Server Component) can still throw and hit the error boundary.
|
|
9
|
+
*/
|
|
10
|
+
export type SettingsActionResult =
|
|
11
|
+
| { ok: true; message: string }
|
|
12
|
+
| { ok: false; error: string };
|
|
@@ -170,14 +170,46 @@ export async function saveEmailSettings(input: SaveEmailSettingsInput): Promise<
|
|
|
170
170
|
throw new Error('Failed to save email credentials.');
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
|
+
|
|
174
|
+
// The mailer memoizes the resolved transport config; make the new values live now.
|
|
175
|
+
invalidateEmailConfigCache();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Resolving SMTP costs two service-role reads plus an AES decrypt of each secret, and
|
|
179
|
+
// transactional email (2FA codes especially) is latency-sensitive. The values only change
|
|
180
|
+
// when an admin saves the form, so memoize briefly and bust the cache on save.
|
|
181
|
+
const CONFIG_CACHE_TTL_MS = 60_000;
|
|
182
|
+
let configCache: { value: ResolvedEmailConfig | null; expiresAt: number } | null = null;
|
|
183
|
+
|
|
184
|
+
/** Drop the memoized SMTP config so the next resolve re-reads the DB. */
|
|
185
|
+
export function invalidateEmailConfigCache(): void {
|
|
186
|
+
configCache = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Cheap "can this instance actually send mail right now?" check for UI gating. Never
|
|
191
|
+
* logs — an unconfigured instance is an expected state here, not an error condition.
|
|
192
|
+
*/
|
|
193
|
+
export async function isEmailConfigured(): Promise<boolean> {
|
|
194
|
+
return (await resolveEmailServerConfig({ silent: true })) !== null;
|
|
173
195
|
}
|
|
174
196
|
|
|
175
197
|
/**
|
|
176
198
|
* Resolve the full SMTP transport config, DB-first with an env fallback. Uses the
|
|
177
199
|
* service-role client so it works from any context (the secret row is ADMIN-only under
|
|
178
200
|
* RLS). Returns null when host/user/pass/from cannot be resolved from either source.
|
|
201
|
+
*
|
|
202
|
+
* Memoized for CONFIG_CACHE_TTL_MS; pass `silent` when "not configured" is an expected
|
|
203
|
+
* answer (UI gating) rather than a misconfiguration worth warning about.
|
|
179
204
|
*/
|
|
180
|
-
export async function resolveEmailServerConfig(
|
|
205
|
+
export async function resolveEmailServerConfig(
|
|
206
|
+
options: { silent?: boolean } = {},
|
|
207
|
+
): Promise<ResolvedEmailConfig | null> {
|
|
208
|
+
const cached = configCache;
|
|
209
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
210
|
+
return cached.value;
|
|
211
|
+
}
|
|
212
|
+
|
|
181
213
|
let pub: EmailPublicSettings = DEFAULT_EMAIL_PUBLIC_SETTINGS;
|
|
182
214
|
let secret: Record<string, unknown> = {};
|
|
183
215
|
|
|
@@ -201,12 +233,15 @@ export async function resolveEmailServerConfig(): Promise<ResolvedEmailConfig |
|
|
|
201
233
|
const pass = resolveConfigValue(tryDecryptWithEnvKey(secret['pass']), 'SMTP_PASS');
|
|
202
234
|
|
|
203
235
|
if (!host || !port || !user || !pass || !fromEmail) {
|
|
204
|
-
|
|
236
|
+
if (!options.silent) {
|
|
237
|
+
console.warn('Email is not configured (CMS or SMTP_* env). Outbound email will not be sent.');
|
|
238
|
+
}
|
|
239
|
+
configCache = { value: null, expiresAt: Date.now() + CONFIG_CACHE_TTL_MS };
|
|
205
240
|
return null;
|
|
206
241
|
}
|
|
207
242
|
|
|
208
243
|
const portNumber = Number(port);
|
|
209
|
-
|
|
244
|
+
const resolved: ResolvedEmailConfig = {
|
|
210
245
|
host,
|
|
211
246
|
port: portNumber,
|
|
212
247
|
// Honor the CMS toggle; fall back to the SMTPS convention (465 ⇒ implicit TLS).
|
|
@@ -214,4 +249,6 @@ export async function resolveEmailServerConfig(): Promise<ResolvedEmailConfig |
|
|
|
214
249
|
auth: { user, pass },
|
|
215
250
|
from: fromName ? `"${fromName}" <${fromEmail}>` : fromEmail,
|
|
216
251
|
};
|
|
252
|
+
configCache = { value: resolved, expiresAt: Date.now() + CONFIG_CACHE_TTL_MS };
|
|
253
|
+
return resolved;
|
|
217
254
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/
|
|
3
|
+
import "./.next/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|