create-nextblock 0.13.7 → 0.13.9

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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/(auth-pages)/two-factor/actions.ts +21 -1
  3. package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +34 -10
  4. package/templates/nextblock-template/app/actions/email.ts +78 -7
  5. package/templates/nextblock-template/app/actions/feedback.ts +57 -14
  6. package/templates/nextblock-template/app/actions/interactions.test.ts +3 -0
  7. package/templates/nextblock-template/app/actions/productGridActions.ts +40 -0
  8. package/templates/nextblock-template/app/actions.ts +17 -4
  9. package/templates/nextblock-template/app/api/cms/ecommerce/product-picker/route.ts +151 -0
  10. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +4 -1
  11. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +17 -5
  12. package/templates/nextblock-template/app/cms/blocks/components/MultiEntityPicker.tsx +251 -0
  13. package/templates/nextblock-template/app/cms/blocks/editors/ProductGridBlockEditor.tsx +375 -18
  14. package/templates/nextblock-template/app/cms/components/EcommerceActiveContext.tsx +27 -0
  15. package/templates/nextblock-template/app/cms/settings/bot-protection/actions.ts +9 -6
  16. package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +1 -5
  17. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +9 -6
  18. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +1 -5
  19. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +18 -8
  20. package/templates/nextblock-template/app/cms/settings/email/actions.ts +59 -29
  21. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +5 -1
  22. package/templates/nextblock-template/app/cms/settings/global-css/actions.ts +6 -5
  23. package/templates/nextblock-template/app/cms/settings/global-css/components/GlobalCssForm.tsx +2 -1
  24. package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +18 -7
  25. package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +1 -1
  26. package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +16 -7
  27. package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +1 -1
  28. package/templates/nextblock-template/app/cms/settings/registration/actions.ts +18 -7
  29. package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +1 -1
  30. package/templates/nextblock-template/app/cms/settings/security/actions.ts +227 -131
  31. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +134 -18
  32. package/templates/nextblock-template/components/blocks/ProductGridClient.tsx +114 -0
  33. package/templates/nextblock-template/lib/auth/twoFactor.test.ts +254 -0
  34. package/templates/nextblock-template/lib/auth/twoFactor.ts +56 -13
  35. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +78 -139
  36. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +3 -3
  37. package/templates/nextblock-template/lib/blocks/blockTypes.ts +19 -0
  38. package/templates/nextblock-template/lib/blocks/ecommerce-block-schemas.ts +61 -2
  39. package/templates/nextblock-template/lib/blocks/product-grid-data.ts +210 -0
  40. package/templates/nextblock-template/lib/cms/action-result.ts +12 -0
  41. package/templates/nextblock-template/lib/config/email-settings.ts +40 -3
  42. package/templates/nextblock-template/next-env.d.ts +1 -1
  43. package/templates/nextblock-template/package.json +1 -1
  44. 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
- const run = (fn: () => Promise<unknown>, after?: () => void) => {
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 = (await fn()) as { message?: string } | undefined;
69
- if (result?.message) setMessage({ success: result.message });
70
- after?.();
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
- const beginEmail = () => {
113
- setMode('email');
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
- run(async () => {
116
- const result = await sendEmailEnrollmentCode();
117
- setEmailSent(true);
118
- return result;
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={beginEmail}
205
- className={`rounded-lg border p-4 text-left transition hover:border-primary ${
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">Secure Email Code</p>
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
- Receive a 6-digit code at your account email each time you sign in.
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={beginEmail}
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,114 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
5
+ import { Button } from '@nextblock-cms/ui';
6
+ import { ProductGrid } from '@nextblock-cms/ecommerce/components/ProductGrid';
7
+ import type { Product } from '@nextblock-cms/ecommerce/types';
8
+ import { cn } from '@nextblock-cms/utils';
9
+
10
+ import { fetchProductGridPage } from '../../app/actions/productGridActions';
11
+ import type { ProductGridQuery } from '../../lib/blocks/product-grid-data';
12
+
13
+ interface ProductGridClientProps {
14
+ initialProducts: Product[];
15
+ totalCount: number;
16
+ /** The block's resolved query, replayed by the server action for later pages. */
17
+ query: ProductGridQuery;
18
+ showPagination: boolean;
19
+ }
20
+
21
+ export default function ProductGridClient({
22
+ initialProducts,
23
+ totalCount,
24
+ query,
25
+ showPagination,
26
+ }: ProductGridClientProps) {
27
+ const [products, setProducts] = React.useState(initialProducts);
28
+ const [currentPage, setCurrentPage] = React.useState(1);
29
+ const [isLoading, setIsLoading] = React.useState(false);
30
+ const [error, setError] = React.useState<string | null>(null);
31
+ const gridRef = React.useRef<HTMLDivElement>(null);
32
+
33
+ // Re-sync when the server sends a new first page (e.g. a live draft edit).
34
+ React.useEffect(() => {
35
+ setProducts(initialProducts);
36
+ setCurrentPage(1);
37
+ }, [initialProducts]);
38
+
39
+ const perPage = query.limit > 0 ? query.limit : products.length || 1;
40
+ const totalPages = showPagination ? Math.max(1, Math.ceil(totalCount / perPage)) : 1;
41
+
42
+ const goToPage = async (nextPage: number) => {
43
+ if (isLoading || nextPage < 1 || nextPage > totalPages || nextPage === currentPage) return;
44
+
45
+ setIsLoading(true);
46
+ setError(null);
47
+ try {
48
+ const result = await fetchProductGridPage({ ...query, page: nextPage });
49
+ if (result.error) {
50
+ setError(result.error);
51
+ } else {
52
+ setProducts(result.products);
53
+ setCurrentPage(nextPage);
54
+ // Keep the top of the grid in view rather than leaving the reader
55
+ // stranded at the bottom of the previous page.
56
+ gridRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
57
+ }
58
+ } catch {
59
+ setError('Failed to load products.');
60
+ } finally {
61
+ setIsLoading(false);
62
+ }
63
+ };
64
+
65
+ return (
66
+ <div ref={gridRef} className="scroll-mt-24">
67
+ <div
68
+ aria-busy={isLoading}
69
+ className={cn('transition-opacity duration-200', isLoading && 'pointer-events-none opacity-50')}
70
+ >
71
+ <ProductGrid products={products} />
72
+ </div>
73
+
74
+ {error && (
75
+ <p role="alert" className="mt-6 text-center text-sm text-destructive">
76
+ {error}
77
+ </p>
78
+ )}
79
+
80
+ {showPagination && totalPages > 1 && (
81
+ <nav
82
+ aria-label="Product grid pagination"
83
+ className="mt-10 flex items-center justify-center gap-3"
84
+ >
85
+ <Button
86
+ variant="outline"
87
+ size="sm"
88
+ onClick={() => goToPage(currentPage - 1)}
89
+ disabled={currentPage === 1 || isLoading}
90
+ >
91
+ <ChevronLeft className="h-4 w-4" />
92
+ Previous
93
+ </Button>
94
+ <span
95
+ aria-live="polite"
96
+ className="flex min-w-[7rem] items-center justify-center gap-1.5 text-sm text-muted-foreground"
97
+ >
98
+ {isLoading && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
99
+ Page {currentPage} of {totalPages}
100
+ </span>
101
+ <Button
102
+ variant="outline"
103
+ size="sm"
104
+ onClick={() => goToPage(currentPage + 1)}
105
+ disabled={currentPage === totalPages || isLoading}
106
+ >
107
+ Next
108
+ <ChevronRight className="h-4 w-4" />
109
+ </Button>
110
+ </nav>
111
+ )}
112
+ </div>
113
+ );
114
+ }
@@ -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
+ });