feat: admin user management routes
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
|||||||
verifyPassword,
|
verifyPassword,
|
||||||
createUser,
|
createUser,
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
|
getUserById,
|
||||||
createSession,
|
createSession,
|
||||||
getUserBySession,
|
getUserBySession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
@@ -93,6 +94,8 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (!user || !ok) {
|
if (!user || !ok) {
|
||||||
return reply.code(401).send({ error: 'invalid_credentials' })
|
return reply.code(401).send({ error: 'invalid_credentials' })
|
||||||
}
|
}
|
||||||
|
const oldToken = request.cookies[COOKIE_NAME]
|
||||||
|
if (oldToken) deleteSession(request.server.db, oldToken)
|
||||||
const token = createSession(request.server.db, user.id)
|
const token = createSession(request.server.db, user.id)
|
||||||
return reply.setCookie(COOKIE_NAME, token, cookieOpts()).code(200).send({ user: toPublicUser(user) })
|
return reply.setCookie(COOKIE_NAME, token, cookieOpts()).code(200).send({ user: toPublicUser(user) })
|
||||||
})
|
})
|
||||||
@@ -106,4 +109,37 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
app.get('/api/me', { preHandler: [requireAuth] }, async (request) => {
|
app.get('/api/me', { preHandler: [requireAuth] }, async (request) => {
|
||||||
return { user: toPublicUser(request.user as UserRow) }
|
return { user: toPublicUser(request.user as UserRow) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/users', { preHandler: [requireAuth, requireAdmin] }, async (request) => {
|
||||||
|
const users = request.server.db.prepare('SELECT * FROM users ORDER BY id').all() as UserRow[]
|
||||||
|
return { users: users.map(toPublicUser) }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/users', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
|
||||||
|
const { username, password } = (request.body ?? {}) as { username?: string; password?: string }
|
||||||
|
const invalid = validateCredentials(username, password)
|
||||||
|
if (invalid) return reply.code(400).send({ error: 'invalid_input', detail: invalid })
|
||||||
|
if (getUserByUsername(request.server.db, username as string)) {
|
||||||
|
return reply.code(409).send({ error: 'username_taken' })
|
||||||
|
}
|
||||||
|
const user = createUser(request.server.db, username as string, await hashPassword(password as string), false)
|
||||||
|
request.server.db.prepare('INSERT INTO settings (user_id) VALUES (?)').run(user.id)
|
||||||
|
return reply.code(200).send(toPublicUser(user))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete(
|
||||||
|
'/api/users/:id',
|
||||||
|
{ preHandler: [requireAuth, requireAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const id = Number((request.params as { id: string }).id)
|
||||||
|
if (id === (request.user as UserRow).id) {
|
||||||
|
return reply.code(400).send({ error: 'cannot_delete_self' })
|
||||||
|
}
|
||||||
|
if (!getUserById(request.server.db, id)) {
|
||||||
|
return reply.code(404).send({ error: 'not_found' })
|
||||||
|
}
|
||||||
|
request.server.db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||||
|
return reply.code(200).send({ ok: true })
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { buildTestApp, setupAdmin, getCookie, auth } from './helpers.js'
|
import { buildTestApp, setupAdmin, getCookie, auth, loginAs } from './helpers.js'
|
||||||
|
|
||||||
describe('auth routes', () => {
|
describe('auth routes', () => {
|
||||||
it('setup creates admin when no users exist, then is closed', async () => {
|
it('setup creates admin when no users exist, then is closed', async () => {
|
||||||
@@ -72,3 +72,73 @@ describe('auth routes', () => {
|
|||||||
await app.close()
|
await app.close()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('user admin', () => {
|
||||||
|
async function adminApp() {
|
||||||
|
const app = await buildTestApp()
|
||||||
|
const cookie = await setupAdmin(app)
|
||||||
|
return { app, cookie }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('admin creates a user and lists users', async () => {
|
||||||
|
const { app, cookie } = await adminApp()
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/users',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { username: 'bob', password: 'bobpass123' },
|
||||||
|
})
|
||||||
|
expect(created.statusCode).toBe(200)
|
||||||
|
expect(created.json()).toEqual({ id: 2, username: 'bob', isAdmin: false })
|
||||||
|
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) })
|
||||||
|
expect(list.json().users.map((u: { username: string }) => u.username)).toEqual(['admin', 'bob'])
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-admin cannot list or create users', async () => {
|
||||||
|
const { app, cookie } = await adminApp()
|
||||||
|
const bobCookie = await loginAs(app, cookie, 'bob', 'bobpass123')
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(bobCookie) })
|
||||||
|
expect(list.statusCode).toBe(403)
|
||||||
|
const create = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/users',
|
||||||
|
...auth(bobCookie),
|
||||||
|
payload: { username: 'eve', password: 'evepass123' },
|
||||||
|
})
|
||||||
|
expect(create.statusCode).toBe(403)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot delete self; deleting another user works and cascades settings', async () => {
|
||||||
|
const { app, cookie } = await adminApp()
|
||||||
|
await loginAs(app, cookie, 'bob', 'bobpass123')
|
||||||
|
const selfDelete = await app.inject({ method: 'DELETE', url: '/api/users/1', ...auth(cookie) })
|
||||||
|
expect(selfDelete.statusCode).toBe(400)
|
||||||
|
|
||||||
|
const del = await app.inject({ method: 'DELETE', url: '/api/users/2', ...auth(cookie) })
|
||||||
|
expect(del.statusCode).toBe(200)
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/users', ...auth(cookie) })
|
||||||
|
expect(list.json().users).toHaveLength(1)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects duplicate username', async () => {
|
||||||
|
const { app, cookie } = await adminApp()
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/users',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { username: 'bob', password: 'bobpass123' },
|
||||||
|
})
|
||||||
|
const again = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/users',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { username: 'bob', password: 'otherpass123' },
|
||||||
|
})
|
||||||
|
expect(again.statusCode).toBe(409)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user