From 979a70439b3dd67de985d11e4455c8f0d9c7cdb2 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 16 Jun 2026 06:40:55 -0600 Subject: [PATCH] feat: add admin password reset --- .../plans/2026-06-16-admin-reset-password.md | 12 ++++++ server/db.test.ts | 29 ++++++++++++- server/db.ts | 10 +++++ server/routes/admin.test.ts | 41 ++++++++++++++++++- server/routes/admin.ts | 16 +++++++- src/components/AdminPage.test.ts | 37 ++++++++++++++++- src/components/AdminPage.vue | 23 +++++++++++ src/style.css | 6 +++ 8 files changed, 168 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-06-16-admin-reset-password.md b/docs/superpowers/plans/2026-06-16-admin-reset-password.md index 82d40fa..9e65313 100644 --- a/docs/superpowers/plans/2026-06-16-admin-reset-password.md +++ b/docs/superpowers/plans/2026-06-16-admin-reset-password.md @@ -226,6 +226,18 @@ In `src/components/AdminPage.test.ts`, update the import line: import { beforeEach, describe, expect, it, vi } from 'vitest' ``` +Update `beforeEach()` so one-shot mock responses from reset tests cannot leak into later tests: + +```ts + beforeEach(() => { + authedFetch.mockReset() + logout.mockReset() + authedFetch.mockResolvedValue([ + { id: 'u1', username: 'teacher', role: 'user', createdAt: '2026-01-01T00:00:00.000Z' }, + ]) + }) +``` + Append these tests inside `describe('AdminPage', () => { ... })`: ```ts diff --git a/server/db.test.ts b/server/db.test.ts index b7accf5..af7a381 100644 --- a/server/db.test.ts +++ b/server/db.test.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, setSystemTime } from 'bun:test' import { createEmptyBook, createEmptyTeachingDesign } from '../src/domain/teachingDesign' import { createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData, - createUser, findUserByUsername, findUserById, listUsers, deleteUser, - createRefreshToken, findRefreshTokenByHash, deleteRefreshTokenByHash, + createUser, findUserByUsername, findUserById, listUsers, deleteUser, updateUserPasswordHash, + createRefreshToken, findRefreshTokenByHash, deleteRefreshTokenByHash, deleteRefreshTokensForUser, } from './db' afterEach(() => { @@ -143,6 +143,19 @@ describe('users and refresh tokens', () => { expect(deleteUser(db, 'missing')).toBe(false) }) + it('updates a user password hash', () => { + const db = openDb(':memory:') + const user = createUser(db, { username: 'frank', passwordHash: 'old-hash', role: 'user' }) + + expect(updateUserPasswordHash(db, user.id, 'new-hash')).toBe(true) + expect(findUserById(db, user.id)?.passwordHash).toBe('new-hash') + }) + + it('returns false when updating password hash for missing user', () => { + const db = openDb(':memory:') + expect(updateUserPasswordHash(db, 'missing', 'new-hash')).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' }) @@ -159,4 +172,16 @@ describe('users and refresh tokens', () => { expect(deleteRefreshTokenByHash(db, 'xyz')).toBe(true) expect(findRefreshTokenByHash(db, 'xyz')).toBeNull() }) + + it('deletes refresh tokens for one user', () => { + const db = openDb(':memory:') + const first = createUser(db, { username: 'grace', passwordHash: 'h', role: 'user' }) + const second = createUser(db, { username: 'heidi', passwordHash: 'h', role: 'user' }) + createRefreshToken(db, { userId: first.id, tokenHash: 'first-token', expiresAt: '2099-01-01T00:00:00.000Z' }) + createRefreshToken(db, { userId: second.id, tokenHash: 'second-token', expiresAt: '2099-01-01T00:00:00.000Z' }) + + expect(deleteRefreshTokensForUser(db, first.id)).toBe(1) + expect(findRefreshTokenByHash(db, 'first-token')).toBeNull() + expect(findRefreshTokenByHash(db, 'second-token')).not.toBeNull() + }) }) diff --git a/server/db.ts b/server/db.ts index 4b3b070..60c75d1 100644 --- a/server/db.ts +++ b/server/db.ts @@ -206,6 +206,11 @@ export function deleteUser(db: Database, id: string): boolean { return result.changes > 0 } +export function updateUserPasswordHash(db: Database, id: string, passwordHash: string): boolean { + const result = db.run('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id]) + return result.changes > 0 +} + export function createRefreshToken( db: Database, params: { userId: string; tokenHash: string; expiresAt: string }, @@ -233,3 +238,8 @@ export function deleteRefreshTokenByHash(db: Database, tokenHash: string): boole const result = db.run('DELETE FROM refresh_tokens WHERE token_hash = ?', [tokenHash]) return result.changes > 0 } + +export function deleteRefreshTokensForUser(db: Database, userId: string): number { + const result = db.run('DELETE FROM refresh_tokens WHERE user_id = ?', [userId]) + return result.changes +} diff --git a/server/routes/admin.test.ts b/server/routes/admin.test.ts index af180c8..6f52485 100644 --- a/server/routes/admin.test.ts +++ b/server/routes/admin.test.ts @@ -1,8 +1,8 @@ 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 { hashPassword, signAccessToken, verifyPassword } from '../auth' +import { createRefreshToken, createUser, findRefreshTokenByHash, findUserById, openDb } from '../db' import { createAdminRouter } from './admin' const JWT_SECRET = 'test-secret' @@ -95,4 +95,41 @@ describe('admin routes', () => { }) expect(res.status).toBe(404) }) + + it('resets a user password to the fixed temporary password', async () => { + createRefreshToken(db, { userId, tokenHash: 'user-refresh-token', expiresAt: '2099-01-01T00:00:00.000Z' }) + + const res = await app.request(`/api/admin/users/${userId}/reset-password`, { + method: 'POST', + headers: { Authorization: `Bearer ${adminToken}` }, + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ ok: true }) + + const updated = findUserById(db, userId) + expect(updated).not.toBeNull() + expect(await verifyPassword('123456', updated!.passwordHash)).toBe(true) + expect(await verifyPassword('userpass', updated!.passwordHash)).toBe(false) + expect(findRefreshTokenByHash(db, 'user-refresh-token')).toBeNull() + }) + + it('returns 404 when resetting password for missing user', async () => { + const res = await app.request('/api/admin/users/missing/reset-password', { + method: 'POST', + headers: { Authorization: `Bearer ${adminToken}` }, + }) + + expect(res.status).toBe(404) + expect(await res.json()).toEqual({ error: '用户不存在' }) + }) + + it('returns 403 when non-admin resets a password', async () => { + const res = await app.request(`/api/admin/users/${adminId}/reset-password`, { + method: 'POST', + headers: { Authorization: `Bearer ${userToken}` }, + }) + + expect(res.status).toBe(403) + }) }) diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 69ca710..7b5253a 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -1,9 +1,11 @@ import type { Database } from 'bun:sqlite' import { Hono } from 'hono' import { hashPassword } from '../auth' -import { createUser, deleteUser, listUsers } from '../db' +import { createUser, deleteRefreshTokensForUser, deleteUser, findUserById, listUsers, updateUserPasswordHash } from '../db' import { bearerAuth, type AuthVariables } from '../middleware/bearerAuth' +const RESET_PASSWORD = '123456' + export function createAdminRouter(db: Database, jwtSecret: string) { const app = new Hono<{ Variables: AuthVariables }>() @@ -39,6 +41,18 @@ export function createAdminRouter(db: Database, jwtSecret: string) { return c.json({ id: user.id, username: user.username, role: user.role, createdAt: user.createdAt }) }) + app.post('/users/:id/reset-password', async (c) => { + const targetId = c.req.param('id') + if (!findUserById(db, targetId)) { + return c.json({ error: '用户不存在' }, 404) + } + + const passwordHash = await hashPassword(RESET_PASSWORD) + updateUserPasswordHash(db, targetId, passwordHash) + deleteRefreshTokensForUser(db, targetId) + return c.json({ ok: true }) + }) + app.delete('/users/:id', (c) => { const targetId = c.req.param('id') if (targetId === c.get('userId')) { diff --git a/src/components/AdminPage.test.ts b/src/components/AdminPage.test.ts index fe0ba11..673b33c 100644 --- a/src/components/AdminPage.test.ts +++ b/src/components/AdminPage.test.ts @@ -14,7 +14,8 @@ vi.mock('../composables/useAuth', () => ({ describe('AdminPage', () => { beforeEach(() => { - vi.clearAllMocks() + authedFetch.mockReset() + logout.mockReset() authedFetch.mockResolvedValue([ { id: 'u1', username: 'teacher', role: 'user', createdAt: '2026-01-01T00:00:00.000Z' }, ]) @@ -33,8 +34,42 @@ describe('AdminPage', () => { expect.arrayContaining(['ui-button', 'ui-button--primary']), ) expect(wrapper.get('table').classes()).toContain('ui-table') + expect(wrapper.get('button[data-testid="reset-password-u1"]').classes()).toContain('ui-button') expect(wrapper.get('button[data-testid="delete-user-u1"]').classes()).toEqual( expect.arrayContaining(['ui-button', 'ui-button--danger']), ) }) + + it('resets a user password after confirmation', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + authedFetch.mockResolvedValueOnce([ + { id: 'u1', username: 'teacher', role: 'user', createdAt: '2026-01-01T00:00:00.000Z' }, + ]) + authedFetch.mockResolvedValueOnce({ ok: true }) + + const wrapper = mount(AdminPage) + await flushPromises() + + await wrapper.get('button[data-testid="reset-password-u1"]').trigger('click') + await flushPromises() + + expect(window.confirm).toHaveBeenCalledWith('确定要将该用户密码重置为 123456 吗?') + expect(authedFetch).toHaveBeenCalledWith('/api/admin/users/u1/reset-password', { method: 'POST' }) + expect(wrapper.text()).toContain('已将密码重置为 123456。') + }) + + it('does not reset a password when confirmation is declined', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false) + authedFetch.mockResolvedValueOnce([ + { id: 'u1', username: 'teacher', role: 'user', createdAt: '2026-01-01T00:00:00.000Z' }, + ]) + + const wrapper = mount(AdminPage) + await flushPromises() + + await wrapper.get('button[data-testid="reset-password-u1"]').trigger('click') + await flushPromises() + + expect(authedFetch).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/components/AdminPage.vue b/src/components/AdminPage.vue index 94209a0..9f122ef 100644 --- a/src/components/AdminPage.vue +++ b/src/components/AdminPage.vue @@ -11,6 +11,7 @@ const newUsername = ref('') const newPassword = ref('') const newRole = ref<'user' | 'admin'>('user') const error = ref('') +const success = ref('') const loading = ref(false) async function loadUsers(): Promise { @@ -24,6 +25,7 @@ async function loadUsers(): Promise { async function createUser(): Promise { if (!newUsername.value.trim() || !newPassword.value) return error.value = '' + success.value = '' loading.value = true try { await authedFetch('/api/admin/users', { @@ -55,6 +57,18 @@ async function removeUser(id: string): Promise { } } +async function resetPassword(id: string): Promise { + if (!confirm('确定要将该用户密码重置为 123456 吗?')) return + error.value = '' + success.value = '' + try { + await authedFetch(`/api/admin/users/${id}/reset-password`, { method: 'POST' }) + success.value = '已将密码重置为 123456。' + } catch (e) { + error.value = e instanceof Error ? e.message : '重置失败' + } +} + async function handleLogout(): Promise { await logout() } @@ -96,6 +110,7 @@ onMounted(loadUsers)

{{ error }}

+

{{ success }}

@@ -115,6 +130,14 @@ onMounted(loadUsers) {{ u.role === 'admin' ? '管理员' : '普通用户' }} {{ new Date(u.createdAt).toLocaleDateString('zh-CN') }} +