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
|
@@ -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
|
|
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
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
{
|
|
25
|
-
<div className=
|
|
26
|
-
|
|
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 {
|
|
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 [
|
|
16
|
-
|
|
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 (
|
|
22
|
-
handleVerify(
|
|
29
|
+
if (otp.length === 6 && email && status === "idle") {
|
|
30
|
+
handleVerify(otp);
|
|
23
31
|
}
|
|
24
|
-
}, [
|
|
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
|
-
|
|
27
|
-
try {
|
|
28
|
-
const result = await verifyEmail(verificationToken);
|
|
56
|
+
const result = await verifyEmailOtp(email, code);
|
|
29
57
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}, 3000);
|
|
41
|
-
} catch (err) {
|
|
67
|
+
useAuthStore.getState().setSession(result.session);
|
|
68
|
+
router.push("/dashboard");
|
|
69
|
+
} else {
|
|
42
70
|
setStatus("error");
|
|
43
|
-
setError("
|
|
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
|
-
|
|
49
|
-
|
|
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'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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
Didn't receive the email? Check your spam folder.
|
|
78
|
-
</p>
|
|
79
|
-
</div>
|
|
78
|
+
setResendDisabled(true);
|
|
79
|
+
setResendCountdown(60);
|
|
80
|
+
setError(null);
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
108
|
-
if (status === "success") {
|
|
88
|
+
if (!email) {
|
|
109
89
|
return (
|
|
110
|
-
<div className="space-y-6">
|
|
111
|
-
<
|
|
112
|
-
|
|
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">
|
|
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">
|
|
103
|
+
<h2 className="text-2xl font-bold">Enter verification code</h2>
|
|
186
104
|
<p className="text-muted-foreground">
|
|
187
|
-
|
|
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'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
|
);
|