create-stackr 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +10 -0
  2. package/dist/prompts/features.d.ts +1 -1
  3. package/dist/prompts/features.d.ts.map +1 -1
  4. package/dist/prompts/features.js +34 -25
  5. package/dist/prompts/features.js.map +1 -1
  6. package/dist/prompts/index.js +33 -6
  7. package/dist/prompts/index.js.map +1 -1
  8. package/dist/prompts/preset.d.ts.map +1 -1
  9. package/dist/prompts/preset.js +69 -34
  10. package/dist/prompts/preset.js.map +1 -1
  11. package/dist/utils/template.js +1 -1
  12. package/dist/utils/template.js.map +1 -1
  13. package/dist/utils/validation.d.ts.map +1 -1
  14. package/dist/utils/validation.js +43 -1
  15. package/dist/utils/validation.js.map +1 -1
  16. package/package.json +1 -1
  17. package/templates/base/backend/controllers/rest-api/routes/auth.ts.ejs +33 -1
  18. package/templates/base/backend/controllers/rest-api/routes/oauth-web.ts.ejs +6 -0
  19. package/templates/base/backend/domain/user/repository.drizzle.ts +28 -1
  20. package/templates/base/backend/domain/user/repository.prisma.ts +25 -0
  21. package/templates/base/backend/drizzle/{schema.drizzle.ts → schema.drizzle.ts.ejs} +25 -0
  22. package/templates/base/backend/lib/auth.drizzle.ts.ejs +27 -18
  23. package/templates/base/backend/lib/auth.prisma.ts.ejs +24 -18
  24. package/templates/base/backend/package.json.ejs +29 -23
  25. package/templates/base/backend/prisma/schema.prisma.ejs +20 -0
  26. package/templates/base/mobile/app/+not-found.tsx +1 -1
  27. package/templates/base/mobile/app/_layout.tsx.ejs +95 -10
  28. package/templates/base/mobile/package.json.ejs +21 -13
  29. package/templates/base/mobile/src/components/ui/Button.tsx +5 -3
  30. package/templates/base/mobile/src/components/ui/Card.tsx +1 -1
  31. package/templates/base/mobile/src/components/ui/Input.tsx +1 -1
  32. package/templates/base/mobile/src/components/ui/Skeleton.tsx +1 -1
  33. package/templates/base/mobile/src/components/ui/Toast.tsx +106 -0
  34. package/templates/base/mobile/src/components/ui/{IconSymbol.tsx → icon-symbol.tsx} +7 -0
  35. package/templates/base/mobile/src/components/ui/{LoadingSpinner.tsx → loading-spinner.tsx} +1 -1
  36. package/templates/base/mobile/src/components/ui/{OnboardingLayout.tsx → onboarding-layout.tsx} +2 -2
  37. package/templates/base/mobile/src/components/ui/{PaywallLayout.tsx → paywall-layout.tsx} +3 -3
  38. package/templates/base/mobile/src/constants/Theme.ts +3 -3
  39. package/templates/base/mobile/src/context/{ThemeContext.tsx → theme-context.tsx} +2 -2
  40. package/templates/base/mobile/src/lib/auth-client.ts.ejs +18 -0
  41. package/templates/base/mobile/src/services/{sdkInitializer.ts.ejs → sdk-initializer.ts.ejs} +4 -4
  42. package/templates/base/web/.prettierignore +6 -0
  43. package/templates/base/web/.prettierrc +8 -0
  44. package/templates/base/web/eslint.config.mjs +31 -7
  45. package/templates/base/web/next.config.ts +50 -1
  46. package/templates/base/web/package.json.ejs +14 -2
  47. package/templates/base/web/src/app/globals.css +1 -1
  48. package/templates/base/web/src/app/layout.tsx.ejs +2 -0
  49. package/templates/base/web/src/components/auth/protected-route.tsx.ejs +32 -5
  50. package/templates/base/web/src/components/providers/device-session-setup.tsx.ejs +1 -1
  51. package/templates/base/web/src/hooks/use-device-session.ts.ejs +2 -2
  52. package/templates/base/web/src/lib/auth/actions.ts.ejs +438 -15
  53. package/templates/base/web/src/lib/auth/config.ts.ejs +13 -0
  54. package/templates/base/web/src/lib/auth/cookies.ts.ejs +61 -0
  55. package/templates/base/web/src/lib/device/actions.ts.ejs +2 -10
  56. package/templates/base/web/src/lib/device/types.ts +37 -0
  57. package/templates/base/web/src/proxy.ts.ejs +12 -2
  58. package/templates/base/web/src/store/{deviceSession.store.ts.ejs → device-session-store.ts.ejs} +1 -1
  59. package/templates/features/mobile/auth/app/(auth)/{_layout.tsx → _layout.tsx.ejs} +7 -0
  60. package/templates/features/mobile/auth/app/(auth)/{login.tsx → login.tsx.ejs} +9 -8
  61. package/templates/features/mobile/auth/app/(auth)/{register.tsx → register.tsx.ejs} +9 -8
  62. package/templates/features/mobile/auth/app/(auth)/two-factor-expired.tsx.ejs +88 -0
  63. package/templates/features/mobile/auth/app/(auth)/two-factor.tsx.ejs +259 -0
  64. package/templates/features/mobile/auth/app/(public)/_layout.tsx +9 -0
  65. package/templates/features/mobile/{tabs/app/(tabs)/index.tsx → auth/app/(public)/index.tsx.ejs} +76 -257
  66. package/templates/features/mobile/auth/app/(tabs)/settings/_layout.tsx.ejs +20 -0
  67. package/templates/features/mobile/auth/app/(tabs)/settings/index.tsx.ejs +137 -0
  68. package/templates/features/mobile/auth/app/(tabs)/settings/security/_layout.tsx.ejs +35 -0
  69. package/templates/features/mobile/auth/app/(tabs)/settings/security/index.tsx.ejs +147 -0
  70. package/templates/features/mobile/auth/app/(tabs)/settings/security/set-password.tsx.ejs +140 -0
  71. package/templates/features/mobile/auth/app/(tabs)/settings/security/two-factor-manage.tsx.ejs +366 -0
  72. package/templates/features/mobile/auth/app/(tabs)/settings/security/two-factor-setup.tsx.ejs +317 -0
  73. package/templates/features/mobile/auth/app/account.tsx.ejs +331 -0
  74. package/templates/features/mobile/auth/app/verify-email.tsx.ejs +315 -0
  75. package/templates/features/mobile/auth/components/auth/{LoginForm.tsx.ejs → login-form.tsx.ejs} +15 -3
  76. package/templates/features/mobile/auth/components/auth/protected-screen.tsx.ejs +56 -0
  77. package/templates/features/mobile/auth/components/auth/{RegisterForm.tsx.ejs → register-form.tsx.ejs} +5 -3
  78. package/templates/features/mobile/auth/hooks/{useAuth.ts.ejs → auth.ts.ejs} +255 -1
  79. package/templates/features/mobile/auth/hooks/two-factor-timeout.ts.ejs +56 -0
  80. package/templates/features/mobile/auth/services/{deviceSession.ts → device-session.ts} +2 -10
  81. package/templates/features/mobile/auth/store/{deviceSession.store.ts → device-session-store.ts} +14 -5
  82. package/templates/features/mobile/auth/types/device-session.ts +37 -0
  83. package/templates/features/mobile/onboarding/app/(onboarding)/page-1.tsx.ejs +3 -1
  84. package/templates/features/mobile/onboarding/app/(onboarding)/page-2.tsx.ejs +3 -1
  85. package/templates/features/mobile/onboarding/app/(onboarding)/page-3.tsx.ejs +6 -1
  86. package/templates/features/mobile/paywall/app/paywall.tsx +4 -4
  87. package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx +0 -6
  88. package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx.ejs +60 -0
  89. package/templates/features/mobile/tabs/app/(tabs)/index.tsx.ejs +264 -0
  90. package/templates/features/web/auth/app/(app)/layout.tsx.ejs +8 -22
  91. package/templates/features/web/auth/app/(auth)/login/page.tsx.ejs +19 -3
  92. package/templates/features/web/auth/app/(auth)/login/two-factor/page.tsx.ejs +24 -0
  93. package/templates/features/web/auth/app/(auth)/verify-email/page.tsx.ejs +117 -152
  94. package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/dashboard-client.tsx.ejs +41 -1
  95. package/templates/features/web/auth/app/{(app) → (protected)}/dashboard/page.tsx.ejs +3 -1
  96. package/templates/features/web/auth/app/(protected)/layout.tsx.ejs +67 -0
  97. package/templates/features/web/auth/app/(protected)/settings/security/page.tsx.ejs +19 -0
  98. package/templates/features/web/auth/app/(protected)/settings/security/security-settings.tsx.ejs +73 -0
  99. package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/page.tsx.ejs +2 -6
  100. package/templates/features/web/auth/app/auth/callback/route.ts.ejs +5 -1
  101. package/templates/features/web/auth/app/auth/session-expired/route.ts.ejs +39 -0
  102. package/templates/features/web/auth/app/auth/two-factor-expired/route.ts.ejs +22 -0
  103. package/templates/features/web/auth/components/auth/login-form.tsx.ejs +18 -0
  104. package/templates/features/web/auth/components/auth/two-factor-verify.tsx.ejs +173 -0
  105. package/templates/features/web/auth/components/settings/set-password-form.tsx.ejs +123 -0
  106. package/templates/features/web/auth/components/settings/two-factor-manage.tsx.ejs +253 -0
  107. package/templates/features/web/auth/components/settings/two-factor-setup.tsx.ejs +249 -0
  108. package/templates/features/web/auth/components/ui/checkbox.tsx +32 -0
  109. package/templates/features/web/auth/components/ui/dialog.tsx +143 -0
  110. package/templates/features/web/auth/components/ui/input-otp.tsx +71 -0
  111. package/templates/integrations/mobile/adjust/store/{adjust.store.ts → adjust-store.ts} +3 -3
  112. package/templates/integrations/mobile/att/store/{att.store.ts → att-store.ts} +2 -2
  113. package/templates/integrations/mobile/revenuecat/store/{revenuecat.store.ts → revenuecat-store.ts} +1 -1
  114. package/templates/integrations/mobile/scate/store/{scate.store.ts → scate-store.ts} +1 -1
  115. package/templates/base/mobile/src/components/ui/index.ts +0 -6
  116. package/templates/base/mobile/src/store/index.ts.ejs +0 -18
  117. package/templates/base/web/src/lib/auth/index.ts.ejs +0 -40
  118. package/templates/features/mobile/auth/components/auth/index.ts +0 -2
  119. package/templates/features/mobile/auth/hooks/index.ts.ejs +0 -1
  120. /package/templates/base/mobile/src/services/{errorService.ts → error-service.ts} +0 -0
  121. /package/templates/base/mobile/src/store/{ui.store.ts → ui-store.ts} +0 -0
  122. /package/templates/features/web/auth/app/{(app) → (protected)}/settings/sessions/sessions-client.tsx.ejs +0 -0
  123. /package/templates/integrations/mobile/adjust/services/{adjustService.ts.ejs → adjust-service.ts.ejs} +0 -0
  124. /package/templates/integrations/mobile/att/services/{attService.ts → att-service.ts} +0 -0
  125. /package/templates/integrations/mobile/att/services/{trackingPermissions.ts → tracking-permissions.ts} +0 -0
  126. /package/templates/integrations/mobile/revenuecat/services/{revenuecatService.ts.ejs → revenuecat-service.ts.ejs} +0 -0
  127. /package/templates/integrations/mobile/scate/services/{scateService.ts.ejs → scate-service.ts.ejs} +0 -0
@@ -0,0 +1,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&apos;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
+ }