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
|
@@ -21,13 +21,23 @@ const protectedRoutes = ["/dashboard", "/settings", "/profile"];
|
|
|
21
21
|
// Routes that should redirect to dashboard if already authenticated
|
|
22
22
|
const authRoutes = ["/login", "/register", "/forgot-password"];
|
|
23
23
|
|
|
24
|
-
// Routes that are always public
|
|
25
|
-
const publicRoutes = ["/", "/auth/callback", "/reset-password", "/verify-email"];
|
|
24
|
+
// Routes that are always public (including 2FA verification which needs session but shouldn't redirect)
|
|
25
|
+
const publicRoutes = ["/", "/auth/callback", "/auth/session-expired", "/reset-password", "/verify-email"<% if (features.authentication.twoFactor) { %>, "/login/two-factor", "/auth/two-factor-expired"<% } %>];
|
|
26
26
|
|
|
27
27
|
export function proxy(request: NextRequest) {
|
|
28
28
|
const { pathname } = request.nextUrl;
|
|
29
29
|
const sessionToken = request.cookies.get(COOKIE_NAMES.SESSION)?.value;
|
|
30
30
|
|
|
31
|
+
// Check if current path matches any public routes (these always pass through)
|
|
32
|
+
const isPublicRoute = publicRoutes.some(
|
|
33
|
+
(route) => pathname === route || pathname.startsWith(`${route}/`)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Public routes always pass through
|
|
37
|
+
if (isPublicRoute) {
|
|
38
|
+
return NextResponse.next();
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
// Check if current path matches any protected routes
|
|
32
42
|
const isProtectedRoute = protectedRoutes.some(
|
|
33
43
|
(route) => pathname === route || pathname.startsWith(`${route}/`)
|
|
@@ -11,6 +11,13 @@ export default function AuthLayout() {
|
|
|
11
11
|
>
|
|
12
12
|
<Stack.Screen name="login" />
|
|
13
13
|
<Stack.Screen name="register" />
|
|
14
|
+
<Stack.Screen name="two-factor" />
|
|
15
|
+
<Stack.Screen
|
|
16
|
+
name="two-factor-expired"
|
|
17
|
+
options={{
|
|
18
|
+
gestureEnabled: false,
|
|
19
|
+
}}
|
|
20
|
+
/>
|
|
14
21
|
</Stack>
|
|
15
22
|
);
|
|
16
23
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import React, { useEffect, useMemo } from 'react';
|
|
2
2
|
import { View, StyleSheet, SafeAreaView, TouchableOpacity } from 'react-native';
|
|
3
3
|
import { router, Stack } from 'expo-router';
|
|
4
|
-
import { LoginForm } from '../../src/components/auth';
|
|
5
|
-
import { IconSymbol } from '../../src/components/ui/
|
|
6
|
-
import { useAppTheme, AppTheme } from '@/context/
|
|
7
|
-
import { useAuth } from '../../src/hooks';
|
|
4
|
+
import { LoginForm } from '../../src/components/auth/login-form';
|
|
5
|
+
import { IconSymbol } from '../../src/components/ui/icon-symbol';
|
|
6
|
+
import { useAppTheme, AppTheme } from '@/context/theme-context';
|
|
7
|
+
import { useAuth } from '../../src/hooks/auth';
|
|
8
8
|
|
|
9
9
|
export default function LoginScreen() {
|
|
10
10
|
const theme = useAppTheme();
|
|
@@ -12,16 +12,17 @@ export default function LoginScreen() {
|
|
|
12
12
|
|
|
13
13
|
const { isAuthenticated } = useAuth();
|
|
14
14
|
|
|
15
|
-
// Watch for auth state changes
|
|
15
|
+
// Watch for auth state changes - close modal when authenticated
|
|
16
|
+
// Root layout handles routing based on email verification status
|
|
16
17
|
useEffect(() => {
|
|
17
18
|
if (isAuthenticated) {
|
|
18
|
-
|
|
19
|
+
// Close the auth modal - root layout will handle navigation
|
|
20
|
+
router.back();
|
|
19
21
|
}
|
|
20
22
|
}, [isAuthenticated]);
|
|
21
23
|
|
|
22
24
|
const handleLoginSuccess = () => {
|
|
23
|
-
//
|
|
24
|
-
router.replace('/(tabs)');
|
|
25
|
+
// Navigation handled by useEffect watching isAuthenticated
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
const handleSwitchToRegister = () => {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import React, { useEffect, useMemo } from 'react';
|
|
2
2
|
import { View, StyleSheet, SafeAreaView, TouchableOpacity } from 'react-native';
|
|
3
3
|
import { router, Stack } from 'expo-router';
|
|
4
|
-
import { RegisterForm } from '../../src/components/auth';
|
|
5
|
-
import { IconSymbol } from '../../src/components/ui/
|
|
6
|
-
import { useAppTheme, AppTheme } from '@/context/
|
|
7
|
-
import { useAuth } from '../../src/hooks';
|
|
4
|
+
import { RegisterForm } from '../../src/components/auth/register-form';
|
|
5
|
+
import { IconSymbol } from '../../src/components/ui/icon-symbol';
|
|
6
|
+
import { useAppTheme, AppTheme } from '@/context/theme-context';
|
|
7
|
+
import { useAuth } from '../../src/hooks/auth';
|
|
8
8
|
|
|
9
9
|
export default function RegisterScreen() {
|
|
10
10
|
const theme = useAppTheme();
|
|
@@ -12,16 +12,17 @@ export default function RegisterScreen() {
|
|
|
12
12
|
|
|
13
13
|
const { isAuthenticated } = useAuth();
|
|
14
14
|
|
|
15
|
-
// Watch for auth state changes
|
|
15
|
+
// Watch for auth state changes - close modal when authenticated
|
|
16
|
+
// Root layout handles routing based on email verification status
|
|
16
17
|
useEffect(() => {
|
|
17
18
|
if (isAuthenticated) {
|
|
18
|
-
|
|
19
|
+
// Close the auth modal - root layout will handle navigation
|
|
20
|
+
router.back();
|
|
19
21
|
}
|
|
20
22
|
}, [isAuthenticated]);
|
|
21
23
|
|
|
22
24
|
const handleRegisterSuccess = () => {
|
|
23
|
-
//
|
|
24
|
-
router.replace('/(tabs)');
|
|
25
|
+
// Navigation handled by useEffect watching isAuthenticated
|
|
25
26
|
};
|
|
26
27
|
|
|
27
28
|
const handleSwitchToLogin = () => {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { View, Text, StyleSheet, SafeAreaView } from 'react-native';
|
|
3
|
+
import { useRouter } from 'expo-router';
|
|
4
|
+
import { Button } from '../../src/components/ui/button';
|
|
5
|
+
import { IconSymbol } from '../../src/components/ui/icon-symbol';
|
|
6
|
+
import { useAppTheme, AppTheme } from '@/context/theme-context';
|
|
7
|
+
import { useAuth } from '../../src/hooks/auth';
|
|
8
|
+
|
|
9
|
+
export default function TwoFactorExpiredScreen() {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const theme = useAppTheme();
|
|
12
|
+
const styles = useMemo(() => createStyles(theme), [theme]);
|
|
13
|
+
const { clearTwoFactorState } = useAuth();
|
|
14
|
+
|
|
15
|
+
const handleReturnToLogin = async () => {
|
|
16
|
+
// Clear the expired 2FA state before navigating back
|
|
17
|
+
await clearTwoFactorState();
|
|
18
|
+
// Navigate back to the existing login screen instead of creating a new one
|
|
19
|
+
// This prevents duplicate login modals in the navigation stack
|
|
20
|
+
if (router.canGoBack()) {
|
|
21
|
+
router.back();
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<SafeAreaView style={styles.container}>
|
|
27
|
+
<View style={styles.content}>
|
|
28
|
+
<View style={styles.iconContainer}>
|
|
29
|
+
<IconSymbol
|
|
30
|
+
name="exclamationmark.triangle.fill"
|
|
31
|
+
size={48}
|
|
32
|
+
color={theme.colors.warning ?? theme.colors.error}
|
|
33
|
+
/>
|
|
34
|
+
</View>
|
|
35
|
+
<Text style={styles.title}>Verification Expired</Text>
|
|
36
|
+
<Text style={styles.description}>
|
|
37
|
+
Your two-factor verification session has expired for security reasons.
|
|
38
|
+
Please sign in again to continue.
|
|
39
|
+
</Text>
|
|
40
|
+
<Button
|
|
41
|
+
onPress={handleReturnToLogin}
|
|
42
|
+
title="Return to Login"
|
|
43
|
+
style={styles.button}
|
|
44
|
+
/>
|
|
45
|
+
</View>
|
|
46
|
+
</SafeAreaView>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const createStyles = (theme: AppTheme) => StyleSheet.create({
|
|
51
|
+
container: {
|
|
52
|
+
flex: 1,
|
|
53
|
+
backgroundColor: theme.colors.background,
|
|
54
|
+
},
|
|
55
|
+
content: {
|
|
56
|
+
flex: 1,
|
|
57
|
+
justifyContent: 'center',
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
padding: theme.spacing[6],
|
|
60
|
+
},
|
|
61
|
+
iconContainer: {
|
|
62
|
+
width: 80,
|
|
63
|
+
height: 80,
|
|
64
|
+
borderRadius: 40,
|
|
65
|
+
backgroundColor: (theme.colors.warning ?? theme.colors.error) + '15',
|
|
66
|
+
justifyContent: 'center',
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
marginBottom: theme.spacing[6],
|
|
69
|
+
},
|
|
70
|
+
title: {
|
|
71
|
+
fontSize: theme.typography.fontSize['2xl'],
|
|
72
|
+
fontWeight: 'bold',
|
|
73
|
+
color: theme.colors.text,
|
|
74
|
+
marginBottom: theme.spacing[2],
|
|
75
|
+
textAlign: 'center',
|
|
76
|
+
},
|
|
77
|
+
description: {
|
|
78
|
+
fontSize: theme.typography.fontSize.base,
|
|
79
|
+
color: theme.colors.textSecondary,
|
|
80
|
+
textAlign: 'center',
|
|
81
|
+
marginBottom: theme.spacing[6],
|
|
82
|
+
lineHeight: 24,
|
|
83
|
+
paddingHorizontal: theme.spacing[4],
|
|
84
|
+
},
|
|
85
|
+
button: {
|
|
86
|
+
minWidth: 200,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import React, { useState, useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
SafeAreaView,
|
|
7
|
+
TouchableOpacity,
|
|
8
|
+
KeyboardAvoidingView,
|
|
9
|
+
Platform,
|
|
10
|
+
ScrollView,
|
|
11
|
+
Alert,
|
|
12
|
+
Switch,
|
|
13
|
+
} from 'react-native';
|
|
14
|
+
import { router } from 'expo-router';
|
|
15
|
+
import { Button } from '../../src/components/ui/button';
|
|
16
|
+
import { Input } from '../../src/components/ui/input';
|
|
17
|
+
import { IconSymbol } from '../../src/components/ui/icon-symbol';
|
|
18
|
+
import { useAppTheme, AppTheme } from '@/context/theme-context';
|
|
19
|
+
import { useAuth } from '../../src/hooks/auth';
|
|
20
|
+
import { useTwoFactorTimeout } from '../../src/hooks/two-factor-timeout';
|
|
21
|
+
|
|
22
|
+
export default function TwoFactorScreen() {
|
|
23
|
+
const theme = useAppTheme();
|
|
24
|
+
const styles = useMemo(() => createStyles(theme), [theme]);
|
|
25
|
+
const { verifyTotpLogin, verifyBackupCode, isLoading } = useAuth();
|
|
26
|
+
const { isExpired } = useTwoFactorTimeout();
|
|
27
|
+
|
|
28
|
+
const [code, setCode] = useState('');
|
|
29
|
+
const [trustDevice, setTrustDevice] = useState(false);
|
|
30
|
+
const [useBackupCode, setUseBackupCode] = useState(false);
|
|
31
|
+
const [error, setError] = useState<string | null>(null);
|
|
32
|
+
|
|
33
|
+
const handleVerify = async () => {
|
|
34
|
+
if (!code.trim()) {
|
|
35
|
+
setError('Please enter a code');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check if session has expired before submitting
|
|
40
|
+
if (isExpired()) {
|
|
41
|
+
router.replace('/(auth)/two-factor-expired');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setError(null);
|
|
46
|
+
|
|
47
|
+
const result = useBackupCode
|
|
48
|
+
? await verifyBackupCode(code.trim())
|
|
49
|
+
: await verifyTotpLogin(code.trim(), trustDevice);
|
|
50
|
+
|
|
51
|
+
if (result.success) {
|
|
52
|
+
// Authentication complete - close auth modal
|
|
53
|
+
// Root layout will handle navigation based on auth state
|
|
54
|
+
if (router.canGoBack()) {
|
|
55
|
+
router.back();
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
Alert.alert('Verification Failed', result.error || 'Invalid code. Please try again.');
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleBack = () => {
|
|
63
|
+
if (router.canGoBack()) {
|
|
64
|
+
router.back();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const toggleMode = () => {
|
|
69
|
+
setUseBackupCode(!useBackupCode);
|
|
70
|
+
setCode('');
|
|
71
|
+
setError(null);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<SafeAreaView style={styles.container}>
|
|
76
|
+
{/* Back Button */}
|
|
77
|
+
<TouchableOpacity
|
|
78
|
+
style={styles.backButton}
|
|
79
|
+
onPress={handleBack}
|
|
80
|
+
activeOpacity={0.7}
|
|
81
|
+
>
|
|
82
|
+
<IconSymbol
|
|
83
|
+
name="chevron.left"
|
|
84
|
+
size={24}
|
|
85
|
+
color={theme.colors.text}
|
|
86
|
+
/>
|
|
87
|
+
</TouchableOpacity>
|
|
88
|
+
|
|
89
|
+
<KeyboardAvoidingView
|
|
90
|
+
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
91
|
+
style={styles.keyboardView}
|
|
92
|
+
>
|
|
93
|
+
<ScrollView
|
|
94
|
+
contentContainerStyle={styles.scrollContent}
|
|
95
|
+
keyboardShouldPersistTaps="handled"
|
|
96
|
+
>
|
|
97
|
+
<View style={styles.form}>
|
|
98
|
+
{/* Icon */}
|
|
99
|
+
<View style={styles.iconContainer}>
|
|
100
|
+
<IconSymbol
|
|
101
|
+
name={useBackupCode ? 'key.fill' : 'lock.shield.fill'}
|
|
102
|
+
size={48}
|
|
103
|
+
color={theme.colors.primary}
|
|
104
|
+
/>
|
|
105
|
+
</View>
|
|
106
|
+
|
|
107
|
+
<Text style={styles.title}>
|
|
108
|
+
{useBackupCode ? 'Enter Backup Code' : 'Two-Factor Authentication'}
|
|
109
|
+
</Text>
|
|
110
|
+
<Text style={styles.subtitle}>
|
|
111
|
+
{useBackupCode
|
|
112
|
+
? 'Enter one of your backup codes to sign in'
|
|
113
|
+
: 'Enter the 6-digit code from your authenticator app'}
|
|
114
|
+
</Text>
|
|
115
|
+
|
|
116
|
+
<Input
|
|
117
|
+
label={useBackupCode ? 'Backup Code' : 'Verification Code'}
|
|
118
|
+
value={code}
|
|
119
|
+
onChangeText={(text) => {
|
|
120
|
+
// For TOTP, only allow digits and limit to 6
|
|
121
|
+
const value = useBackupCode
|
|
122
|
+
? text
|
|
123
|
+
: text.replace(/\D/g, '').slice(0, 6);
|
|
124
|
+
setCode(value);
|
|
125
|
+
if (error) setError(null);
|
|
126
|
+
}}
|
|
127
|
+
placeholder={useBackupCode ? 'Enter backup code' : '000000'}
|
|
128
|
+
keyboardType={useBackupCode ? 'default' : 'number-pad'}
|
|
129
|
+
autoCapitalize="none"
|
|
130
|
+
autoComplete="one-time-code"
|
|
131
|
+
maxLength={useBackupCode ? 20 : 6}
|
|
132
|
+
error={error || undefined}
|
|
133
|
+
containerStyle={styles.inputContainer}
|
|
134
|
+
/>
|
|
135
|
+
|
|
136
|
+
{!useBackupCode && (
|
|
137
|
+
<View style={styles.trustDeviceContainer}>
|
|
138
|
+
<Text style={styles.trustDeviceText}>
|
|
139
|
+
Trust this device for 30 days
|
|
140
|
+
</Text>
|
|
141
|
+
<Switch
|
|
142
|
+
value={trustDevice}
|
|
143
|
+
onValueChange={setTrustDevice}
|
|
144
|
+
trackColor={{
|
|
145
|
+
false: theme.colors.border,
|
|
146
|
+
true: theme.colors.primary,
|
|
147
|
+
}}
|
|
148
|
+
thumbColor={theme.colors.background}
|
|
149
|
+
/>
|
|
150
|
+
</View>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
<Button
|
|
154
|
+
title="Verify"
|
|
155
|
+
onPress={handleVerify}
|
|
156
|
+
loading={isLoading}
|
|
157
|
+
disabled={isLoading}
|
|
158
|
+
style={styles.submitButton}
|
|
159
|
+
/>
|
|
160
|
+
|
|
161
|
+
<TouchableOpacity
|
|
162
|
+
onPress={toggleMode}
|
|
163
|
+
style={styles.toggleButton}
|
|
164
|
+
activeOpacity={0.7}
|
|
165
|
+
>
|
|
166
|
+
<Text style={styles.toggleButtonText}>
|
|
167
|
+
{useBackupCode
|
|
168
|
+
? 'Use authenticator app instead'
|
|
169
|
+
: 'Use a backup code instead'}
|
|
170
|
+
</Text>
|
|
171
|
+
</TouchableOpacity>
|
|
172
|
+
</View>
|
|
173
|
+
</ScrollView>
|
|
174
|
+
</KeyboardAvoidingView>
|
|
175
|
+
</SafeAreaView>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const createStyles = (theme: AppTheme) => StyleSheet.create({
|
|
180
|
+
container: {
|
|
181
|
+
flex: 1,
|
|
182
|
+
backgroundColor: theme.colors.background,
|
|
183
|
+
},
|
|
184
|
+
backButton: {
|
|
185
|
+
position: 'absolute',
|
|
186
|
+
top: 60,
|
|
187
|
+
left: 16,
|
|
188
|
+
width: 44,
|
|
189
|
+
height: 44,
|
|
190
|
+
backgroundColor: theme.colors.backgroundSecondary,
|
|
191
|
+
borderRadius: 22,
|
|
192
|
+
justifyContent: 'center',
|
|
193
|
+
alignItems: 'center',
|
|
194
|
+
zIndex: 10,
|
|
195
|
+
...theme.shadows.small,
|
|
196
|
+
},
|
|
197
|
+
keyboardView: {
|
|
198
|
+
flex: 1,
|
|
199
|
+
},
|
|
200
|
+
scrollContent: {
|
|
201
|
+
flexGrow: 1,
|
|
202
|
+
justifyContent: 'center',
|
|
203
|
+
padding: theme.spacing[5],
|
|
204
|
+
},
|
|
205
|
+
form: {
|
|
206
|
+
width: '100%',
|
|
207
|
+
maxWidth: 400,
|
|
208
|
+
alignSelf: 'center',
|
|
209
|
+
},
|
|
210
|
+
iconContainer: {
|
|
211
|
+
width: 80,
|
|
212
|
+
height: 80,
|
|
213
|
+
borderRadius: 40,
|
|
214
|
+
backgroundColor: theme.colors.primary + '15',
|
|
215
|
+
justifyContent: 'center',
|
|
216
|
+
alignItems: 'center',
|
|
217
|
+
alignSelf: 'center',
|
|
218
|
+
marginBottom: theme.spacing[6],
|
|
219
|
+
},
|
|
220
|
+
title: {
|
|
221
|
+
fontSize: theme.typography.fontSize['2xl'],
|
|
222
|
+
fontWeight: 'bold',
|
|
223
|
+
color: theme.colors.text,
|
|
224
|
+
textAlign: 'center',
|
|
225
|
+
marginBottom: theme.spacing[2],
|
|
226
|
+
},
|
|
227
|
+
subtitle: {
|
|
228
|
+
fontSize: theme.typography.fontSize.base,
|
|
229
|
+
color: theme.colors.textSecondary,
|
|
230
|
+
textAlign: 'center',
|
|
231
|
+
marginBottom: theme.spacing[8],
|
|
232
|
+
},
|
|
233
|
+
inputContainer: {
|
|
234
|
+
marginBottom: theme.spacing[4],
|
|
235
|
+
},
|
|
236
|
+
trustDeviceContainer: {
|
|
237
|
+
flexDirection: 'row',
|
|
238
|
+
justifyContent: 'space-between',
|
|
239
|
+
alignItems: 'center',
|
|
240
|
+
marginBottom: theme.spacing[6],
|
|
241
|
+
paddingHorizontal: theme.spacing[1],
|
|
242
|
+
},
|
|
243
|
+
trustDeviceText: {
|
|
244
|
+
fontSize: theme.typography.fontSize.sm,
|
|
245
|
+
color: theme.colors.textSecondary,
|
|
246
|
+
},
|
|
247
|
+
submitButton: {
|
|
248
|
+
marginBottom: theme.spacing[4],
|
|
249
|
+
},
|
|
250
|
+
toggleButton: {
|
|
251
|
+
alignItems: 'center',
|
|
252
|
+
paddingVertical: theme.spacing[2],
|
|
253
|
+
},
|
|
254
|
+
toggleButtonText: {
|
|
255
|
+
fontSize: theme.typography.fontSize.sm,
|
|
256
|
+
color: theme.colors.primary,
|
|
257
|
+
fontWeight: '500',
|
|
258
|
+
},
|
|
259
|
+
});
|