Files
teaching-design/docs/superpowers/plans/2026-06-15-auth-system.md

47 KiB
Raw Permalink Blame History

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:

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:

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:

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:

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:

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:

import {
  createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData,
  createUser, findUserByUsername, findUserById, listUsers, deleteUser,
  createRefreshToken, findRefreshTokenByHash, deleteRefreshTokenByHash,
} from './db'
  • Step 5: Run tests
bun test server/db.test.ts

Expected: all tests pass.

  • Step 6: Commit
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

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
bun test server/auth.test.ts

Expected: FAIL with "Cannot find module './auth'"

  • Step 3: Create server/auth.ts
import { sign, verify } from 'hono/jwt'

export interface AccessTokenPayload {
  userId: string
  role: string
}

export async function hashPassword(password: string): Promise<string> {
  return Bun.password.hash(password)
}

export async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return Bun.password.verify(password, hash)
}

export async function signAccessToken(
  userId: string,
  role: string,
  secret: string,
): Promise<string> {
  return sign(
    { userId, role, exp: Math.floor(Date.now() / 1000) + 15 * 60 },
    secret,
  )
}

export async function verifyAccessToken(
  token: string,
  secret: string,
): Promise<AccessTokenPayload | null> {
  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
bun test server/auth.test.ts

Expected: all tests pass.

  • Step 5: Commit
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

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
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

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
bun test server/routes/auth.test.ts

Expected: FAIL with "Cannot find module './auth'"

  • Step 3: Create server/routes/auth.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
bun test server/routes/auth.test.ts

Expected: all tests pass.

  • Step 5: Commit
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

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
bun test server/routes/admin.test.ts

Expected: FAIL with "Cannot find module './admin'"

  • Step 3: Create server/routes/admin.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
bun test server/routes/admin.test.ts

Expected: all tests pass.

  • Step 5: Commit
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

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<void> {
  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
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:

openssl rand -base64 32

Add to .env:

JWT_SECRET=<output from above command>
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<choose a strong password>
  • Step 4: Commit
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

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<string | null>(localStorage.getItem('access_token'))
const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'))
const user = ref<AuthUser | null>(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<boolean> {
  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<T>(path: string, init?: RequestInit): Promise<T> {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    ...((init?.headers as Record<string, string>) ?? {}),
  }
  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<T>
}

export function useAuth() {
  async function login(username: string, password: string): Promise<void> {
    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<void> {
    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<void> {
    if (!accessToken.value) return
    try {
      user.value = await authedFetch<AuthUser>('/api/auth/me')
    } catch {
      clearTokens()
    }
  }

  return { isLoggedIn, user, login, logout, fetchMe }
}
  • Step 2: Commit
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:

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<BookSummary[]> {
  return authedFetch('/api/books')
}

export function createBook(name: string): Promise<BookRecord> {
  return authedFetch('/api/books', { method: 'POST', body: JSON.stringify({ name }) })
}

export function getBook(id: string): Promise<BookRecord> {
  return authedFetch(`/api/books/${id}`)
}

export function updateBook(id: string, data: TeachingBook): Promise<BookMeta> {
  return authedFetch(`/api/books/${id}`, { method: 'PUT', body: JSON.stringify({ data }) })
}

export function renameBook(id: string, name: string): Promise<BookMeta> {
  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<GenerateResult> {
  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
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
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

<script setup lang="ts">
import { ref } from 'vue'
import { useAuth } from '../composables/useAuth'

const emit = defineEmits<{ success: [] }>()

const { login } = useAuth()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)

async function handleSubmit(): Promise<void> {
  if (!username.value.trim() || !password.value) return
  error.value = ''
  loading.value = true
  try {
    await login(username.value.trim(), password.value)
    emit('success')
  } catch (e) {
    error.value = e instanceof Error ? e.message : '登录失败'
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <div class="login-wrapper">
    <form class="login-form" @submit.prevent="handleSubmit">
      <h1>教学设计</h1>
      <div class="field">
        <label for="username">用户名</label>
        <input
          id="username"
          v-model="username"
          type="text"
          autocomplete="username"
          :disabled="loading"
        />
      </div>
      <div class="field">
        <label for="password">密码</label>
        <input
          id="password"
          v-model="password"
          type="password"
          autocomplete="current-password"
          :disabled="loading"
        />
      </div>
      <p v-if="error" class="error">{{ error }}</p>
      <button type="submit" :disabled="loading || !username || !password">
        {{ loading ? '登录中…' : '登录' }}
      </button>
    </form>
  </div>
</template>

<style scoped>
.login-wrapper {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  background: #f5f5f5;
}

.login-form {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  width: 320px;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.login-form h1 {
  margin: 0;
  font-size: 1.5rem;
  text-align: center;
}

.field {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

.field label {
  font-size: 0.875rem;
  color: #555;
}

.field input {
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 1rem;
}

.field input:disabled {
  background: #f5f5f5;
}

.error {
  color: #c0392b;
  font-size: 0.875rem;
  margin: 0;
}

button[type='submit'] {
  padding: 0.6rem;
  background: #2c3e50;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
}

button[type='submit']:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>
  • Step 2: Commit
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

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AdminPage from './components/AdminPage.vue'
import BookListPage from './components/BookListPage.vue'
import LoginPage from './components/LoginPage.vue'
import WorkspaceView from './components/WorkspaceView.vue'
import { useAuth } from './composables/useAuth'

const { isLoggedIn, fetchMe } = useAuth()
const currentBookId = ref<string | null>(null)
const showAdmin = ref(false)

onMounted(async () => {
  await fetchMe()
})

function openBook(id: string): void {
  currentBookId.value = id
  showAdmin.value = false
}

function backToList(): void {
  currentBookId.value = null
}

function openAdmin(): void {
  showAdmin.value = true
  currentBookId.value = null
}

function closeAdmin(): void {
  showAdmin.value = false
}
</script>

<template>
  <LoginPage v-if="!isLoggedIn" @success="fetchMe" />
  <template v-else>
    <AdminPage v-if="showAdmin" @back="closeAdmin" />
    <WorkspaceView
      v-else-if="currentBookId"
      :key="currentBookId"
      :book-id="currentBookId"
      @back="backToList"
    />
    <BookListPage v-else @open="openBook" @admin="openAdmin" />
  </template>
</template>
  • Step 2: Commit
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

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { authedFetch } from '../composables/useAuth'
import { authedFetch, useAuth } from '../composables/useAuth'
import type { UserSummary } from '../composables/useAuth'

const emit = defineEmits<{ back: [] }>()

const { logout } = useAuth()
const users = ref<UserSummary[]>([])
const newUsername = ref('')
const newPassword = ref('')
const newRole = ref<'user' | 'admin'>('user')
const error = ref('')
const loading = ref(false)

async function loadUsers(): Promise<void> {
  users.value = await authedFetch<UserSummary[]>('/api/admin/users')
}

async function createUser(): Promise<void> {
  if (!newUsername.value.trim() || !newPassword.value) return
  error.value = ''
  loading.value = true
  try {
    await authedFetch('/api/admin/users', {
      method: 'POST',
      body: JSON.stringify({
        username: newUsername.value.trim(),
        password: newPassword.value,
        role: newRole.value,
      }),
    })
    newUsername.value = ''
    newPassword.value = ''
    newRole.value = 'user'
    await loadUsers()
  } catch (e) {
    error.value = e instanceof Error ? e.message : '创建失败'
  } finally {
    loading.value = false
  }
}

async function removeUser(id: string): Promise<void> {
  if (!confirm('确定要删除该用户吗?')) return
  try {
    await authedFetch(`/api/admin/users/${id}`, { method: 'DELETE' })
    await loadUsers()
  } catch (e) {
    error.value = e instanceof Error ? e.message : '删除失败'
  }
}

async function handleLogout(): Promise<void> {
  await logout()
}

onMounted(loadUsers)
</script>

<template>
  <div class="admin-page">
    <header>
      <button @click="emit('back')"> 返回</button>
      <h1>用户管理</h1>
      <button @click="handleLogout">退出登录</button>
    </header>

    <section class="create-user">
      <h2>新建用户</h2>
      <form @submit.prevent="createUser">
        <input v-model="newUsername" placeholder="用户名" :disabled="loading" />
        <input v-model="newPassword" type="password" placeholder="密码" :disabled="loading" />
        <select v-model="newRole" :disabled="loading">
          <option value="user">普通用户</option>
          <option value="admin">管理员</option>
        </select>
        <button type="submit" :disabled="loading || !newUsername || !newPassword">创建</button>
      </form>
      <p v-if="error" class="error">{{ error }}</p>
    </section>

    <section class="user-list">
      <h2>所有用户</h2>
      <table>
        <thead>
          <tr>
            <th>用户名</th>
            <th>角色</th>
            <th>创建时间</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="u in users" :key="u.id">
            <td>{{ u.username }}</td>
            <td>{{ u.role === 'admin' ? '管理员' : '普通用户' }}</td>
            <td>{{ new Date(u.createdAt).toLocaleDateString('zh-CN') }}</td>
            <td>
              <button @click="removeUser(u.id)">删除</button>
            </td>
          </tr>
        </tbody>
      </table>
    </section>
  </div>
</template>

<style scoped>
.admin-page {
  padding: 1.5rem;
  max-width: 800px;
  margin: 0 auto;
}

header {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin-bottom: 2rem;
}

header h1 {
  flex: 1;
  margin: 0;
}

.create-user form {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  align-items: center;
}

.create-user input,
.create-user select {
  padding: 0.4rem 0.6rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.error {
  color: #c0392b;
  font-size: 0.875rem;
}

table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 0.5rem;
}

th, td {
  text-align: left;
  padding: 0.5rem;
  border-bottom: 1px solid #eee;
}

th {
  font-weight: 600;
  color: #555;
}

button {
  padding: 0.3rem 0.7rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
  background: white;
}

button:hover {
  background: #f5f5f5;
}
</style>
  • 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 <script setup>:

import { useAuth } from '../composables/useAuth'

const { user, logout } = useAuth()
const emit = defineEmits<{ open: [id: string]; admin: [] }>()

(If emit is already defined, just add admin: [] to its type and add the useAuth import.)

Then in the template header area, add:

<button v-if="user?.role === 'admin'" @click="emit('admin')">用户管理</button>
<button @click="logout">退出登录</button>

Note: Read BookListPage.vue first to find the exact location of the existing header/toolbar, then insert the buttons in the appropriate place.

  • Step 3: Run all tests
npm run test && bun test server

Expected: all tests pass.

  • Step 4: Commit
git add src/components/AdminPage.vue src/components/BookListPage.vue
git commit -m "feat: add AdminPage and admin button in BookListPage"

Task 12: Verify end-to-end in browser

Files: none

  • Step 1: Start dev server and backend

Terminal 1:

npm run dev

Terminal 2:

bun run server:dev
  • Step 2: Verify login gate

Open http://localhost:5173. Confirm the app shows the login form, not the book list.

  • Step 3: Verify login flow

Log in with the admin credentials from .env. Confirm the book list appears.

  • Step 4: Verify protected API

Without logging in (open an incognito window), try to access http://localhost:5173. Confirm login form is shown and GET /api/books returns 401.

  • Step 5: Verify admin panel

Log in as admin. Click "用户管理". Create a new user. Log out. Log in as the new user. Confirm the new user can see books but the "用户管理" button is not visible.

  • Step 6: Verify token refresh

After logging in, wait 15+ minutes (or set the system clock forward). Perform an action (e.g., rename a book). Confirm it succeeds via automatic token refresh.

  • Step 7: Final commit
git add .
git commit -m "feat: complete auth system — login gate, JWT tokens, admin panel"