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
package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/dashboard-client.tsx.ejs
RENAMED
|
@@ -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("/
|
|
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
|
+
}
|
package/templates/features/web/auth/app/(protected)/settings/security/security-settings.tsx.ejs
ADDED
|
@@ -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
|
+
}
|
package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/page.tsx.ejs
RENAMED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
|
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
|
+
}
|