feat: add admin password reset
This commit is contained in:
@@ -226,6 +226,18 @@ In `src/components/AdminPage.test.ts`, update the import line:
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
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', () => { ... })`:
|
Append these tests inside `describe('AdminPage', () => { ... })`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, setSystemTime } from 'bun:test'
|
|||||||
import { createEmptyBook, createEmptyTeachingDesign } from '../src/domain/teachingDesign'
|
import { createEmptyBook, createEmptyTeachingDesign } from '../src/domain/teachingDesign'
|
||||||
import {
|
import {
|
||||||
createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData,
|
createBook, deleteBook, getBook, listBooks, openDb, renameBook, saveBookData,
|
||||||
createUser, findUserByUsername, findUserById, listUsers, deleteUser,
|
createUser, findUserByUsername, findUserById, listUsers, deleteUser, updateUserPasswordHash,
|
||||||
createRefreshToken, findRefreshTokenByHash, deleteRefreshTokenByHash,
|
createRefreshToken, findRefreshTokenByHash, deleteRefreshTokenByHash, deleteRefreshTokensForUser,
|
||||||
} from './db'
|
} from './db'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -143,6 +143,19 @@ describe('users and refresh tokens', () => {
|
|||||||
expect(deleteUser(db, 'missing')).toBe(false)
|
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', () => {
|
it('creates and finds a refresh token by hash', () => {
|
||||||
const db = openDb(':memory:')
|
const db = openDb(':memory:')
|
||||||
const user = createUser(db, { username: 'dave', passwordHash: 'h', role: 'user' })
|
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(deleteRefreshTokenByHash(db, 'xyz')).toBe(true)
|
||||||
expect(findRefreshTokenByHash(db, 'xyz')).toBeNull()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
10
server/db.ts
10
server/db.ts
@@ -206,6 +206,11 @@ export function deleteUser(db: Database, id: string): boolean {
|
|||||||
return result.changes > 0
|
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(
|
export function createRefreshToken(
|
||||||
db: Database,
|
db: Database,
|
||||||
params: { userId: string; tokenHash: string; expiresAt: string },
|
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])
|
const result = db.run('DELETE FROM refresh_tokens WHERE token_hash = ?', [tokenHash])
|
||||||
return result.changes > 0
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'bun:test'
|
import { beforeEach, describe, expect, it } from 'bun:test'
|
||||||
import type { Database } from 'bun:sqlite'
|
import type { Database } from 'bun:sqlite'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { hashPassword, signAccessToken } from '../auth'
|
import { hashPassword, signAccessToken, verifyPassword } from '../auth'
|
||||||
import { createUser, openDb } from '../db'
|
import { createRefreshToken, createUser, findRefreshTokenByHash, findUserById, openDb } from '../db'
|
||||||
import { createAdminRouter } from './admin'
|
import { createAdminRouter } from './admin'
|
||||||
|
|
||||||
const JWT_SECRET = 'test-secret'
|
const JWT_SECRET = 'test-secret'
|
||||||
@@ -95,4 +95,41 @@ describe('admin routes', () => {
|
|||||||
})
|
})
|
||||||
expect(res.status).toBe(404)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Database } from 'bun:sqlite'
|
import type { Database } from 'bun:sqlite'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { hashPassword } from '../auth'
|
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'
|
import { bearerAuth, type AuthVariables } from '../middleware/bearerAuth'
|
||||||
|
|
||||||
|
const RESET_PASSWORD = '123456'
|
||||||
|
|
||||||
export function createAdminRouter(db: Database, jwtSecret: string) {
|
export function createAdminRouter(db: Database, jwtSecret: string) {
|
||||||
const app = new Hono<{ Variables: AuthVariables }>()
|
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 })
|
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) => {
|
app.delete('/users/:id', (c) => {
|
||||||
const targetId = c.req.param('id')
|
const targetId = c.req.param('id')
|
||||||
if (targetId === c.get('userId')) {
|
if (targetId === c.get('userId')) {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ vi.mock('../composables/useAuth', () => ({
|
|||||||
|
|
||||||
describe('AdminPage', () => {
|
describe('AdminPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
authedFetch.mockReset()
|
||||||
|
logout.mockReset()
|
||||||
authedFetch.mockResolvedValue([
|
authedFetch.mockResolvedValue([
|
||||||
{ id: 'u1', username: 'teacher', role: 'user', createdAt: '2026-01-01T00:00:00.000Z' },
|
{ 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.arrayContaining(['ui-button', 'ui-button--primary']),
|
||||||
)
|
)
|
||||||
expect(wrapper.get('table').classes()).toContain('ui-table')
|
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(wrapper.get('button[data-testid="delete-user-u1"]').classes()).toEqual(
|
||||||
expect.arrayContaining(['ui-button', 'ui-button--danger']),
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const newUsername = ref('')
|
|||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
const newRole = ref<'user' | 'admin'>('user')
|
const newRole = ref<'user' | 'admin'>('user')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
async function loadUsers(): Promise<void> {
|
async function loadUsers(): Promise<void> {
|
||||||
@@ -24,6 +25,7 @@ async function loadUsers(): Promise<void> {
|
|||||||
async function createUser(): Promise<void> {
|
async function createUser(): Promise<void> {
|
||||||
if (!newUsername.value.trim() || !newPassword.value) return
|
if (!newUsername.value.trim() || !newPassword.value) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await authedFetch('/api/admin/users', {
|
await authedFetch('/api/admin/users', {
|
||||||
@@ -55,6 +57,18 @@ async function removeUser(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetPassword(id: string): Promise<void> {
|
||||||
|
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<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
await logout()
|
await logout()
|
||||||
}
|
}
|
||||||
@@ -96,6 +110,7 @@ onMounted(loadUsers)
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="error" class="ui-error">{{ error }}</p>
|
<p v-if="error" class="ui-error">{{ error }}</p>
|
||||||
|
<p v-if="success" class="ui-success">{{ success }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="user-list">
|
<section class="user-list">
|
||||||
@@ -115,6 +130,14 @@ onMounted(loadUsers)
|
|||||||
<td>{{ u.role === 'admin' ? '管理员' : '普通用户' }}</td>
|
<td>{{ u.role === 'admin' ? '管理员' : '普通用户' }}</td>
|
||||||
<td>{{ new Date(u.createdAt).toLocaleDateString('zh-CN') }}</td>
|
<td>{{ new Date(u.createdAt).toLocaleDateString('zh-CN') }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
<button
|
||||||
|
class="ui-button"
|
||||||
|
type="button"
|
||||||
|
:data-testid="`reset-password-${u.id}`"
|
||||||
|
@click="resetPassword(u.id)"
|
||||||
|
>
|
||||||
|
重置密码
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ui-button ui-button--danger"
|
class="ui-button ui-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -162,6 +162,12 @@ input {
|
|||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-success {
|
||||||
|
color: var(--green-700);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Toolbar */
|
/* Toolbar */
|
||||||
.workspace-toolbar {
|
.workspace-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user