# Auth System 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 login-gated user account system so only authenticated users can access the app's books and AI generation features. **Architecture:** JWT access token (15 min, stateless) + refresh token (7 days, stored in DB for revocability). Server uses Hono middleware to protect existing routes. Frontend uses a module-level singleton `useAuth.ts` composable with `authedFetch` replacing all `booksApi.ts` fetch calls. App.vue reactively shows `LoginPage` when `isLoggedIn` is false. **Tech Stack:** Hono (hono/jwt for sign/verify), Bun.password (built-in bcrypt), bun:sqlite, Vue 3 Composition API, bun:test (server tests), vitest (frontend tests) --- ## File Map **Create:** - `server/routes/auth.ts` — login / refresh / logout / me endpoints - `server/routes/auth.test.ts` — bun:test for auth routes - `server/routes/admin.ts` — user CRUD endpoints (admin only) - `server/routes/admin.test.ts` — bun:test for admin routes - `server/auth.ts` — pure crypto utilities (hash, JWT) - `server/auth.test.ts` — bun:test for crypto utilities - `server/middleware/bearerAuth.ts` — Hono middleware that validates Bearer JWT - `src/composables/useAuth.ts` — singleton auth state + authedFetch - `src/components/LoginPage.vue` — login form - `src/components/AdminPage.vue` — user management (admin only) **Modify:** - `server/db.ts` — add users + refresh_tokens tables and CRUD - `server/db.test.ts` — add tests for new CRUD functions - `server/index.ts` — mount auth/admin routes, protect books/generate, init admin - `src/services/booksApi.ts` — replace request() with authedFetch() - `src/App.vue` — login gate + admin page navigation - `src/components/BookListPage.vue` — add admin button for admin users - `.env` — add JWT_SECRET, ADMIN_USERNAME, ADMIN_PASSWORD --- ## Task 1: Extend DB schema with users + refresh_tokens **Files:** - Modify: `server/db.ts` - Modify: `server/db.test.ts` - [ ] **Step 1: Add types and schema to server/db.ts** Append after the existing `BookRow` interface and `SCHEMA` constant. Replace the full `SCHEMA` string: ```ts export interface UserRecord { id: string username: string passwordHash: string role: 'admin' | 'user' createdAt: string } export interface UserSummary { id: string username: string role: 'admin' | 'user' createdAt: string } export interface RefreshTokenRecord { id: string userId: string tokenHash: string expiresAt: string createdAt: string } ``` Change `const SCHEMA = ...` to: ```ts const SCHEMA = ` CREATE TABLE IF NOT EXISTS books ( id TEXT PRIMARY KEY, name TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS refresh_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL ) ` ``` - [ ] **Step 2: Add user CRUD functions to server/db.ts** Append after `deleteBook`: ```ts export function createUser( db: Database, params: { username: string; passwordHash: string; role: 'admin' | 'user' }, ): UserRecord { const id = crypto.randomUUID() const now = new Date().toISOString() db.run('INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)', [ id, params.username, params.passwordHash, params.role, now, ]) return { id, username: params.username, passwordHash: params.passwordHash, role: params.role, createdAt: now } } export function findUserByUsername(db: Database, username: string): UserRecord | null { const row = db .query<{ id: string; username: string; password_hash: string; role: string; created_at: string }, [string]>( 'SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?', ) .get(username) if (!row) return null return { id: row.id, username: row.username, passwordHash: row.password_hash, role: row.role as 'admin' | 'user', createdAt: row.created_at } } export function findUserById(db: Database, id: string): UserRecord | null { const row = db .query<{ id: string; username: string; password_hash: string; role: string; created_at: string }, [string]>( 'SELECT id, username, password_hash, role, created_at FROM users WHERE id = ?', ) .get(id) if (!row) return null return { id: row.id, username: row.username, passwordHash: row.password_hash, role: row.role as 'admin' | 'user', createdAt: row.created_at } } export function listUsers(db: Database): UserSummary[] { return db .query<{ id: string; username: string; role: string; created_at: string }, []>( 'SELECT id, username, role, created_at FROM users ORDER BY created_at ASC', ) .all() .map((row) => ({ id: row.id, username: row.username, role: row.role as 'admin' | 'user', createdAt: row.created_at })) } export function deleteUser(db: Database, id: string): boolean { const result = db.run('DELETE FROM users WHERE id = ?', [id]) return result.changes > 0 } ``` - [ ] **Step 3: Add refresh token CRUD functions to server/db.ts** Append after `deleteUser`: ```ts export function createRefreshToken( db: Database, params: { userId: string; tokenHash: string; expiresAt: string }, ): RefreshTokenRecord { const id = crypto.randomUUID() const now = new Date().toISOString() db.run( 'INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)', [id, params.userId, params.tokenHash, params.expiresAt, now], ) return { id, userId: params.userId, tokenHash: params.tokenHash, expiresAt: params.expiresAt, createdAt: now } } export function findRefreshTokenByHash(db: Database, tokenHash: string): RefreshTokenRecord | null { const row = db .query<{ id: string; user_id: string; token_hash: string; expires_at: string; created_at: string }, [string]>( 'SELECT id, user_id, token_hash, expires_at, created_at FROM refresh_tokens WHERE token_hash = ?', ) .get(tokenHash) if (!row) return null return { id: row.id, userId: row.user_id, tokenHash: row.token_hash, expiresAt: row.expires_at, createdAt: row.created_at } } export function deleteRefreshTokenByHash(db: Database, tokenHash: string): boolean { const result = db.run('DELETE FROM refresh_tokens WHERE token_hash = ?', [tokenHash]) return result.changes > 0 } ``` - [ ] **Step 4: Write failing tests in server/db.test.ts** Add a new `describe('users and refresh tokens', ...)` block at the end of the file: ```ts describe('users and refresh tokens', () => { it('creates a user and finds them by username', () => { const db = openDb(':memory:') const user = createUser(db, { username: 'alice', passwordHash: 'hash1', role: 'user' }) expect(user.username).toBe('alice') expect(user.role).toBe('user') expect(findUserByUsername(db, 'alice')).toEqual(user) }) it('returns null for unknown username', () => { const db = openDb(':memory:') expect(findUserByUsername(db, 'nobody')).toBeNull() }) it('finds user by id', () => { const db = openDb(':memory:') const user = createUser(db, { username: 'bob', passwordHash: 'hash2', role: 'admin' }) expect(findUserById(db, user.id)).toEqual(user) }) it('lists users ordered by creation time', () => { const db = openDb(':memory:') const a = createUser(db, { username: 'alice', passwordHash: 'h', role: 'user' }) const b = createUser(db, { username: 'bob', passwordHash: 'h', role: 'admin' }) const list = listUsers(db) expect(list.map((u) => u.id)).toEqual([a.id, b.id]) expect(list[0]).not.toHaveProperty('passwordHash') }) it('deletes a user and cascades to refresh tokens', () => { const db = openDb(':memory:') const user = createUser(db, { username: 'carol', passwordHash: 'h', role: 'user' }) createRefreshToken(db, { userId: user.id, tokenHash: 'hash123', expiresAt: '2099-01-01T00:00:00.000Z' }) expect(deleteUser(db, user.id)).toBe(true) expect(findUserByUsername(db, 'carol')).toBeNull() expect(findRefreshTokenByHash(db, 'hash123')).toBeNull() }) it('returns false when deleting missing user', () => { const db = openDb(':memory:') expect(deleteUser(db, 'missing')).toBe(false) }) it('creates and finds a refresh token by hash', () => { const db = openDb(':memory:') const user = createUser(db, { username: 'dave', passwordHash: 'h', role: 'user' }) const token = createRefreshToken(db, { userId: user.id, tokenHash: 'abc123', expiresAt: '2099-01-01T00:00:00.000Z' }) expect(findRefreshTokenByHash(db, 'abc123')).toEqual(token) }) it('deletes a refresh token by hash', () => { const db = openDb(':memory:') const user = createUser(db, { username: 'eve', passwordHash: 'h', role: 'user' }) createRefreshToken(db, { userId: user.id, tokenHash: 'xyz', expiresAt: '2099-01-01T00:00:00.000Z' }) expect(deleteRefreshTokenByHash(db, 'xyz')).toBe(true) expect(findRefreshTokenByHash(db, 'xyz')).toBeNull() }) }) ``` Also add the new imports at the top of the import line: ```ts import { createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData, createUser, findUserByUsername, findUserById, listUsers, deleteUser, createRefreshToken, findRefreshTokenByHash, deleteRefreshTokenByHash, } from './db' ``` - [ ] **Step 5: Run tests** ```bash bun test server/db.test.ts ``` Expected: all tests pass. - [ ] **Step 6: Commit** ```bash git add server/db.ts server/db.test.ts git commit -m "feat: add users and refresh_tokens tables and CRUD" ``` --- ## Task 2: Create server/auth.ts — pure crypto utilities **Files:** - Create: `server/auth.ts` - Create: `server/auth.test.ts` - [ ] **Step 1: Write failing tests in server/auth.test.ts** ```ts import { afterEach, describe, expect, it, setSystemTime } from 'bun:test' import { hashPassword, hashToken, signAccessToken, verifyAccessToken, verifyPassword } from './auth' afterEach(() => { setSystemTime() }) describe('auth utilities', () => { it('hashes and verifies a password', async () => { const hash = await hashPassword('secret123') expect(await verifyPassword('secret123', hash)).toBe(true) expect(await verifyPassword('wrong', hash)).toBe(false) }) it('signs and verifies an access token', async () => { const token = await signAccessToken('user1', 'admin', 'mysecret') const payload = await verifyAccessToken(token, 'mysecret') expect(payload).toEqual({ userId: 'user1', role: 'admin' }) }) it('returns null for an expired access token', async () => { setSystemTime(new Date('2026-01-01T00:00:00.000Z')) const token = await signAccessToken('user1', 'user', 'mysecret') setSystemTime(new Date('2026-01-01T00:16:00.000Z')) const payload = await verifyAccessToken(token, 'mysecret') expect(payload).toBeNull() }) it('returns null for an access token with wrong secret', async () => { const token = await signAccessToken('user1', 'user', 'mysecret') const payload = await verifyAccessToken(token, 'wrongsecret') expect(payload).toBeNull() }) it('hashToken is deterministic', () => { expect(hashToken('abc')).toBe(hashToken('abc')) expect(hashToken('abc')).not.toBe(hashToken('xyz')) }) }) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash bun test server/auth.test.ts ``` Expected: FAIL with "Cannot find module './auth'" - [ ] **Step 3: Create server/auth.ts** ```ts import { sign, verify } from 'hono/jwt' export interface AccessTokenPayload { userId: string role: string } export async function hashPassword(password: string): Promise { return Bun.password.hash(password) } export async function verifyPassword(password: string, hash: string): Promise { return Bun.password.verify(password, hash) } export async function signAccessToken( userId: string, role: string, secret: string, ): Promise { return sign( { userId, role, exp: Math.floor(Date.now() / 1000) + 15 * 60 }, secret, ) } export async function verifyAccessToken( token: string, secret: string, ): Promise { try { const payload = await verify(token, secret) as { userId: string; role: string } return { userId: payload.userId, role: payload.role } } catch { return null } } export function hashToken(token: string): string { const hasher = new Bun.CryptoHasher('sha256') hasher.update(token) return hasher.digest('hex') } ``` - [ ] **Step 4: Run tests** ```bash bun test server/auth.test.ts ``` Expected: all tests pass. - [ ] **Step 5: Commit** ```bash git add server/auth.ts server/auth.test.ts git commit -m "feat: add auth crypto utilities (password hash, JWT, token hash)" ``` --- ## Task 3: Create server/middleware/bearerAuth.ts **Files:** - Create: `server/middleware/bearerAuth.ts` - [ ] **Step 1: Create the middleware** ```ts import type { MiddlewareHandler } from 'hono' import { verifyAccessToken } from '../auth' export type AuthVariables = { userId: string; role: string } export function bearerAuth(secret: string): MiddlewareHandler<{ Variables: AuthVariables }> { return async (c, next) => { const auth = c.req.header('Authorization') if (!auth?.startsWith('Bearer ')) { return c.json({ error: '未授权' }, 401) } const token = auth.slice(7) const payload = await verifyAccessToken(token, secret) if (!payload) { return c.json({ error: '未授权' }, 401) } c.set('userId', payload.userId) c.set('role', payload.role) await next() } } ``` - [ ] **Step 2: Commit** ```bash git add server/middleware/bearerAuth.ts git commit -m "feat: add bearerAuth Hono middleware" ``` --- ## Task 4: Create server/routes/auth.ts + tests **Files:** - Create: `server/routes/auth.ts` - Create: `server/routes/auth.test.ts` - [ ] **Step 1: Write failing tests in server/routes/auth.test.ts** ```ts import { beforeEach, describe, expect, it } from 'bun:test' import type { Database } from 'bun:sqlite' import { Hono } from 'hono' import { hashPassword } from '../auth' import { createUser, openDb } from '../db' import { createAuthRouter } from './auth' const JWT_SECRET = 'test-secret' describe('auth routes', () => { let app: Hono let db: Database beforeEach(async () => { db = openDb(':memory:') const hash = await hashPassword('password123') createUser(db, { username: 'alice', passwordHash: hash, role: 'user' }) createUser(db, { username: 'admin', passwordHash: await hashPassword('adminpass'), role: 'admin' }) app = new Hono().route('/api/auth', createAuthRouter(db, JWT_SECRET)) }) it('returns 401 for wrong password', async () => { const res = await app.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'alice', password: 'wrong' }), }) expect(res.status).toBe(401) }) it('returns 401 for unknown user', async () => { const res = await app.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'nobody', password: 'password123' }), }) expect(res.status).toBe(401) }) it('logs in and returns access + refresh tokens', async () => { const res = await app.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'alice', password: 'password123' }), }) expect(res.status).toBe(200) const body = await res.json() as { accessToken: string; refreshToken: string; user: { username: string; role: string } } expect(typeof body.accessToken).toBe('string') expect(typeof body.refreshToken).toBe('string') expect(body.user.username).toBe('alice') expect(body.user.role).toBe('user') expect(body.user).not.toHaveProperty('passwordHash') }) it('returns user info via /me with valid token', async () => { const loginRes = await app.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'alice', password: 'password123' }), }) const { accessToken } = await loginRes.json() as { accessToken: string } const meRes = await app.request('/api/auth/me', { headers: { Authorization: `Bearer ${accessToken}` }, }) expect(meRes.status).toBe(200) const me = await meRes.json() as { username: string } expect(me.username).toBe('alice') }) it('returns 401 for /me without token', async () => { const res = await app.request('/api/auth/me') expect(res.status).toBe(401) }) it('refreshes access token using refresh token', async () => { const loginRes = await app.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'alice', password: 'password123' }), }) const { refreshToken } = await loginRes.json() as { refreshToken: string } const refreshRes = await app.request('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }) expect(refreshRes.status).toBe(200) const body = await refreshRes.json() as { accessToken: string } expect(typeof body.accessToken).toBe('string') }) it('returns 401 for unknown refresh token', async () => { const res = await app.request('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: 'bogus-token' }), }) expect(res.status).toBe(401) }) it('logs out by invalidating refresh token', async () => { const loginRes = await app.request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'alice', password: 'password123' }), }) const { refreshToken } = await loginRes.json() as { refreshToken: string } const logoutRes = await app.request('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }) expect(logoutRes.status).toBe(200) const refreshRes = await app.request('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }) expect(refreshRes.status).toBe(401) }) }) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash bun test server/routes/auth.test.ts ``` Expected: FAIL with "Cannot find module './auth'" - [ ] **Step 3: Create server/routes/auth.ts** ```ts import type { Database } from 'bun:sqlite' import { Hono } from 'hono' import { hashToken, signAccessToken, verifyPassword } from '../auth' import { createRefreshToken, deleteRefreshTokenByHash, findRefreshTokenByHash, findUserById, findUserByUsername, } from '../db' import { bearerAuth } from '../middleware/bearerAuth' function refreshExpiresAt(): string { const d = new Date() d.setDate(d.getDate() + 7) return d.toISOString() } export function createAuthRouter(db: Database, jwtSecret: string): Hono { const app = new Hono<{ Variables: { userId: string; role: string } }>() app.post('/login', async (c) => { const body = (await c.req.json().catch(() => null)) as { username?: unknown; password?: unknown } | null if (typeof body?.username !== 'string' || typeof body?.password !== 'string') { return c.json({ error: '请提供用户名和密码' }, 400) } const user = findUserByUsername(db, body.username) if (!user || !(await verifyPassword(body.password, user.passwordHash))) { return c.json({ error: '用户名或密码错误' }, 401) } const accessToken = await signAccessToken(user.id, user.role, jwtSecret) const rawRefresh = crypto.randomUUID() createRefreshToken(db, { userId: user.id, tokenHash: hashToken(rawRefresh), expiresAt: refreshExpiresAt(), }) return c.json({ accessToken, refreshToken: rawRefresh, user: { id: user.id, username: user.username, role: user.role }, }) }) app.post('/refresh', async (c) => { const body = (await c.req.json().catch(() => null)) as { refreshToken?: unknown } | null if (typeof body?.refreshToken !== 'string') { return c.json({ error: '请提供 refresh token' }, 400) } const record = findRefreshTokenByHash(db, hashToken(body.refreshToken)) if (!record || new Date(record.expiresAt) < new Date()) { return c.json({ error: '无效或已过期的 refresh token' }, 401) } const user = findUserById(db, record.userId) if (!user) return c.json({ error: '用户不存在' }, 401) const accessToken = await signAccessToken(user.id, user.role, jwtSecret) return c.json({ accessToken }) }) app.post('/logout', async (c) => { const body = (await c.req.json().catch(() => null)) as { refreshToken?: unknown } | null if (typeof body?.refreshToken === 'string') { deleteRefreshTokenByHash(db, hashToken(body.refreshToken)) } return c.json({ ok: true }) }) app.get('/me', bearerAuth(jwtSecret), (c) => { const user = findUserById(db, c.get('userId')) if (!user) return c.json({ error: '用户不存在' }, 404) return c.json({ id: user.id, username: user.username, role: user.role }) }) return app } ``` - [ ] **Step 4: Run tests** ```bash bun test server/routes/auth.test.ts ``` Expected: all tests pass. - [ ] **Step 5: Commit** ```bash git add server/routes/auth.ts server/routes/auth.test.ts git commit -m "feat: add auth routes (login, refresh, logout, me)" ``` --- ## Task 5: Create server/routes/admin.ts + tests **Files:** - Create: `server/routes/admin.ts` - Create: `server/routes/admin.test.ts` - [ ] **Step 1: Write failing tests in server/routes/admin.test.ts** ```ts import { beforeEach, describe, expect, it } from 'bun:test' import type { Database } from 'bun:sqlite' import { Hono } from 'hono' import { hashPassword, signAccessToken } from '../auth' import { createUser, openDb } from '../db' import { createAdminRouter } from './admin' const JWT_SECRET = 'test-secret' describe('admin routes', () => { let app: Hono let db: Database let adminToken: string let userToken: string let adminId: string let userId: string beforeEach(async () => { db = openDb(':memory:') const adminHash = await hashPassword('adminpass') const userHash = await hashPassword('userpass') const admin = createUser(db, { username: 'admin', passwordHash: adminHash, role: 'admin' }) const user = createUser(db, { username: 'alice', passwordHash: userHash, role: 'user' }) adminId = admin.id userId = user.id adminToken = await signAccessToken(admin.id, 'admin', JWT_SECRET) userToken = await signAccessToken(user.id, 'user', JWT_SECRET) app = new Hono().route('/api/admin', createAdminRouter(db, JWT_SECRET)) }) it('lists users for admin', async () => { const res = await app.request('/api/admin/users', { headers: { Authorization: `Bearer ${adminToken}` }, }) expect(res.status).toBe(200) const body = await res.json() as { id: string; username: string }[] expect(body.length).toBe(2) expect(body[0]).not.toHaveProperty('passwordHash') }) it('returns 401 for unauthenticated request', async () => { const res = await app.request('/api/admin/users') expect(res.status).toBe(401) }) it('returns 403 for non-admin user', async () => { const res = await app.request('/api/admin/users', { headers: { Authorization: `Bearer ${userToken}` }, }) expect(res.status).toBe(403) }) it('creates a new user', async () => { const res = await app.request('/api/admin/users', { method: 'POST', headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'bob', password: 'pass123', role: 'user' }), }) expect(res.status).toBe(200) const body = await res.json() as { username: string; role: string } expect(body.username).toBe('bob') expect(body.role).toBe('user') expect(body).not.toHaveProperty('passwordHash') }) it('returns 400 when creating user without required fields', async () => { const res = await app.request('/api/admin/users', { method: 'POST', headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'bob' }), }) expect(res.status).toBe(400) }) it('deletes a user', async () => { const res = await app.request(`/api/admin/users/${userId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${adminToken}` }, }) expect(res.status).toBe(200) }) it('prevents deleting yourself', async () => { const res = await app.request(`/api/admin/users/${adminId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${adminToken}` }, }) expect(res.status).toBe(400) }) it('returns 404 when deleting missing user', async () => { const res = await app.request('/api/admin/users/missing', { method: 'DELETE', headers: { Authorization: `Bearer ${adminToken}` }, }) expect(res.status).toBe(404) }) }) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash bun test server/routes/admin.test.ts ``` Expected: FAIL with "Cannot find module './admin'" - [ ] **Step 3: Create server/routes/admin.ts** ```ts import type { Database } from 'bun:sqlite' import { Hono } from 'hono' import { hashPassword } from '../auth' import { createUser, deleteUser, listUsers } from '../db' import { bearerAuth, type AuthVariables } from '../middleware/bearerAuth' export function createAdminRouter(db: Database, jwtSecret: string): Hono { const app = new Hono<{ Variables: AuthVariables }>() app.use('/*', bearerAuth(jwtSecret)) app.use('/*', async (c, next) => { if (c.get('role') !== 'admin') return c.json({ error: '无权限' }, 403) await next() }) app.get('/users', (c) => { return c.json(listUsers(db)) }) app.post('/users', async (c) => { const body = (await c.req.json().catch(() => null)) as { username?: unknown password?: unknown role?: unknown } | null if ( typeof body?.username !== 'string' || body.username.trim() === '' || typeof body?.password !== 'string' || body.password.length < 1 ) { return c.json({ error: '请提供用户名和密码' }, 400) } const role = body.role === 'admin' ? 'admin' : 'user' const passwordHash = await hashPassword(body.password) const user = createUser(db, { username: body.username.trim(), passwordHash, role }) return c.json({ id: user.id, username: user.username, role: user.role, createdAt: user.createdAt }) }) app.delete('/users/:id', (c) => { const targetId = c.req.param('id') if (targetId === c.get('userId')) { return c.json({ error: '不能删除自己的账号' }, 400) } if (!deleteUser(db, targetId)) { return c.json({ error: '用户不存在' }, 404) } return c.json({ ok: true }) }) return app } ``` - [ ] **Step 4: Run tests** ```bash bun test server/routes/admin.test.ts ``` Expected: all tests pass. - [ ] **Step 5: Commit** ```bash git add server/routes/admin.ts server/routes/admin.test.ts git commit -m "feat: add admin routes (list/create/delete users)" ``` --- ## Task 6: Wire server/index.ts — mount routes, protect existing endpoints, init admin **Files:** - Modify: `server/index.ts` - [ ] **Step 1: Replace server/index.ts** ```ts import { Hono } from 'hono' import { serveStatic } from 'hono/bun' import { hashPassword } from './auth' import { createUser, findUserByUsername, openDb } from './db' import { bearerAuth } from './middleware/bearerAuth' import { createAuthRouter } from './routes/auth' import { createAdminRouter } from './routes/admin' import { createBooksRouter } from './routes/books' import { createGenerateRouter } from './routes/generate' const db = openDb(process.env.TEACHING_BOOKS_DB ?? 'data/teaching-books.db') async function initAdmin(): Promise { const username = process.env.ADMIN_USERNAME const password = process.env.ADMIN_PASSWORD if (!username || !password) return if (findUserByUsername(db, username)) return const hash = await hashPassword(password) createUser(db, { username, passwordHash: hash, role: 'admin' }) console.log(`[auth] admin user "${username}" created`) } const jwtSecret = process.env.JWT_SECRET ?? '' export const app = new Hono() app.route('/api/auth', createAuthRouter(db, jwtSecret)) app.route('/api/admin', createAdminRouter(db, jwtSecret)) app.use('/api/books/*', bearerAuth(jwtSecret)) app.use('/api/generate/*', bearerAuth(jwtSecret)) app.route('/api/books', createBooksRouter(db)) app.route('/api/generate', createGenerateRouter(process.env.DEEPSEEK_API_KEY)) app.use('/*', serveStatic({ root: './dist' })) app.get('*', serveStatic({ path: './dist/index.html' })) if (import.meta.main) { await initAdmin() Bun.serve({ port: process.env.PORT ? Number(process.env.PORT) : 3001, fetch: app.fetch, }) } ``` - [ ] **Step 2: Verify existing server tests still pass** ```bash bun test server ``` Expected: all tests pass. Note — books.test.ts creates its own Hono app without auth middleware, so it is unaffected. - [ ] **Step 3: Update .env** Generate a secure JWT secret: ```bash openssl rand -base64 32 ``` Add to `.env`: ``` JWT_SECRET= ADMIN_USERNAME=admin ADMIN_PASSWORD= ``` - [ ] **Step 4: Commit** ```bash git add server/index.ts .env git commit -m "feat: wire auth middleware and init admin on startup" ``` --- ## Task 7: Create src/composables/useAuth.ts **Files:** - Create: `src/composables/useAuth.ts` - [ ] **Step 1: Create src/composables/useAuth.ts** ```ts import { computed, ref } from 'vue' export interface AuthUser { id: string username: string role: 'admin' | 'user' } export interface UserSummary extends AuthUser { createdAt: string } const accessToken = ref(localStorage.getItem('access_token')) const refreshToken = ref(localStorage.getItem('refresh_token')) const user = ref(null) export const isLoggedIn = computed(() => !!accessToken.value) function clearTokens(): void { accessToken.value = null refreshToken.value = null user.value = null localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') } async function doRefresh(): Promise { if (!refreshToken.value) return false const res = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: refreshToken.value }), }) if (!res.ok) { clearTokens() return false } const body = (await res.json()) as { accessToken: string } accessToken.value = body.accessToken localStorage.setItem('access_token', body.accessToken) return true } export async function authedFetch(path: string, init?: RequestInit): Promise { const headers: Record = { 'Content-Type': 'application/json', ...((init?.headers as Record) ?? {}), } if (accessToken.value) headers['Authorization'] = `Bearer ${accessToken.value}` let res = await fetch(path, { ...init, headers }) if (res.status === 401) { const refreshed = await doRefresh() if (!refreshed) throw new Error('未登录') if (accessToken.value) headers['Authorization'] = `Bearer ${accessToken.value}` res = await fetch(path, { ...init, headers }) } if (!res.ok) { const body = (await res.json().catch(() => null)) as { error?: string } | null throw new Error(body?.error ?? `请求失败(${res.status})`) } return res.json() as Promise } export function useAuth() { async function login(username: string, password: string): Promise { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }) if (!res.ok) { const body = (await res.json().catch(() => null)) as { error?: string } | null throw new Error(body?.error ?? '登录失败') } const body = (await res.json()) as { accessToken: string refreshToken: string user: AuthUser } accessToken.value = body.accessToken refreshToken.value = body.refreshToken user.value = body.user localStorage.setItem('access_token', body.accessToken) localStorage.setItem('refresh_token', body.refreshToken) } async function logout(): Promise { if (refreshToken.value) { await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: refreshToken.value }), }).catch(() => {}) } clearTokens() } async function fetchMe(): Promise { if (!accessToken.value) return try { user.value = await authedFetch('/api/auth/me') } catch { clearTokens() } } return { isLoggedIn, user, login, logout, fetchMe } } ``` - [ ] **Step 2: Commit** ```bash git add src/composables/useAuth.ts git commit -m "feat: add useAuth composable with singleton token state and authedFetch" ``` --- ## Task 8: Update src/services/booksApi.ts to use authedFetch **Files:** - Modify: `src/services/booksApi.ts` - [ ] **Step 1: Replace the file** Replace the entire `request` helper and all function bodies to use `authedFetch`: ```ts import type { TeachingBook } from '../domain/teachingDesign' import { authedFetch } from '../composables/useAuth' export interface BookSummary { id: string name: string updatedAt: string lessonCount: number } export interface BookRecord { id: string name: string updatedAt: string data: TeachingBook } export interface BookMeta { id: string name: string updatedAt: string } export interface GenerateResult { filename: string markdown: string } export function listBooks(): Promise { return authedFetch('/api/books') } export function createBook(name: string): Promise { return authedFetch('/api/books', { method: 'POST', body: JSON.stringify({ name }) }) } export function getBook(id: string): Promise { return authedFetch(`/api/books/${id}`) } export function updateBook(id: string, data: TeachingBook): Promise { return authedFetch(`/api/books/${id}`, { method: 'PUT', body: JSON.stringify({ data }) }) } export function renameBook(id: string, name: string): Promise { return authedFetch(`/api/books/${id}`, { method: 'PATCH', body: JSON.stringify({ name }) }) } export function deleteBook(id: string): Promise<{ ok: true }> { return authedFetch(`/api/books/${id}`, { method: 'DELETE' }) } export function generateLesson(topic: string): Promise { return authedFetch('/api/generate', { method: 'POST', body: JSON.stringify({ topic }) }) } export function generateOutline(theme: string): Promise<{ titles: string[] }> { return authedFetch('/api/generate/outline', { method: 'POST', body: JSON.stringify({ theme }) }) } ``` - [ ] **Step 2: Run frontend tests to catch type regressions** ```bash npm run test ``` Expected: all existing tests pass. (booksApi.ts tests mock fetch globally, so they may need updating — see note below.) > **Note:** `src/services/booksApi.test.ts` mocks `fetch` globally. After this change, `authedFetch` is called instead of the local `request` helper. The tests should still pass because `authedFetch` also calls `fetch` internally. If they fail with "Cannot read properties of undefined", check that the mock returns `{ ok: true, json: ... }` correctly. - [ ] **Step 3: Commit** ```bash git add src/services/booksApi.ts git commit -m "feat: use authedFetch in booksApi for auth-aware requests" ``` --- ## Task 9: Create src/components/LoginPage.vue **Files:** - Create: `src/components/LoginPage.vue` - [ ] **Step 1: Create src/components/LoginPage.vue** ```vue ``` - [ ] **Step 2: Commit** ```bash git add src/components/LoginPage.vue git commit -m "feat: add LoginPage component" ``` --- ## Task 10: Update src/App.vue with login gate and admin navigation **Files:** - Modify: `src/App.vue` - [ ] **Step 1: Replace src/App.vue** ```vue ``` - [ ] **Step 2: Commit** ```bash git add src/App.vue git commit -m "feat: add login gate and admin navigation to App.vue" ``` --- ## Task 11: Create src/components/AdminPage.vue + add admin button to BookListPage **Files:** - Create: `src/components/AdminPage.vue` - Modify: `src/components/BookListPage.vue` - [ ] **Step 1: Create src/components/AdminPage.vue** ```vue ``` - [ ] **Step 2: Add admin button to BookListPage.vue** Find the header/toolbar area of `src/components/BookListPage.vue` and add an admin button that only shows for admin users. The exact location depends on the existing template. Add the following at the top of the `