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
@@ -21,7 +21,6 @@ export function DashboardClient({ user }: DashboardClientProps) {
21
21
  try {
22
22
  await signOut();
23
23
  router.push("/login");
24
- router.refresh();
25
24
  } catch (error) {
26
25
  console.error("Sign out error:", error);
27
26
  setIsSigningOut(false);
@@ -139,6 +138,47 @@ export function DashboardClient({ user }: DashboardClientProps) {
139
138
  />
140
139
  </svg>
141
140
  </Link>
141
+ <% if (features.authentication.twoFactor) { %>
142
+ <Link
143
+ href="/settings/security"
144
+ className="flex items-center justify-between p-3 rounded-lg hover:bg-muted/50 transition-colors group"
145
+ >
146
+ <div className="flex items-center gap-3">
147
+ <div className="w-9 h-9 rounded-lg bg-muted/50 dark:bg-muted/30 flex items-center justify-center group-hover:bg-muted transition-colors">
148
+ <svg
149
+ className="w-5 h-5 text-muted-foreground"
150
+ fill="none"
151
+ viewBox="0 0 24 24"
152
+ stroke="currentColor"
153
+ >
154
+ <path
155
+ strokeLinecap="round"
156
+ strokeLinejoin="round"
157
+ strokeWidth={2}
158
+ d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
159
+ />
160
+ </svg>
161
+ </div>
162
+ <div>
163
+ <span className="font-medium">Security Settings</span>
164
+ <p className="text-sm text-muted-foreground">Two-factor authentication</p>
165
+ </div>
166
+ </div>
167
+ <svg
168
+ className="w-5 h-5 text-muted-foreground group-hover:translate-x-0.5 transition-transform"
169
+ fill="none"
170
+ viewBox="0 0 24 24"
171
+ stroke="currentColor"
172
+ >
173
+ <path
174
+ strokeLinecap="round"
175
+ strokeLinejoin="round"
176
+ strokeWidth={2}
177
+ d="M9 5l7 7-7 7"
178
+ />
179
+ </svg>
180
+ </Link>
181
+ <% } %>
142
182
  </div>
143
183
  </div>
144
184
  <% } %>
@@ -4,8 +4,10 @@ import { DashboardClient } from "./dashboard-client";
4
4
 
5
5
  export default async function DashboardPage() {
6
6
  const session = await getSession();
7
+
8
+ // Handle concurrent rendering - layout redirects but page may render first
7
9
  if (!session) {
8
- redirect("/login?redirect=/dashboard");
10
+ redirect("/auth/session-expired");
9
11
  }
10
12
 
11
13
  return (
@@ -0,0 +1,67 @@
1
+ import Link from "next/link";
2
+ import { redirect } from "next/navigation";
3
+ import { getSession } from "@/lib/auth/actions";
4
+ import { ThemeToggle } from "@/components/theme-toggle";
5
+
6
+ export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
7
+ const session = await getSession();
8
+
9
+ // CRITICAL: Use route handler to properly clear stale cookies
10
+ // Direct redirect("/login") doesn't clear browser cookies because Server Components
11
+ // can't send Set-Cookie headers. This causes infinite redirect loops when:
12
+ // 1. User has stale cookie
13
+ // 2. Backend returns 401
14
+ // 3. Redirect to /login
15
+ // 4. Proxy sees cookie, redirects back to /dashboard
16
+ // 5. Loop repeats
17
+ if (!session) {
18
+ redirect("/auth/session-expired");
19
+ }
20
+
21
+ return (
22
+ <div className="min-h-screen flex flex-col bg-background">
23
+ {/* App Header - matching landing page style */}
24
+ <header className="w-full border-b bg-background/80 backdrop-blur-sm sticky top-0 z-50">
25
+ <div className="max-w-4xl mx-auto px-6 h-16 flex items-center justify-between">
26
+ <div className="flex items-center gap-8">
27
+ <Link href="/dashboard" className="text-xl font-semibold tracking-tight">
28
+ <%= projectName %>
29
+ </Link>
30
+ <nav className="hidden sm:flex items-center gap-6">
31
+ <Link
32
+ href="/dashboard"
33
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
34
+ >
35
+ Dashboard
36
+ </Link>
37
+ <% if (features.sessionManagement) { %>
38
+ <Link
39
+ href="/settings/sessions"
40
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
41
+ >
42
+ Sessions
43
+ </Link>
44
+ <% } %>
45
+ <% if (features.authentication.twoFactor) { %>
46
+ <Link
47
+ href="/settings/security"
48
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors"
49
+ >
50
+ Security
51
+ </Link>
52
+ <% } %>
53
+ </nav>
54
+ </div>
55
+ <ThemeToggle />
56
+ </div>
57
+ </header>
58
+
59
+ {/* Main content area */}
60
+ <main className="flex-1">
61
+ <div className="max-w-4xl mx-auto px-6 py-8">
62
+ {children}
63
+ </div>
64
+ </main>
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,19 @@
1
+ import { getSession } from "@/lib/auth/actions";
2
+ import { SecuritySettings } from "./security-settings";
3
+
4
+ export default async function SecurityPage() {
5
+ // Session is guaranteed by (protected) layout - it redirects if not authenticated
6
+ const session = (await getSession())!;
7
+
8
+ return (
9
+ <div className="space-y-8">
10
+ <div className="space-y-1">
11
+ <h1 className="text-2xl font-bold tracking-tight">Security Settings</h1>
12
+ <p className="text-muted-foreground">
13
+ Manage your account security settings including two-factor authentication.
14
+ </p>
15
+ </div>
16
+ <SecuritySettings user={session.user} />
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { TwoFactorSetup } from '@/components/settings/two-factor-setup';
6
+ import { TwoFactorManage } from '@/components/settings/two-factor-manage';
7
+ import { SetPasswordForm } from '@/components/settings/set-password-form';
8
+ import { checkHasPassword, type User } from '@/lib/auth/actions';
9
+ import { Loader2 } from 'lucide-react';
10
+
11
+ interface SecuritySettingsProps {
12
+ user: User;
13
+ }
14
+
15
+ type SetupStep = 'idle' | 'checking' | 'set-password' | 'setup-2fa';
16
+
17
+ export function SecuritySettings({ user }: SecuritySettingsProps) {
18
+ const router = useRouter();
19
+ const [setupStep, setSetupStep] = useState<SetupStep>('idle');
20
+ const [twoFactorEnabled, setTwoFactorEnabled] = useState(user.twoFactorEnabled);
21
+
22
+ const handleEnableClick = async () => {
23
+ setSetupStep('checking');
24
+ const { hasPassword } = await checkHasPassword();
25
+ setSetupStep(hasPassword ? 'setup-2fa' : 'set-password');
26
+ };
27
+
28
+ // Show loading state while checking password
29
+ if (setupStep === 'checking') {
30
+ return (
31
+ <div className="flex items-center justify-center py-12">
32
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
33
+ </div>
34
+ );
35
+ }
36
+
37
+ // Show set password form for OAuth users
38
+ if (setupStep === 'set-password') {
39
+ return (
40
+ <SetPasswordForm
41
+ onComplete={() => setSetupStep('setup-2fa')}
42
+ onCancel={() => setSetupStep('idle')}
43
+ />
44
+ );
45
+ }
46
+
47
+ // Show 2FA setup form
48
+ if (setupStep === 'setup-2fa') {
49
+ return (
50
+ <TwoFactorSetup
51
+ onComplete={() => {
52
+ setSetupStep('idle');
53
+ setTwoFactorEnabled(true);
54
+ router.refresh();
55
+ }}
56
+ />
57
+ );
58
+ }
59
+
60
+ // Default: show 2FA manage (enable/disable) UI
61
+ return (
62
+ <div className="space-y-6">
63
+ <TwoFactorManage
64
+ enabled={twoFactorEnabled}
65
+ onSetupClick={handleEnableClick}
66
+ onDisabled={() => {
67
+ setTwoFactorEnabled(false);
68
+ router.refresh();
69
+ }}
70
+ />
71
+ </div>
72
+ );
73
+ }
@@ -1,14 +1,10 @@
1
- import { redirect } from "next/navigation";
2
1
  import { getSession } from "@/lib/auth/actions";
3
2
  import { listSessions } from "@/lib/auth/sessions";
4
3
  import { SessionsClient } from "./sessions-client";
5
4
 
6
5
  export default async function SessionsPage() {
7
- const authSession = await getSession();
8
- if (!authSession) {
9
- redirect("/login?redirect=/settings/sessions");
10
- }
11
-
6
+ // Session is guaranteed by (protected) layout - it redirects if not authenticated
7
+ const authSession = (await getSession())!;
12
8
  const { sessions } = await listSessions();
13
9
 
14
10
  return (
@@ -93,7 +93,11 @@ export async function GET(request: NextRequest) {
93
93
  return response;
94
94
  }
95
95
 
96
- const { session_token } = await exchangeResponse.json();
96
+ const data = await exchangeResponse.json();
97
+
98
+ // Note: We trust OAuth providers for 2FA - they typically handle 2FA themselves.
99
+ // Better Auth by design doesn't support 2FA for OAuth users.
100
+ const { session_token } = data;
97
101
 
98
102
  // Success! Create response with session cookie and redirect to dashboard
99
103
  const response = NextResponse.redirect(new URL('/dashboard', baseUrl));
@@ -0,0 +1,39 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { COOKIE_NAMES } from '@/lib/auth/config';
3
+
4
+ /**
5
+ * Session Expiration Route Handler
6
+ *
7
+ * CRITICAL: We use a Route Handler because:
8
+ * - Route Handlers can delete cookies that are sent to the browser
9
+ * - Server Components that call redirect() never send Set-Cookie headers
10
+ * - This prevents infinite redirect loops with stale cookies
11
+ *
12
+ * Flow:
13
+ * 1. Protected layout detects invalid session (backend returns 401)
14
+ * 2. Layout redirects to this route handler
15
+ * 3. This handler clears the session cookie (browser receives Set-Cookie header)
16
+ * 4. Redirects to /login with error message
17
+ * 5. User can log in fresh - no more stale cookie causing loops
18
+ */
19
+ export async function GET(request: NextRequest) {
20
+ const searchParams = request.nextUrl.searchParams;
21
+ const callbackUrl = searchParams.get('callbackUrl');
22
+
23
+ // Build login URL with error message
24
+ const loginUrl = new URL('/login', request.url);
25
+ loginUrl.searchParams.set('error', 'session_expired');
26
+ if (callbackUrl) {
27
+ loginUrl.searchParams.set('redirect', callbackUrl);
28
+ }
29
+
30
+ // Create redirect response
31
+ const response = NextResponse.redirect(loginUrl);
32
+
33
+ // CRITICAL: Clear session cookie - Route Handler properly sends Set-Cookie header
34
+ // This is the key difference from Server Components where cookies().delete() only
35
+ // affects the server-side cookie jar and doesn't send headers to the browser
36
+ response.cookies.delete(COOKIE_NAMES.SESSION);
37
+
38
+ return response;
39
+ }
@@ -0,0 +1,22 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { COOKIE_NAMES } from '@/lib/auth/config';
3
+
4
+ /**
5
+ * Route handler for 2FA session expiration
6
+ * Clears all 2FA-related cookies and redirects to login with error message
7
+ */
8
+ export async function GET(request: NextRequest) {
9
+ const loginUrl = new URL('/login', request.url);
10
+ loginUrl.searchParams.set('error', 'two_factor_expired');
11
+
12
+ const response = NextResponse.redirect(loginUrl);
13
+
14
+ // Clear 2FA cookies
15
+ response.cookies.delete(COOKIE_NAMES.TWO_FACTOR);
16
+ response.cookies.delete(COOKIE_NAMES.TWO_FACTOR_EXPIRES_AT);
17
+
18
+ // Clear session cookie (the temporary 2FA session is no longer valid)
19
+ response.cookies.delete(COOKIE_NAMES.SESSION);
20
+
21
+ return response;
22
+ }
@@ -28,10 +28,28 @@ export function LoginForm({ redirectTo = "/dashboard" }: LoginFormProps) {
28
28
 
29
29
  try {
30
30
  const result = await signIn(email, password);
31
+
32
+ <% if (features.authentication.twoFactor) { %>
33
+ // Check if 2FA is required
34
+ if (result.requiresTwoFactor) {
35
+ router.push('/login/two-factor');
36
+ return;
37
+ }
38
+ <% } %>
39
+
40
+ <% if (features.authentication.emailVerification) { %>
41
+ // Check if email verification is needed (can happen on success)
42
+ if (result.needsEmailVerification) {
43
+ router.push(`/verify-email?email=${encodeURIComponent(email)}`);
44
+ return;
45
+ }
46
+ <% } %>
47
+
31
48
  if (!result.success) {
32
49
  setError(result.error || "Failed to sign in");
33
50
  return;
34
51
  }
52
+
35
53
  if (result.session) setSession(result.session);
36
54
  router.push(redirectTo);
37
55
  router.refresh();
@@ -0,0 +1,173 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Label } from '@/components/ui/label';
8
+ import { Checkbox } from '@/components/ui/checkbox';
9
+ import { Loader2 } from 'lucide-react';
10
+ import { toast } from 'sonner';
11
+ import { verifyTotpLogin, verifyBackupCode } from '@/lib/auth/actions';
12
+ import { useAuthStore } from '@/store/auth.store';
13
+
14
+ interface TwoFactorVerifyProps {
15
+ redirectTo?: string;
16
+ expiresAt?: number | null;
17
+ }
18
+
19
+ export function TwoFactorVerify({
20
+ redirectTo = '/dashboard',
21
+ expiresAt
22
+ }: TwoFactorVerifyProps) {
23
+ const router = useRouter();
24
+ const setSession = useAuthStore((s) => s.setSession);
25
+ const [code, setCode] = useState('');
26
+ const [trustDevice, setTrustDevice] = useState(false);
27
+ const [isLoading, setIsLoading] = useState(false);
28
+ const [useBackupCode, setUseBackupCode] = useState(false);
29
+
30
+ // Single delayed redirect at exact expiration time
31
+ useEffect(() => {
32
+ if (!expiresAt) return;
33
+
34
+ const now = Math.floor(Date.now() / 1000);
35
+ const remainingMs = (expiresAt - now) * 1000;
36
+
37
+ // Already expired
38
+ if (remainingMs <= 0) {
39
+ router.push('/auth/two-factor-expired');
40
+ return;
41
+ }
42
+
43
+ // Schedule redirect at exact expiration time
44
+ const timeout = setTimeout(() => {
45
+ router.push('/auth/two-factor-expired');
46
+ }, remainingMs);
47
+
48
+ return () => clearTimeout(timeout);
49
+ }, [expiresAt, router]);
50
+
51
+ const handleSubmit = async (e: React.FormEvent) => {
52
+ e.preventDefault();
53
+
54
+ if (!code) {
55
+ toast.error('Please enter a code');
56
+ return;
57
+ }
58
+
59
+ // Check if expired before submitting
60
+ if (expiresAt) {
61
+ const now = Math.floor(Date.now() / 1000);
62
+ if (now >= expiresAt) {
63
+ router.push('/auth/two-factor-expired');
64
+ return;
65
+ }
66
+ }
67
+
68
+ setIsLoading(true);
69
+
70
+ const result = useBackupCode
71
+ ? await verifyBackupCode(code)
72
+ : await verifyTotpLogin(code, trustDevice);
73
+
74
+ setIsLoading(false);
75
+
76
+ if (!result.success) {
77
+ const errorLower = (result.error || '').toLowerCase();
78
+ if (
79
+ errorLower.includes('expired') ||
80
+ errorLower.includes('invalid session') ||
81
+ errorLower.includes('two factor')
82
+ ) {
83
+ router.push('/auth/two-factor-expired');
84
+ return;
85
+ }
86
+
87
+ toast.error(result.error || 'Invalid code');
88
+ return;
89
+ }
90
+
91
+ if (result.session) {
92
+ setSession(result.session);
93
+ }
94
+
95
+ router.push(redirectTo);
96
+ };
97
+
98
+ return (
99
+ <div className="space-y-6">
100
+ <div className="space-y-2 text-center">
101
+ <h2 className="text-2xl font-bold">
102
+ {useBackupCode ? 'Enter Backup Code' : 'Two-Factor Authentication'}
103
+ </h2>
104
+ <p className="text-muted-foreground">
105
+ {useBackupCode
106
+ ? 'Enter one of your backup codes to sign in'
107
+ : 'Enter the 6-digit code from your authenticator app'}
108
+ </p>
109
+ </div>
110
+
111
+ <form onSubmit={handleSubmit} className="space-y-4">
112
+ <div className="space-y-1.5">
113
+ <Label htmlFor="code" className="text-sm">
114
+ {useBackupCode ? 'Backup Code' : 'Verification Code'}
115
+ </Label>
116
+ <Input
117
+ id="code"
118
+ value={code}
119
+ onChange={(e) => {
120
+ const value = useBackupCode
121
+ ? e.target.value
122
+ : e.target.value.replace(/\D/g, '').slice(0, 6);
123
+ setCode(value);
124
+ }}
125
+ placeholder={useBackupCode ? 'Enter backup code' : '000000'}
126
+ className={useBackupCode ? 'font-mono' : 'text-center font-mono text-lg tracking-widest'}
127
+ maxLength={useBackupCode ? 20 : 6}
128
+ autoComplete="one-time-code"
129
+ autoFocus
130
+ />
131
+ </div>
132
+
133
+ {!useBackupCode && (
134
+ <div className="flex items-center space-x-2">
135
+ <Checkbox
136
+ id="trust-device"
137
+ checked={trustDevice}
138
+ onCheckedChange={(checked) => setTrustDevice(checked === true)}
139
+ />
140
+ <Label htmlFor="trust-device" className="text-sm font-normal">
141
+ Trust this device for 30 days
142
+ </Label>
143
+ </div>
144
+ )}
145
+
146
+ <Button type="submit" disabled={isLoading} className="w-full">
147
+ {isLoading ? (
148
+ <>
149
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
150
+ Verifying...
151
+ </>
152
+ ) : (
153
+ 'Verify'
154
+ )}
155
+ </Button>
156
+
157
+ <Button
158
+ type="button"
159
+ variant="link"
160
+ className="w-full text-sm"
161
+ onClick={() => {
162
+ setUseBackupCode(!useBackupCode);
163
+ setCode('');
164
+ }}
165
+ >
166
+ {useBackupCode
167
+ ? 'Use authenticator app instead'
168
+ : 'Use a backup code instead'}
169
+ </Button>
170
+ </form>
171
+ </div>
172
+ );
173
+ }
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Input } from '@/components/ui/input';
6
+ import { Label } from '@/components/ui/label';
7
+ import {
8
+ Card,
9
+ CardContent,
10
+ CardDescription,
11
+ CardHeader,
12
+ CardTitle,
13
+ } from '@/components/ui/card';
14
+ import { KeyRound, Loader2 } from 'lucide-react';
15
+ import { toast } from 'sonner';
16
+ import { setInitialPassword } from '@/lib/auth/actions';
17
+
18
+ interface SetPasswordFormProps {
19
+ onComplete: () => void;
20
+ onCancel: () => void;
21
+ }
22
+
23
+ export function SetPasswordForm({ onComplete, onCancel }: SetPasswordFormProps) {
24
+ const [newPassword, setNewPassword] = useState('');
25
+ const [confirmPassword, setConfirmPassword] = useState('');
26
+ const [isLoading, setIsLoading] = useState(false);
27
+
28
+ const handleSubmit = async (e: React.FormEvent) => {
29
+ e.preventDefault();
30
+
31
+ if (!newPassword) {
32
+ toast.error('Please enter a password');
33
+ return;
34
+ }
35
+
36
+ if (newPassword.length < 8) {
37
+ toast.error('Password must be at least 8 characters');
38
+ return;
39
+ }
40
+
41
+ if (newPassword !== confirmPassword) {
42
+ toast.error('Passwords do not match');
43
+ return;
44
+ }
45
+
46
+ setIsLoading(true);
47
+ const result = await setInitialPassword(newPassword);
48
+ setIsLoading(false);
49
+
50
+ if (!result.success) {
51
+ toast.error(result.error || 'Failed to set password');
52
+ return;
53
+ }
54
+
55
+ toast.success('Password set successfully');
56
+ onComplete();
57
+ };
58
+
59
+ return (
60
+ <Card>
61
+ <CardHeader>
62
+ <CardTitle className="flex items-center gap-2">
63
+ <KeyRound className="h-5 w-5" />
64
+ Set Account Password
65
+ </CardTitle>
66
+ <CardDescription>
67
+ To enable two-factor authentication, you need to set a password for your account first.
68
+ This password will allow you to sign in with email and password.
69
+ <span className="block mt-2 text-amber-600 dark:text-amber-400">
70
+ Note: Two-factor authentication only applies when signing in with email and password.
71
+ OAuth providers (Google, Apple, etc.) handle their own security verification.
72
+ </span>
73
+ </CardDescription>
74
+ </CardHeader>
75
+ <CardContent>
76
+ <form onSubmit={handleSubmit} className="space-y-4">
77
+ <div className="space-y-2">
78
+ <Label htmlFor="new-password">New Password</Label>
79
+ <Input
80
+ id="new-password"
81
+ type="password"
82
+ value={newPassword}
83
+ onChange={(e) => setNewPassword(e.target.value)}
84
+ placeholder="Enter a password"
85
+ disabled={isLoading}
86
+ autoComplete="new-password"
87
+ />
88
+ </div>
89
+ <div className="space-y-2">
90
+ <Label htmlFor="confirm-password">Confirm Password</Label>
91
+ <Input
92
+ id="confirm-password"
93
+ type="password"
94
+ value={confirmPassword}
95
+ onChange={(e) => setConfirmPassword(e.target.value)}
96
+ placeholder="Confirm your password"
97
+ disabled={isLoading}
98
+ autoComplete="new-password"
99
+ />
100
+ </div>
101
+ <p className="text-xs text-muted-foreground">
102
+ Password must be at least 8 characters long.
103
+ </p>
104
+ <div className="flex gap-2">
105
+ <Button type="button" variant="outline" onClick={onCancel} disabled={isLoading}>
106
+ Cancel
107
+ </Button>
108
+ <Button type="submit" disabled={isLoading} className="flex-1">
109
+ {isLoading ? (
110
+ <>
111
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
112
+ Setting password...
113
+ </>
114
+ ) : (
115
+ 'Set Password & Continue'
116
+ )}
117
+ </Button>
118
+ </div>
119
+ </form>
120
+ </CardContent>
121
+ </Card>
122
+ );
123
+ }