@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.
- package/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# Skill: Configure Magic Links
|
|
2
|
+
|
|
3
|
+
Set up passwordless authentication via magic links in a MARS application.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add passwordless login, magic link authentication, email-only login, or remove the password requirement from sign-in.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Email provider configured (see `configure-email` skill)
|
|
12
|
+
- Prisma schema includes a `User` model
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
Magic link flow:
|
|
17
|
+
1. User enters email → server generates a single-use token, stores it hashed with expiry, sends the raw token via email
|
|
18
|
+
2. User clicks link → server hashes the incoming token, validates against DB, creates a session, and invalidates the token
|
|
19
|
+
3. Tokens are single-use and time-limited to prevent replay attacks
|
|
20
|
+
|
|
21
|
+
The magic link service lives in `src/features/auth/server/magic-link.ts`.
|
|
22
|
+
|
|
23
|
+
## Step 1: Prisma Schema
|
|
24
|
+
|
|
25
|
+
Add magic link fields to the `User` model or create a dedicated model:
|
|
26
|
+
|
|
27
|
+
```prisma
|
|
28
|
+
// prisma/schema/auth.prisma
|
|
29
|
+
model MagicLink {
|
|
30
|
+
id String @id @default(cuid())
|
|
31
|
+
token String @unique
|
|
32
|
+
email String
|
|
33
|
+
expiresAt DateTime
|
|
34
|
+
usedAt DateTime?
|
|
35
|
+
createdAt DateTime @default(now())
|
|
36
|
+
|
|
37
|
+
@@index([token])
|
|
38
|
+
@@index([email])
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Run `yarn db:push` after changes.
|
|
43
|
+
|
|
44
|
+
## Step 2: Configuration
|
|
45
|
+
|
|
46
|
+
Add magic link settings to `src/config/app.config.ts`:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
auth: {
|
|
50
|
+
magicLink: {
|
|
51
|
+
enabled: true,
|
|
52
|
+
tokenExpiryMinutes: 15,
|
|
53
|
+
tokenLength: 32,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Step 3: Magic Link Service
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// src/features/auth/server/magic-link.ts
|
|
62
|
+
import 'server-only';
|
|
63
|
+
|
|
64
|
+
import { randomBytes, createHash } from 'crypto';
|
|
65
|
+
import { prisma } from '@/lib/prisma';
|
|
66
|
+
import { sendEmail } from '@/lib/mars';
|
|
67
|
+
import { appConfig } from '@/config/app.config';
|
|
68
|
+
|
|
69
|
+
function hashToken(token: string): string {
|
|
70
|
+
return createHash('sha256').update(token).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function createMagicLink(email: string): Promise<void> {
|
|
74
|
+
const rawToken = randomBytes(appConfig.auth.magicLink.tokenLength).toString('hex');
|
|
75
|
+
const hashedToken = hashToken(rawToken);
|
|
76
|
+
const expiryMinutes = appConfig.auth.magicLink.tokenExpiryMinutes;
|
|
77
|
+
|
|
78
|
+
// Invalidate any existing unused tokens for this email
|
|
79
|
+
await prisma.magicLink.updateMany({
|
|
80
|
+
where: { email, usedAt: null },
|
|
81
|
+
data: { usedAt: new Date() },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await prisma.magicLink.create({
|
|
85
|
+
data: {
|
|
86
|
+
token: hashedToken,
|
|
87
|
+
email,
|
|
88
|
+
expiresAt: new Date(Date.now() + expiryMinutes * 60 * 1000),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const baseUrl = process.env.APP_URL || 'http://localhost:3000';
|
|
93
|
+
const loginUrl = `${baseUrl}/api/auth/magic-link/verify?token=${rawToken}`;
|
|
94
|
+
|
|
95
|
+
await sendEmail({
|
|
96
|
+
to: email,
|
|
97
|
+
subject: `Sign in to ${appConfig.name}`,
|
|
98
|
+
text: `Click here to sign in: ${loginUrl}\n\nThis link expires in ${expiryMinutes} minutes and can only be used once.`,
|
|
99
|
+
html: `
|
|
100
|
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
|
101
|
+
<h2>Sign in to ${appConfig.name}</h2>
|
|
102
|
+
<p>Click the button below to sign in:</p>
|
|
103
|
+
<div style="text-align: center; margin: 30px 0;">
|
|
104
|
+
<a href="${loginUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
|
|
105
|
+
Sign In
|
|
106
|
+
</a>
|
|
107
|
+
</div>
|
|
108
|
+
<p style="color: #6b7280; font-size: 14px;">
|
|
109
|
+
This link expires in ${expiryMinutes} minutes and can only be used once.
|
|
110
|
+
</p>
|
|
111
|
+
<p style="color: #6b7280; font-size: 14px;">
|
|
112
|
+
If you didn't request this, you can safely ignore this email.
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function verifyMagicLink(
|
|
120
|
+
rawToken: string,
|
|
121
|
+
): Promise<{ success: boolean; email?: string; error?: string }> {
|
|
122
|
+
const hashedToken = hashToken(rawToken);
|
|
123
|
+
|
|
124
|
+
const magicLink = await prisma.magicLink.findUnique({
|
|
125
|
+
where: { token: hashedToken },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (!magicLink) {
|
|
129
|
+
return { success: false, error: 'Invalid or expired link' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (magicLink.usedAt) {
|
|
133
|
+
return { success: false, error: 'This link has already been used' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (magicLink.expiresAt < new Date()) {
|
|
137
|
+
return { success: false, error: 'This link has expired' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Mark token as used (single-use enforcement)
|
|
141
|
+
await prisma.magicLink.update({
|
|
142
|
+
where: { id: magicLink.id },
|
|
143
|
+
data: { usedAt: new Date() },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return { success: true, email: magicLink.email };
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Step 4: Request Magic Link Route
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// src/app/api/auth/magic-link/route.ts
|
|
154
|
+
import { handleApiError } from '@/lib/mars';
|
|
155
|
+
import { checkRateLimit } from '@/lib/core/rate-limit';
|
|
156
|
+
import { createMagicLink } from '@/features/auth/server/magic-link';
|
|
157
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
158
|
+
import { z } from 'zod';
|
|
159
|
+
|
|
160
|
+
const requestSchema = z.object({
|
|
161
|
+
email: z.string().email('Invalid email address'),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
export async function POST(request: NextRequest) {
|
|
165
|
+
try {
|
|
166
|
+
const { email } = requestSchema.parse(await request.json());
|
|
167
|
+
|
|
168
|
+
await checkRateLimit(`magic-link:${email}`, {
|
|
169
|
+
maxAttempts: 5,
|
|
170
|
+
windowMs: 15 * 60 * 1000,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Always return success to prevent email enumeration
|
|
174
|
+
try {
|
|
175
|
+
await createMagicLink(email);
|
|
176
|
+
} catch {
|
|
177
|
+
// Silently fail — don't reveal whether the email exists
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return NextResponse.json({
|
|
181
|
+
message: 'If an account exists with that email, a sign-in link has been sent.',
|
|
182
|
+
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return handleApiError(error, { endpoint: '/api/auth/magic-link' });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Step 5: Verify Magic Link Route
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
// src/app/api/auth/magic-link/verify/route.ts
|
|
193
|
+
import { handleApiError } from '@/lib/mars';
|
|
194
|
+
import { verifyMagicLink } from '@/features/auth/server/magic-link';
|
|
195
|
+
import { createSession } from '@/features/auth/server/sessions';
|
|
196
|
+
import { prisma } from '@/lib/prisma';
|
|
197
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
198
|
+
import { z } from 'zod';
|
|
199
|
+
|
|
200
|
+
const verifySchema = z.object({
|
|
201
|
+
token: z.string().min(1),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
export async function GET(request: NextRequest) {
|
|
205
|
+
try {
|
|
206
|
+
const { searchParams } = new URL(request.url);
|
|
207
|
+
const { token } = verifySchema.parse({ token: searchParams.get('token') });
|
|
208
|
+
|
|
209
|
+
const result = await verifyMagicLink(token);
|
|
210
|
+
|
|
211
|
+
if (!result.success) {
|
|
212
|
+
return NextResponse.redirect(
|
|
213
|
+
new URL(`/auth/login?error=${encodeURIComponent(result.error!)}`, request.url),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Find or create the user
|
|
218
|
+
let user = await prisma.user.findUnique({ where: { email: result.email! } });
|
|
219
|
+
|
|
220
|
+
if (!user) {
|
|
221
|
+
user = await prisma.user.create({
|
|
222
|
+
data: {
|
|
223
|
+
email: result.email!,
|
|
224
|
+
emailVerified: true,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
} else if (!user.emailVerified) {
|
|
228
|
+
await prisma.user.update({
|
|
229
|
+
where: { id: user.id },
|
|
230
|
+
data: { emailVerified: true },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Create session and redirect
|
|
235
|
+
const response = NextResponse.redirect(new URL('/dashboard', request.url));
|
|
236
|
+
await createSession(user.id, response);
|
|
237
|
+
|
|
238
|
+
return response;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return handleApiError(error, { endpoint: '/api/auth/magic-link/verify' });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Step 6: Login Page — Magic Link Form
|
|
246
|
+
|
|
247
|
+
Add a magic link option to the login page:
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
// src/features/auth/components/MagicLinkForm.tsx
|
|
251
|
+
'use client';
|
|
252
|
+
|
|
253
|
+
import { useState } from 'react';
|
|
254
|
+
|
|
255
|
+
export function MagicLinkForm() {
|
|
256
|
+
const [email, setEmail] = useState('');
|
|
257
|
+
const [submitted, setSubmitted] = useState(false);
|
|
258
|
+
const [loading, setLoading] = useState(false);
|
|
259
|
+
const [error, setError] = useState('');
|
|
260
|
+
|
|
261
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
262
|
+
e.preventDefault();
|
|
263
|
+
setLoading(true);
|
|
264
|
+
setError('');
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const res = await fetch('/api/auth/magic-link', {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers: { 'Content-Type': 'application/json' },
|
|
270
|
+
body: JSON.stringify({ email }),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (!res.ok) {
|
|
274
|
+
const data = await res.json();
|
|
275
|
+
setError(data.error || 'Something went wrong');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
setSubmitted(true);
|
|
280
|
+
} catch {
|
|
281
|
+
setError('Network error. Please try again.');
|
|
282
|
+
} finally {
|
|
283
|
+
setLoading(false);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (submitted) {
|
|
288
|
+
return (
|
|
289
|
+
<div className="text-center">
|
|
290
|
+
<h2 className="text-xl font-semibold text-content-primary">Check your email</h2>
|
|
291
|
+
<p className="mt-2 text-content-secondary">
|
|
292
|
+
We sent a sign-in link to <strong>{email}</strong>.
|
|
293
|
+
</p>
|
|
294
|
+
<p className="mt-1 text-sm text-content-tertiary">
|
|
295
|
+
The link expires in 15 minutes.
|
|
296
|
+
</p>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
303
|
+
<div>
|
|
304
|
+
<label htmlFor="email" className="block text-sm font-medium text-content-primary">
|
|
305
|
+
Email address
|
|
306
|
+
</label>
|
|
307
|
+
<input
|
|
308
|
+
id="email"
|
|
309
|
+
type="email"
|
|
310
|
+
value={email}
|
|
311
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
312
|
+
required
|
|
313
|
+
className="mt-1 block w-full rounded-md border border-border-primary bg-surface-primary px-3 py-2 text-content-primary placeholder:text-content-tertiary focus:border-interactive-primary focus:outline-none focus:ring-1 focus:ring-interactive-primary"
|
|
314
|
+
placeholder="you@example.com"
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
{error && <p className="text-sm text-status-error">{error}</p>}
|
|
318
|
+
<button
|
|
319
|
+
type="submit"
|
|
320
|
+
disabled={loading}
|
|
321
|
+
className="w-full rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover disabled:opacity-50"
|
|
322
|
+
>
|
|
323
|
+
{loading ? 'Sending...' : 'Send Magic Link'}
|
|
324
|
+
</button>
|
|
325
|
+
</form>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## Step 7: Cleanup Expired Tokens
|
|
331
|
+
|
|
332
|
+
Add a lazy cleanup function to prevent unbounded growth:
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
// In magic-link.ts
|
|
336
|
+
export async function cleanupExpiredTokens(): Promise<void> {
|
|
337
|
+
await prisma.magicLink.deleteMany({
|
|
338
|
+
where: {
|
|
339
|
+
OR: [
|
|
340
|
+
{ expiresAt: { lt: new Date() } },
|
|
341
|
+
{ usedAt: { not: null }, createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
|
|
342
|
+
],
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Call this lazily during token creation (1% chance per request) to keep the table clean without `setInterval`:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
if (Math.random() < 0.01) {
|
|
352
|
+
cleanupExpiredTokens().catch(() => {});
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Security Considerations
|
|
357
|
+
|
|
358
|
+
- **Single-use tokens**: Each token is marked as used immediately upon verification.
|
|
359
|
+
- **Previous tokens invalidated**: When a new magic link is requested, all previous unused tokens for that email are invalidated.
|
|
360
|
+
- **Hashed storage**: Raw tokens are never stored; only SHA-256 hashes are persisted.
|
|
361
|
+
- **No email enumeration**: The request endpoint always returns a success message regardless of whether the email exists.
|
|
362
|
+
- **Rate limiting**: Magic link requests are rate-limited per email address.
|
|
363
|
+
- **Short expiry**: Default 15 minutes prevents stale token abuse.
|
|
364
|
+
|
|
365
|
+
## Testing
|
|
366
|
+
|
|
367
|
+
1. Request a magic link with `console` email provider — check terminal for the link.
|
|
368
|
+
2. Click the link — verify a session is created and the user is redirected.
|
|
369
|
+
3. Click the same link again — should show "already used" error.
|
|
370
|
+
4. Request a link, wait for expiry, then click — should show "expired" error.
|
|
371
|
+
5. Request two links for the same email — only the second should work.
|
|
372
|
+
6. Send 6 requests in 15 minutes — the 6th should be rate-limited.
|
|
373
|
+
|
|
374
|
+
## Checklist
|
|
375
|
+
|
|
376
|
+
- [ ] `MagicLink` model added to Prisma schema
|
|
377
|
+
- [ ] Magic link config added to `app.config.ts`
|
|
378
|
+
- [ ] Token generation stores hashed token with expiry
|
|
379
|
+
- [ ] Previous tokens invalidated on new request
|
|
380
|
+
- [ ] Verification marks token as used before creating session
|
|
381
|
+
- [ ] Request endpoint prevents email enumeration
|
|
382
|
+
- [ ] Rate limiting applied to magic link requests
|
|
383
|
+
- [ ] Login page includes `MagicLinkForm` component
|
|
384
|
+
- [ ] Expired token cleanup runs lazily
|
|
385
|
+
- [ ] `db:push` run after schema changes
|