create-stackr 0.2.0 → 0.3.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.
Files changed (127) hide show
  1. package/README.md +10 -0
  2. package/dist/prompts/features.d.ts +1 -1
  3. package/dist/prompts/features.d.ts.map +1 -1
  4. package/dist/prompts/features.js +34 -25
  5. package/dist/prompts/features.js.map +1 -1
  6. package/dist/prompts/index.js +33 -6
  7. package/dist/prompts/index.js.map +1 -1
  8. package/dist/prompts/preset.d.ts.map +1 -1
  9. package/dist/prompts/preset.js +69 -34
  10. package/dist/prompts/preset.js.map +1 -1
  11. package/dist/utils/template.js +1 -1
  12. package/dist/utils/template.js.map +1 -1
  13. package/dist/utils/validation.d.ts.map +1 -1
  14. package/dist/utils/validation.js +43 -1
  15. package/dist/utils/validation.js.map +1 -1
  16. package/package.json +1 -1
  17. package/templates/base/backend/controllers/rest-api/routes/auth.ts.ejs +33 -1
  18. package/templates/base/backend/controllers/rest-api/routes/oauth-web.ts.ejs +6 -0
  19. package/templates/base/backend/domain/user/repository.drizzle.ts +28 -1
  20. package/templates/base/backend/domain/user/repository.prisma.ts +25 -0
  21. package/templates/base/backend/drizzle/{schema.drizzle.ts → schema.drizzle.ts.ejs} +25 -0
  22. package/templates/base/backend/lib/auth.drizzle.ts.ejs +27 -18
  23. package/templates/base/backend/lib/auth.prisma.ts.ejs +24 -18
  24. package/templates/base/backend/package.json.ejs +29 -23
  25. package/templates/base/backend/prisma/schema.prisma.ejs +20 -0
  26. package/templates/base/mobile/app/+not-found.tsx +1 -1
  27. package/templates/base/mobile/app/_layout.tsx.ejs +95 -10
  28. package/templates/base/mobile/package.json.ejs +21 -13
  29. package/templates/base/mobile/src/components/ui/Button.tsx +5 -3
  30. package/templates/base/mobile/src/components/ui/Card.tsx +1 -1
  31. package/templates/base/mobile/src/components/ui/Input.tsx +1 -1
  32. package/templates/base/mobile/src/components/ui/Skeleton.tsx +1 -1
  33. package/templates/base/mobile/src/components/ui/Toast.tsx +106 -0
  34. package/templates/base/mobile/src/components/ui/{IconSymbol.tsx → icon-symbol.tsx} +7 -0
  35. package/templates/base/mobile/src/components/ui/{LoadingSpinner.tsx → loading-spinner.tsx} +1 -1
  36. package/templates/base/mobile/src/components/ui/{OnboardingLayout.tsx → onboarding-layout.tsx} +2 -2
  37. package/templates/base/mobile/src/components/ui/{PaywallLayout.tsx → paywall-layout.tsx} +3 -3
  38. package/templates/base/mobile/src/constants/Theme.ts +3 -3
  39. package/templates/base/mobile/src/context/{ThemeContext.tsx → theme-context.tsx} +2 -2
  40. package/templates/base/mobile/src/lib/auth-client.ts.ejs +18 -0
  41. package/templates/base/mobile/src/services/{sdkInitializer.ts.ejs → sdk-initializer.ts.ejs} +4 -4
  42. package/templates/base/web/.prettierignore +6 -0
  43. package/templates/base/web/.prettierrc +8 -0
  44. package/templates/base/web/eslint.config.mjs +31 -7
  45. package/templates/base/web/next.config.ts +50 -1
  46. package/templates/base/web/package.json.ejs +14 -2
  47. package/templates/base/web/src/app/globals.css +1 -1
  48. package/templates/base/web/src/app/layout.tsx.ejs +2 -0
  49. package/templates/base/web/src/components/auth/protected-route.tsx.ejs +32 -5
  50. package/templates/base/web/src/components/providers/device-session-setup.tsx.ejs +1 -1
  51. package/templates/base/web/src/hooks/use-device-session.ts.ejs +2 -2
  52. package/templates/base/web/src/lib/auth/actions.ts.ejs +438 -15
  53. package/templates/base/web/src/lib/auth/config.ts.ejs +13 -0
  54. package/templates/base/web/src/lib/auth/cookies.ts.ejs +61 -0
  55. package/templates/base/web/src/lib/device/actions.ts.ejs +2 -10
  56. package/templates/base/web/src/lib/device/types.ts +37 -0
  57. package/templates/base/web/src/proxy.ts.ejs +12 -2
  58. package/templates/base/web/src/store/{deviceSession.store.ts.ejs → device-session-store.ts.ejs} +1 -1
  59. package/templates/features/mobile/auth/app/(auth)/{_layout.tsx → _layout.tsx.ejs} +7 -0
  60. package/templates/features/mobile/auth/app/(auth)/{login.tsx → login.tsx.ejs} +9 -8
  61. package/templates/features/mobile/auth/app/(auth)/{register.tsx → register.tsx.ejs} +9 -8
  62. package/templates/features/mobile/auth/app/(auth)/two-factor-expired.tsx.ejs +88 -0
  63. package/templates/features/mobile/auth/app/(auth)/two-factor.tsx.ejs +259 -0
  64. package/templates/features/mobile/auth/app/(public)/_layout.tsx +9 -0
  65. package/templates/features/mobile/{tabs/app/(tabs)/index.tsx → auth/app/(public)/index.tsx.ejs} +76 -257
  66. package/templates/features/mobile/auth/app/(tabs)/settings/_layout.tsx.ejs +20 -0
  67. package/templates/features/mobile/auth/app/(tabs)/settings/index.tsx.ejs +137 -0
  68. package/templates/features/mobile/auth/app/(tabs)/settings/security/_layout.tsx.ejs +35 -0
  69. package/templates/features/mobile/auth/app/(tabs)/settings/security/index.tsx.ejs +147 -0
  70. package/templates/features/mobile/auth/app/(tabs)/settings/security/set-password.tsx.ejs +140 -0
  71. package/templates/features/mobile/auth/app/(tabs)/settings/security/two-factor-manage.tsx.ejs +366 -0
  72. package/templates/features/mobile/auth/app/(tabs)/settings/security/two-factor-setup.tsx.ejs +317 -0
  73. package/templates/features/mobile/auth/app/account.tsx.ejs +331 -0
  74. package/templates/features/mobile/auth/app/verify-email.tsx.ejs +315 -0
  75. package/templates/features/mobile/auth/components/auth/{LoginForm.tsx.ejs → login-form.tsx.ejs} +15 -3
  76. package/templates/features/mobile/auth/components/auth/protected-screen.tsx.ejs +56 -0
  77. package/templates/features/mobile/auth/components/auth/{RegisterForm.tsx.ejs → register-form.tsx.ejs} +5 -3
  78. package/templates/features/mobile/auth/hooks/{useAuth.ts.ejs → auth.ts.ejs} +255 -1
  79. package/templates/features/mobile/auth/hooks/two-factor-timeout.ts.ejs +56 -0
  80. package/templates/features/mobile/auth/services/{deviceSession.ts → device-session.ts} +2 -10
  81. package/templates/features/mobile/auth/store/{deviceSession.store.ts → device-session-store.ts} +14 -5
  82. package/templates/features/mobile/auth/types/device-session.ts +37 -0
  83. package/templates/features/mobile/onboarding/app/(onboarding)/page-1.tsx.ejs +3 -1
  84. package/templates/features/mobile/onboarding/app/(onboarding)/page-2.tsx.ejs +3 -1
  85. package/templates/features/mobile/onboarding/app/(onboarding)/page-3.tsx.ejs +6 -1
  86. package/templates/features/mobile/paywall/app/paywall.tsx +4 -4
  87. package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx +0 -6
  88. package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx.ejs +60 -0
  89. package/templates/features/mobile/tabs/app/(tabs)/index.tsx.ejs +264 -0
  90. package/templates/features/web/auth/app/(app)/layout.tsx.ejs +8 -22
  91. package/templates/features/web/auth/app/(auth)/login/page.tsx.ejs +19 -3
  92. package/templates/features/web/auth/app/(auth)/login/two-factor/page.tsx.ejs +24 -0
  93. package/templates/features/web/auth/app/(auth)/verify-email/page.tsx.ejs +117 -152
  94. package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/dashboard-client.tsx.ejs +41 -1
  95. package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/page.tsx.ejs +3 -1
  96. package/templates/features/web/auth/app/(protected)/layout.tsx.ejs +67 -0
  97. package/templates/features/web/auth/app/(protected)/settings/security/page.tsx.ejs +19 -0
  98. package/templates/features/web/auth/app/(protected)/settings/security/security-settings.tsx.ejs +73 -0
  99. package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/page.tsx.ejs +2 -6
  100. package/templates/features/web/auth/app/auth/callback/route.ts.ejs +5 -1
  101. package/templates/features/web/auth/app/auth/session-expired/route.ts.ejs +39 -0
  102. package/templates/features/web/auth/app/auth/two-factor-expired/route.ts.ejs +22 -0
  103. package/templates/features/web/auth/components/auth/login-form.tsx.ejs +18 -0
  104. package/templates/features/web/auth/components/auth/two-factor-verify.tsx.ejs +173 -0
  105. package/templates/features/web/auth/components/settings/set-password-form.tsx.ejs +123 -0
  106. package/templates/features/web/auth/components/settings/two-factor-manage.tsx.ejs +253 -0
  107. package/templates/features/web/auth/components/settings/two-factor-setup.tsx.ejs +249 -0
  108. package/templates/features/web/auth/components/ui/checkbox.tsx +32 -0
  109. package/templates/features/web/auth/components/ui/dialog.tsx +143 -0
  110. package/templates/features/web/auth/components/ui/input-otp.tsx +71 -0
  111. package/templates/integrations/mobile/adjust/store/{adjust.store.ts → adjust-store.ts} +3 -3
  112. package/templates/integrations/mobile/att/store/{att.store.ts → att-store.ts} +2 -2
  113. package/templates/integrations/mobile/revenuecat/store/{revenuecat.store.ts → revenuecat-store.ts} +1 -1
  114. package/templates/integrations/mobile/scate/store/{scate.store.ts → scate-store.ts} +1 -1
  115. package/templates/base/mobile/src/components/ui/index.ts +0 -6
  116. package/templates/base/mobile/src/store/index.ts.ejs +0 -18
  117. package/templates/base/web/src/lib/auth/index.ts.ejs +0 -40
  118. package/templates/features/mobile/auth/components/auth/index.ts +0 -2
  119. package/templates/features/mobile/auth/hooks/index.ts.ejs +0 -1
  120. /package/templates/base/mobile/src/services/{errorService.ts → error-service.ts} +0 -0
  121. /package/templates/base/mobile/src/store/{ui.store.ts → ui-store.ts} +0 -0
  122. /package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/sessions-client.tsx.ejs +0 -0
  123. /package/templates/integrations/mobile/adjust/services/{adjustService.ts.ejs → adjust-service.ts.ejs} +0 -0
  124. /package/templates/integrations/mobile/att/services/{attService.ts → att-service.ts} +0 -0
  125. /package/templates/integrations/mobile/att/services/{trackingPermissions.ts → tracking-permissions.ts} +0 -0
  126. /package/templates/integrations/mobile/revenuecat/services/{revenuecatService.ts.ejs → revenuecat-service.ts.ejs} +0 -0
  127. /package/templates/integrations/mobile/scate/services/{scateService.ts.ejs → scate-service.ts.ejs} +0 -0
@@ -5,8 +5,13 @@ import {
5
5
  getSessionToken,
6
6
  clearSessionCookie,
7
7
  getDeviceSessionToken,
8
+ <% if (features.authentication.twoFactor) { %>
9
+ setTwoFactorCookie,
10
+ getTwoFactorToken,
11
+ clearTwoFactorCookie,
12
+ <% } %>
8
13
  } from './cookies';
9
- import { AUTH_CONFIG, BETTER_AUTH_COOKIE_NAME } from './config';
14
+ import { AUTH_CONFIG, BETTER_AUTH_COOKIE_NAME<% if (features.authentication.twoFactor) { %>, BETTER_AUTH_TWO_FACTOR_COOKIE_NAME, COOKIE_NAMES<% } %> } from './config';
10
15
 
11
16
  // Types
12
17
  export interface User {
@@ -17,6 +22,9 @@ export interface User {
17
22
  image: string | null;
18
23
  createdAt: string;
19
24
  updatedAt: string;
25
+ <% if (features.authentication.twoFactor) { %>
26
+ twoFactorEnabled: boolean;
27
+ <% } %>
20
28
  }
21
29
 
22
30
  export interface Session {
@@ -39,6 +47,12 @@ interface SignInResult {
39
47
  success: boolean;
40
48
  error?: string;
41
49
  session?: AuthSession;
50
+ <% if (features.authentication.emailVerification) { %>
51
+ needsEmailVerification?: boolean;
52
+ <% } %>
53
+ <% if (features.authentication.twoFactor) { %>
54
+ requiresTwoFactor?: boolean;
55
+ <% } %>
42
56
  }
43
57
 
44
58
  interface SignUpResult {
@@ -49,6 +63,21 @@ interface SignUpResult {
49
63
  <% } %>
50
64
  }
51
65
 
66
+ <% if (features.authentication.twoFactor) { %>
67
+ interface EnableTwoFactorResult {
68
+ success: boolean;
69
+ error?: string;
70
+ totpURI?: string;
71
+ backupCodes?: string[];
72
+ }
73
+
74
+ interface VerifyTotpResult {
75
+ success: boolean;
76
+ error?: string;
77
+ session?: AuthSession;
78
+ }
79
+ <% } %>
80
+
52
81
  /**
53
82
  * Build headers for Fastify requests
54
83
  * Includes session token in Better Auth cookie format and Origin for CSRF protection.
@@ -62,9 +91,24 @@ export async function buildAuthHeaders(): Promise<HeadersInit> {
62
91
  'Origin': AUTH_CONFIG.appUrl,
63
92
  };
64
93
 
94
+ // Build cookie header with all auth-related cookies
95
+ const cookieParts: string[] = [];
96
+
65
97
  const sessionToken = await getSessionToken();
66
98
  if (sessionToken) {
67
- headers['Cookie'] = `${BETTER_AUTH_COOKIE_NAME}=${sessionToken}`;
99
+ cookieParts.push(`${BETTER_AUTH_COOKIE_NAME}=${sessionToken}`);
100
+ }
101
+
102
+ <% if (features.authentication.twoFactor) { %>
103
+ // Include two-factor cookie if present (needed for 2FA verification)
104
+ const twoFactorToken = await getTwoFactorToken();
105
+ if (twoFactorToken) {
106
+ cookieParts.push(`${BETTER_AUTH_TWO_FACTOR_COOKIE_NAME}=${twoFactorToken}`);
107
+ }
108
+ <% } %>
109
+
110
+ if (cookieParts.length > 0) {
111
+ headers['Cookie'] = cookieParts.join('; ');
68
112
  }
69
113
 
70
114
  const deviceToken = await getDeviceSessionToken();
@@ -87,11 +131,31 @@ export async function signIn(email: string, password: string): Promise<SignInRes
87
131
  cache: 'no-store',
88
132
  });
89
133
 
134
+ const data = await response.json().catch(() => ({}));
135
+
136
+ <% if (features.authentication.twoFactor) { %>
137
+ // Check if 2FA is required (Better Auth returns twoFactorRedirect: true)
138
+ if (data.twoFactorRedirect) {
139
+ // Extract and store temporary 2FA session token
140
+ const sessionToken = extractSessionToken(response);
141
+ if (sessionToken) {
142
+ await setSessionCookie(sessionToken);
143
+ }
144
+
145
+ // Extract and store the two-factor cookie with expiration (required for TOTP verification)
146
+ const { token: twoFactorToken, expiresAt } = extractTwoFactorTokenAndExpiration(response);
147
+ if (twoFactorToken && expiresAt) {
148
+ await setTwoFactorCookie(twoFactorToken, expiresAt);
149
+ }
150
+
151
+ return { success: true, requiresTwoFactor: true };
152
+ }
153
+ <% } %>
154
+
90
155
  if (!response.ok) {
91
- const error = await response.json().catch(() => ({}));
92
156
  return {
93
157
  success: false,
94
- error: error.message || 'Invalid email or password',
158
+ error: data.message || 'Invalid email or password',
95
159
  };
96
160
  }
97
161
 
@@ -108,6 +172,17 @@ export async function signIn(email: string, password: string): Promise<SignInRes
108
172
  // Get session data
109
173
  const session = await getSession();
110
174
 
175
+ <% if (features.authentication.emailVerification) { %>
176
+ // Check if email verification is needed
177
+ if (session?.user?.emailVerified === false) {
178
+ return {
179
+ success: true,
180
+ session: session,
181
+ needsEmailVerification: true,
182
+ };
183
+ }
184
+ <% } %>
185
+
111
186
  return { success: true, session: session || undefined };
112
187
  } catch (error) {
113
188
  console.error('Sign in error:', error);
@@ -142,12 +217,19 @@ export async function signUp(
142
217
  <% if (features.authentication.emailVerification) { %>
143
218
  const data = await response.json();
144
219
 
145
- // Check if email verification is required
146
- if (data.emailVerificationRequired) {
220
+ // Extract and set session token (user IS logged in even if email not verified)
221
+ const sessionToken = extractSessionToken(response);
222
+ if (sessionToken) {
223
+ await setSessionCookie(sessionToken);
224
+ }
225
+
226
+ // Check if email verification is needed by checking user's emailVerified status
227
+ if (data.user?.emailVerified === false) {
147
228
  return { success: true, needsEmailVerification: true };
148
229
  }
149
- <% } %>
150
230
 
231
+ return { success: true };
232
+ <% } else { %>
151
233
  // Extract and set session token
152
234
  const sessionToken = extractSessionToken(response);
153
235
 
@@ -156,6 +238,7 @@ export async function signUp(
156
238
  }
157
239
 
158
240
  return { success: true };
241
+ <% } %>
159
242
  } catch (error) {
160
243
  console.error('Sign up error:', error);
161
244
  return { success: false, error: 'An error occurred during sign up' };
@@ -204,8 +287,10 @@ export async function getSession(): Promise<AuthSession | null> {
204
287
  });
205
288
 
206
289
  if (!response.ok) {
207
- // Session invalid, clear cookie
208
- await clearSessionCookie();
290
+ // Don't try to clear cookie here - it doesn't work in Server Components
291
+ // Server Components can't send Set-Cookie headers when using redirect()
292
+ // The protected layout redirects to /auth/session-expired which is a
293
+ // Route Handler that properly clears the cookie
209
294
  return null;
210
295
  }
211
296
 
@@ -275,25 +360,284 @@ export async function resetPassword(
275
360
 
276
361
  <% if (features.authentication.emailVerification) { %>
277
362
  /**
278
- * Verify email with token
363
+ * Send verification OTP to email
279
364
  */
280
- export async function verifyEmail(token: string): Promise<{ success: boolean; error?: string }> {
365
+ export async function sendVerificationOtp(email: string): Promise<{ success: boolean; error?: string }> {
281
366
  try {
282
- const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/verify-email`, {
367
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/email-otp/send-verification-otp`, {
283
368
  method: 'POST',
284
369
  headers: { 'Content-Type': 'application/json' },
285
- body: JSON.stringify({ token }),
370
+ body: JSON.stringify({ email, type: 'email-verification' }),
286
371
  cache: 'no-store',
287
372
  });
288
373
 
289
374
  if (!response.ok) {
290
375
  const error = await response.json().catch(() => ({}));
291
- return { success: false, error: error.message || 'Failed to verify email' };
376
+ return { success: false, error: error.message || 'Failed to send verification code' };
292
377
  }
293
378
 
294
379
  return { success: true };
295
380
  } catch (error) {
296
- console.error('Email verification error:', error);
381
+ console.error('Send OTP error:', error);
382
+ return { success: false, error: 'An error occurred' };
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Verify email with OTP code
388
+ */
389
+ export async function verifyEmailOtp(email: string, otp: string): Promise<{
390
+ success: boolean;
391
+ error?: string;
392
+ session?: AuthSession;
393
+ }> {
394
+ try {
395
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/email-otp/verify-email`, {
396
+ method: 'POST',
397
+ headers: await buildAuthHeaders(), // Include existing session cookie
398
+ body: JSON.stringify({ email, otp }),
399
+ cache: 'no-store',
400
+ });
401
+
402
+ if (!response.ok) {
403
+ const error = await response.json().catch(() => ({}));
404
+ return { success: false, error: error.message || 'Invalid verification code' };
405
+ }
406
+
407
+ // Get the updated session (emailVerified is now true)
408
+ const session = await getSession();
409
+ return { success: true, session: session || undefined };
410
+ } catch (error) {
411
+ console.error('Verify OTP error:', error);
412
+ return { success: false, error: 'An error occurred' };
413
+ }
414
+ }
415
+ <% } %>
416
+
417
+ <% if (features.authentication.twoFactor) { %>
418
+ /**
419
+ * Enable two-factor authentication
420
+ * Returns TOTP URI for QR code and backup codes
421
+ */
422
+ export async function enableTwoFactor(password: string): Promise<EnableTwoFactorResult> {
423
+ try {
424
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/enable`, {
425
+ method: 'POST',
426
+ headers: await buildAuthHeaders(),
427
+ body: JSON.stringify({ password }),
428
+ cache: 'no-store',
429
+ });
430
+
431
+ if (!response.ok) {
432
+ const error = await response.json().catch(() => ({}));
433
+ return { success: false, error: error.message || 'Failed to enable 2FA' };
434
+ }
435
+
436
+ const data = await response.json();
437
+ return {
438
+ success: true,
439
+ totpURI: data.totpURI,
440
+ backupCodes: data.backupCodes,
441
+ };
442
+ } catch (error) {
443
+ console.error('Enable 2FA error:', error);
444
+ return { success: false, error: 'An error occurred' };
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Verify TOTP code during 2FA setup (activates 2FA)
450
+ */
451
+ export async function verifyTotpSetup(code: string): Promise<VerifyTotpResult> {
452
+ try {
453
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/verify-totp`, {
454
+ method: 'POST',
455
+ headers: await buildAuthHeaders(),
456
+ body: JSON.stringify({ code }),
457
+ cache: 'no-store',
458
+ });
459
+
460
+ if (!response.ok) {
461
+ const error = await response.json().catch(() => ({}));
462
+ return { success: false, error: error.message || 'Invalid verification code' };
463
+ }
464
+
465
+ // Extract and set session token after successful 2FA setup
466
+ const sessionToken = extractSessionToken(response);
467
+ if (sessionToken) {
468
+ await setSessionCookie(sessionToken);
469
+ }
470
+
471
+ const session = await getSession();
472
+ return { success: true, session: session || undefined };
473
+ } catch (error) {
474
+ console.error('Verify TOTP setup error:', error);
475
+ return { success: false, error: 'An error occurred' };
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Verify TOTP code during login (2FA challenge)
481
+ */
482
+ export async function verifyTotpLogin(code: string, trustDevice?: boolean): Promise<VerifyTotpResult> {
483
+ try {
484
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/verify-totp`, {
485
+ method: 'POST',
486
+ headers: await buildAuthHeaders(),
487
+ body: JSON.stringify({ code, trustDevice }),
488
+ cache: 'no-store',
489
+ });
490
+
491
+ if (!response.ok) {
492
+ const error = await response.json().catch(() => ({}));
493
+ return { success: false, error: error.message || 'Invalid verification code' };
494
+ }
495
+
496
+ // Extract and set session token after successful 2FA
497
+ const sessionToken = extractSessionToken(response);
498
+ if (sessionToken) {
499
+ await setSessionCookie(sessionToken);
500
+ }
501
+
502
+ // Clear 2FA cookies after successful verification
503
+ await clearTwoFactorCookie();
504
+
505
+ const session = await getSession();
506
+ return { success: true, session: session || undefined };
507
+ } catch (error) {
508
+ console.error('Verify TOTP login error:', error);
509
+ return { success: false, error: 'An error occurred' };
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Disable two-factor authentication
515
+ */
516
+ export async function disableTwoFactor(password: string): Promise<{ success: boolean; error?: string }> {
517
+ try {
518
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/disable`, {
519
+ method: 'POST',
520
+ headers: await buildAuthHeaders(),
521
+ body: JSON.stringify({ password }),
522
+ cache: 'no-store',
523
+ });
524
+
525
+ if (!response.ok) {
526
+ const error = await response.json().catch(() => ({}));
527
+ return { success: false, error: error.message || 'Failed to disable 2FA' };
528
+ }
529
+
530
+ return { success: true };
531
+ } catch (error) {
532
+ console.error('Disable 2FA error:', error);
533
+ return { success: false, error: 'An error occurred' };
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Generate new backup codes
539
+ */
540
+ export async function generateBackupCodes(password: string): Promise<{ success: boolean; error?: string; backupCodes?: string[] }> {
541
+ try {
542
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/generate-backup-codes`, {
543
+ method: 'POST',
544
+ headers: await buildAuthHeaders(),
545
+ body: JSON.stringify({ password }),
546
+ cache: 'no-store',
547
+ });
548
+
549
+ if (!response.ok) {
550
+ const error = await response.json().catch(() => ({}));
551
+ return { success: false, error: error.message || 'Failed to generate backup codes' };
552
+ }
553
+
554
+ const data = await response.json();
555
+ return { success: true, backupCodes: data.backupCodes };
556
+ } catch (error) {
557
+ console.error('Generate backup codes error:', error);
558
+ return { success: false, error: 'An error occurred' };
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Verify backup code during login
564
+ */
565
+ export async function verifyBackupCode(code: string): Promise<VerifyTotpResult> {
566
+ try {
567
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/two-factor/verify-backup-code`, {
568
+ method: 'POST',
569
+ headers: await buildAuthHeaders(),
570
+ body: JSON.stringify({ code }),
571
+ cache: 'no-store',
572
+ });
573
+
574
+ if (!response.ok) {
575
+ const error = await response.json().catch(() => ({}));
576
+ return { success: false, error: error.message || 'Invalid backup code' };
577
+ }
578
+
579
+ const sessionToken = extractSessionToken(response);
580
+ if (sessionToken) {
581
+ await setSessionCookie(sessionToken);
582
+ }
583
+
584
+ // Clear 2FA cookies after successful verification
585
+ await clearTwoFactorCookie();
586
+
587
+ const session = await getSession();
588
+ return { success: true, session: session || undefined };
589
+ } catch (error) {
590
+ console.error('Verify backup code error:', error);
591
+ return { success: false, error: 'An error occurred' };
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Check if user has a password (credential account)
597
+ * Used to determine if OAuth user needs to set password before enabling 2FA
598
+ */
599
+ export async function checkHasPassword(): Promise<{ hasPassword: boolean }> {
600
+ try {
601
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/has-password`, {
602
+ method: 'GET',
603
+ headers: await buildAuthHeaders(),
604
+ cache: 'no-store',
605
+ });
606
+
607
+ if (!response.ok) {
608
+ return { hasPassword: false };
609
+ }
610
+
611
+ return response.json();
612
+ } catch (error) {
613
+ console.error('Check has password error:', error);
614
+ return { hasPassword: false };
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Set initial password for OAuth users
620
+ * Allows OAuth users to add a password to their account for 2FA
621
+ */
622
+ export async function setInitialPassword(
623
+ newPassword: string
624
+ ): Promise<{ success: boolean; error?: string }> {
625
+ try {
626
+ const response = await fetch(`${AUTH_CONFIG.backendUrl}/api/auth/set-password`, {
627
+ method: 'POST',
628
+ headers: await buildAuthHeaders(),
629
+ body: JSON.stringify({ newPassword }),
630
+ cache: 'no-store',
631
+ });
632
+
633
+ if (!response.ok) {
634
+ const error = await response.json().catch(() => ({}));
635
+ return { success: false, error: error.message || 'Failed to set password' };
636
+ }
637
+
638
+ return { success: true };
639
+ } catch (error) {
640
+ console.error('Set initial password error:', error);
297
641
  return { success: false, error: 'An error occurred' };
298
642
  }
299
643
  }
@@ -332,3 +676,82 @@ function extractSessionToken(response: Response): string | null {
332
676
 
333
677
  return null;
334
678
  }
679
+ <% if (features.authentication.twoFactor) { %>
680
+
681
+ /**
682
+ * Extract two-factor token and expiration from Set-Cookie header
683
+ * Used during 2FA verification flow
684
+ *
685
+ * Extracts the absolute Expires attribute to avoid time delta issues between
686
+ * backend and frontend.
687
+ */
688
+ function extractTwoFactorTokenAndExpiration(response: Response): {
689
+ token: string | null;
690
+ expiresAt: number | null; // Absolute Unix timestamp (seconds)
691
+ } {
692
+ const setCookies = response.headers.getSetCookie?.() ?? [];
693
+ let token: string | null = null;
694
+ let expiresAt: number | null = null;
695
+
696
+ /**
697
+ * Parse expiration from a cookie string.
698
+ * Handles both Expires (absolute) and Max-Age (relative) attributes.
699
+ *
700
+ * IMPORTANT: We subtract a safety buffer when using Max-Age to account for
701
+ * the time delta between when the backend created the cookie (t_0) and when
702
+ * we process it (t_1). This ensures our cookie expires BEFORE the backend's,
703
+ * preventing edge cases where we think we have a valid token but the backend
704
+ * has already expired it.
705
+ */
706
+ const SAFETY_BUFFER_SECONDS = 5;
707
+
708
+ const parseExpiration = (cookie: string): number | null => {
709
+ // Try Expires first (absolute timestamp) - no buffer needed since it's absolute
710
+ const expiresMatch = cookie.match(/Expires=([^;]+)/i);
711
+ if (expiresMatch) {
712
+ const parsedDate = new Date(expiresMatch[1]);
713
+ if (!isNaN(parsedDate.getTime())) {
714
+ return Math.floor(parsedDate.getTime() / 1000);
715
+ }
716
+ }
717
+
718
+ // Fall back to Max-Age (relative seconds from now)
719
+ // Subtract safety buffer to ensure we expire before the backend
720
+ const maxAgeMatch = cookie.match(/Max-Age=(\d+)/i);
721
+ if (maxAgeMatch) {
722
+ const maxAgeSeconds = parseInt(maxAgeMatch[1], 10);
723
+ return Math.floor(Date.now() / 1000) + maxAgeSeconds - SAFETY_BUFFER_SECONDS;
724
+ }
725
+
726
+ return null;
727
+ };
728
+
729
+ if (setCookies.length === 0) {
730
+ const setCookieHeader = response.headers.get('set-cookie');
731
+ if (!setCookieHeader) return { token: null, expiresAt: null };
732
+
733
+ const tokenMatch = setCookieHeader.match(
734
+ new RegExp(`${BETTER_AUTH_TWO_FACTOR_COOKIE_NAME}=([^;]+)`)
735
+ );
736
+ token = tokenMatch ? tokenMatch[1] : null;
737
+
738
+ if (token) {
739
+ expiresAt = parseExpiration(setCookieHeader);
740
+ }
741
+ return { token, expiresAt };
742
+ }
743
+
744
+ for (const cookie of setCookies) {
745
+ const tokenMatch = cookie.match(
746
+ new RegExp(`${BETTER_AUTH_TWO_FACTOR_COOKIE_NAME}=([^;]+)`)
747
+ );
748
+ if (tokenMatch) {
749
+ token = tokenMatch[1];
750
+ expiresAt = parseExpiration(cookie);
751
+ break;
752
+ }
753
+ }
754
+
755
+ return { token, expiresAt };
756
+ }
757
+ <% } %>
@@ -56,6 +56,12 @@ export const COOKIE_NAMES = {
56
56
 
57
57
  // OAuth state for CSRF protection (temporary, during OAuth flow)
58
58
  OAUTH_STATE: "oauth_state",
59
+ <% if (features.authentication.twoFactor) { %>
60
+
61
+ // Two-factor authentication cookies (temporary, during 2FA verification)
62
+ TWO_FACTOR: "two_factor",
63
+ TWO_FACTOR_EXPIRES_AT: "two_factor_expires",
64
+ <% } %>
59
65
  } as const;
60
66
 
61
67
  /**
@@ -63,3 +69,10 @@ export const COOKIE_NAMES = {
63
69
  * This must match the cookie name that Better Auth uses
64
70
  */
65
71
  export const BETTER_AUTH_COOKIE_NAME = "better-auth.session_token";
72
+ <% if (features.authentication.twoFactor) { %>
73
+
74
+ /**
75
+ * Better Auth two-factor cookie name (used during 2FA verification)
76
+ */
77
+ export const BETTER_AUTH_TWO_FACTOR_COOKIE_NAME = "better-auth.two_factor";
78
+ <% } %>
@@ -72,3 +72,64 @@ export async function clearDeviceSessionCookie(): Promise<void> {
72
72
  const cookieStore = await cookies();
73
73
  cookieStore.delete(COOKIE_NAMES.DEVICE_SESSION);
74
74
  }
75
+ <% if (features.authentication.twoFactor) { %>
76
+
77
+ /**
78
+ * Set the two-factor cookie for 2FA verification
79
+ * Stores both the token and expiration timestamp for client-side timeout handling
80
+ *
81
+ * Uses absolute expiration (expires) instead of relative (maxAge) to ensure
82
+ * frontend cookies expire at exactly the same time as backend cookies.
83
+ */
84
+ export async function setTwoFactorCookie(
85
+ token: string,
86
+ expiresAt: number // Absolute Unix timestamp (seconds)
87
+ ): Promise<void> {
88
+ const cookieStore = await cookies();
89
+ const expiresDate = new Date(expiresAt * 1000); // Convert to Date object
90
+
91
+ // Store the token (httpOnly for security)
92
+ cookieStore.set(COOKIE_NAMES.TWO_FACTOR, token, {
93
+ httpOnly: true,
94
+ secure: process.env.NODE_ENV === 'production',
95
+ sameSite: 'lax',
96
+ path: '/',
97
+ expires: expiresDate, // Use absolute expiration directly
98
+ });
99
+
100
+ // Store expiration timestamp (readable by client for timeout handling)
101
+ cookieStore.set(COOKIE_NAMES.TWO_FACTOR_EXPIRES_AT, expiresAt.toString(), {
102
+ httpOnly: false,
103
+ secure: process.env.NODE_ENV === 'production',
104
+ sameSite: 'lax',
105
+ path: '/',
106
+ expires: expiresDate, // Same absolute expiration
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Get the two-factor token from cookies
112
+ */
113
+ export async function getTwoFactorToken(): Promise<string | undefined> {
114
+ const cookieStore = await cookies();
115
+ return cookieStore.get(COOKIE_NAMES.TWO_FACTOR)?.value;
116
+ }
117
+
118
+ /**
119
+ * Get the two-factor expiration timestamp from cookies
120
+ */
121
+ export async function getTwoFactorExpiresAt(): Promise<number | undefined> {
122
+ const cookieStore = await cookies();
123
+ const value = cookieStore.get(COOKIE_NAMES.TWO_FACTOR_EXPIRES_AT)?.value;
124
+ return value ? parseInt(value, 10) : undefined;
125
+ }
126
+
127
+ /**
128
+ * Clear all two-factor cookies
129
+ */
130
+ export async function clearTwoFactorCookie(): Promise<void> {
131
+ const cookieStore = await cookies();
132
+ cookieStore.delete(COOKIE_NAMES.TWO_FACTOR);
133
+ cookieStore.delete(COOKIE_NAMES.TWO_FACTOR_EXPIRES_AT);
134
+ }
135
+ <% } %>
@@ -7,16 +7,8 @@ import {
7
7
  } from '../auth/cookies';
8
8
  import { AUTH_CONFIG } from '../auth/config';
9
9
 
10
- export interface DeviceSession {
11
- id: string;
12
- deviceId: string;
13
- sessionToken: string;
14
- createdAt: string;
15
- lastActiveAt: string;
16
- migrated: boolean;
17
- migratedToUserId: string | null;
18
- preferredCurrency: string;
19
- }
10
+ export type { DeviceSession } from './types';
11
+ import type { DeviceSession } from './types';
20
12
 
21
13
  /**
22
14
  * Create a new device session
@@ -0,0 +1,37 @@
1
+ // Base session properties shared by all states
2
+ interface BaseDeviceSession {
3
+ id: string;
4
+ deviceId: string;
5
+ sessionToken: string;
6
+ createdAt: string;
7
+ lastActiveAt: string;
8
+ preferredCurrency: string;
9
+ }
10
+
11
+ // Active (non-migrated) session
12
+ interface ActiveDeviceSession extends BaseDeviceSession {
13
+ migrated: false;
14
+ migratedToUserId: null;
15
+ }
16
+
17
+ // Migrated session (device was linked to a user account)
18
+ interface MigratedDeviceSession extends BaseDeviceSession {
19
+ migrated: true;
20
+ migratedToUserId: string;
21
+ }
22
+
23
+ // Discriminated union - TypeScript will narrow based on `migrated` field
24
+ export type DeviceSession = ActiveDeviceSession | MigratedDeviceSession;
25
+
26
+ // Type guards for explicit narrowing when needed
27
+ export function isMigratedSession(
28
+ session: DeviceSession
29
+ ): session is MigratedDeviceSession {
30
+ return session.migrated === true;
31
+ }
32
+
33
+ export function isActiveSession(
34
+ session: DeviceSession
35
+ ): session is ActiveDeviceSession {
36
+ return session.migrated === false;
37
+ }