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
@@ -1,7 +1,14 @@
1
- import { useCallback, useState } from 'react';
1
+ import { useCallback, useState, useEffect } from 'react';
2
2
  import { Platform } from 'react-native';
3
3
  import { useSession, authClient, getCookies } from '../lib/auth-client';
4
4
  import Constants from 'expo-constants';
5
+ <% if (features.authentication.twoFactor) { %>
6
+ import AsyncStorage from '@react-native-async-storage/async-storage';
7
+
8
+ const TWO_FACTOR_EXPIRES_KEY = '@auth:two_factor_expires_at';
9
+ const TWO_FACTOR_TIMEOUT_SECONDS = 180;
10
+ const SAFETY_BUFFER_SECONDS = 5;
11
+ <% } %>
5
12
  <% if (features.authentication.providers.google) { %>
6
13
  import { GoogleSignin, statusCodes } from '@react-native-google-signin/google-signin';
7
14
  <% } %>
@@ -24,6 +31,41 @@ export const useAuth = () => {
24
31
  const { data: session, isPending, error: sessionError, refetch } = useSession();
25
32
  const [isLoading, setIsLoading] = useState(false);
26
33
  const [error, setError] = useState<string | null>(null);
34
+ <% if (features.authentication.twoFactor) { %>
35
+ // 2FA expiration state
36
+ const [twoFactorExpiresAt, setTwoFactorExpiresAtState] = useState<number | null>(null);
37
+
38
+ // Load persisted 2FA expiration on mount
39
+ useEffect(() => {
40
+ AsyncStorage.getItem(TWO_FACTOR_EXPIRES_KEY).then((value) => {
41
+ if (value) {
42
+ const expiresAt = parseInt(value, 10);
43
+ // Only restore if not already expired
44
+ if (expiresAt > Math.floor(Date.now() / 1000)) {
45
+ setTwoFactorExpiresAtState(expiresAt);
46
+ } else {
47
+ AsyncStorage.removeItem(TWO_FACTOR_EXPIRES_KEY);
48
+ }
49
+ }
50
+ });
51
+ }, []);
52
+
53
+ // Setter that persists to AsyncStorage
54
+ const setTwoFactorExpiration = useCallback(async (expiresAt: number | null) => {
55
+ setTwoFactorExpiresAtState(expiresAt);
56
+ if (expiresAt) {
57
+ await AsyncStorage.setItem(TWO_FACTOR_EXPIRES_KEY, expiresAt.toString());
58
+ } else {
59
+ await AsyncStorage.removeItem(TWO_FACTOR_EXPIRES_KEY);
60
+ }
61
+ }, []);
62
+
63
+ // Clear 2FA state (used after expiration or successful verification)
64
+ const clearTwoFactorState = useCallback(async () => {
65
+ setTwoFactorExpiresAtState(null);
66
+ await AsyncStorage.removeItem(TWO_FACTOR_EXPIRES_KEY);
67
+ }, []);
68
+ <% } %>
27
69
 
28
70
  // Computed state from BetterAuth session
29
71
  const isAuthenticated = !!(session?.user && session?.session);
@@ -38,6 +80,16 @@ export const useAuth = () => {
38
80
  email: credentials.email,
39
81
  password: credentials.password,
40
82
  });
83
+ <% if (features.authentication.twoFactor) { %>
84
+ // Check if 2FA is required (Better Auth returns twoFactorRedirect: true)
85
+ if (result.data?.twoFactorRedirect) {
86
+ // Calculate expiration: current time + backend timeout - safety buffer
87
+ // The safety buffer ensures mobile expires BEFORE backend (same pattern as web's Max-Age fallback)
88
+ const expiresAt = Math.floor(Date.now() / 1000) + TWO_FACTOR_TIMEOUT_SECONDS - SAFETY_BUFFER_SECONDS;
89
+ await setTwoFactorExpiration(expiresAt);
90
+ return { success: true, requiresTwoFactor: true, twoFactorExpiresAt: expiresAt };
91
+ }
92
+ <% } %>
41
93
  if (result.error) {
42
94
  throw new Error(result.error.message || 'Sign in failed');
43
95
  }
@@ -64,7 +116,14 @@ export const useAuth = () => {
64
116
  if (result.error) {
65
117
  throw new Error(result.error.message || 'Sign up failed');
66
118
  }
119
+ <% if (features.authentication.emailVerification) { %>
120
+ // Check if email verification is required
121
+ // When emailOTP plugin is enabled with sendVerificationOnSignUp,
122
+ // the user needs to verify their email before being signed in
123
+ return { success: true, needsEmailVerification: true, email: data.email };
124
+ <% } else { %>
67
125
  return { success: true };
126
+ <% } %>
68
127
  } catch (err) {
69
128
  const message = err instanceof Error ? err.message : 'Sign up failed';
70
129
  setError(message);
@@ -331,6 +390,185 @@ export const useAuth = () => {
331
390
  setIsLoading(false);
332
391
  }
333
392
  }, []);
393
+ <% if (features.authentication.twoFactor) { %>
394
+
395
+ // Verify TOTP code during login (2FA challenge)
396
+ const verifyTotpLogin = useCallback(async (code: string, trustDevice?: boolean) => {
397
+ setIsLoading(true);
398
+ setError(null);
399
+ try {
400
+ const result = await authClient.twoFactor.verifyTotp({
401
+ code,
402
+ trustDevice,
403
+ });
404
+ if (result.error) {
405
+ throw new Error(result.error.message || 'Invalid verification code');
406
+ }
407
+ // Clear 2FA state on successful verification
408
+ await clearTwoFactorState();
409
+ return { success: true };
410
+ } catch (err) {
411
+ const message = err instanceof Error ? err.message : 'Verification failed';
412
+ setError(message);
413
+ return { success: false, error: message };
414
+ } finally {
415
+ setIsLoading(false);
416
+ }
417
+ }, [clearTwoFactorState]);
418
+
419
+ // Verify backup code during login
420
+ const verifyBackupCode = useCallback(async (code: string) => {
421
+ setIsLoading(true);
422
+ setError(null);
423
+ try {
424
+ const result = await authClient.twoFactor.verifyBackupCode({
425
+ code,
426
+ });
427
+ if (result.error) {
428
+ throw new Error(result.error.message || 'Invalid backup code');
429
+ }
430
+ // Clear 2FA state on successful verification
431
+ await clearTwoFactorState();
432
+ return { success: true };
433
+ } catch (err) {
434
+ const message = err instanceof Error ? err.message : 'Verification failed';
435
+ setError(message);
436
+ return { success: false, error: message };
437
+ } finally {
438
+ setIsLoading(false);
439
+ }
440
+ }, [clearTwoFactorState]);
441
+
442
+ // Enable 2FA - initiates the setup flow and returns TOTP URI + backup codes
443
+ const enableTwoFactor = useCallback(async (password: string) => {
444
+ setIsLoading(true);
445
+ setError(null);
446
+ try {
447
+ const result = await authClient.twoFactor.enable({ password });
448
+ if (result.error) {
449
+ throw new Error(result.error.message || 'Failed to enable 2FA');
450
+ }
451
+ return {
452
+ success: true,
453
+ totpURI: result.data?.totpURI,
454
+ backupCodes: result.data?.backupCodes,
455
+ };
456
+ } catch (err) {
457
+ const message = err instanceof Error ? err.message : 'Failed to enable 2FA';
458
+ setError(message);
459
+ return { success: false, error: message };
460
+ } finally {
461
+ setIsLoading(false);
462
+ }
463
+ }, []);
464
+
465
+ // Verify TOTP setup (confirms user has set up their authenticator correctly)
466
+ const verifyTotpSetup = useCallback(async (code: string) => {
467
+ setIsLoading(true);
468
+ setError(null);
469
+ try {
470
+ const result = await authClient.twoFactor.verifyTotp({ code });
471
+ if (result.error) {
472
+ throw new Error(result.error.message || 'Verification failed');
473
+ }
474
+ // Refresh session to get updated twoFactorEnabled status
475
+ await refetch();
476
+ return { success: true };
477
+ } catch (err) {
478
+ const message = err instanceof Error ? err.message : 'Verification failed';
479
+ setError(message);
480
+ return { success: false, error: message };
481
+ } finally {
482
+ setIsLoading(false);
483
+ }
484
+ }, [refetch]);
485
+
486
+ // Disable 2FA
487
+ const disableTwoFactor = useCallback(async (password: string) => {
488
+ setIsLoading(true);
489
+ setError(null);
490
+ try {
491
+ const result = await authClient.twoFactor.disable({ password });
492
+ if (result.error) {
493
+ throw new Error(result.error.message || 'Failed to disable 2FA');
494
+ }
495
+ await refetch();
496
+ return { success: true };
497
+ } catch (err) {
498
+ const message = err instanceof Error ? err.message : 'Failed to disable 2FA';
499
+ setError(message);
500
+ return { success: false, error: message };
501
+ } finally {
502
+ setIsLoading(false);
503
+ }
504
+ }, [refetch]);
505
+
506
+ // Generate new backup codes (invalidates old ones)
507
+ const generateBackupCodes = useCallback(async (password: string) => {
508
+ setIsLoading(true);
509
+ setError(null);
510
+ try {
511
+ const result = await authClient.twoFactor.generateBackupCodes({ password });
512
+ if (result.error) {
513
+ throw new Error(result.error.message || 'Failed to generate backup codes');
514
+ }
515
+ return {
516
+ success: true,
517
+ backupCodes: result.data?.backupCodes,
518
+ };
519
+ } catch (err) {
520
+ const message = err instanceof Error ? err.message : 'Failed to generate backup codes';
521
+ setError(message);
522
+ return { success: false, error: message };
523
+ } finally {
524
+ setIsLoading(false);
525
+ }
526
+ }, []);
527
+
528
+ // Check if user has a password set (for OAuth users who need to set one for 2FA)
529
+ const checkHasPassword = useCallback(async (): Promise<boolean> => {
530
+ try {
531
+ const response = await fetch(`${API_URL}/api/auth/has-password`, {
532
+ headers: {
533
+ 'Cookie': getCookies() || '',
534
+ },
535
+ credentials: 'omit',
536
+ });
537
+ const data = await response.json();
538
+ return data.hasPassword ?? false;
539
+ } catch {
540
+ return false;
541
+ }
542
+ }, []);
543
+
544
+ // Set initial password (for OAuth users enabling 2FA)
545
+ const setInitialPassword = useCallback(async (password: string) => {
546
+ setIsLoading(true);
547
+ setError(null);
548
+ try {
549
+ const response = await fetch(`${API_URL}/api/auth/set-password`, {
550
+ method: 'POST',
551
+ headers: {
552
+ 'Content-Type': 'application/json',
553
+ 'Cookie': getCookies() || '',
554
+ },
555
+ credentials: 'omit',
556
+ body: JSON.stringify({ newPassword: password }),
557
+ });
558
+ if (!response.ok) {
559
+ const data = await response.json();
560
+ throw new Error(data.message || 'Failed to set password');
561
+ }
562
+ return { success: true };
563
+ } catch (err) {
564
+ const message = err instanceof Error ? err.message : 'Failed to set password';
565
+ setError(message);
566
+ return { success: false, error: message };
567
+ } finally {
568
+ setIsLoading(false);
569
+ }
570
+ }, []);
571
+ <% } %>
334
572
 
335
573
  const clearError = useCallback(() => setError(null), []);
336
574
 
@@ -363,5 +601,21 @@ export const useAuth = () => {
363
601
  deleteAccount,
364
602
  clearError,
365
603
  refetch,
604
+ <% if (features.authentication.twoFactor) { %>
605
+ // 2FA login verification
606
+ verifyTotpLogin,
607
+ verifyBackupCode,
608
+ // 2FA expiration state
609
+ twoFactorExpiresAt,
610
+ setTwoFactorExpiration,
611
+ clearTwoFactorState,
612
+ // 2FA setup/management
613
+ enableTwoFactor,
614
+ verifyTotpSetup,
615
+ disableTwoFactor,
616
+ generateBackupCodes,
617
+ checkHasPassword,
618
+ setInitialPassword,
619
+ <% } %>
366
620
  };
367
621
  };
@@ -0,0 +1,56 @@
1
+ import { useEffect, useCallback } from 'react';
2
+ import { useRouter } from 'expo-router';
3
+ import { useAuth } from './auth';
4
+
5
+ /**
6
+ * Hook for managing 2FA session timeout
7
+ *
8
+ * - Automatically redirects to expired screen when timeout occurs
9
+ * - Provides `isExpired()` check for pre-submission validation
10
+ * - Uses absolute Unix timestamps (not relative durations)
11
+ */
12
+ export function useTwoFactorTimeout() {
13
+ const router = useRouter();
14
+ const { twoFactorExpiresAt, clearTwoFactorState } = useAuth();
15
+
16
+ const handleExpiration = useCallback(async () => {
17
+ await clearTwoFactorState();
18
+ router.replace('/(auth)/two-factor-expired');
19
+ }, [clearTwoFactorState, router]);
20
+
21
+ useEffect(() => {
22
+ if (!twoFactorExpiresAt) return;
23
+
24
+ const now = Math.floor(Date.now() / 1000);
25
+ const remainingMs = (twoFactorExpiresAt - now) * 1000;
26
+
27
+ // Already expired
28
+ if (remainingMs <= 0) {
29
+ handleExpiration();
30
+ return;
31
+ }
32
+
33
+ // Schedule redirect at exact expiration time
34
+ const timeout = setTimeout(handleExpiration, remainingMs);
35
+ return () => clearTimeout(timeout);
36
+ }, [twoFactorExpiresAt, handleExpiration]);
37
+
38
+ // Utility to check expiration before submission
39
+ const isExpired = useCallback((): boolean => {
40
+ if (!twoFactorExpiresAt) return true;
41
+ return Math.floor(Date.now() / 1000) >= twoFactorExpiresAt;
42
+ }, [twoFactorExpiresAt]);
43
+
44
+ // Calculate remaining time for UI display (optional)
45
+ const getRemainingSeconds = useCallback((): number => {
46
+ if (!twoFactorExpiresAt) return 0;
47
+ const remaining = twoFactorExpiresAt - Math.floor(Date.now() / 1000);
48
+ return Math.max(0, remaining);
49
+ }, [twoFactorExpiresAt]);
50
+
51
+ return {
52
+ isExpired,
53
+ twoFactorExpiresAt,
54
+ getRemainingSeconds,
55
+ };
56
+ }
@@ -6,16 +6,8 @@ import { Platform } from 'react-native';
6
6
  import * as Application from 'expo-application';
7
7
  import * as SecureStore from 'expo-secure-store';
8
8
 
9
- export interface DeviceSession {
10
- id: string;
11
- deviceId: string;
12
- sessionToken: string;
13
- createdAt: string;
14
- lastActiveAt: string;
15
- migrated: boolean;
16
- migratedToUserId?: string;
17
- preferredCurrency: string;
18
- }
9
+ export type { DeviceSession } from '../types/device-session';
10
+ import type { DeviceSession } from '../types/device-session';
19
11
 
20
12
  export interface CreateDeviceSessionResponse {
21
13
  session: DeviceSession;
@@ -1,8 +1,10 @@
1
1
  import { create } from 'zustand';
2
2
  import { persist, createJSONStorage } from 'zustand/middleware';
3
3
  import AsyncStorage from '@react-native-async-storage/async-storage';
4
- import { deviceSessionService, DeviceSession, DeviceSessionMigrationEligibilityResponse } from '../services/deviceSession';
4
+ import { deviceSessionService, DeviceSessionMigrationEligibilityResponse } from '../services/device-session';
5
+ import type { DeviceSession } from '../types/device-session';
5
6
  import { logger } from '../utils/logger';
7
+ import { useUIStore } from '@/store/ui-store';
6
8
 
7
9
  export interface MigrateSessionData {
8
10
  name: string;
@@ -158,17 +160,24 @@ export const useSessionStore = create<SessionState>()(
158
160
  isSessionValid: async () => {
159
161
  try {
160
162
  logger.debug('SessionStore: Checking session validity...');
161
-
163
+
162
164
  const isValid = await deviceSessionService.isDeviceSessionValid();
163
-
165
+
164
166
  if (!isValid && get().session) {
165
167
  // Session became invalid, clear it
166
168
  logger.warn('SessionStore: Session is no longer valid, clearing');
167
169
  set({ session: null, sessionToken: null, error: 'Session expired' });
170
+
171
+ // Show toast notification
172
+ useUIStore.getState().showNotification({
173
+ type: 'error',
174
+ title: 'Session Expired',
175
+ message: 'Your session has expired. Please sign in again.',
176
+ });
168
177
  }
169
-
178
+
170
179
  return isValid;
171
-
180
+
172
181
  } catch (error) {
173
182
  logger.error('SessionStore: Error checking session validity', { error });
174
183
  return false;
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { View, Image, StyleSheet } from 'react-native';
3
3
  import { router } from 'expo-router';
4
- import OnboardingLayout from '@/components/ui/OnboardingLayout';
4
+ import OnboardingLayout from '@/components/ui/onboarding-layout';
5
5
  import { responsive } from '@/utils/responsive';
6
6
 
7
7
  export default function OnboardingPage1() {
@@ -9,6 +9,8 @@ export default function OnboardingPage1() {
9
9
  <% if (features.onboarding.pages === 1) { %>
10
10
  <% if (features.paywall) { %>
11
11
  router.replace('/paywall');
12
+ <% } else if (features.authentication.enabled) { %>
13
+ router.replace('/(public)');
12
14
  <% } else { %>
13
15
  router.replace('/(tabs)');
14
16
  <% } %>
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { View, Image, StyleSheet } from 'react-native';
3
3
  import { router } from 'expo-router';
4
- import OnboardingLayout from '@/components/ui/OnboardingLayout';
4
+ import OnboardingLayout from '@/components/ui/onboarding-layout';
5
5
  import { responsive } from '@/utils/responsive';
6
6
 
7
7
  export default function OnboardingPage2() {
@@ -9,6 +9,8 @@ export default function OnboardingPage2() {
9
9
  <% if (features.onboarding.pages === 2) { %>
10
10
  <% if (features.paywall) { %>
11
11
  router.replace('/paywall');
12
+ <% } else if (features.authentication.enabled) { %>
13
+ router.replace('/(public)');
12
14
  <% } else { %>
13
15
  router.replace('/(tabs)');
14
16
  <% } %>
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import { View, Image, StyleSheet } from 'react-native';
3
3
  import { router } from 'expo-router';
4
4
  import AsyncStorage from '@react-native-async-storage/async-storage';
5
- import OnboardingLayout from '@/components/ui/OnboardingLayout';
5
+ import OnboardingLayout from '@/components/ui/onboarding-layout';
6
6
  import { responsive } from '@/utils/responsive';
7
7
 
8
8
  export default function OnboardingPage3() {
@@ -11,6 +11,9 @@ export default function OnboardingPage3() {
11
11
  await AsyncStorage.setItem('onboarding_completed', 'true');
12
12
  <% if (features.paywall) { %>
13
13
  router.replace('/paywall');
14
+ <% } else if (features.authentication.enabled) { %>
15
+ // Navigate to landing page for unauthenticated users
16
+ router.replace('/(public)');
14
17
  <% } else { %>
15
18
  router.replace('/(tabs)');
16
19
  <% } %>
@@ -18,6 +21,8 @@ export default function OnboardingPage3() {
18
21
  console.error('Failed to save onboarding completion status:', error);
19
22
  <% if (features.paywall) { %>
20
23
  router.replace('/paywall');
24
+ <% } else if (features.authentication.enabled) { %>
25
+ router.replace('/(public)');
21
26
  <% } else { %>
22
27
  router.replace('/(tabs)');
23
28
  <% } %>
@@ -7,13 +7,13 @@ import {
7
7
  Alert,
8
8
  ActivityIndicator,
9
9
  } from 'react-native';
10
- import { IconSymbol } from '../src/components/ui/IconSymbol';
11
- import { Theme } from '@/constants/Theme';
10
+ import { IconSymbol } from '../src/components/ui/icon-symbol';
11
+ import { Theme } from '@/constants/theme';
12
12
  import { router, Stack } from 'expo-router';
13
- import { useRevenueCat, useRevenueCatActions } from '../src/store/revenuecat.store';
13
+ import { useRevenueCat, useRevenueCatActions } from '../src/store/revenuecat-store';
14
14
  import AsyncStorage from '@react-native-async-storage/async-storage';
15
15
  import { logger } from '../src/utils/logger';
16
- import OnboardingLayout from '../src/components/ui/OnboardingLayout';
16
+ import OnboardingLayout from '../src/components/ui/onboarding-layout';
17
17
  import { responsive, fontSize, getSpacing } from '@/utils/responsive';
18
18
 
19
19
  interface PlanOption {
@@ -1,13 +1,7 @@
1
1
  import { Tabs } from 'expo-router';
2
2
  import React from 'react';
3
- import { useAuth } from '../../src/hooks';
4
3
 
5
4
  export default function TabLayout() {
6
- const { isAuthenticated } = useAuth();
7
-
8
- // This layout should only render if user is authenticated
9
- // The root layout handles the redirect
10
-
11
5
  return (
12
6
  <Tabs
13
7
  screenOptions={{
@@ -0,0 +1,60 @@
1
+ import { Tabs } from 'expo-router';
2
+ import React from 'react';
3
+ <% if (features.authentication.enabled) { %>
4
+ import { ProtectedScreen } from '../../src/components/auth/protected-screen';
5
+ import { IconSymbol } from '../../src/components/ui/icon-symbol';
6
+ import { useAppTheme } from '@/context/theme-context';
7
+ <% } %>
8
+
9
+ export default function TabLayout() {
10
+ <% if (features.authentication.enabled) { %>
11
+ const theme = useAppTheme();
12
+
13
+ return (
14
+ <ProtectedScreen>
15
+ <Tabs
16
+ screenOptions={{
17
+ headerShown: false,
18
+ tabBarActiveTintColor: theme.colors.primary,
19
+ tabBarInactiveTintColor: theme.colors.textSecondary,
20
+ tabBarStyle: {
21
+ backgroundColor: theme.colors.background,
22
+ borderTopColor: theme.colors.border,
23
+ },
24
+ }}
25
+ >
26
+ <Tabs.Screen
27
+ name="index"
28
+ options={{
29
+ title: 'Home',
30
+ tabBarIcon: ({ color }) => <IconSymbol name="house.fill" size={24} color={color} />,
31
+ }}
32
+ />
33
+ <Tabs.Screen
34
+ name="settings"
35
+ options={{
36
+ title: 'Settings',
37
+ tabBarIcon: ({ color }) => <IconSymbol name="gearshape.fill" size={24} color={color} />,
38
+ }}
39
+ />
40
+ </Tabs>
41
+ </ProtectedScreen>
42
+ );
43
+ <% } else { %>
44
+ return (
45
+ <Tabs
46
+ screenOptions={{
47
+ headerShown: false,
48
+ tabBarStyle: { display: 'none' },
49
+ }}
50
+ >
51
+ <Tabs.Screen
52
+ name="index"
53
+ options={{
54
+ title: 'Home',
55
+ }}
56
+ />
57
+ </Tabs>
58
+ );
59
+ <% } %>
60
+ }