@helloleo/plugins 0.2.0-beta.3 → 0.2.0-beta.5
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/dist/index.js +568 -14
- package/dist/templates/tanstack-start-2026-06-18/package.json +63 -63
- package/dist/templates/tanstack-start-2026-06-18/src/components/ui/calendar.tsx +1 -1
- package/dist/templates/tanstack-start-2026-06-18/src/components/ui/chart.tsx +253 -213
- package/dist/templates/tanstack-start-2026-06-18/src/routeTree.gen.ts +30 -3
- package/dist/templates/tanstack-start-2026-06-18/src/routes/README.md +15 -0
- package/dist/templates/tanstack-start-2026-06-18/src/routes/__root.tsx +3 -2
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/templates/tanstack-start-2026-06-18/package.json +63 -63
- package/src/templates/tanstack-start-2026-06-18/src/components/ui/calendar.tsx +1 -1
- package/src/templates/tanstack-start-2026-06-18/src/components/ui/chart.tsx +253 -213
- package/src/templates/tanstack-start-2026-06-18/src/routeTree.gen.ts +30 -3
- package/src/templates/tanstack-start-2026-06-18/src/routes/README.md +15 -0
- package/src/templates/tanstack-start-2026-06-18/src/routes/__root.tsx +3 -2
- package/src/tools/inspect-http.ts +47 -12
- package/src/tools/scaffold.ts +1 -1
- package/src/tools/setup-password-auth.ts +599 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
4
|
+
import { tool } from "@opencode-ai/plugin";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Adds email/password auth on top of an existing scaffold: a `users` table,
|
|
8
|
+
* a stateless JWT session (HMAC-signed, httpOnly cookie -- never
|
|
9
|
+
* localStorage), and signup/login/logout/me API routes. Session verification
|
|
10
|
+
* needs no database round-trip.
|
|
11
|
+
*
|
|
12
|
+
* Auth lives behind `src/routes/api/auth/*` server routes (per this
|
|
13
|
+
* template's own README: "prefer API routes ... server functions are served
|
|
14
|
+
* from generated URLs that read as random gibberish"), never
|
|
15
|
+
* `createServerFn`. This also sidesteps vite's `[import-protection]` error
|
|
16
|
+
* class entirely: server routes are never imported by client route files,
|
|
17
|
+
* they're only fetched by URL, so there is no server-only import to leak
|
|
18
|
+
* into the client bundle.
|
|
19
|
+
*
|
|
20
|
+
* `vinxi/http` does NOT exist in this template (Vite-based Start, no vinxi)
|
|
21
|
+
* -- do not import it. Nothing here does.
|
|
22
|
+
*
|
|
23
|
+
* Only additive files are written, except `src/db/schema.ts`, which is
|
|
24
|
+
* drizzle.config.ts's one hardcoded schema path -- the `users` table MUST
|
|
25
|
+
* live there for `drizzle-kit push`/`generate` to see it. That file is
|
|
26
|
+
* appended to (idempotently), never replaced.
|
|
27
|
+
*
|
|
28
|
+
* Called AFTER `scaffold`, never instead of it.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const SCHEMA_MARKER = "sqliteTable('users'";
|
|
32
|
+
|
|
33
|
+
function usersTableBlock(): string {
|
|
34
|
+
return `
|
|
35
|
+
export const users = sqliteTable('users', {
|
|
36
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
37
|
+
email: text('email').notNull().unique(),
|
|
38
|
+
passwordHash: text('password_hash').notNull(),
|
|
39
|
+
createdAt: integer('created_at')
|
|
40
|
+
.notNull()
|
|
41
|
+
.default(sql\`(unixepoch())\`),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export type User = typeof users.$inferSelect
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function authLibTs(): string {
|
|
49
|
+
return `import { db } from '#/db'
|
|
50
|
+
import { users } from '#/db/schema'
|
|
51
|
+
import { env } from '@helloleo/runtime'
|
|
52
|
+
import { eq } from 'drizzle-orm'
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
* Password hashing, session JWTs, and user lookup. Used ONLY from
|
|
56
|
+
* src/routes/api/auth/*.ts server routes -- never imported by a client
|
|
57
|
+
* route file. Sessions are a stateless HMAC-signed JWT in an httpOnly
|
|
58
|
+
* cookie, not a server-side session store: verifying a session costs one
|
|
59
|
+
* signature check, no database or KV round-trip.
|
|
60
|
+
*
|
|
61
|
+
* AUTH_JWT_SECRET must be set for signing/verifying to work:
|
|
62
|
+
* - dev: add \`AUTH_JWT_SECRET=<random value>\` to .env (gitignored;
|
|
63
|
+
* the cf-shim exposes process.env vars as-is in dev).
|
|
64
|
+
* - prod: \`wrangler secret put AUTH_JWT_SECRET\`.
|
|
65
|
+
* Generate a value with: openssl rand -hex 32
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
const encoder = new TextEncoder()
|
|
69
|
+
const SESSION_COOKIE = 'session'
|
|
70
|
+
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7
|
|
71
|
+
|
|
72
|
+
function getAuthSecret(): string {
|
|
73
|
+
const secret = (env as { AUTH_JWT_SECRET?: string }).AUTH_JWT_SECRET
|
|
74
|
+
if (!secret) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'AUTH_JWT_SECRET is not set. Add it to .env (dev) or run ' +
|
|
77
|
+
'\`wrangler secret put AUTH_JWT_SECRET\` (prod).',
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return secret
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toHex(bytes: Uint8Array): string {
|
|
84
|
+
return Array.from(bytes)
|
|
85
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
86
|
+
.join('')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
90
|
+
if (a.length !== b.length) return false
|
|
91
|
+
let diff = 0
|
|
92
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
93
|
+
return diff === 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- password hashing (salted SHA-256; sufficient for a single-tenant
|
|
97
|
+
// demo app -- swap for a WASM bcrypt/argon2 if this ever guards real data) ----
|
|
98
|
+
|
|
99
|
+
export function generateSalt(): string {
|
|
100
|
+
return toHex(crypto.getRandomValues(new Uint8Array(16)))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
104
|
+
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(input))
|
|
105
|
+
return toHex(new Uint8Array(digest))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function hashPassword(password: string, salt: string): Promise<string> {
|
|
109
|
+
return \`\${salt}:\${await sha256Hex(salt + password)}\`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
113
|
+
const [salt, hash] = stored.split(':')
|
|
114
|
+
if (!salt || !hash) return false
|
|
115
|
+
return timingSafeEqual(await sha256Hex(salt + password), hash)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- session JWT (HS256, hand-rolled -- no dependency needed for one HMAC) ----
|
|
119
|
+
|
|
120
|
+
function base64url(bytes: Uint8Array): string {
|
|
121
|
+
let binary = ''
|
|
122
|
+
for (const b of bytes) binary += String.fromCharCode(b)
|
|
123
|
+
return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function base64urlDecode(value: string): string {
|
|
127
|
+
const padded = value.replace(/-/g, '+').replace(/_/g, '/')
|
|
128
|
+
return atob(padded)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function hmacKey(secret: string) {
|
|
132
|
+
return crypto.subtle.importKey(
|
|
133
|
+
'raw',
|
|
134
|
+
encoder.encode(secret),
|
|
135
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
136
|
+
false,
|
|
137
|
+
['sign'],
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function signSession(userId: number): Promise<string> {
|
|
142
|
+
const header = base64url(encoder.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })))
|
|
143
|
+
const payload = base64url(
|
|
144
|
+
encoder.encode(
|
|
145
|
+
JSON.stringify({ sub: userId, exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS }),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
const key = await hmacKey(getAuthSecret())
|
|
149
|
+
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(\`\${header}.\${payload}\`))
|
|
150
|
+
return \`\${header}.\${payload}.\${base64url(new Uint8Array(sig))}\`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function verifySession(token: string): Promise<number | null> {
|
|
154
|
+
const parts = token.split('.')
|
|
155
|
+
if (parts.length !== 3) return null
|
|
156
|
+
const [header, payload, sig] = parts
|
|
157
|
+
const key = await hmacKey(getAuthSecret())
|
|
158
|
+
const expectedSig = base64url(
|
|
159
|
+
new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(\`\${header}.\${payload}\`))),
|
|
160
|
+
)
|
|
161
|
+
if (!timingSafeEqual(expectedSig, sig)) return null
|
|
162
|
+
try {
|
|
163
|
+
const claims = JSON.parse(base64urlDecode(payload)) as { sub: number; exp: number }
|
|
164
|
+
if (claims.exp < Math.floor(Date.now() / 1000)) return null
|
|
165
|
+
return claims.sub
|
|
166
|
+
} catch {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function sessionCookieHeader(token: string): string {
|
|
172
|
+
return \`\${SESSION_COOKIE}=\${token}; Path=/; Max-Age=\${SESSION_TTL_SECONDS}; HttpOnly; SameSite=Lax\`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function clearSessionCookieHeader(): string {
|
|
176
|
+
return \`\${SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax\`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function readSessionToken(request: Request): string | null {
|
|
180
|
+
const cookieHeader = request.headers.get('cookie') ?? ''
|
|
181
|
+
for (const part of cookieHeader.split(/;\\s*/)) {
|
|
182
|
+
const eq = part.indexOf('=')
|
|
183
|
+
if (eq === -1) continue
|
|
184
|
+
if (part.slice(0, eq) === SESSION_COOKIE) return part.slice(eq + 1)
|
|
185
|
+
}
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function getUserFromRequest(request: Request) {
|
|
190
|
+
const token = readSessionToken(request)
|
|
191
|
+
if (!token) return null
|
|
192
|
+
const userId = await verifySession(token)
|
|
193
|
+
if (userId === null) return null
|
|
194
|
+
const [user] = await db
|
|
195
|
+
.select({ id: users.id, email: users.email })
|
|
196
|
+
.from(users)
|
|
197
|
+
.where(eq(users.id, userId))
|
|
198
|
+
.limit(1)
|
|
199
|
+
return user ?? null
|
|
200
|
+
}
|
|
201
|
+
`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function signupRouteTs(): string {
|
|
205
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
206
|
+
import { db } from '#/db'
|
|
207
|
+
import { users } from '#/db/schema'
|
|
208
|
+
import { generateSalt, hashPassword, sessionCookieHeader, signSession } from '#/lib/auth'
|
|
209
|
+
import { eq } from 'drizzle-orm'
|
|
210
|
+
import { z } from 'zod'
|
|
211
|
+
|
|
212
|
+
const bodySchema = z.object({ email: z.string().email(), password: z.string().min(8) })
|
|
213
|
+
|
|
214
|
+
export const Route = createFileRoute('/api/auth/signup')({
|
|
215
|
+
server: {
|
|
216
|
+
handlers: {
|
|
217
|
+
POST: async ({ request }) => {
|
|
218
|
+
const parsed = bodySchema.safeParse(await request.json().catch(() => null))
|
|
219
|
+
if (!parsed.success) {
|
|
220
|
+
return Response.json({ error: 'Invalid email or password (min 8 characters).' }, { status: 400 })
|
|
221
|
+
}
|
|
222
|
+
const email = parsed.data.email.toLowerCase()
|
|
223
|
+
|
|
224
|
+
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1)
|
|
225
|
+
if (existing) {
|
|
226
|
+
return Response.json({ error: 'An account with this email already exists.' }, { status: 409 })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const salt = generateSalt()
|
|
230
|
+
const passwordHash = await hashPassword(parsed.data.password, salt)
|
|
231
|
+
const [user] = await db
|
|
232
|
+
.insert(users)
|
|
233
|
+
.values({ email, passwordHash })
|
|
234
|
+
.returning({ id: users.id, email: users.email })
|
|
235
|
+
|
|
236
|
+
const token = await signSession(user.id)
|
|
237
|
+
return Response.json(
|
|
238
|
+
{ user },
|
|
239
|
+
{ status: 201, headers: { 'Set-Cookie': sessionCookieHeader(token) } },
|
|
240
|
+
)
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function loginRouteTs(): string {
|
|
249
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
250
|
+
import { db } from '#/db'
|
|
251
|
+
import { users } from '#/db/schema'
|
|
252
|
+
import { sessionCookieHeader, signSession, verifyPassword } from '#/lib/auth'
|
|
253
|
+
import { eq } from 'drizzle-orm'
|
|
254
|
+
import { z } from 'zod'
|
|
255
|
+
|
|
256
|
+
const bodySchema = z.object({ email: z.string().email(), password: z.string() })
|
|
257
|
+
|
|
258
|
+
export const Route = createFileRoute('/api/auth/login')({
|
|
259
|
+
server: {
|
|
260
|
+
handlers: {
|
|
261
|
+
POST: async ({ request }) => {
|
|
262
|
+
const parsed = bodySchema.safeParse(await request.json().catch(() => null))
|
|
263
|
+
if (!parsed.success) {
|
|
264
|
+
return Response.json({ error: 'Invalid email or password.' }, { status: 400 })
|
|
265
|
+
}
|
|
266
|
+
const email = parsed.data.email.toLowerCase()
|
|
267
|
+
|
|
268
|
+
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1)
|
|
269
|
+
if (!user || !(await verifyPassword(parsed.data.password, user.passwordHash))) {
|
|
270
|
+
return Response.json({ error: 'Invalid email or password.' }, { status: 401 })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const token = await signSession(user.id)
|
|
274
|
+
return Response.json(
|
|
275
|
+
{ user: { id: user.id, email: user.email } },
|
|
276
|
+
{ status: 200, headers: { 'Set-Cookie': sessionCookieHeader(token) } },
|
|
277
|
+
)
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function logoutRouteTs(): string {
|
|
286
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
287
|
+
import { clearSessionCookieHeader } from '#/lib/auth'
|
|
288
|
+
|
|
289
|
+
export const Route = createFileRoute('/api/auth/logout')({
|
|
290
|
+
server: {
|
|
291
|
+
handlers: {
|
|
292
|
+
POST: async () => {
|
|
293
|
+
return Response.json({ ok: true }, { headers: { 'Set-Cookie': clearSessionCookieHeader() } })
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
})
|
|
298
|
+
`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function meRouteTs(): string {
|
|
302
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
303
|
+
import { getUserFromRequest } from '#/lib/auth'
|
|
304
|
+
|
|
305
|
+
export const Route = createFileRoute('/api/auth/me')({
|
|
306
|
+
server: {
|
|
307
|
+
handlers: {
|
|
308
|
+
GET: async ({ request }) => {
|
|
309
|
+
const user = await getUserFromRequest(request)
|
|
310
|
+
return Response.json({ user })
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function sessionClientTs(): string {
|
|
319
|
+
return `/*
|
|
320
|
+
* Client-side session helpers. getUser() is the getUser() referenced by the
|
|
321
|
+
* "Authenticated routes" pattern in src/routes/README.md -- wire it into
|
|
322
|
+
* __root.tsx's beforeLoad (see that file's manual step below).
|
|
323
|
+
*/
|
|
324
|
+
|
|
325
|
+
export interface SessionUser {
|
|
326
|
+
id: number
|
|
327
|
+
email: string
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function getUser(): Promise<SessionUser | null> {
|
|
331
|
+
const res = await fetch('/api/auth/me')
|
|
332
|
+
if (!res.ok) return null
|
|
333
|
+
const data = (await res.json()) as { user: SessionUser | null }
|
|
334
|
+
return data.user
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function login(email: string, password: string): Promise<{ user: SessionUser } | { error: string }> {
|
|
338
|
+
const res = await fetch('/api/auth/login', {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
headers: { 'Content-Type': 'application/json' },
|
|
341
|
+
body: JSON.stringify({ email, password }),
|
|
342
|
+
})
|
|
343
|
+
const data = await res.json()
|
|
344
|
+
return res.ok ? { user: data.user } : { error: data.error ?? 'Something went wrong.' }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function signup(email: string, password: string): Promise<{ user: SessionUser } | { error: string }> {
|
|
348
|
+
const res = await fetch('/api/auth/signup', {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: { 'Content-Type': 'application/json' },
|
|
351
|
+
body: JSON.stringify({ email, password }),
|
|
352
|
+
})
|
|
353
|
+
const data = await res.json()
|
|
354
|
+
return res.ok ? { user: data.user } : { error: data.error ?? 'Something went wrong.' }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function logout(): Promise<void> {
|
|
358
|
+
await fetch('/api/auth/logout', { method: 'POST' })
|
|
359
|
+
}
|
|
360
|
+
`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function authedLayoutTsx(): string {
|
|
364
|
+
return `import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
365
|
+
|
|
366
|
+
// Pathless guard -- no URL segment of its own. Put protected pages under
|
|
367
|
+
// src/routes/_authed/ (e.g. _authed/dashboard.tsx -> /dashboard) and they
|
|
368
|
+
// inherit this beforeLoad. See src/routes/README.md, "Authenticated routes".
|
|
369
|
+
export const Route = createFileRoute('/_authed')({
|
|
370
|
+
beforeLoad: ({ context }) => {
|
|
371
|
+
if (!context.user) {
|
|
372
|
+
throw redirect({ to: '/login' })
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function loginPageTsx(): string {
|
|
380
|
+
return `import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
|
381
|
+
import { useState } from 'react'
|
|
382
|
+
import { login } from '#/lib/session-client'
|
|
383
|
+
import { Button } from '#/components/ui/button'
|
|
384
|
+
import { Input } from '#/components/ui/input'
|
|
385
|
+
import { Label } from '#/components/ui/label'
|
|
386
|
+
import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'
|
|
387
|
+
|
|
388
|
+
export const Route = createFileRoute('/login')({
|
|
389
|
+
beforeLoad: ({ context }) => {
|
|
390
|
+
if (context.user) throw redirect({ to: '/' })
|
|
391
|
+
},
|
|
392
|
+
component: LoginPage,
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
function LoginPage() {
|
|
396
|
+
const router = useRouter()
|
|
397
|
+
const [email, setEmail] = useState('')
|
|
398
|
+
const [password, setPassword] = useState('')
|
|
399
|
+
const [error, setError] = useState('')
|
|
400
|
+
const [loading, setLoading] = useState(false)
|
|
401
|
+
|
|
402
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
403
|
+
e.preventDefault()
|
|
404
|
+
setError('')
|
|
405
|
+
setLoading(true)
|
|
406
|
+
const result = await login(email, password)
|
|
407
|
+
setLoading(false)
|
|
408
|
+
if ('error' in result) {
|
|
409
|
+
setError(result.error)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
await router.invalidate()
|
|
413
|
+
router.navigate({ to: '/' })
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
|
418
|
+
<Card className="w-full max-w-sm">
|
|
419
|
+
<CardHeader>
|
|
420
|
+
<CardTitle>Log in</CardTitle>
|
|
421
|
+
</CardHeader>
|
|
422
|
+
<CardContent>
|
|
423
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
424
|
+
<div className="space-y-2">
|
|
425
|
+
<Label htmlFor="email">Email</Label>
|
|
426
|
+
<Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
|
|
427
|
+
</div>
|
|
428
|
+
<div className="space-y-2">
|
|
429
|
+
<Label htmlFor="password">Password</Label>
|
|
430
|
+
<Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
|
|
431
|
+
</div>
|
|
432
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
433
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
434
|
+
{loading ? 'Logging in…' : 'Log in'}
|
|
435
|
+
</Button>
|
|
436
|
+
<p className="text-sm text-center text-muted-foreground">
|
|
437
|
+
No account? <Link to="/signup" className="underline">Sign up</Link>
|
|
438
|
+
</p>
|
|
439
|
+
</form>
|
|
440
|
+
</CardContent>
|
|
441
|
+
</Card>
|
|
442
|
+
</div>
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function signupPageTsx(): string {
|
|
449
|
+
return `import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
|
450
|
+
import { useState } from 'react'
|
|
451
|
+
import { signup } from '#/lib/session-client'
|
|
452
|
+
import { Button } from '#/components/ui/button'
|
|
453
|
+
import { Input } from '#/components/ui/input'
|
|
454
|
+
import { Label } from '#/components/ui/label'
|
|
455
|
+
import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'
|
|
456
|
+
|
|
457
|
+
export const Route = createFileRoute('/signup')({
|
|
458
|
+
beforeLoad: ({ context }) => {
|
|
459
|
+
if (context.user) throw redirect({ to: '/' })
|
|
460
|
+
},
|
|
461
|
+
component: SignupPage,
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
function SignupPage() {
|
|
465
|
+
const router = useRouter()
|
|
466
|
+
const [email, setEmail] = useState('')
|
|
467
|
+
const [password, setPassword] = useState('')
|
|
468
|
+
const [error, setError] = useState('')
|
|
469
|
+
const [loading, setLoading] = useState(false)
|
|
470
|
+
|
|
471
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
472
|
+
e.preventDefault()
|
|
473
|
+
setError('')
|
|
474
|
+
setLoading(true)
|
|
475
|
+
const result = await signup(email, password)
|
|
476
|
+
setLoading(false)
|
|
477
|
+
if ('error' in result) {
|
|
478
|
+
setError(result.error)
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
await router.invalidate()
|
|
482
|
+
router.navigate({ to: '/' })
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return (
|
|
486
|
+
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
|
487
|
+
<Card className="w-full max-w-sm">
|
|
488
|
+
<CardHeader>
|
|
489
|
+
<CardTitle>Sign up</CardTitle>
|
|
490
|
+
</CardHeader>
|
|
491
|
+
<CardContent>
|
|
492
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
493
|
+
<div className="space-y-2">
|
|
494
|
+
<Label htmlFor="email">Email</Label>
|
|
495
|
+
<Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
|
|
496
|
+
</div>
|
|
497
|
+
<div className="space-y-2">
|
|
498
|
+
<Label htmlFor="password">Password</Label>
|
|
499
|
+
<Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="new-password" minLength={8} />
|
|
500
|
+
</div>
|
|
501
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
502
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
503
|
+
{loading ? 'Signing up…' : 'Sign up'}
|
|
504
|
+
</Button>
|
|
505
|
+
<p className="text-sm text-center text-muted-foreground">
|
|
506
|
+
Already have an account? <Link to="/login" className="underline">Log in</Link>
|
|
507
|
+
</p>
|
|
508
|
+
</form>
|
|
509
|
+
</CardContent>
|
|
510
|
+
</Card>
|
|
511
|
+
</div>
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function appendUsersTable(dir: string): Promise<boolean> {
|
|
518
|
+
const schemaPath = path.join(dir, "src", "db", "schema.ts");
|
|
519
|
+
const current = await readFile(schemaPath, "utf8").catch(() => null);
|
|
520
|
+
if (current === null) {
|
|
521
|
+
throw new Error(`src/db/schema.ts not found -- run scaffold first.`);
|
|
522
|
+
}
|
|
523
|
+
if (current.includes(SCHEMA_MARKER)) return false;
|
|
524
|
+
await Bun.write(schemaPath, `${current.trimEnd()}\n${usersTableBlock()}`);
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export const setupPasswordAuth = tool({
|
|
529
|
+
description:
|
|
530
|
+
"Add email/password auth to an existing project. Call scaffold first, " +
|
|
531
|
+
"then this. Writes a users table, stateless-JWT session helpers, and " +
|
|
532
|
+
"signup/login/logout/me API routes.",
|
|
533
|
+
args: {},
|
|
534
|
+
async execute(_args, context) {
|
|
535
|
+
const dir = context.directory;
|
|
536
|
+
|
|
537
|
+
await Promise.all([
|
|
538
|
+
Bun.write(path.join(dir, "src", "lib", "auth.ts"), authLibTs()),
|
|
539
|
+
Bun.write(
|
|
540
|
+
path.join(dir, "src", "lib", "session-client.ts"),
|
|
541
|
+
sessionClientTs(),
|
|
542
|
+
),
|
|
543
|
+
Bun.write(
|
|
544
|
+
path.join(dir, "src", "routes", "api", "auth", "signup.ts"),
|
|
545
|
+
signupRouteTs(),
|
|
546
|
+
),
|
|
547
|
+
Bun.write(
|
|
548
|
+
path.join(dir, "src", "routes", "api", "auth", "login.ts"),
|
|
549
|
+
loginRouteTs(),
|
|
550
|
+
),
|
|
551
|
+
Bun.write(
|
|
552
|
+
path.join(dir, "src", "routes", "api", "auth", "logout.ts"),
|
|
553
|
+
logoutRouteTs(),
|
|
554
|
+
),
|
|
555
|
+
Bun.write(
|
|
556
|
+
path.join(dir, "src", "routes", "api", "auth", "me.ts"),
|
|
557
|
+
meRouteTs(),
|
|
558
|
+
),
|
|
559
|
+
Bun.write(
|
|
560
|
+
path.join(dir, "src", "routes", "_authed.tsx"),
|
|
561
|
+
authedLayoutTsx(),
|
|
562
|
+
),
|
|
563
|
+
Bun.write(path.join(dir, "src", "routes", "login.tsx"), loginPageTsx()),
|
|
564
|
+
Bun.write(path.join(dir, "src", "routes", "signup.tsx"), signupPageTsx()),
|
|
565
|
+
]);
|
|
566
|
+
|
|
567
|
+
const schemaAppended = await appendUsersTable(dir);
|
|
568
|
+
|
|
569
|
+
return [
|
|
570
|
+
"Wrote password auth (users table, JWT session, API routes):",
|
|
571
|
+
` - src/db/schema.ts ${schemaAppended ? "(appended `users` table)" : "(already had a `users` table, left as-is)"}`,
|
|
572
|
+
" - src/lib/auth.ts (hashing, JWT sign/verify, getUserFromRequest -- server-only, never import from a client route)",
|
|
573
|
+
" - src/lib/session-client.ts (getUser, login, signup, logout -- client fetch helpers)",
|
|
574
|
+
" - src/routes/api/auth/{signup,login,logout,me}.ts (server routes -- never createServerFn)",
|
|
575
|
+
" - src/routes/_authed.tsx (pathless guard for protected routes)",
|
|
576
|
+
" - src/routes/login.tsx, src/routes/signup.tsx",
|
|
577
|
+
"",
|
|
578
|
+
"Session is a stateless HMAC-signed JWT in an httpOnly cookie (no localStorage, no server-side session store).",
|
|
579
|
+
"",
|
|
580
|
+
"Required: set AUTH_JWT_SECRET or every request 500s.",
|
|
581
|
+
" - dev: add `AUTH_JWT_SECRET=<value>` to .env",
|
|
582
|
+
" - prod: `wrangler secret put AUTH_JWT_SECRET`.",
|
|
583
|
+
"",
|
|
584
|
+
"Next (manual, one edit): in src/routes/__root.tsx, load the user into router context, per src/routes/README.md's Authenticated routes section:",
|
|
585
|
+
"",
|
|
586
|
+
" import { getUser } from '#/lib/session-client'",
|
|
587
|
+
" // in createRootRouteWithContext(...)({ ... }):",
|
|
588
|
+
" beforeLoad: async () => ({ user: await getUser() }),",
|
|
589
|
+
"",
|
|
590
|
+
"Then put protected pages under src/routes/_authed/ (e.g. _authed/dashboard.tsx -> /dashboard); they inherit the guard.",
|
|
591
|
+
].join("\n");
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
export const SetupPasswordAuthPlugin: Plugin = async () => {
|
|
596
|
+
return {
|
|
597
|
+
tool: { setup_password_auth: setupPasswordAuth },
|
|
598
|
+
};
|
|
599
|
+
};
|