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,253 @@
|
|
|
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 {
|
|
15
|
+
Dialog,
|
|
16
|
+
DialogContent,
|
|
17
|
+
DialogDescription,
|
|
18
|
+
DialogFooter,
|
|
19
|
+
DialogHeader,
|
|
20
|
+
DialogTitle,
|
|
21
|
+
DialogTrigger,
|
|
22
|
+
} from '@/components/ui/dialog';
|
|
23
|
+
import { Shield, ShieldCheck, ShieldOff, Download, Loader2, RefreshCw } from 'lucide-react';
|
|
24
|
+
import { toast } from 'sonner';
|
|
25
|
+
import { disableTwoFactor, generateBackupCodes } from '@/lib/auth/actions';
|
|
26
|
+
|
|
27
|
+
interface TwoFactorManageProps {
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
onSetupClick: () => void;
|
|
30
|
+
onDisabled: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function TwoFactorManage({ enabled, onSetupClick, onDisabled }: TwoFactorManageProps) {
|
|
34
|
+
const [password, setPassword] = useState('');
|
|
35
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
36
|
+
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
|
|
37
|
+
const [backupDialogOpen, setBackupDialogOpen] = useState(false);
|
|
38
|
+
const [newBackupCodes, setNewBackupCodes] = useState<string[]>([]);
|
|
39
|
+
|
|
40
|
+
const handleDisable = async () => {
|
|
41
|
+
if (!password) {
|
|
42
|
+
toast.error('Please enter your password');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setIsLoading(true);
|
|
47
|
+
const result = await disableTwoFactor(password);
|
|
48
|
+
setIsLoading(false);
|
|
49
|
+
|
|
50
|
+
if (!result.success) {
|
|
51
|
+
toast.error(result.error || 'Failed to disable 2FA');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
toast.success('Two-factor authentication disabled');
|
|
56
|
+
setDisableDialogOpen(false);
|
|
57
|
+
setPassword('');
|
|
58
|
+
onDisabled();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleGenerateBackupCodes = async () => {
|
|
62
|
+
if (!password) {
|
|
63
|
+
toast.error('Please enter your password');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setIsLoading(true);
|
|
68
|
+
const result = await generateBackupCodes(password);
|
|
69
|
+
setIsLoading(false);
|
|
70
|
+
|
|
71
|
+
if (!result.success) {
|
|
72
|
+
toast.error(result.error || 'Failed to generate backup codes');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setNewBackupCodes(result.backupCodes || []);
|
|
77
|
+
toast.success('New backup codes generated');
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const downloadBackupCodes = () => {
|
|
81
|
+
const content = `Two-Factor Authentication Backup Codes\n${'='.repeat(40)}\n\nKeep these codes safe. Each code can only be used once.\n\n${newBackupCodes.join('\n')}`;
|
|
82
|
+
const blob = new Blob([content], { type: 'text/plain' });
|
|
83
|
+
const url = URL.createObjectURL(blob);
|
|
84
|
+
const a = document.createElement('a');
|
|
85
|
+
a.href = url;
|
|
86
|
+
a.download = '2fa-backup-codes.txt';
|
|
87
|
+
a.click();
|
|
88
|
+
URL.revokeObjectURL(url);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (!enabled) {
|
|
92
|
+
return (
|
|
93
|
+
<Card>
|
|
94
|
+
<CardHeader>
|
|
95
|
+
<CardTitle className="flex items-center gap-2">
|
|
96
|
+
<Shield className="h-5 w-5" />
|
|
97
|
+
Two-Factor Authentication
|
|
98
|
+
</CardTitle>
|
|
99
|
+
<CardDescription>
|
|
100
|
+
Add an extra layer of security to your account
|
|
101
|
+
</CardDescription>
|
|
102
|
+
</CardHeader>
|
|
103
|
+
<CardContent>
|
|
104
|
+
<div className="flex items-center justify-between">
|
|
105
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
106
|
+
<ShieldOff className="h-4 w-4" />
|
|
107
|
+
<span className="text-sm">Not enabled</span>
|
|
108
|
+
</div>
|
|
109
|
+
<Button onClick={onSetupClick}>Enable 2FA</Button>
|
|
110
|
+
</div>
|
|
111
|
+
</CardContent>
|
|
112
|
+
</Card>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Card>
|
|
118
|
+
<CardHeader>
|
|
119
|
+
<CardTitle className="flex items-center gap-2">
|
|
120
|
+
<ShieldCheck className="h-5 w-5 text-green-600" />
|
|
121
|
+
Two-Factor Authentication
|
|
122
|
+
</CardTitle>
|
|
123
|
+
<CardDescription>
|
|
124
|
+
Your account is protected with two-factor authentication
|
|
125
|
+
</CardDescription>
|
|
126
|
+
</CardHeader>
|
|
127
|
+
<CardContent className="space-y-4">
|
|
128
|
+
<div className="flex items-center gap-2 text-green-600">
|
|
129
|
+
<ShieldCheck className="h-4 w-4" />
|
|
130
|
+
<span className="text-sm font-medium">Enabled</span>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<div className="flex flex-wrap gap-2">
|
|
134
|
+
{/* Generate new backup codes */}
|
|
135
|
+
<Dialog open={backupDialogOpen} onOpenChange={setBackupDialogOpen}>
|
|
136
|
+
<DialogTrigger asChild>
|
|
137
|
+
<Button variant="outline" size="sm">
|
|
138
|
+
<RefreshCw className="mr-2 h-4 w-4" />
|
|
139
|
+
New Backup Codes
|
|
140
|
+
</Button>
|
|
141
|
+
</DialogTrigger>
|
|
142
|
+
<DialogContent>
|
|
143
|
+
<DialogHeader>
|
|
144
|
+
<DialogTitle>Generate New Backup Codes</DialogTitle>
|
|
145
|
+
<DialogDescription>
|
|
146
|
+
This will invalidate your existing backup codes and generate new ones.
|
|
147
|
+
</DialogDescription>
|
|
148
|
+
</DialogHeader>
|
|
149
|
+
|
|
150
|
+
{newBackupCodes.length === 0 ? (
|
|
151
|
+
<div className="space-y-4">
|
|
152
|
+
<div className="space-y-2">
|
|
153
|
+
<Label htmlFor="backup-password">Confirm your password</Label>
|
|
154
|
+
<Input
|
|
155
|
+
id="backup-password"
|
|
156
|
+
type="password"
|
|
157
|
+
value={password}
|
|
158
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
159
|
+
placeholder="Enter your password"
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
<Button onClick={handleGenerateBackupCodes} disabled={isLoading} className="w-full">
|
|
163
|
+
{isLoading ? (
|
|
164
|
+
<>
|
|
165
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
166
|
+
Generating...
|
|
167
|
+
</>
|
|
168
|
+
) : (
|
|
169
|
+
'Generate New Codes'
|
|
170
|
+
)}
|
|
171
|
+
</Button>
|
|
172
|
+
</div>
|
|
173
|
+
) : (
|
|
174
|
+
<div className="space-y-4">
|
|
175
|
+
<div className="rounded-lg border bg-muted/50 p-4">
|
|
176
|
+
<div className="grid grid-cols-2 gap-2">
|
|
177
|
+
{newBackupCodes.map((code, i) => (
|
|
178
|
+
<code
|
|
179
|
+
key={i}
|
|
180
|
+
className="rounded bg-background px-2 py-1 text-center text-sm font-mono"
|
|
181
|
+
>
|
|
182
|
+
{code}
|
|
183
|
+
</code>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
<Button onClick={downloadBackupCodes} className="w-full">
|
|
188
|
+
<Download className="mr-2 h-4 w-4" />
|
|
189
|
+
Download Backup Codes
|
|
190
|
+
</Button>
|
|
191
|
+
<Button
|
|
192
|
+
variant="outline"
|
|
193
|
+
onClick={() => {
|
|
194
|
+
setBackupDialogOpen(false);
|
|
195
|
+
setNewBackupCodes([]);
|
|
196
|
+
setPassword('');
|
|
197
|
+
}}
|
|
198
|
+
className="w-full"
|
|
199
|
+
>
|
|
200
|
+
Done
|
|
201
|
+
</Button>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</DialogContent>
|
|
205
|
+
</Dialog>
|
|
206
|
+
|
|
207
|
+
{/* Disable 2FA */}
|
|
208
|
+
<Dialog open={disableDialogOpen} onOpenChange={setDisableDialogOpen}>
|
|
209
|
+
<DialogTrigger asChild>
|
|
210
|
+
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
|
|
211
|
+
<ShieldOff className="mr-2 h-4 w-4" />
|
|
212
|
+
Disable 2FA
|
|
213
|
+
</Button>
|
|
214
|
+
</DialogTrigger>
|
|
215
|
+
<DialogContent>
|
|
216
|
+
<DialogHeader>
|
|
217
|
+
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
|
|
218
|
+
<DialogDescription>
|
|
219
|
+
This will make your account less secure. Are you sure you want to disable 2FA?
|
|
220
|
+
</DialogDescription>
|
|
221
|
+
</DialogHeader>
|
|
222
|
+
<div className="space-y-2">
|
|
223
|
+
<Label htmlFor="disable-password">Confirm your password</Label>
|
|
224
|
+
<Input
|
|
225
|
+
id="disable-password"
|
|
226
|
+
type="password"
|
|
227
|
+
value={password}
|
|
228
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
229
|
+
placeholder="Enter your password"
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
<DialogFooter>
|
|
233
|
+
<Button variant="outline" onClick={() => setDisableDialogOpen(false)}>
|
|
234
|
+
Cancel
|
|
235
|
+
</Button>
|
|
236
|
+
<Button variant="destructive" onClick={handleDisable} disabled={isLoading}>
|
|
237
|
+
{isLoading ? (
|
|
238
|
+
<>
|
|
239
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
240
|
+
Disabling...
|
|
241
|
+
</>
|
|
242
|
+
) : (
|
|
243
|
+
'Disable 2FA'
|
|
244
|
+
)}
|
|
245
|
+
</Button>
|
|
246
|
+
</DialogFooter>
|
|
247
|
+
</DialogContent>
|
|
248
|
+
</Dialog>
|
|
249
|
+
</div>
|
|
250
|
+
</CardContent>
|
|
251
|
+
</Card>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import QRCode from 'react-qr-code';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Input } from '@/components/ui/input';
|
|
7
|
+
import { Label } from '@/components/ui/label';
|
|
8
|
+
import {
|
|
9
|
+
Card,
|
|
10
|
+
CardContent,
|
|
11
|
+
CardDescription,
|
|
12
|
+
CardHeader,
|
|
13
|
+
CardTitle,
|
|
14
|
+
} from '@/components/ui/card';
|
|
15
|
+
import { Shield, Copy, Download, Check, Loader2 } from 'lucide-react';
|
|
16
|
+
import { toast } from 'sonner';
|
|
17
|
+
import { enableTwoFactor, verifyTotpSetup } from '@/lib/auth/actions';
|
|
18
|
+
|
|
19
|
+
interface TwoFactorSetupProps {
|
|
20
|
+
onComplete: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Step = 'password' | 'qr' | 'verify' | 'backup';
|
|
24
|
+
|
|
25
|
+
export function TwoFactorSetup({ onComplete }: TwoFactorSetupProps) {
|
|
26
|
+
const [step, setStep] = useState<Step>('password');
|
|
27
|
+
const [password, setPassword] = useState('');
|
|
28
|
+
const [totpURI, setTotpURI] = useState<string | null>(null);
|
|
29
|
+
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
30
|
+
const [verificationCode, setVerificationCode] = useState('');
|
|
31
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
32
|
+
const [copied, setCopied] = useState(false);
|
|
33
|
+
|
|
34
|
+
const handleStartSetup = async (e: React.FormEvent) => {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
if (!password) {
|
|
37
|
+
toast.error('Please enter your password');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setIsLoading(true);
|
|
42
|
+
const result = await enableTwoFactor(password);
|
|
43
|
+
setIsLoading(false);
|
|
44
|
+
|
|
45
|
+
if (!result.success) {
|
|
46
|
+
toast.error(result.error || 'Failed to start 2FA setup');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setTotpURI(result.totpURI || null);
|
|
51
|
+
setBackupCodes(result.backupCodes || []);
|
|
52
|
+
setStep('qr');
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleVerify = async (e: React.FormEvent) => {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
if (verificationCode.length !== 6) {
|
|
58
|
+
toast.error('Please enter a 6-digit code');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setIsLoading(true);
|
|
63
|
+
const result = await verifyTotpSetup(verificationCode);
|
|
64
|
+
setIsLoading(false);
|
|
65
|
+
|
|
66
|
+
if (!result.success) {
|
|
67
|
+
toast.error(result.error || 'Invalid verification code');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setStep('backup');
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const copySecret = () => {
|
|
75
|
+
if (totpURI) {
|
|
76
|
+
// Extract secret from URI
|
|
77
|
+
const match = totpURI.match(/secret=([^&]+)/);
|
|
78
|
+
if (match) {
|
|
79
|
+
navigator.clipboard.writeText(match[1]);
|
|
80
|
+
setCopied(true);
|
|
81
|
+
setTimeout(() => setCopied(false), 2000);
|
|
82
|
+
toast.success('Secret copied to clipboard');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const downloadBackupCodes = () => {
|
|
88
|
+
const content = `Two-Factor Authentication Backup Codes\n${'='.repeat(40)}\n\nKeep these codes safe. Each code can only be used once.\n\n${backupCodes.join('\n')}`;
|
|
89
|
+
const blob = new Blob([content], { type: 'text/plain' });
|
|
90
|
+
const url = URL.createObjectURL(blob);
|
|
91
|
+
const a = document.createElement('a');
|
|
92
|
+
a.href = url;
|
|
93
|
+
a.download = '2fa-backup-codes.txt';
|
|
94
|
+
a.click();
|
|
95
|
+
URL.revokeObjectURL(url);
|
|
96
|
+
toast.success('Backup codes downloaded');
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleFinish = () => {
|
|
100
|
+
toast.success('Two-factor authentication enabled!');
|
|
101
|
+
onComplete();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Card>
|
|
106
|
+
<CardHeader>
|
|
107
|
+
<CardTitle className="flex items-center gap-2">
|
|
108
|
+
<Shield className="h-5 w-5" />
|
|
109
|
+
Two-Factor Authentication
|
|
110
|
+
</CardTitle>
|
|
111
|
+
<CardDescription>
|
|
112
|
+
Add an extra layer of security to your account
|
|
113
|
+
</CardDescription>
|
|
114
|
+
</CardHeader>
|
|
115
|
+
<CardContent>
|
|
116
|
+
{step === 'password' && (
|
|
117
|
+
<form onSubmit={handleStartSetup} className="space-y-4">
|
|
118
|
+
<p className="text-sm text-muted-foreground">
|
|
119
|
+
Two-factor authentication adds an additional layer of security by
|
|
120
|
+
requiring a code from your authenticator app when signing in.
|
|
121
|
+
</p>
|
|
122
|
+
<div className="space-y-2">
|
|
123
|
+
<Label htmlFor="password">Confirm your password</Label>
|
|
124
|
+
<Input
|
|
125
|
+
id="password"
|
|
126
|
+
type="password"
|
|
127
|
+
value={password}
|
|
128
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
129
|
+
placeholder="Enter your password"
|
|
130
|
+
disabled={isLoading}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
<Button type="submit" disabled={isLoading}>
|
|
134
|
+
{isLoading ? (
|
|
135
|
+
<>
|
|
136
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
137
|
+
Setting up...
|
|
138
|
+
</>
|
|
139
|
+
) : (
|
|
140
|
+
'Enable 2FA'
|
|
141
|
+
)}
|
|
142
|
+
</Button>
|
|
143
|
+
</form>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{step === 'qr' && totpURI && (
|
|
147
|
+
<div className="space-y-4">
|
|
148
|
+
<p className="text-sm text-muted-foreground">
|
|
149
|
+
Scan this QR code with your authenticator app (Google Authenticator,
|
|
150
|
+
Authy, 1Password, etc.)
|
|
151
|
+
</p>
|
|
152
|
+
|
|
153
|
+
<div className="flex justify-center">
|
|
154
|
+
<div className="rounded-lg bg-white p-4">
|
|
155
|
+
<QRCode value={totpURI} size={200} />
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div className="flex items-center gap-2">
|
|
160
|
+
<Input
|
|
161
|
+
value={totpURI.match(/secret=([^&]+)/)?.[1] || ''}
|
|
162
|
+
readOnly
|
|
163
|
+
className="font-mono text-sm"
|
|
164
|
+
/>
|
|
165
|
+
<Button variant="outline" size="icon" onClick={copySecret}>
|
|
166
|
+
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
|
167
|
+
</Button>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<p className="text-xs text-muted-foreground">
|
|
171
|
+
Can't scan? Enter the secret key manually in your app.
|
|
172
|
+
</p>
|
|
173
|
+
|
|
174
|
+
<Button onClick={() => setStep('verify')} className="w-full">
|
|
175
|
+
Continue
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
{step === 'verify' && (
|
|
181
|
+
<form onSubmit={handleVerify} className="space-y-4">
|
|
182
|
+
<p className="text-sm text-muted-foreground">
|
|
183
|
+
Enter the 6-digit code from your authenticator app to verify setup.
|
|
184
|
+
</p>
|
|
185
|
+
|
|
186
|
+
<div className="space-y-2">
|
|
187
|
+
<Label htmlFor="verification-code">Verification Code</Label>
|
|
188
|
+
<Input
|
|
189
|
+
id="verification-code"
|
|
190
|
+
value={verificationCode}
|
|
191
|
+
onChange={(e) =>
|
|
192
|
+
setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))
|
|
193
|
+
}
|
|
194
|
+
placeholder="000000"
|
|
195
|
+
className="text-center font-mono text-lg tracking-widest"
|
|
196
|
+
maxLength={6}
|
|
197
|
+
autoComplete="one-time-code"
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<Button type="submit" disabled={isLoading} className="w-full">
|
|
202
|
+
{isLoading ? (
|
|
203
|
+
<>
|
|
204
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
205
|
+
Verifying...
|
|
206
|
+
</>
|
|
207
|
+
) : (
|
|
208
|
+
'Verify'
|
|
209
|
+
)}
|
|
210
|
+
</Button>
|
|
211
|
+
</form>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{step === 'backup' && (
|
|
215
|
+
<div className="space-y-4">
|
|
216
|
+
<div className="rounded-lg border bg-muted/50 p-4">
|
|
217
|
+
<p className="mb-3 text-sm font-medium">Backup Codes</p>
|
|
218
|
+
<div className="grid grid-cols-2 gap-2">
|
|
219
|
+
{backupCodes.map((code, i) => (
|
|
220
|
+
<code
|
|
221
|
+
key={i}
|
|
222
|
+
className="rounded bg-background px-2 py-1 text-center text-sm font-mono"
|
|
223
|
+
>
|
|
224
|
+
{code}
|
|
225
|
+
</code>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<p className="text-sm text-muted-foreground">
|
|
231
|
+
Save these backup codes in a safe place. You can use them to access
|
|
232
|
+
your account if you lose your authenticator device. Each code can
|
|
233
|
+
only be used once.
|
|
234
|
+
</p>
|
|
235
|
+
|
|
236
|
+
<Button variant="outline" onClick={downloadBackupCodes} className="w-full">
|
|
237
|
+
<Download className="mr-2 h-4 w-4" />
|
|
238
|
+
Download Backup Codes
|
|
239
|
+
</Button>
|
|
240
|
+
|
|
241
|
+
<Button onClick={handleFinish} className="w-full">
|
|
242
|
+
Done
|
|
243
|
+
</Button>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
</CardContent>
|
|
247
|
+
</Card>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
|
5
|
+
import { CheckIcon } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "@/lib/utils"
|
|
8
|
+
|
|
9
|
+
function Checkbox({
|
|
10
|
+
className,
|
|
11
|
+
...props
|
|
12
|
+
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
|
13
|
+
return (
|
|
14
|
+
<CheckboxPrimitive.Root
|
|
15
|
+
data-slot="checkbox"
|
|
16
|
+
className={cn(
|
|
17
|
+
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
|
18
|
+
className
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
>
|
|
22
|
+
<CheckboxPrimitive.Indicator
|
|
23
|
+
data-slot="checkbox-indicator"
|
|
24
|
+
className="grid place-content-center text-current transition-none"
|
|
25
|
+
>
|
|
26
|
+
<CheckIcon className="size-3.5" />
|
|
27
|
+
</CheckboxPrimitive.Indicator>
|
|
28
|
+
</CheckboxPrimitive.Root>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { Checkbox }
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|
5
|
+
import { XIcon } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "@/lib/utils"
|
|
8
|
+
|
|
9
|
+
function Dialog({
|
|
10
|
+
...props
|
|
11
|
+
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
12
|
+
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function DialogTrigger({
|
|
16
|
+
...props
|
|
17
|
+
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
18
|
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function DialogPortal({
|
|
22
|
+
...props
|
|
23
|
+
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
24
|
+
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function DialogClose({
|
|
28
|
+
...props
|
|
29
|
+
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
30
|
+
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function DialogOverlay({
|
|
34
|
+
className,
|
|
35
|
+
...props
|
|
36
|
+
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
37
|
+
return (
|
|
38
|
+
<DialogPrimitive.Overlay
|
|
39
|
+
data-slot="dialog-overlay"
|
|
40
|
+
className={cn(
|
|
41
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function DialogContent({
|
|
50
|
+
className,
|
|
51
|
+
children,
|
|
52
|
+
showCloseButton = true,
|
|
53
|
+
...props
|
|
54
|
+
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
55
|
+
showCloseButton?: boolean
|
|
56
|
+
}) {
|
|
57
|
+
return (
|
|
58
|
+
<DialogPortal data-slot="dialog-portal">
|
|
59
|
+
<DialogOverlay />
|
|
60
|
+
<DialogPrimitive.Content
|
|
61
|
+
data-slot="dialog-content"
|
|
62
|
+
className={cn(
|
|
63
|
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
|
64
|
+
className
|
|
65
|
+
)}
|
|
66
|
+
{...props}
|
|
67
|
+
>
|
|
68
|
+
{children}
|
|
69
|
+
{showCloseButton && (
|
|
70
|
+
<DialogPrimitive.Close
|
|
71
|
+
data-slot="dialog-close"
|
|
72
|
+
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
|
73
|
+
>
|
|
74
|
+
<XIcon />
|
|
75
|
+
<span className="sr-only">Close</span>
|
|
76
|
+
</DialogPrimitive.Close>
|
|
77
|
+
)}
|
|
78
|
+
</DialogPrimitive.Content>
|
|
79
|
+
</DialogPortal>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
data-slot="dialog-header"
|
|
87
|
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
|
88
|
+
{...props}
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
data-slot="dialog-footer"
|
|
97
|
+
className={cn(
|
|
98
|
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
99
|
+
className
|
|
100
|
+
)}
|
|
101
|
+
{...props}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function DialogTitle({
|
|
107
|
+
className,
|
|
108
|
+
...props
|
|
109
|
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
110
|
+
return (
|
|
111
|
+
<DialogPrimitive.Title
|
|
112
|
+
data-slot="dialog-title"
|
|
113
|
+
className={cn("text-lg leading-none font-semibold", className)}
|
|
114
|
+
{...props}
|
|
115
|
+
/>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function DialogDescription({
|
|
120
|
+
className,
|
|
121
|
+
...props
|
|
122
|
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
123
|
+
return (
|
|
124
|
+
<DialogPrimitive.Description
|
|
125
|
+
data-slot="dialog-description"
|
|
126
|
+
className={cn("text-muted-foreground text-sm", className)}
|
|
127
|
+
{...props}
|
|
128
|
+
/>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export {
|
|
133
|
+
Dialog,
|
|
134
|
+
DialogClose,
|
|
135
|
+
DialogContent,
|
|
136
|
+
DialogDescription,
|
|
137
|
+
DialogFooter,
|
|
138
|
+
DialogHeader,
|
|
139
|
+
DialogOverlay,
|
|
140
|
+
DialogPortal,
|
|
141
|
+
DialogTitle,
|
|
142
|
+
DialogTrigger,
|
|
143
|
+
}
|