feat(auth): admin user management service, routes, and integration tests
This commit is contained in:
81
packages/backend/src/routes/admin-users.ts
Normal file
81
packages/backend/src/routes/admin-users.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Router } from 'express';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
adminUserUpdateSchema, inviteUserSchema,
|
||||
} from '@flashcard/shared';
|
||||
import type { Db } from '../db/client.js';
|
||||
import { users } from '../db/schema.js';
|
||||
import { listUsers, adminUpdateUser } from '../services/users.js';
|
||||
import { createAuthToken, invalidateTokensForUser } from '../services/auth/tokens.js';
|
||||
import { getMailer } from '../services/auth/email.js';
|
||||
import { inviteTemplate, passwordResetTemplate } from '../services/auth/templates.js';
|
||||
import { ApiError } from '../lib/errors.js';
|
||||
|
||||
const INVITE_TTL = 24 * 60 * 60;
|
||||
const RESET_TTL = 60 * 60;
|
||||
function appUrl() { return process.env.APP_URL ?? 'http://localhost:5173'; }
|
||||
|
||||
export function adminUsersRouter(db: Db): Router {
|
||||
const r = Router();
|
||||
|
||||
r.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||
const role = req.query.role === 'user' || req.query.role === 'sysadmin' ? req.query.role : undefined;
|
||||
const active = req.query.active === 'true' ? true : req.query.active === 'false' ? false : undefined;
|
||||
const limit = req.query.limit ? Number(req.query.limit) : undefined;
|
||||
const offset = req.query.offset ? Number(req.query.offset) : undefined;
|
||||
res.json(await listUsers(db, { q, role, active, limit, offset }));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
r.post('/invite', async (req, res, next) => {
|
||||
try {
|
||||
const input = inviteUserSchema.parse(req.body);
|
||||
const existing = db.select().from(users).where(eq(users.email, input.email)).get();
|
||||
if (existing) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use');
|
||||
const [u] = db.insert(users).values({
|
||||
email: input.email,
|
||||
displayName: input.email.split('@')[0]!,
|
||||
role: input.role,
|
||||
passwordHash: null,
|
||||
emailVerifiedAt: null,
|
||||
isActive: true,
|
||||
}).returning().all();
|
||||
const { plaintext } = await createAuthToken(db, u!.id, 'invite', INVITE_TTL);
|
||||
const inviter = req.user!;
|
||||
const tpl = inviteTemplate(appUrl(), plaintext, inviter.displayName);
|
||||
await getMailer().send(u!.email, tpl);
|
||||
res.status(201).json({ id: u!.id, email: u!.email, role: u!.role });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
r.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const input = adminUserUpdateSchema.parse(req.body);
|
||||
const actor = req.user!;
|
||||
const updated = await adminUpdateUser(db, actor.id, Number(req.params.id), input);
|
||||
res.json(updated);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
r.post('/:id/send-reset', async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const u = db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!u) throw ApiError.notFound('User');
|
||||
if (!u.emailVerifiedAt) {
|
||||
await invalidateTokensForUser(db, u.id, 'invite');
|
||||
const { plaintext } = await createAuthToken(db, u.id, 'invite', INVITE_TTL);
|
||||
await getMailer().send(u.email, inviteTemplate(appUrl(), plaintext, req.user!.displayName));
|
||||
} else {
|
||||
await invalidateTokensForUser(db, u.id, 'password_reset');
|
||||
const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL);
|
||||
await getMailer().send(u.email, passwordResetTemplate(appUrl(), plaintext, u.displayName));
|
||||
}
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
64
packages/backend/src/services/users.ts
Normal file
64
packages/backend/src/services/users.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { and, asc, eq, ne, or, sql } from 'drizzle-orm';
|
||||
import type { Db } from '../db/client.js';
|
||||
import { users } from '../db/schema.js';
|
||||
import type { Role, User } from '@flashcard/shared';
|
||||
import { ApiError } from '../lib/errors.js';
|
||||
|
||||
function rowToUser(r: typeof users.$inferSelect): User {
|
||||
return {
|
||||
id: r.id, email: r.email, displayName: r.displayName, role: r.role,
|
||||
isActive: r.isActive, emailVerifiedAt: r.emailVerifiedAt ?? null,
|
||||
pendingEmail: r.pendingEmail ?? null, createdAt: r.createdAt, updatedAt: r.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListUsersParams { q?: string; role?: Role; active?: boolean; limit?: number; offset?: number; }
|
||||
export interface ListUsersResult { rows: User[]; total: number; }
|
||||
|
||||
export async function listUsers(db: Db, p: ListUsersParams = {}): Promise<ListUsersResult> {
|
||||
const conditions = [] as ReturnType<typeof eq>[];
|
||||
if (p.q && p.q.trim() !== '') {
|
||||
const q = `%${p.q.toLowerCase()}%`;
|
||||
conditions.push(or(sql`lower(${users.email}) like ${q}`, sql`lower(${users.displayName}) like ${q}`)!);
|
||||
}
|
||||
if (p.role) conditions.push(eq(users.role, p.role));
|
||||
if (p.active !== undefined) conditions.push(eq(users.isActive, p.active));
|
||||
const where = conditions.length ? and(...conditions) : undefined;
|
||||
const limit = Math.min(200, p.limit ?? 50);
|
||||
const offset = Math.max(0, p.offset ?? 0);
|
||||
|
||||
const rows = db.select().from(users)
|
||||
.where(where as any)
|
||||
.orderBy(asc(users.email))
|
||||
.limit(limit).offset(offset).all();
|
||||
const totalRow = db.select({ c: sql<number>`count(*)`.as('c') }).from(users).where(where as any).get();
|
||||
return { rows: rows.map(rowToUser), total: Number(totalRow?.c ?? 0) };
|
||||
}
|
||||
|
||||
export async function adminUpdateUser(
|
||||
db: Db,
|
||||
actorId: number,
|
||||
id: number,
|
||||
updates: { displayName?: string; role?: Role; isActive?: boolean }
|
||||
): Promise<User> {
|
||||
const target = db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!target) throw ApiError.notFound('User');
|
||||
|
||||
if (target.id === actorId && (updates.isActive === false || updates.role === 'user')) {
|
||||
const otherActiveSysadmins = db.select({ c: sql<number>`count(*)`.as('c') })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, 'sysadmin'), eq(users.isActive, true), ne(users.id, actorId)))
|
||||
.get()?.c ?? 0;
|
||||
if (Number(otherActiveSysadmins) === 0) {
|
||||
throw new ApiError(409, 'LAST_SYSADMIN', 'Cannot demote or disable the last active sysadmin');
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = db.update(users).set({
|
||||
...(updates.displayName !== undefined && { displayName: updates.displayName }),
|
||||
...(updates.role !== undefined && { role: updates.role }),
|
||||
...(updates.isActive !== undefined && { isActive: updates.isActive }),
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
}).where(eq(users.id, id)).returning().all();
|
||||
return rowToUser(row!);
|
||||
}
|
||||
96
packages/backend/src/tests/admin-users.integration.test.ts
Normal file
96
packages/backend/src/tests/admin-users.integration.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../app.js';
|
||||
import { makeTestDb, createUserDirect } from './dbHelper.js';
|
||||
import { hashPassword } from '../services/auth/passwords.js';
|
||||
import { setMailerForTests, type Mailer } from '../services/auth/email.js';
|
||||
|
||||
class CaptureMailer implements Mailer {
|
||||
sent: { to: string; subject: string; text: string }[] = [];
|
||||
async send(to: string, m: { subject: string; html: string; text: string }) {
|
||||
this.sent.push({ to, subject: m.subject, text: m.text });
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAs(app: ReturnType<typeof createApp>, email: string, password = 'secretpass') {
|
||||
const r = await request(app).post('/api/auth/login').send({ email, password });
|
||||
if (r.status !== 200) throw new Error(`login failed: ${r.status} ${JSON.stringify(r.body)}`);
|
||||
const cookies = (r.headers['set-cookie'] as unknown as string[]);
|
||||
const csrf = cookies.find((c) => c.startsWith('flashcard_csrf='))!.split(';')[0]!.split('=')[1]!;
|
||||
return { cookies, csrf };
|
||||
}
|
||||
|
||||
async function makeAdmin(env: ReturnType<typeof makeTestDb>, email: string) {
|
||||
return createUserDirect(env.db, {
|
||||
email, role: 'sysadmin', isActive: true,
|
||||
passwordHash: await hashPassword('secretpass'),
|
||||
emailVerifiedAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
let env: ReturnType<typeof makeTestDb>;
|
||||
let mailer: CaptureMailer;
|
||||
let app: ReturnType<typeof createApp>;
|
||||
|
||||
beforeEach(async () => {
|
||||
env = makeTestDb();
|
||||
mailer = new CaptureMailer();
|
||||
setMailerForTests(mailer);
|
||||
app = createApp(env.db);
|
||||
});
|
||||
|
||||
describe('admin users', () => {
|
||||
it('invites a user, target accepts and can log in', async () => {
|
||||
await makeAdmin(env, 'admin@example.com');
|
||||
const { cookies, csrf } = await loginAs(app, 'admin@example.com');
|
||||
|
||||
const inv = await request(app).post('/api/admin/users/invite')
|
||||
.set('Cookie', cookies).set('x-csrf-token', csrf)
|
||||
.send({ email: 'newbie@example.com', role: 'user' });
|
||||
expect(inv.status).toBe(201);
|
||||
expect(mailer.sent).toHaveLength(1);
|
||||
const link = mailer.sent[0]!.text;
|
||||
const token = decodeURIComponent(link.match(/token=([^\s&"]+)/)![1]!);
|
||||
|
||||
const accept = await request(app).post('/api/auth/accept-invite')
|
||||
.send({ token, displayName: 'Newbie', password: 'anotherpass' });
|
||||
expect(accept.status).toBe(200);
|
||||
expect(accept.body.email).toBe('newbie@example.com');
|
||||
});
|
||||
|
||||
it('lists users with filtering', async () => {
|
||||
await makeAdmin(env, 'admin@example.com');
|
||||
await createUserDirect(env.db, { email: 'a@example.com', role: 'user' });
|
||||
await createUserDirect(env.db, { email: 'b@example.com', role: 'user' });
|
||||
const { cookies } = await loginAs(app, 'admin@example.com');
|
||||
const list = await request(app).get('/api/admin/users?q=a').set('Cookie', cookies);
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body.rows.find((u: { email: string }) => u.email === 'a@example.com')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('blocks demoting the last sysadmin', async () => {
|
||||
const admin = await makeAdmin(env, 'admin@example.com');
|
||||
const { cookies, csrf } = await loginAs(app, 'admin@example.com');
|
||||
const r = await request(app).patch(`/api/admin/users/${admin.id}`)
|
||||
.set('Cookie', cookies).set('x-csrf-token', csrf)
|
||||
.send({ role: 'user' });
|
||||
expect(r.status).toBe(409);
|
||||
expect(r.body.error.code).toBe('LAST_SYSADMIN');
|
||||
});
|
||||
|
||||
it('rejects non-admin access to admin routes', async () => {
|
||||
await createUserDirect(env.db, {
|
||||
email: 'plain@example.com', role: 'user', isActive: true,
|
||||
passwordHash: await hashPassword('secretpass'),
|
||||
emailVerifiedAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
const { cookies } = await loginAs(app, 'plain@example.com');
|
||||
const r = await request(app).get('/api/admin/users').set('Cookie', cookies);
|
||||
expect(r.status).toBe(403);
|
||||
});
|
||||
|
||||
it('blocks unauthenticated access to admin routes', async () => {
|
||||
const r = await request(app).get('/api/admin/users');
|
||||
expect(r.status).toBe(401);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user