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.
- package/README.md +10 -0
- package/dist/prompts/features.d.ts +1 -1
- package/dist/prompts/features.d.ts.map +1 -1
- package/dist/prompts/features.js +34 -25
- package/dist/prompts/features.js.map +1 -1
- package/dist/prompts/index.js +33 -6
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/preset.d.ts.map +1 -1
- package/dist/prompts/preset.js +69 -34
- package/dist/prompts/preset.js.map +1 -1
- package/dist/utils/template.js +1 -1
- package/dist/utils/template.js.map +1 -1
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +43 -1
- package/dist/utils/validation.js.map +1 -1
- package/package.json +1 -1
- package/templates/base/backend/controllers/rest-api/routes/auth.ts.ejs +33 -1
- package/templates/base/backend/controllers/rest-api/routes/oauth-web.ts.ejs +6 -0
- package/templates/base/backend/domain/user/repository.drizzle.ts +28 -1
- package/templates/base/backend/domain/user/repository.prisma.ts +25 -0
- package/templates/base/backend/drizzle/{schema.drizzle.ts → schema.drizzle.ts.ejs} +25 -0
- package/templates/base/backend/lib/auth.drizzle.ts.ejs +27 -18
- package/templates/base/backend/lib/auth.prisma.ts.ejs +24 -18
- package/templates/base/backend/package.json.ejs +29 -23
- package/templates/base/backend/prisma/schema.prisma.ejs +20 -0
- package/templates/base/mobile/app/+not-found.tsx +1 -1
- package/templates/base/mobile/app/_layout.tsx.ejs +95 -10
- package/templates/base/mobile/package.json.ejs +21 -13
- package/templates/base/mobile/src/components/ui/Button.tsx +5 -3
- package/templates/base/mobile/src/components/ui/Card.tsx +1 -1
- package/templates/base/mobile/src/components/ui/Input.tsx +1 -1
- package/templates/base/mobile/src/components/ui/Skeleton.tsx +1 -1
- package/templates/base/mobile/src/components/ui/Toast.tsx +106 -0
- package/templates/base/mobile/src/components/ui/{IconSymbol.tsx → icon-symbol.tsx} +7 -0
- package/templates/base/mobile/src/components/ui/{LoadingSpinner.tsx → loading-spinner.tsx} +1 -1
- package/templates/base/mobile/src/components/ui/{OnboardingLayout.tsx → onboarding-layout.tsx} +2 -2
- package/templates/base/mobile/src/components/ui/{PaywallLayout.tsx → paywall-layout.tsx} +3 -3
- package/templates/base/mobile/src/constants/Theme.ts +3 -3
- package/templates/base/mobile/src/context/{ThemeContext.tsx → theme-context.tsx} +2 -2
- package/templates/base/mobile/src/lib/auth-client.ts.ejs +18 -0
- package/templates/base/mobile/src/services/{sdkInitializer.ts.ejs → sdk-initializer.ts.ejs} +4 -4
- package/templates/base/web/.prettierignore +6 -0
- package/templates/base/web/.prettierrc +8 -0
- package/templates/base/web/eslint.config.mjs +31 -7
- package/templates/base/web/next.config.ts +50 -1
- package/templates/base/web/package.json.ejs +14 -2
- package/templates/base/web/src/app/globals.css +1 -1
- package/templates/base/web/src/app/layout.tsx.ejs +2 -0
- package/templates/base/web/src/components/auth/protected-route.tsx.ejs +32 -5
- package/templates/base/web/src/components/providers/device-session-setup.tsx.ejs +1 -1
- package/templates/base/web/src/hooks/use-device-session.ts.ejs +2 -2
- package/templates/base/web/src/lib/auth/actions.ts.ejs +438 -15
- package/templates/base/web/src/lib/auth/config.ts.ejs +13 -0
- package/templates/base/web/src/lib/auth/cookies.ts.ejs +61 -0
- package/templates/base/web/src/lib/device/actions.ts.ejs +2 -10
- package/templates/base/web/src/lib/device/types.ts +37 -0
- package/templates/base/web/src/proxy.ts.ejs +12 -2
- package/templates/base/web/src/store/{deviceSession.store.ts.ejs → device-session-store.ts.ejs} +1 -1
- package/templates/features/mobile/auth/app/(auth)/{_layout.tsx → _layout.tsx.ejs} +7 -0
- package/templates/features/mobile/auth/app/(auth)/{login.tsx → login.tsx.ejs} +9 -8
- package/templates/features/mobile/auth/app/(auth)/{register.tsx → register.tsx.ejs} +9 -8
- package/templates/features/mobile/auth/app/(auth)/two-factor-expired.tsx.ejs +88 -0
- package/templates/features/mobile/auth/app/(auth)/two-factor.tsx.ejs +259 -0
- package/templates/features/mobile/auth/app/(public)/_layout.tsx +9 -0
- package/templates/features/mobile/{tabs/app/(tabs)/index.tsx → auth/app/(public)/index.tsx.ejs} +76 -257
- package/templates/features/mobile/auth/app/(tabs)/settings/_layout.tsx.ejs +20 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/index.tsx.ejs +137 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/security/_layout.tsx.ejs +35 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/security/index.tsx.ejs +147 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/security/set-password.tsx.ejs +140 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/security/two-factor-manage.tsx.ejs +366 -0
- package/templates/features/mobile/auth/app/(tabs)/settings/security/two-factor-setup.tsx.ejs +317 -0
- package/templates/features/mobile/auth/app/account.tsx.ejs +331 -0
- package/templates/features/mobile/auth/app/verify-email.tsx.ejs +315 -0
- package/templates/features/mobile/auth/components/auth/{LoginForm.tsx.ejs → login-form.tsx.ejs} +15 -3
- package/templates/features/mobile/auth/components/auth/protected-screen.tsx.ejs +56 -0
- package/templates/features/mobile/auth/components/auth/{RegisterForm.tsx.ejs → register-form.tsx.ejs} +5 -3
- package/templates/features/mobile/auth/hooks/{useAuth.ts.ejs → auth.ts.ejs} +255 -1
- package/templates/features/mobile/auth/hooks/two-factor-timeout.ts.ejs +56 -0
- package/templates/features/mobile/auth/services/{deviceSession.ts → device-session.ts} +2 -10
- package/templates/features/mobile/auth/store/{deviceSession.store.ts → device-session-store.ts} +14 -5
- package/templates/features/mobile/auth/types/device-session.ts +37 -0
- package/templates/features/mobile/onboarding/app/(onboarding)/page-1.tsx.ejs +3 -1
- package/templates/features/mobile/onboarding/app/(onboarding)/page-2.tsx.ejs +3 -1
- package/templates/features/mobile/onboarding/app/(onboarding)/page-3.tsx.ejs +6 -1
- package/templates/features/mobile/paywall/app/paywall.tsx +4 -4
- package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx +0 -6
- package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx.ejs +60 -0
- package/templates/features/mobile/tabs/app/(tabs)/index.tsx.ejs +264 -0
- package/templates/features/web/auth/app/(app)/layout.tsx.ejs +8 -22
- package/templates/features/web/auth/app/(auth)/login/page.tsx.ejs +19 -3
- package/templates/features/web/auth/app/(auth)/login/two-factor/page.tsx.ejs +24 -0
- package/templates/features/web/auth/app/(auth)/verify-email/page.tsx.ejs +117 -152
- package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/dashboard-client.tsx.ejs +41 -1
- package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/page.tsx.ejs +3 -1
- package/templates/features/web/auth/app/(protected)/layout.tsx.ejs +67 -0
- package/templates/features/web/auth/app/(protected)/settings/security/page.tsx.ejs +19 -0
- package/templates/features/web/auth/app/(protected)/settings/security/security-settings.tsx.ejs +73 -0
- package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/page.tsx.ejs +2 -6
- package/templates/features/web/auth/app/auth/callback/route.ts.ejs +5 -1
- package/templates/features/web/auth/app/auth/session-expired/route.ts.ejs +39 -0
- package/templates/features/web/auth/app/auth/two-factor-expired/route.ts.ejs +22 -0
- package/templates/features/web/auth/components/auth/login-form.tsx.ejs +18 -0
- package/templates/features/web/auth/components/auth/two-factor-verify.tsx.ejs +173 -0
- package/templates/features/web/auth/components/settings/set-password-form.tsx.ejs +123 -0
- package/templates/features/web/auth/components/settings/two-factor-manage.tsx.ejs +253 -0
- package/templates/features/web/auth/components/settings/two-factor-setup.tsx.ejs +249 -0
- package/templates/features/web/auth/components/ui/checkbox.tsx +32 -0
- package/templates/features/web/auth/components/ui/dialog.tsx +143 -0
- package/templates/features/web/auth/components/ui/input-otp.tsx +71 -0
- package/templates/integrations/mobile/adjust/store/{adjust.store.ts → adjust-store.ts} +3 -3
- package/templates/integrations/mobile/att/store/{att.store.ts → att-store.ts} +2 -2
- package/templates/integrations/mobile/revenuecat/store/{revenuecat.store.ts → revenuecat-store.ts} +1 -1
- package/templates/integrations/mobile/scate/store/{scate.store.ts → scate-store.ts} +1 -1
- package/templates/base/mobile/src/components/ui/index.ts +0 -6
- package/templates/base/mobile/src/store/index.ts.ejs +0 -18
- package/templates/base/web/src/lib/auth/index.ts.ejs +0 -40
- package/templates/features/mobile/auth/components/auth/index.ts +0 -2
- package/templates/features/mobile/auth/hooks/index.ts.ejs +0 -1
- /package/templates/base/mobile/src/services/{errorService.ts → error-service.ts} +0 -0
- /package/templates/base/mobile/src/store/{ui.store.ts → ui-store.ts} +0 -0
- /package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/sessions-client.tsx.ejs +0 -0
- /package/templates/integrations/mobile/adjust/services/{adjustService.ts.ejs → adjust-service.ts.ejs} +0 -0
- /package/templates/integrations/mobile/att/services/{attService.ts → att-service.ts} +0 -0
- /package/templates/integrations/mobile/att/services/{trackingPermissions.ts → tracking-permissions.ts} +0 -0
- /package/templates/integrations/mobile/revenuecat/services/{revenuecatService.ts.ejs → revenuecat-service.ts.ejs} +0 -0
- /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
|
|
10
|
-
|
|
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;
|
package/templates/features/mobile/auth/store/{deviceSession.store.ts → device-session-store.ts}
RENAMED
|
@@ -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,
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
11
|
-
import { Theme } from '@/constants/
|
|
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
|
|
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/
|
|
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
|
+
}
|