1 changed files with 469 additions and 0 deletions
@ -0,0 +1,469 @@ |
|||
# Registration Page Implementation Plan |
|||
|
|||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
|||
|
|||
**Goal:** Add a registration page at `/register` with username, password, confirm password, and SVG captcha. On success, redirect to `/login?registered=1`. |
|||
|
|||
**Architecture:** Server-side captcha generation via `svg-captcha` stored in an in-memory Map with 5-min TTL. Registration API validates input with Zod, verifies captcha, hashes password with bcryptjs, and inserts into the existing SQLite `users` table. Client-side uses Nuxt UI `UForm` with Zod schema validation and `useToast` for error feedback. |
|||
|
|||
**Tech Stack:** Nuxt 4.4, Nuxt UI 4.6, svg-captcha, bcryptjs (already installed), zod (already installed), Drizzle ORM + SQLite |
|||
|
|||
--- |
|||
|
|||
### Task 1: Install svg-captcha |
|||
|
|||
**Files:** |
|||
- Modify: `package.json` |
|||
|
|||
- [ ] **Step 1: Install the dependency** |
|||
|
|||
Run: `cd /home/dash/coding/nuxt-app && bun add svg-captcha` |
|||
|
|||
- [ ] **Step 2: Commit** |
|||
|
|||
```bash |
|||
git add package.json bun.lock |
|||
git commit -m "chore: add svg-captcha dependency" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 2: Create captcha utility module |
|||
|
|||
**Files:** |
|||
- Create: `server/utils/auth/captcha.ts` |
|||
|
|||
- [ ] **Step 1: Create the captcha utility** |
|||
|
|||
```ts |
|||
import svgCaptcha from 'svg-captcha' |
|||
|
|||
interface CaptchaRecord { |
|||
text: string |
|||
createdAt: number |
|||
} |
|||
|
|||
const captchaStore = new Map<string, CaptchaRecord>() |
|||
const CAPTCHA_TTL = 5 * 60 * 1000 |
|||
|
|||
function cleanupExpired(): void { |
|||
const now = Date.now() |
|||
for (const [token, record] of captchaStore) { |
|||
if (now - record.createdAt > CAPTCHA_TTL) { |
|||
captchaStore.delete(token) |
|||
} |
|||
} |
|||
} |
|||
|
|||
export function generateCaptcha(): { token: string; svg: string } { |
|||
cleanupExpired() |
|||
const { text, data: svg } = svgCaptcha.create({ |
|||
noise: 3, |
|||
color: true, |
|||
background: '#f8f9fa', |
|||
}) |
|||
const token = crypto.randomUUID() |
|||
captchaStore.set(token, { text, createdAt: Date.now() }) |
|||
return { token, svg } |
|||
} |
|||
|
|||
export function verifyCaptcha(token: string, text: string): boolean { |
|||
const record = captchaStore.get(token) |
|||
if (!record) return false |
|||
if (Date.now() - record.createdAt > CAPTCHA_TTL) { |
|||
captchaStore.delete(token) |
|||
return false |
|||
} |
|||
const match = record.text.toLowerCase() === text.toLowerCase() |
|||
if (match) { |
|||
captchaStore.delete(token) |
|||
} |
|||
return match |
|||
} |
|||
``` |
|||
|
|||
- [ ] **Step 2: Commit** |
|||
|
|||
```bash |
|||
git add server/utils/auth/captcha.ts |
|||
git commit -m "feat: add captcha generation and verification utility" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 3: Create registration validation schema |
|||
|
|||
**Files:** |
|||
- Create: `server/utils/auth/validation.ts` |
|||
|
|||
- [ ] **Step 1: Create the validation module** |
|||
|
|||
```ts |
|||
import { z } from 'zod' |
|||
|
|||
export const registerSchema = z |
|||
.object({ |
|||
username: z |
|||
.string() |
|||
.min(3, '用户名至少需要3个字符') |
|||
.max(30, '用户名最多30个字符'), |
|||
password: z.string().min(8, '密码至少需要8个字符'), |
|||
confirmPassword: z.string(), |
|||
captchaToken: z.string().min(1, '验证码令牌不能为空'), |
|||
captchaText: z.string().min(1, '验证码不能为空'), |
|||
}) |
|||
.refine((data) => data.password === data.confirmPassword, { |
|||
message: '两次输入的密码不一致', |
|||
path: ['confirmPassword'], |
|||
}) |
|||
``` |
|||
|
|||
- [ ] **Step 2: Commit** |
|||
|
|||
```bash |
|||
git add server/utils/auth/validation.ts |
|||
git commit -m "feat: add registration Zod validation schema" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 4: Create captcha API endpoint |
|||
|
|||
**Files:** |
|||
- Create: `server/api/auth/captcha.get.ts` |
|||
|
|||
- [ ] **Step 1: Create the captcha GET endpoint** |
|||
|
|||
```ts |
|||
import { generateCaptcha } from '../../utils/auth/captcha' |
|||
|
|||
export default defineWrappedResponseHandler(async () => { |
|||
const { token, svg } = generateCaptcha() |
|||
return R.success({ token, svg }) |
|||
}) |
|||
``` |
|||
|
|||
- [ ] **Step 2: Verify the endpoint works** |
|||
|
|||
Run the dev server: `cd /home/dash/coding/nuxt-app && bun run dev` |
|||
|
|||
Test: `curl -s http://localhost:3000/api/auth/captcha | head -c 200` |
|||
|
|||
Expected: JSON response with `{"code":0,"data":{"token":"<uuid>","svg":"<svg>...</svg>"}}` |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add server/api/auth/captcha.get.ts |
|||
git commit -m "feat: add GET /api/auth/captcha endpoint" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 5: Create registration API endpoint |
|||
|
|||
**Files:** |
|||
- Create: `server/api/auth/register.post.ts` |
|||
|
|||
- [ ] **Step 1: Create the register POST endpoint** |
|||
|
|||
```ts |
|||
import { registerSchema } from '../../utils/auth/validation' |
|||
import { verifyCaptcha } from '../../utils/auth/captcha' |
|||
import { dbGlobal } from 'drizzle-pkg/lib/db' |
|||
import { users } from 'drizzle-pkg/lib/schema/auth' |
|||
import { eq } from 'drizzle-orm' |
|||
import { hash } from 'bcryptjs' |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
const body = await readBody(event) |
|||
|
|||
const parsed = registerSchema.safeParse(body) |
|||
if (!parsed.success) { |
|||
return R.error(parsed.error.errors[0]?.message || '表单验证失败', null) |
|||
} |
|||
|
|||
const { username, password, captchaToken, captchaText } = parsed.data |
|||
|
|||
if (!verifyCaptcha(captchaToken, captchaText)) { |
|||
return R.error('验证码错误或已过期', null) |
|||
} |
|||
|
|||
const existing = await dbGlobal |
|||
.select() |
|||
.from(users) |
|||
.where(eq(users.username, username)) |
|||
if (existing.length > 0) { |
|||
return R.error('用户名已存在', null) |
|||
} |
|||
|
|||
const hashedPassword = await hash(password, 10) |
|||
|
|||
const result = await dbGlobal |
|||
.insert(users) |
|||
.values({ |
|||
username, |
|||
password: hashedPassword, |
|||
role: 'user', |
|||
status: 'active', |
|||
}) |
|||
.returning({ id: users.id }) |
|||
|
|||
return R.success({ id: result[0].id, username }) |
|||
}) |
|||
``` |
|||
|
|||
- [ ] **Step 2: Test registration with curl** |
|||
|
|||
```bash |
|||
# First get a captcha |
|||
CAPTCHA=$(curl -s http://localhost:3000/api/auth/captcha) |
|||
TOKEN=$(echo $CAPTCHA | jq -r '.data.token') |
|||
# The captcha text is human-readable from the SVG — you'll need to decode it manually |
|||
# For an automated test, just verify validation works: |
|||
curl -s -X POST http://localhost:3000/api/auth/register \ |
|||
-H 'Content-Type: application/json' \ |
|||
-d "{\"username\":\"ab\",\"password\":\"12345\",\"confirmPassword\":\"12345\",\"captchaToken\":\"\",\"captchaText\":\"\"}" |
|||
# Expected: {"code":1,"message":"用户名至少需要3个字符","data":null} |
|||
``` |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add server/api/auth/register.post.ts |
|||
git commit -m "feat: add POST /api/auth/register endpoint" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 6: Create registration page component |
|||
|
|||
**Files:** |
|||
- Create: `app/pages/register.vue` |
|||
|
|||
- [ ] **Step 1: Create the registration page** |
|||
|
|||
```vue |
|||
<script setup lang="ts"> |
|||
import { z } from 'zod' |
|||
|
|||
const formSchema = z |
|||
.object({ |
|||
username: z.string().min(3, '用户名至少需要3个字符').max(30), |
|||
password: z.string().min(8, '密码至少需要8个字符'), |
|||
confirmPassword: z.string(), |
|||
captchaText: z.string().min(1, '请输入验证码'), |
|||
}) |
|||
.refine((data) => data.password === data.confirmPassword, { |
|||
message: '两次输入的密码不一致', |
|||
path: ['confirmPassword'], |
|||
}) |
|||
|
|||
const state = reactive({ |
|||
username: '', |
|||
password: '', |
|||
confirmPassword: '', |
|||
captchaText: '', |
|||
}) |
|||
|
|||
const captchaToken = ref('') |
|||
const captchaSvg = ref('') |
|||
const loading = ref(false) |
|||
const toast = useToast() |
|||
|
|||
async function fetchCaptcha() { |
|||
const res = await $fetch<{ code: number; data: { token: string; svg: string } }>( |
|||
'/api/auth/captcha' |
|||
) |
|||
captchaToken.value = res.data.token |
|||
captchaSvg.value = res.data.svg |
|||
} |
|||
|
|||
async function onSubmit() { |
|||
loading.value = true |
|||
try { |
|||
const res = await $fetch<{ code: number; message: string; data: unknown }>( |
|||
'/api/auth/register', |
|||
{ |
|||
method: 'POST', |
|||
body: { |
|||
username: state.username, |
|||
password: state.password, |
|||
confirmPassword: state.confirmPassword, |
|||
captchaToken: captchaToken.value, |
|||
captchaText: state.captchaText, |
|||
}, |
|||
} |
|||
) |
|||
|
|||
if (res.code !== 0) { |
|||
toast.add({ title: res.message, color: 'error' }) |
|||
if (res.message.includes('验证码')) { |
|||
fetchCaptcha() |
|||
state.captchaText = '' |
|||
} |
|||
return |
|||
} |
|||
|
|||
navigateTo('/login?registered=1') |
|||
} catch { |
|||
toast.add({ title: '注册失败,请稍后重试', color: 'error' }) |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
onMounted(fetchCaptcha) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="flex items-center justify-center py-8"> |
|||
<UCard class="w-full max-w-[380px]"> |
|||
<template #header> |
|||
<h1 class="text-xl font-semibold">Create Account</h1> |
|||
<p class="text-sm text-muted mt-1">Fill in the details to get started</p> |
|||
</template> |
|||
|
|||
<UForm :state="state" :schema="formSchema" @submit="onSubmit"> |
|||
<UFormField name="username" label="Username" required class="mb-4"> |
|||
<UInput v-model="state.username" placeholder="Enter your username" /> |
|||
</UFormField> |
|||
|
|||
<UFormField name="password" label="Password" required class="mb-4"> |
|||
<UInput |
|||
v-model="state.password" |
|||
type="password" |
|||
placeholder="At least 8 characters" |
|||
/> |
|||
</UFormField> |
|||
|
|||
<UFormField name="confirmPassword" label="Confirm Password" required class="mb-4"> |
|||
<UInput |
|||
v-model="state.confirmPassword" |
|||
type="password" |
|||
placeholder="Re-enter your password" |
|||
/> |
|||
</UFormField> |
|||
|
|||
<UFormField name="captchaText" label="Captcha" required class="mb-4"> |
|||
<div class="flex gap-2 items-start"> |
|||
<!-- eslint-disable-next-line vue/no-v-html --> |
|||
<div |
|||
class="flex-1 h-10 border rounded-md overflow-hidden bg-[#f8f9fa]" |
|||
v-html="captchaSvg" |
|||
/> |
|||
<UButton |
|||
variant="ghost" |
|||
color="neutral" |
|||
icon="i-lucide-refresh-cw" |
|||
square |
|||
@click="fetchCaptcha" |
|||
/> |
|||
</div> |
|||
<UInput |
|||
v-model="state.captchaText" |
|||
placeholder="Enter the code above" |
|||
class="mt-2" |
|||
/> |
|||
</UFormField> |
|||
|
|||
<UButton type="submit" block :loading="loading" class="mt-6"> |
|||
Create Account |
|||
</UButton> |
|||
</UForm> |
|||
|
|||
<template #footer> |
|||
<p class="text-sm text-center text-muted"> |
|||
Already have an account? |
|||
<NuxtLink to="/login" class="text-primary hover:underline">Log in</NuxtLink> |
|||
</p> |
|||
</template> |
|||
</UCard> |
|||
</div> |
|||
</template> |
|||
``` |
|||
|
|||
- [ ] **Step 2: Commit** |
|||
|
|||
```bash |
|||
git add app/pages/register.vue |
|||
git commit -m "feat: add registration page with captcha support" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 7: Wire header button to /register |
|||
|
|||
**Files:** |
|||
- Modify: `app/layouts/default.vue:15` |
|||
|
|||
- [ ] **Step 1: Make the login/register button a link** |
|||
|
|||
Find the line: |
|||
```html |
|||
<UButton color="neutral" variant="ghost" label="登录 / 注册" /> |
|||
``` |
|||
|
|||
Replace with: |
|||
```html |
|||
<UButton color="neutral" variant="ghost" label="登录 / 注册" to="/register" /> |
|||
``` |
|||
|
|||
- [ ] **Step 2: Stop dev server (if running) and run type check** |
|||
|
|||
Run: `cd /home/dash/coding/nuxt-app && bunx nuxi typecheck 2>&1 | tail -5` |
|||
Expected: No new type errors. |
|||
|
|||
- [ ] **Step 3: Commit** |
|||
|
|||
```bash |
|||
git add app/layouts/default.vue |
|||
git commit -m "feat: wire header login button to /register" |
|||
``` |
|||
|
|||
--- |
|||
|
|||
### Task 8: End-to-end verification |
|||
|
|||
- [ ] **Step 1: Start dev server** |
|||
|
|||
```bash |
|||
cd /home/dash/coding/nuxt-app && bun run dev & |
|||
``` |
|||
|
|||
- [ ] **Step 2: Verify the page loads** |
|||
|
|||
Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/register` |
|||
Expected: `200` |
|||
|
|||
- [ ] **Step 3: Verify captcha endpoint** |
|||
|
|||
Run: `curl -s http://localhost:3000/api/auth/captcha | python3 -m json.tool | head -10` |
|||
Expected: Valid JSON with `code: 0`, `data.token`, `data.svg` |
|||
|
|||
- [ ] **Step 4: Verify registration validation (short username)** |
|||
|
|||
```bash |
|||
curl -s -X POST http://localhost:3000/api/auth/register \ |
|||
-H 'Content-Type: application/json' \ |
|||
-d '{"username":"ab","password":"12345678","confirmPassword":"12345678","captchaToken":"dummy","captchaText":"dummy"}' |
|||
``` |
|||
Expected: `{"code":1,"message":"用户名至少需要3个字符","data":null}` |
|||
|
|||
- [ ] **Step 5: Verify registration validation (password mismatch)** |
|||
|
|||
```bash |
|||
curl -s -X POST http://localhost:3000/api/auth/register \ |
|||
-H 'Content-Type: application/json' \ |
|||
-d '{"username":"testuser","password":"12345678","confirmPassword":"different","captchaToken":"dummy","captchaText":"dummy"}' |
|||
``` |
|||
Expected: `{"code":1,"message":"两次输入的密码不一致","data":null}` |
|||
|
|||
- [ ] **Step 6: Verify invalid captcha returns error** |
|||
|
|||
```bash |
|||
curl -s -X POST http://localhost:3000/api/auth/register \ |
|||
-H 'Content-Type: application/json' \ |
|||
-d '{"username":"testuser","password":"12345678","confirmPassword":"12345678","captchaToken":"invalid-token","captchaText":"abc"}' |
|||
``` |
|||
Expected: `{"code":1,"message":"验证码错误或已过期","data":null}` |
|||
Loading…
Reference in new issue