@mars-stack/cli 0.2.0 → 0.2.2

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 (173) hide show
  1. package/package.json +2 -2
  2. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  3. package/template/.cursor/rules/data-access.mdc +29 -0
  4. package/template/.cursor/rules/project-structure.mdc +34 -0
  5. package/template/.cursor/rules/security.mdc +25 -0
  6. package/template/.cursor/rules/testing.mdc +24 -0
  7. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  8. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  9. package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
  10. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  11. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  12. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  13. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  14. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  15. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  16. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  17. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  18. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  19. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  20. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  21. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  22. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  23. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  24. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  25. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  26. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  27. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  28. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  29. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  30. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  31. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  32. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  33. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  34. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  35. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  36. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  37. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  38. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  39. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  40. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  41. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  42. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  43. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  44. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  45. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  46. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  47. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  48. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  49. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  50. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  51. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  52. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  53. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  54. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  55. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  56. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  57. package/template/AGENTS.md +104 -0
  58. package/template/ARCHITECTURE.md +102 -0
  59. package/template/docs/QUALITY_SCORE.md +20 -0
  60. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  61. package/template/docs/design-docs/core-beliefs.md +43 -0
  62. package/template/docs/design-docs/index.md +8 -0
  63. package/template/docs/exec-plans/active/.gitkeep +0 -0
  64. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  65. package/template/docs/exec-plans/tech-debt.md +7 -0
  66. package/template/docs/generated/.gitkeep +0 -0
  67. package/template/docs/product-specs/index.md +7 -0
  68. package/template/docs/references/index.md +18 -0
  69. package/template/e2e/api.spec.ts +20 -0
  70. package/template/e2e/auth.spec.ts +24 -0
  71. package/template/e2e/public.spec.ts +25 -0
  72. package/template/eslint.config.mjs +24 -0
  73. package/template/next-env.d.ts +6 -0
  74. package/template/next.config.ts +45 -0
  75. package/template/package.json +80 -0
  76. package/template/playwright.config.ts +31 -0
  77. package/template/postcss.config.mjs +8 -0
  78. package/template/prisma/generated/prisma/browser.ts +49 -0
  79. package/template/prisma/generated/prisma/client.ts +73 -0
  80. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  81. package/template/prisma/generated/prisma/enums.ts +15 -0
  82. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  83. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  84. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  85. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  86. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  87. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  88. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  89. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  90. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  91. package/template/prisma/generated/prisma/models.ts +17 -0
  92. package/template/prisma/schema/auth.prisma +69 -0
  93. package/template/prisma/schema/base.prisma +8 -0
  94. package/template/prisma/schema/file.prisma +15 -0
  95. package/template/prisma/schema/subscription.prisma +17 -0
  96. package/template/prisma.config.ts +13 -0
  97. package/template/scripts/check-architecture.ts +221 -0
  98. package/template/scripts/check-doc-freshness.ts +242 -0
  99. package/template/scripts/ensure-db.mjs +291 -0
  100. package/template/scripts/generate-docs.ts +143 -0
  101. package/template/scripts/generate-env-example.ts +89 -0
  102. package/template/scripts/seed.ts +56 -0
  103. package/template/scripts/update-quality-score.ts +263 -0
  104. package/template/src/__tests__/architecture.test.ts +114 -0
  105. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  106. package/template/src/app/(auth)/layout.tsx +11 -0
  107. package/template/src/app/(auth)/register/page.tsx +162 -0
  108. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  109. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  110. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  111. package/template/src/app/(auth)/verify/page.tsx +56 -0
  112. package/template/src/app/(protected)/admin/page.tsx +108 -0
  113. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  114. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  115. package/template/src/app/(protected)/layout.tsx +262 -0
  116. package/template/src/app/(protected)/settings/page.tsx +370 -0
  117. package/template/src/app/api/auth/forgot/route.ts +63 -0
  118. package/template/src/app/api/auth/login/route.ts +121 -0
  119. package/template/src/app/api/auth/logout/route.ts +19 -0
  120. package/template/src/app/api/auth/me/route.ts +30 -0
  121. package/template/src/app/api/auth/reset/route.ts +45 -0
  122. package/template/src/app/api/auth/signup/route.ts +85 -0
  123. package/template/src/app/api/auth/verify/route.ts +46 -0
  124. package/template/src/app/api/csrf/route.ts +12 -0
  125. package/template/src/app/api/health/route.ts +10 -0
  126. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  127. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  128. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  129. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  130. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  131. package/template/src/app/api/protected/user/password/route.ts +63 -0
  132. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  133. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  134. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  135. package/template/src/app/api/readiness/route.ts +15 -0
  136. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  137. package/template/src/app/error.tsx +33 -0
  138. package/template/src/app/layout.tsx +29 -0
  139. package/template/src/app/not-found.tsx +20 -0
  140. package/template/src/app/page.tsx +136 -0
  141. package/template/src/app/privacy/page.tsx +178 -0
  142. package/template/src/app/providers.tsx +8 -0
  143. package/template/src/app/terms/page.tsx +139 -0
  144. package/template/src/config/app.config.ts +70 -0
  145. package/template/src/config/routes.ts +17 -0
  146. package/template/src/features/admin/index.ts +11 -0
  147. package/template/src/features/admin/permissions.ts +64 -0
  148. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  149. package/template/src/features/auth/context/index.ts +2 -0
  150. package/template/src/features/auth/index.ts +3 -0
  151. package/template/src/features/auth/server/consent.ts +66 -0
  152. package/template/src/features/auth/server/session-revocation.ts +20 -0
  153. package/template/src/features/auth/server/sessions.ts +66 -0
  154. package/template/src/features/auth/server/user.ts +166 -0
  155. package/template/src/features/auth/types.ts +19 -0
  156. package/template/src/features/auth/validators.ts +29 -0
  157. package/template/src/features/billing/server/index.ts +66 -0
  158. package/template/src/features/billing/types.ts +43 -0
  159. package/template/src/features/uploads/server/index.ts +49 -0
  160. package/template/src/features/uploads/types.ts +26 -0
  161. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  162. package/template/src/lib/core/email/templates/index.ts +4 -0
  163. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  164. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  165. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  166. package/template/src/lib/mars.ts +56 -0
  167. package/template/src/lib/prisma.ts +19 -0
  168. package/template/src/proxy.ts +92 -0
  169. package/template/src/styles/brand.css +17 -0
  170. package/template/src/styles/globals.css +6 -0
  171. package/template/tsconfig.json +59 -0
  172. package/template/vitest.config.ts +41 -0
  173. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,518 @@
1
+ # Skill: Configure Two-Factor Authentication
2
+
3
+ Set up TOTP-based two-factor authentication (2FA) with backup codes in a MARS application.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add 2FA, two-factor authentication, TOTP, authenticator app support, or backup codes.
8
+
9
+ ## Prerequisites
10
+
11
+ - Auth system configured with session management
12
+ - User model exists in Prisma schema
13
+
14
+ ## Architecture
15
+
16
+ MARS 2FA uses TOTP (Time-based One-Time Password) compatible with Google Authenticator, Authy, 1Password, and similar apps. The flow:
17
+
18
+ 1. **Setup**: User enables 2FA → server generates a TOTP secret, displays QR code → user scans and enters a code to confirm
19
+ 2. **Login**: After password verification, if 2FA is enabled → user enters TOTP code or backup code → session is created
20
+ 3. **Backup codes**: Generated during setup, hashed and stored, each single-use
21
+
22
+ ## Step 1: Install Dependencies
23
+
24
+ ```bash
25
+ yarn add otpauth qrcode
26
+ yarn add -D @types/qrcode
27
+ ```
28
+
29
+ - `otpauth` — TOTP/HOTP token generation and verification (lightweight, standards-compliant)
30
+ - `qrcode` — QR code generation for the setup URI
31
+
32
+ ## Step 2: Prisma Schema
33
+
34
+ ```prisma
35
+ // prisma/schema/auth.prisma — add to User model
36
+ model User {
37
+ // ... existing fields
38
+ twoFactorSecret String?
39
+ twoFactorEnabled Boolean @default(false)
40
+ backupCodes String? // JSON array of hashed codes
41
+ }
42
+ ```
43
+
44
+ Run `yarn db:push` after changes.
45
+
46
+ ## Step 3: TOTP Service
47
+
48
+ ```typescript
49
+ // src/features/auth/server/two-factor.ts
50
+ import 'server-only';
51
+
52
+ import { TOTP } from 'otpauth';
53
+ import { randomBytes, createHash } from 'crypto';
54
+ import { prisma } from '@/lib/prisma';
55
+ import { appConfig } from '@/config/app.config';
56
+
57
+ function hashCode(code: string): string {
58
+ return createHash('sha256').update(code).digest('hex');
59
+ }
60
+
61
+ function generateBackupCodes(count: number = 10): string[] {
62
+ return Array.from({ length: count }, () =>
63
+ randomBytes(4).toString('hex').toUpperCase().match(/.{4}/g)!.join('-'),
64
+ );
65
+ }
66
+
67
+ export function createTOTP(secret: string, email: string): TOTP {
68
+ return new TOTP({
69
+ issuer: appConfig.name,
70
+ label: email,
71
+ algorithm: 'SHA1',
72
+ digits: 6,
73
+ period: 30,
74
+ secret,
75
+ });
76
+ }
77
+
78
+ export async function initiate2FASetup(
79
+ userId: string,
80
+ email: string,
81
+ ): Promise<{ secret: string; uri: string; qrDataUrl: string }> {
82
+ const { Secret } = await import('otpauth');
83
+ const QRCode = await import('qrcode');
84
+
85
+ const secret = new Secret({ size: 20 });
86
+ const totp = createTOTP(secret.base32, email);
87
+ const uri = totp.toString();
88
+ const qrDataUrl = await QRCode.toDataURL(uri);
89
+
90
+ // Store the secret but don't enable 2FA yet (requires confirmation)
91
+ await prisma.user.update({
92
+ where: { id: userId },
93
+ data: { twoFactorSecret: secret.base32 },
94
+ });
95
+
96
+ return { secret: secret.base32, uri, qrDataUrl };
97
+ }
98
+
99
+ export async function confirm2FASetup(
100
+ userId: string,
101
+ code: string,
102
+ ): Promise<{ success: boolean; backupCodes?: string[]; error?: string }> {
103
+ const user = await prisma.user.findUnique({
104
+ where: { id: userId },
105
+ select: { twoFactorSecret: true, email: true },
106
+ });
107
+
108
+ if (!user?.twoFactorSecret) {
109
+ return { success: false, error: '2FA setup not initiated' };
110
+ }
111
+
112
+ const totp = createTOTP(user.twoFactorSecret, user.email);
113
+ const isValid = totp.validate({ token: code, window: 1 }) !== null;
114
+
115
+ if (!isValid) {
116
+ return { success: false, error: 'Invalid code. Please try again.' };
117
+ }
118
+
119
+ const backupCodes = generateBackupCodes();
120
+ const hashedCodes = backupCodes.map(hashCode);
121
+
122
+ await prisma.user.update({
123
+ where: { id: userId },
124
+ data: {
125
+ twoFactorEnabled: true,
126
+ backupCodes: JSON.stringify(hashedCodes),
127
+ },
128
+ });
129
+
130
+ return { success: true, backupCodes };
131
+ }
132
+
133
+ export async function verify2FACode(
134
+ userId: string,
135
+ code: string,
136
+ ): Promise<{ success: boolean; error?: string }> {
137
+ const user = await prisma.user.findUnique({
138
+ where: { id: userId },
139
+ select: { twoFactorSecret: true, twoFactorEnabled: true, email: true, backupCodes: true },
140
+ });
141
+
142
+ if (!user?.twoFactorEnabled || !user.twoFactorSecret) {
143
+ return { success: false, error: '2FA is not enabled' };
144
+ }
145
+
146
+ // Try TOTP code first
147
+ const totp = createTOTP(user.twoFactorSecret, user.email);
148
+ const isValidTOTP = totp.validate({ token: code, window: 1 }) !== null;
149
+
150
+ if (isValidTOTP) {
151
+ return { success: true };
152
+ }
153
+
154
+ // Try backup code
155
+ const hashedInput = hashCode(code);
156
+ const storedCodes: string[] = JSON.parse(user.backupCodes || '[]');
157
+ const codeIndex = storedCodes.findIndex((stored) => stored === hashedInput);
158
+
159
+ if (codeIndex !== -1) {
160
+ // Remove the used backup code
161
+ storedCodes.splice(codeIndex, 1);
162
+ await prisma.user.update({
163
+ where: { id: userId },
164
+ data: { backupCodes: JSON.stringify(storedCodes) },
165
+ });
166
+ return { success: true };
167
+ }
168
+
169
+ return { success: false, error: 'Invalid code' };
170
+ }
171
+
172
+ export async function disable2FA(userId: string): Promise<void> {
173
+ await prisma.user.update({
174
+ where: { id: userId },
175
+ data: {
176
+ twoFactorEnabled: false,
177
+ twoFactorSecret: null,
178
+ backupCodes: null,
179
+ },
180
+ });
181
+ }
182
+
183
+ export async function regenerateBackupCodes(userId: string): Promise<string[]> {
184
+ const backupCodes = generateBackupCodes();
185
+ const hashedCodes = backupCodes.map(hashCode);
186
+
187
+ await prisma.user.update({
188
+ where: { id: userId },
189
+ data: { backupCodes: JSON.stringify(hashedCodes) },
190
+ });
191
+
192
+ return backupCodes;
193
+ }
194
+ ```
195
+
196
+ ## Step 4: 2FA Setup API Routes
197
+
198
+ ```typescript
199
+ // src/app/api/protected/user/two-factor/setup/route.ts
200
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
201
+ import { initiate2FASetup } from '@/features/auth/server/two-factor';
202
+ import { NextResponse } from 'next/server';
203
+
204
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
205
+ try {
206
+ const result = await initiate2FASetup(request.session.userId, request.session.email);
207
+ return NextResponse.json({
208
+ qrDataUrl: result.qrDataUrl,
209
+ secret: result.secret,
210
+ });
211
+ } catch (error) {
212
+ return handleApiError(error, { endpoint: '/api/protected/user/two-factor/setup' });
213
+ }
214
+ });
215
+ ```
216
+
217
+ ```typescript
218
+ // src/app/api/protected/user/two-factor/confirm/route.ts
219
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
220
+ import { confirm2FASetup } from '@/features/auth/server/two-factor';
221
+ import { NextResponse } from 'next/server';
222
+ import { z } from 'zod';
223
+
224
+ const confirmSchema = z.object({
225
+ code: z.string().length(6, 'Code must be 6 digits'),
226
+ });
227
+
228
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
229
+ try {
230
+ const { code } = confirmSchema.parse(await request.json());
231
+ const result = await confirm2FASetup(request.session.userId, code);
232
+
233
+ if (!result.success) {
234
+ return NextResponse.json({ error: result.error }, { status: 400 });
235
+ }
236
+
237
+ return NextResponse.json({ backupCodes: result.backupCodes });
238
+ } catch (error) {
239
+ return handleApiError(error, { endpoint: '/api/protected/user/two-factor/confirm' });
240
+ }
241
+ });
242
+ ```
243
+
244
+ ```typescript
245
+ // src/app/api/protected/user/two-factor/disable/route.ts
246
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
247
+ import { disable2FA } from '@/features/auth/server/two-factor';
248
+ import { NextResponse } from 'next/server';
249
+
250
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
251
+ try {
252
+ await disable2FA(request.session.userId);
253
+ return NextResponse.json({ message: '2FA has been disabled' });
254
+ } catch (error) {
255
+ return handleApiError(error, { endpoint: '/api/protected/user/two-factor/disable' });
256
+ }
257
+ });
258
+ ```
259
+
260
+ ## Step 5: Modify Login Flow
261
+
262
+ Update the login route to check for 2FA after password verification:
263
+
264
+ ```typescript
265
+ // In src/app/api/auth/login/route.ts — after password verification
266
+ import { prisma } from '@/lib/prisma';
267
+
268
+ // After verifying password...
269
+ const user = await prisma.user.findUnique({
270
+ where: { email },
271
+ select: { id: true, twoFactorEnabled: true },
272
+ });
273
+
274
+ if (user.twoFactorEnabled) {
275
+ // Return a pending-2FA response with a temporary token
276
+ const pendingToken = randomBytes(32).toString('hex');
277
+ // Store in short-lived cache or DB with 5-minute expiry
278
+ await storePending2FAToken(pendingToken, user.id);
279
+
280
+ return NextResponse.json({
281
+ requiresTwoFactor: true,
282
+ pendingToken,
283
+ });
284
+ }
285
+
286
+ // If no 2FA, create session as normal
287
+ ```
288
+
289
+ ```typescript
290
+ // src/app/api/auth/two-factor/verify/route.ts
291
+ import { handleApiError } from '@/lib/mars';
292
+ import { verify2FACode } from '@/features/auth/server/two-factor';
293
+ import { createSession } from '@/features/auth/server/sessions';
294
+ import { NextResponse, type NextRequest } from 'next/server';
295
+ import { z } from 'zod';
296
+
297
+ const verifySchema = z.object({
298
+ pendingToken: z.string().min(1),
299
+ code: z.string().min(1),
300
+ });
301
+
302
+ export async function POST(request: NextRequest) {
303
+ try {
304
+ const { pendingToken, code } = verifySchema.parse(await request.json());
305
+
306
+ const userId = await validatePending2FAToken(pendingToken);
307
+ if (!userId) {
308
+ return NextResponse.json({ error: 'Invalid or expired session' }, { status: 401 });
309
+ }
310
+
311
+ const result = await verify2FACode(userId, code);
312
+ if (!result.success) {
313
+ return NextResponse.json({ error: result.error }, { status: 401 });
314
+ }
315
+
316
+ const response = NextResponse.json({ success: true });
317
+ await createSession(userId, response);
318
+ return response;
319
+ } catch (error) {
320
+ return handleApiError(error, { endpoint: '/api/auth/two-factor/verify' });
321
+ }
322
+ }
323
+ ```
324
+
325
+ ## Step 6: Settings UI — 2FA Management
326
+
327
+ ```typescript
328
+ // src/features/auth/components/TwoFactorSetup.tsx
329
+ 'use client';
330
+
331
+ import { useState } from 'react';
332
+
333
+ type SetupStep = 'idle' | 'scanning' | 'confirming' | 'backup-codes' | 'complete';
334
+
335
+ export function TwoFactorSetup({ enabled }: { enabled: boolean }) {
336
+ const [step, setStep] = useState<SetupStep>('idle');
337
+ const [qrDataUrl, setQrDataUrl] = useState('');
338
+ const [secret, setSecret] = useState('');
339
+ const [code, setCode] = useState('');
340
+ const [backupCodes, setBackupCodes] = useState<string[]>([]);
341
+ const [error, setError] = useState('');
342
+ const [loading, setLoading] = useState(false);
343
+
344
+ async function handleStartSetup() {
345
+ setLoading(true);
346
+ setError('');
347
+ try {
348
+ const res = await fetch('/api/protected/user/two-factor/setup', { method: 'POST' });
349
+ const data = await res.json();
350
+ setQrDataUrl(data.qrDataUrl);
351
+ setSecret(data.secret);
352
+ setStep('scanning');
353
+ } catch {
354
+ setError('Failed to start setup');
355
+ } finally {
356
+ setLoading(false);
357
+ }
358
+ }
359
+
360
+ async function handleConfirm() {
361
+ setLoading(true);
362
+ setError('');
363
+ try {
364
+ const res = await fetch('/api/protected/user/two-factor/confirm', {
365
+ method: 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: JSON.stringify({ code }),
368
+ });
369
+ const data = await res.json();
370
+
371
+ if (!res.ok) {
372
+ setError(data.error || 'Invalid code');
373
+ return;
374
+ }
375
+
376
+ setBackupCodes(data.backupCodes);
377
+ setStep('backup-codes');
378
+ } catch {
379
+ setError('Verification failed');
380
+ } finally {
381
+ setLoading(false);
382
+ }
383
+ }
384
+
385
+ async function handleDisable() {
386
+ setLoading(true);
387
+ try {
388
+ await fetch('/api/protected/user/two-factor/disable', { method: 'POST' });
389
+ window.location.reload();
390
+ } catch {
391
+ setError('Failed to disable 2FA');
392
+ } finally {
393
+ setLoading(false);
394
+ }
395
+ }
396
+
397
+ if (enabled) {
398
+ return (
399
+ <div className="space-y-4">
400
+ <div className="flex items-center gap-2">
401
+ <span className="h-2 w-2 rounded-full bg-status-success" />
402
+ <span className="text-content-primary font-medium">Two-factor authentication is enabled</span>
403
+ </div>
404
+ <button
405
+ onClick={handleDisable}
406
+ disabled={loading}
407
+ className="rounded-md bg-status-error px-4 py-2 text-white hover:bg-status-error/90 disabled:opacity-50"
408
+ >
409
+ Disable 2FA
410
+ </button>
411
+ </div>
412
+ );
413
+ }
414
+
415
+ // Render setup flow based on current step...
416
+ // (scanning → QR code display, confirming → code input, backup-codes → display codes)
417
+ return (
418
+ <div className="space-y-4">
419
+ {step === 'idle' && (
420
+ <button
421
+ onClick={handleStartSetup}
422
+ disabled={loading}
423
+ className="rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover disabled:opacity-50"
424
+ >
425
+ Enable Two-Factor Authentication
426
+ </button>
427
+ )}
428
+
429
+ {step === 'scanning' && (
430
+ <div className="space-y-4">
431
+ <p className="text-content-secondary">Scan this QR code with your authenticator app:</p>
432
+ {/* eslint-disable-next-line @next/next/no-img-element */}
433
+ <img src={qrDataUrl} alt="2FA QR Code" className="mx-auto h-48 w-48" />
434
+ <details className="text-sm">
435
+ <summary className="cursor-pointer text-content-tertiary">Can&apos;t scan? Enter manually</summary>
436
+ <code className="mt-2 block break-all rounded bg-surface-secondary p-2 text-xs">{secret}</code>
437
+ </details>
438
+ <button
439
+ onClick={() => setStep('confirming')}
440
+ className="rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover"
441
+ >
442
+ Next
443
+ </button>
444
+ </div>
445
+ )}
446
+
447
+ {step === 'confirming' && (
448
+ <div className="space-y-4">
449
+ <p className="text-content-secondary">Enter the 6-digit code from your authenticator app:</p>
450
+ <input
451
+ type="text"
452
+ value={code}
453
+ onChange={(e) => setCode(e.target.value)}
454
+ maxLength={6}
455
+ placeholder="000000"
456
+ className="block w-full rounded-md border border-border-primary bg-surface-primary px-3 py-2 text-center text-2xl tracking-widest text-content-primary focus:border-interactive-primary focus:outline-none focus:ring-1 focus:ring-interactive-primary"
457
+ />
458
+ {error && <p className="text-sm text-status-error">{error}</p>}
459
+ <button
460
+ onClick={handleConfirm}
461
+ disabled={loading || code.length !== 6}
462
+ className="w-full rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover disabled:opacity-50"
463
+ >
464
+ Verify & Enable
465
+ </button>
466
+ </div>
467
+ )}
468
+
469
+ {step === 'backup-codes' && (
470
+ <div className="space-y-4">
471
+ <h3 className="text-lg font-semibold text-content-primary">Save your backup codes</h3>
472
+ <p className="text-content-secondary">
473
+ Store these codes in a safe place. Each can be used once if you lose access to your authenticator.
474
+ </p>
475
+ <div className="grid grid-cols-2 gap-2 rounded-md bg-surface-secondary p-4">
476
+ {backupCodes.map((bc) => (
477
+ <code key={bc} className="font-mono text-sm text-content-primary">{bc}</code>
478
+ ))}
479
+ </div>
480
+ <button
481
+ onClick={() => setStep('complete')}
482
+ className="rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover"
483
+ >
484
+ I&apos;ve saved my codes
485
+ </button>
486
+ </div>
487
+ )}
488
+
489
+ {step === 'complete' && (
490
+ <p className="text-status-success font-medium">Two-factor authentication is now enabled.</p>
491
+ )}
492
+ </div>
493
+ );
494
+ }
495
+ ```
496
+
497
+ ## Testing
498
+
499
+ 1. Enable 2FA in settings — scan QR with an authenticator app (or use the manual key).
500
+ 2. Enter the 6-digit code — verify backup codes are displayed.
501
+ 3. Log out and log back in — verify 2FA prompt appears after password.
502
+ 4. Enter a valid TOTP code — verify login succeeds.
503
+ 5. Enter an invalid code — verify it's rejected.
504
+ 6. Use a backup code — verify login succeeds and the code is consumed.
505
+ 7. Try the same backup code again — verify it's rejected.
506
+ 8. Disable 2FA — verify login no longer requires a code.
507
+
508
+ ## Checklist
509
+
510
+ - [ ] `otpauth` and `qrcode` installed
511
+ - [ ] User model updated with `twoFactorSecret`, `twoFactorEnabled`, `backupCodes`
512
+ - [ ] TOTP service handles setup, confirm, verify, disable, and backup code regeneration
513
+ - [ ] Login flow branched to handle 2FA prompt
514
+ - [ ] 2FA verification route creates session after code validation
515
+ - [ ] Backup codes are hashed and single-use
516
+ - [ ] Settings UI allows enable/disable with QR code flow
517
+ - [ ] Pending-2FA tokens are short-lived (5 minutes)
518
+ - [ ] `db:push` run after schema changes