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
@@ -0,0 +1,264 @@
1
+ import React, { useState, useMemo } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ SafeAreaView,
7
+ ScrollView,
8
+ Modal,
9
+ Pressable,
10
+ Alert,
11
+ } from 'react-native';
12
+ <% if (features.authentication.enabled) { %>import { useRouter } from 'expo-router';
13
+ <% } %>import { LinearGradient } from 'expo-linear-gradient';
14
+ <% if (features.authentication.enabled) { %>import { useAuth } from '@/hooks/auth';
15
+ <% } %>import { IconSymbol } from '@/components/ui/icon-symbol';
16
+ <% if (features.authentication.enabled) { %>import { formatDisplayName } from '@/utils/formatters';
17
+ <% } %>import { useAppTheme, AppTheme } from '@/context/theme-context';
18
+
19
+ export default function HomeScreen() {
20
+ <% if (features.authentication.enabled) { %> const router = useRouter();
21
+ <% } %> const theme = useAppTheme();
22
+ const styles = useMemo(() => createStyles(theme), [theme]);
23
+ <% if (features.authentication.enabled) { %> const { user, signOut } = useAuth();
24
+ const [showProfileModal, setShowProfileModal] = useState(false);
25
+ <% } %>
26
+ // Gradient colors based on theme
27
+ const gradientColors = theme.mode === 'dark'
28
+ ? [theme.colors.card, 'transparent'] as const
29
+ : ['#ffffff', '#f8fafc'] as const;
30
+
31
+ <% if (features.authentication.enabled) { %> const handleLogout = async () => {
32
+ const result = await signOut();
33
+ if (!result.success) {
34
+ Alert.alert('Error', 'Failed to sign out. Please try again.');
35
+ }
36
+ setShowProfileModal(false);
37
+ };
38
+
39
+ const navigateToAccount = () => {
40
+ setShowProfileModal(false);
41
+ router.push('/account');
42
+ };
43
+
44
+ <% } %> const features = [
45
+ 'Authentication system',
46
+ 'Zustand state management',
47
+ 'Error handling',
48
+ 'Form validation',
49
+ 'API integration',
50
+ ];
51
+
52
+ return (
53
+ <SafeAreaView style={styles.container}>
54
+ <% if (features.authentication.enabled) { %> {/* Custom Header */}
55
+ <View style={styles.header}>
56
+ <View>
57
+ <Text style={styles.headerGreeting}>Hello,</Text>
58
+ <Text style={styles.headerName}>{formatDisplayName(user?.name)}</Text>
59
+ </View>
60
+ <Pressable
61
+ style={styles.profileButton}
62
+ onPress={() => setShowProfileModal(true)}
63
+ hitSlop={8}
64
+ >
65
+ <IconSymbol
66
+ name="person.circle.fill"
67
+ size={40}
68
+ color={theme.colors.primary}
69
+ />
70
+ </Pressable>
71
+ </View>
72
+
73
+ <% } %> <ScrollView contentContainerStyle={styles.scrollContent}>
74
+ <View style={styles.content}>
75
+ <LinearGradient
76
+ colors={gradientColors}
77
+ style={styles.heroSection}
78
+ start={{ x: 0, y: 0 }}
79
+ end={{ x: 0, y: 1 }}
80
+ >
81
+ <Text style={styles.heroTitle}>Getting Started</Text>
82
+ <Text style={styles.heroText}>
83
+ Welcome to your new app. Here are the core features ready for you to build upon.
84
+ </Text>
85
+
86
+ <View style={styles.featureList}>
87
+ {features.map((feature, index) => (
88
+ <View key={index} style={styles.featureRow}>
89
+ <View style={styles.featureIcon}>
90
+ <IconSymbol
91
+ name="checkmark.circle.fill"
92
+ size={20}
93
+ color={theme.colors.success}
94
+ />
95
+ </View>
96
+ <Text style={styles.featureText}>{feature}</Text>
97
+ </View>
98
+ ))}
99
+ </View>
100
+ </LinearGradient>
101
+ </View>
102
+ </ScrollView>
103
+
104
+ <% if (features.authentication.enabled) { %> {/* Profile Dropdown Modal */}
105
+ <Modal
106
+ visible={showProfileModal}
107
+ transparent
108
+ animationType="fade"
109
+ onRequestClose={() => setShowProfileModal(false)}
110
+ >
111
+ <Pressable
112
+ style={styles.modalOverlay}
113
+ onPress={() => setShowProfileModal(false)}
114
+ >
115
+ <View style={styles.menuContainer}>
116
+ {/* Pointing arrow indicator */}
117
+ <View style={styles.menuArrow} />
118
+
119
+ <Pressable style={styles.menuItem} onPress={navigateToAccount}>
120
+ <IconSymbol name="person.fill" size={20} color={theme.colors.text} />
121
+ <Text style={styles.menuText}>Account</Text>
122
+ </Pressable>
123
+
124
+ <View style={styles.menuDivider} />
125
+
126
+ <Pressable style={styles.menuItem} onPress={handleLogout}>
127
+ <IconSymbol name="rectangle.portrait.and.arrow.right" size={20} color={theme.colors.error} />
128
+ <Text style={[styles.menuText, { color: theme.colors.error }]}>Sign Out</Text>
129
+ </Pressable>
130
+ </View>
131
+ </Pressable>
132
+ </Modal>
133
+ <% } %> </SafeAreaView>
134
+ );
135
+ }
136
+
137
+ const createStyles = (theme: AppTheme) => StyleSheet.create({
138
+ container: {
139
+ flex: 1,
140
+ backgroundColor: theme.colors.background,
141
+ },
142
+ <% if (features.authentication.enabled) { %> header: {
143
+ flexDirection: 'row',
144
+ justifyContent: 'space-between',
145
+ alignItems: 'center',
146
+ paddingHorizontal: theme.spacing[5],
147
+ paddingTop: theme.spacing[4],
148
+ paddingBottom: theme.spacing[4],
149
+ },
150
+ headerGreeting: {
151
+ fontSize: theme.typography.fontSize.base,
152
+ color: theme.colors.textSecondary,
153
+ fontWeight: '500',
154
+ },
155
+ headerName: {
156
+ fontSize: theme.typography.fontSize['2xl'],
157
+ fontWeight: '800',
158
+ color: theme.colors.text,
159
+ letterSpacing: -0.5,
160
+ },
161
+ profileButton: {
162
+ shadowColor: theme.colors.text,
163
+ shadowOffset: { width: 0, height: 2 },
164
+ shadowOpacity: 0.1,
165
+ shadowRadius: 4,
166
+ elevation: 2,
167
+ backgroundColor: theme.colors.background,
168
+ borderRadius: 20, // Half of size 40
169
+ },
170
+ <% } %> scrollContent: {
171
+ padding: theme.spacing[5],
172
+ },
173
+ content: {
174
+ gap: theme.spacing[6],
175
+ },
176
+ heroSection: {
177
+ padding: theme.spacing[6],
178
+ borderRadius: theme.borderRadius['2xl'],
179
+ borderWidth: 1,
180
+ borderColor: theme.colors.borderLight,
181
+ },
182
+ heroTitle: {
183
+ fontSize: theme.typography.fontSize['xl'],
184
+ fontWeight: '700',
185
+ color: theme.colors.text,
186
+ marginBottom: theme.spacing[2],
187
+ },
188
+ heroText: {
189
+ fontSize: theme.typography.fontSize.base,
190
+ color: theme.colors.textSecondary,
191
+ lineHeight: 24,
192
+ marginBottom: theme.spacing[6],
193
+ },
194
+ featureList: {
195
+ gap: theme.spacing[4],
196
+ },
197
+ featureRow: {
198
+ flexDirection: 'row',
199
+ alignItems: 'center',
200
+ gap: theme.spacing[3],
201
+ backgroundColor: theme.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)',
202
+ padding: theme.spacing[3],
203
+ borderRadius: theme.borderRadius.lg,
204
+ },
205
+ featureIcon: {
206
+ width: 32,
207
+ height: 32,
208
+ borderRadius: 16,
209
+ backgroundColor: theme.mode === 'dark' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(16, 185, 129, 0.1)',
210
+ alignItems: 'center',
211
+ justifyContent: 'center',
212
+ },
213
+ featureText: {
214
+ fontSize: theme.typography.fontSize.base,
215
+ color: theme.colors.text,
216
+ fontWeight: '500',
217
+ },
218
+ <% if (features.authentication.enabled) { %> // Modal/Menu Styles
219
+ modalOverlay: {
220
+ flex: 1,
221
+ backgroundColor: 'rgba(0,0,0,0.2)', // Dim background slightly
222
+ },
223
+ menuContainer: {
224
+ position: 'absolute',
225
+ top: 128, // Adjusted to be closer to the header
226
+ right: 20,
227
+ width: 200,
228
+ backgroundColor: theme.colors.background,
229
+ borderRadius: theme.borderRadius.xl,
230
+ paddingVertical: theme.spacing[2],
231
+ borderWidth: 1,
232
+ borderColor: theme.colors.borderLight,
233
+ ...theme.shadows.medium,
234
+ },
235
+ menuArrow: {
236
+ position: 'absolute',
237
+ top: -6,
238
+ right: 14, // Aligns with the center of the profile button
239
+ width: 12,
240
+ height: 12,
241
+ backgroundColor: theme.colors.background,
242
+ transform: [{ rotate: '45deg' }],
243
+ borderLeftWidth: 1,
244
+ borderTopWidth: 1,
245
+ borderColor: theme.colors.borderLight,
246
+ },
247
+ menuItem: {
248
+ flexDirection: 'row',
249
+ alignItems: 'center',
250
+ paddingVertical: theme.spacing[3],
251
+ paddingHorizontal: theme.spacing[4],
252
+ gap: theme.spacing[3],
253
+ },
254
+ menuText: {
255
+ fontSize: theme.typography.fontSize.base,
256
+ fontWeight: '500',
257
+ color: theme.colors.text,
258
+ },
259
+ menuDivider: {
260
+ height: 1,
261
+ backgroundColor: theme.colors.borderLight,
262
+ marginHorizontal: theme.spacing[2],
263
+ },
264
+ <% } %>});
@@ -1,33 +1,19 @@
1
1
  import Link from "next/link";
2
2
  import { ThemeToggle } from "@/components/theme-toggle";
3
3
 
4
+ /**
5
+ * Layout for regular (non-protected) app routes.
6
+ * For protected routes, use the (protected) route group instead.
7
+ */
4
8
  export default function AppLayout({ children }: { children: React.ReactNode }) {
5
9
  return (
6
10
  <div className="min-h-screen flex flex-col bg-background">
7
- {/* App Header - matching landing page style */}
11
+ {/* App Header */}
8
12
  <header className="w-full border-b bg-background/80 backdrop-blur-sm sticky top-0 z-50">
9
13
  <div className="max-w-4xl mx-auto px-6 h-16 flex items-center justify-between">
10
- <div className="flex items-center gap-8">
11
- <Link href="/dashboard" className="text-xl font-semibold tracking-tight">
12
- <%= projectName %>
13
- </Link>
14
- <nav className="hidden sm:flex items-center gap-6">
15
- <Link
16
- href="/dashboard"
17
- className="text-sm text-muted-foreground hover:text-foreground transition-colors"
18
- >
19
- Dashboard
20
- </Link>
21
- <% if (features.sessionManagement) { %>
22
- <Link
23
- href="/settings/sessions"
24
- className="text-sm text-muted-foreground hover:text-foreground transition-colors"
25
- >
26
- Sessions
27
- </Link>
28
- <% } %>
29
- </nav>
30
- </div>
14
+ <Link href="/" className="text-xl font-semibold tracking-tight">
15
+ <%= projectName %>
16
+ </Link>
31
17
  <ThemeToggle />
32
18
  </div>
33
19
  </header>
@@ -8,10 +8,22 @@ interface LoginPageProps {
8
8
  }>;
9
9
  }
10
10
 
11
+ // Map error codes to user-friendly messages
12
+ const errorMessages: Record<string, string> = {
13
+ session_expired: 'Your session has expired. Please sign in again.',
14
+ two_factor_expired: 'Two-factor verification timed out. Please sign in again.',
15
+ oauth_error: 'OAuth authentication failed. Please try again.',
16
+ };
17
+
11
18
  export default async function LoginPage({ searchParams }: LoginPageProps) {
12
19
  const params = await searchParams;
13
20
  const { redirect, error } = params;
14
21
 
22
+ // Get error message from mapping or decode the error
23
+ const errorMessage = error
24
+ ? (errorMessages[error] || decodeURIComponent(error))
25
+ : null;
26
+
15
27
  return (
16
28
  <div className="space-y-6">
17
29
  <div className="space-y-2 text-center">
@@ -21,9 +33,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
21
33
  </p>
22
34
  </div>
23
35
 
24
- {error && (
25
- <div className="p-3 text-sm text-red-500 bg-red-50 dark:bg-red-950/20 rounded-md">
26
- {decodeURIComponent(error)}
36
+ {errorMessage && (
37
+ <div className={`p-3 text-sm rounded-md ${
38
+ error === 'session_expired' || error === 'two_factor_expired'
39
+ ? 'text-amber-600 bg-amber-50 dark:text-amber-400 dark:bg-amber-950/20'
40
+ : 'text-red-500 bg-red-50 dark:bg-red-950/20'
41
+ }`}>
42
+ {errorMessage}
27
43
  </div>
28
44
  )}
29
45
 
@@ -0,0 +1,24 @@
1
+ import { cookies } from 'next/headers';
2
+ import { redirect } from 'next/navigation';
3
+ import { TwoFactorVerify } from '@/components/auth/two-factor-verify';
4
+ import { COOKIE_NAMES } from '@/lib/auth/config';
5
+
6
+ export default async function TwoFactorPage() {
7
+ const cookieStore = await cookies();
8
+ const twoFactorToken = cookieStore.get(COOKIE_NAMES.TWO_FACTOR)?.value;
9
+ const expiresAtStr = cookieStore.get(COOKIE_NAMES.TWO_FACTOR_EXPIRES_AT)?.value;
10
+ const expiresAt = expiresAtStr ? parseInt(expiresAtStr, 10) : null;
11
+
12
+ // Redirect to expiration handler if no 2FA token
13
+ if (!twoFactorToken) {
14
+ redirect('/auth/two-factor-expired');
15
+ }
16
+
17
+ // Redirect if already expired
18
+ const now = Math.floor(Date.now() / 1000);
19
+ if (expiresAt && expiresAt <= now) {
20
+ redirect('/auth/two-factor-expired');
21
+ }
22
+
23
+ return <TwoFactorVerify expiresAt={expiresAt} />;
24
+ }
@@ -4,194 +4,159 @@ import { useEffect, useState } from "react";
4
4
  import { useSearchParams, useRouter } from "next/navigation";
5
5
  import Link from "next/link";
6
6
  import { Button } from "@/components/ui/button";
7
- import { verifyEmail } from "@/lib/auth/actions";
7
+ import {
8
+ InputOTP,
9
+ InputOTPGroup,
10
+ InputOTPSlot,
11
+ InputOTPSeparator,
12
+ } from "@/components/ui/input-otp";
13
+ import { verifyEmailOtp, sendVerificationOtp } from "@/lib/auth/actions";
14
+ import { useAuthStore } from "@/store/auth.store";
8
15
 
9
16
  export default function VerifyEmailPage() {
10
17
  const searchParams = useSearchParams();
11
18
  const router = useRouter();
12
- const token = searchParams.get("token");
13
19
  const email = searchParams.get("email");
14
20
 
15
- const [status, setStatus] = useState<"idle" | "verifying" | "success" | "error">(
16
- token ? "verifying" : "idle"
17
- );
21
+ const [otp, setOtp] = useState("");
22
+ const [status, setStatus] = useState<"idle" | "verifying" | "success" | "error">("idle");
18
23
  const [error, setError] = useState<string | null>(null);
24
+ const [resendDisabled, setResendDisabled] = useState(false);
25
+ const [resendCountdown, setResendCountdown] = useState(0);
19
26
 
27
+ // Auto-verify when all 6 digits entered
20
28
  useEffect(() => {
21
- if (token && status === "verifying") {
22
- handleVerify(token);
29
+ if (otp.length === 6 && email && status === "idle") {
30
+ handleVerify(otp);
23
31
  }
24
- }, [token, status]);
32
+ }, [otp, email, status]);
33
+
34
+ // Countdown timer with proper cleanup
35
+ useEffect(() => {
36
+ if (resendCountdown <= 0) return;
37
+
38
+ const timer = setTimeout(() => {
39
+ setResendCountdown((prev) => {
40
+ if (prev <= 1) {
41
+ setResendDisabled(false);
42
+ return 0;
43
+ }
44
+ return prev - 1;
45
+ });
46
+ }, 1000);
47
+
48
+ return () => clearTimeout(timer);
49
+ }, [resendCountdown]);
50
+
51
+ const handleVerify = async (code: string) => {
52
+ if (!email) return;
53
+ setStatus("verifying");
54
+ setError(null);
25
55
 
26
- const handleVerify = async (verificationToken: string) => {
27
- try {
28
- const result = await verifyEmail(verificationToken);
56
+ const result = await verifyEmailOtp(email, code);
29
57
 
30
- if (!result.success) {
31
- setStatus("error");
32
- setError(result.error || "Failed to verify email");
33
- return;
34
- }
58
+ if (!result.success) {
59
+ setStatus("error");
60
+ setError(result.error || "Invalid code");
61
+ setOtp("");
62
+ return;
63
+ }
35
64
 
65
+ if (result.session) {
36
66
  setStatus("success");
37
- // Redirect to login after 3 seconds
38
- setTimeout(() => {
39
- router.push("/login?message=Email+verified+successfully");
40
- }, 3000);
41
- } catch (err) {
67
+ useAuthStore.getState().setSession(result.session);
68
+ router.push("/dashboard");
69
+ } else {
42
70
  setStatus("error");
43
- setError("An unexpected error occurred");
44
- console.error("Email verification error:", err);
71
+ setError("Verification succeeded but sign-in failed. Please sign in manually.");
45
72
  }
46
73
  };
47
74
 
48
- // Waiting for user to check their email
49
- if (status === "idle" && email) {
50
- return (
51
- <div className="space-y-6">
52
- <div className="space-y-2 text-center">
53
- <div className="mx-auto w-12 h-12 bg-blue-100 dark:bg-blue-950/20 rounded-full flex items-center justify-center">
54
- <svg
55
- className="w-6 h-6 text-blue-600 dark:text-blue-400"
56
- fill="none"
57
- viewBox="0 0 24 24"
58
- stroke="currentColor"
59
- >
60
- <path
61
- strokeLinecap="round"
62
- strokeLinejoin="round"
63
- strokeWidth={2}
64
- d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
65
- />
66
- </svg>
67
- </div>
68
- <h2 className="text-2xl font-bold">Check your email</h2>
69
- <p className="text-muted-foreground">
70
- We&apos;ve sent a verification link to <strong>{email}</strong>
71
- </p>
72
- </div>
75
+ const handleResend = async () => {
76
+ if (!email || resendDisabled) return;
73
77
 
74
- <div className="text-sm text-muted-foreground text-center space-y-2">
75
- <p>Click the link in the email to verify your account.</p>
76
- <p>
77
- Didn&apos;t receive the email? Check your spam folder.
78
- </p>
79
- </div>
78
+ setResendDisabled(true);
79
+ setResendCountdown(60);
80
+ setError(null);
80
81
 
81
- <Link href="/login">
82
- <Button variant="outline" className="w-full">
83
- Back to sign in
84
- </Button>
85
- </Link>
86
- </div>
87
- );
88
- }
89
-
90
- // Verifying token
91
- if (status === "verifying") {
92
- return (
93
- <div className="space-y-6">
94
- <div className="space-y-2 text-center">
95
- <div className="mx-auto w-12 h-12 flex items-center justify-center">
96
- <div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
97
- </div>
98
- <h2 className="text-2xl font-bold">Verifying your email</h2>
99
- <p className="text-muted-foreground">
100
- Please wait while we verify your email address...
101
- </p>
102
- </div>
103
- </div>
104
- );
105
- }
82
+ const result = await sendVerificationOtp(email);
83
+ if (!result.success) {
84
+ setError(result.error || "Failed to resend code");
85
+ }
86
+ };
106
87
 
107
- // Verification successful
108
- if (status === "success") {
88
+ if (!email) {
109
89
  return (
110
- <div className="space-y-6">
111
- <div className="space-y-2 text-center">
112
- <div className="mx-auto w-12 h-12 bg-green-100 dark:bg-green-950/20 rounded-full flex items-center justify-center">
113
- <svg
114
- className="w-6 h-6 text-green-600 dark:text-green-400"
115
- fill="none"
116
- viewBox="0 0 24 24"
117
- stroke="currentColor"
118
- >
119
- <path
120
- strokeLinecap="round"
121
- strokeLinejoin="round"
122
- strokeWidth={2}
123
- d="M5 13l4 4L19 7"
124
- />
125
- </svg>
126
- </div>
127
- <h2 className="text-2xl font-bold">Email verified!</h2>
128
- <p className="text-muted-foreground">
129
- Your email has been verified. Redirecting you to sign in...
130
- </p>
131
- </div>
132
-
90
+ <div className="space-y-6 text-center">
91
+ <h2 className="text-2xl font-bold">Verify your email</h2>
92
+ <p className="text-muted-foreground">No email address provided.</p>
133
93
  <Link href="/login">
134
- <Button className="w-full">Continue to sign in</Button>
94
+ <Button variant="outline" className="w-full">Back to sign in</Button>
135
95
  </Link>
136
96
  </div>
137
97
  );
138
98
  }
139
99
 
140
- // Verification error
141
- if (status === "error") {
142
- return (
143
- <div className="space-y-6">
144
- <div className="space-y-2 text-center">
145
- <div className="mx-auto w-12 h-12 bg-red-100 dark:bg-red-950/20 rounded-full flex items-center justify-center">
146
- <svg
147
- className="w-6 h-6 text-red-600 dark:text-red-400"
148
- fill="none"
149
- viewBox="0 0 24 24"
150
- stroke="currentColor"
151
- >
152
- <path
153
- strokeLinecap="round"
154
- strokeLinejoin="round"
155
- strokeWidth={2}
156
- d="M6 18L18 6M6 6l12 12"
157
- />
158
- </svg>
159
- </div>
160
- <h2 className="text-2xl font-bold">Verification failed</h2>
161
- <p className="text-muted-foreground">
162
- {error || "Unable to verify your email address"}
163
- </p>
164
- </div>
165
-
166
- <div className="space-y-3">
167
- <p className="text-sm text-muted-foreground text-center">
168
- The verification link may have expired or already been used.
169
- </p>
170
-
171
- <Link href="/login">
172
- <Button variant="outline" className="w-full">
173
- Back to sign in
174
- </Button>
175
- </Link>
176
- </div>
177
- </div>
178
- );
179
- }
180
-
181
- // No token or email - generic page
182
100
  return (
183
101
  <div className="space-y-6">
184
102
  <div className="space-y-2 text-center">
185
- <h2 className="text-2xl font-bold">Verify your email</h2>
103
+ <h2 className="text-2xl font-bold">Enter verification code</h2>
186
104
  <p className="text-muted-foreground">
187
- Please check your email for a verification link.
105
+ We sent a 6-digit code to <strong>{email}</strong>
188
106
  </p>
189
107
  </div>
190
108
 
109
+ {/* OTP Input using shadcn InputOTP */}
110
+ <div className="flex justify-center">
111
+ <InputOTP
112
+ maxLength={6}
113
+ value={otp}
114
+ onChange={setOtp}
115
+ disabled={status === "verifying"}
116
+ >
117
+ <InputOTPGroup>
118
+ <InputOTPSlot index={0} />
119
+ <InputOTPSlot index={1} />
120
+ <InputOTPSlot index={2} />
121
+ </InputOTPGroup>
122
+ <InputOTPSeparator />
123
+ <InputOTPGroup>
124
+ <InputOTPSlot index={3} />
125
+ <InputOTPSlot index={4} />
126
+ <InputOTPSlot index={5} />
127
+ </InputOTPGroup>
128
+ </InputOTP>
129
+ </div>
130
+
131
+ {error && (
132
+ <p className="text-sm text-red-500 text-center">{error}</p>
133
+ )}
134
+
135
+ {status === "verifying" && (
136
+ <div className="flex justify-center">
137
+ <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
138
+ </div>
139
+ )}
140
+
141
+ {status === "success" && (
142
+ <div className="text-center">
143
+ <p className="text-sm text-green-600">Email verified! Redirecting...</p>
144
+ </div>
145
+ )}
146
+
147
+ <div className="text-center text-sm text-muted-foreground">
148
+ Didn&apos;t receive the code?{" "}
149
+ <button
150
+ onClick={handleResend}
151
+ disabled={resendDisabled}
152
+ className="text-primary hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
153
+ >
154
+ {resendDisabled ? `Resend in ${resendCountdown}s` : "Resend"}
155
+ </button>
156
+ </div>
157
+
191
158
  <Link href="/login">
192
- <Button variant="outline" className="w-full">
193
- Back to sign in
194
- </Button>
159
+ <Button variant="outline" className="w-full">Back to sign in</Button>
195
160
  </Link>
196
161
  </div>
197
162
  );