feat(api): admin-users router with enriched roster + user creation
This commit is contained in:
@@ -5,6 +5,7 @@ import { me } from './routes/me';
|
|||||||
import { activitiesRoutes } from './routes/activities';
|
import { activitiesRoutes } from './routes/activities';
|
||||||
import { sessionsRoutes } from './routes/sessions';
|
import { sessionsRoutes } from './routes/sessions';
|
||||||
import { adminRoutes } from './routes/admin';
|
import { adminRoutes } from './routes/admin';
|
||||||
|
import { adminUsersRoutes } from './routes/admin-users';
|
||||||
import { auth } from './auth';
|
import { auth } from './auth';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
|
|
||||||
@@ -26,5 +27,6 @@ export function createApp(): Hono {
|
|||||||
app.route('/', activitiesRoutes);
|
app.route('/', activitiesRoutes);
|
||||||
app.route('/', sessionsRoutes);
|
app.route('/', sessionsRoutes);
|
||||||
app.route('/', adminRoutes);
|
app.route('/', adminRoutes);
|
||||||
|
app.route('/', adminUsersRoutes);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
56
apps/api/src/routes/admin-users.ts
Normal file
56
apps/api/src/routes/admin-users.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { asc, eq } from 'drizzle-orm';
|
||||||
|
import { CreateUserInput, type AdminUser, type Role } from '@solelog/shared';
|
||||||
|
import { db } from '../db/client';
|
||||||
|
import { session, user } from '../db/schema';
|
||||||
|
import { auth } from '../auth';
|
||||||
|
import { adminGuard } from '../lib/require-user';
|
||||||
|
|
||||||
|
export const adminUsersRoutes = new Hono();
|
||||||
|
|
||||||
|
adminUsersRoutes.use('/api/admin/users', adminGuard);
|
||||||
|
adminUsersRoutes.use('/api/admin/users/*', adminGuard);
|
||||||
|
|
||||||
|
type UserRow = typeof user.$inferSelect;
|
||||||
|
|
||||||
|
// Map a user row to the public list shape.
|
||||||
|
export function toListItem(row: UserRow): AdminUser {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
role: (row.role ?? 'worker') as Role,
|
||||||
|
status: row.banned ? 'inactive' : 'active',
|
||||||
|
created_at: new Date(row.createdAt).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enriched roster (replaces the old id/name/email version from admin.ts).
|
||||||
|
adminUsersRoutes.get('/api/admin/users', async (c) => {
|
||||||
|
const rows = await db.select().from(user).orderBy(asc(user.name));
|
||||||
|
return c.json(rows.map(toListItem));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a user. role/password validated by zod; password hashing via better-auth.
|
||||||
|
adminUsersRoutes.post('/api/admin/users', async (c) => {
|
||||||
|
const parsed = CreateUserInput.safeParse(await c.req.json().catch(() => null));
|
||||||
|
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
// better-auth types role as its built-in set; our 'worker' role is valid at runtime (same cast as seed.ts).
|
||||||
|
const createUser = auth.api.createUser as (args: {
|
||||||
|
body: { email: string; password: string; name: string; role: 'worker' | 'admin' };
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createUser({
|
||||||
|
body: { email: d.email, password: d.password, name: d.name, role: d.role },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Inputs are pre-validated, so the realistic failure is a duplicate email.
|
||||||
|
return c.json({ error: 'E-mailadres bestaat al.' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db.select().from(user).where(eq(user.email, d.email));
|
||||||
|
return c.json(toListItem(row));
|
||||||
|
});
|
||||||
@@ -105,15 +105,6 @@ adminRoutes.get('/api/admin/sessions/active', async (c) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Roster for the create-form worker picker. Direct DB read — no better-auth client dependency.
|
|
||||||
adminRoutes.get('/api/admin/users', async (c) => {
|
|
||||||
const rows = await db
|
|
||||||
.select({ id: user.id, name: user.name, email: user.email })
|
|
||||||
.from(user)
|
|
||||||
.orderBy(asc(user.name));
|
|
||||||
return c.json(rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.get('/api/admin/report', async (c) => {
|
adminRoutes.get('/api/admin/report', async (c) => {
|
||||||
const q = parseReportQuery(c);
|
const q = parseReportQuery(c);
|
||||||
if (!q) return c.json({ error: 'Invalid query' }, 400);
|
if (!q) return c.json({ error: 'Invalid query' }, 400);
|
||||||
|
|||||||
116
apps/api/test/admin-users.test.ts
Normal file
116
apps/api/test/admin-users.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { createApp } from '../src/app';
|
||||||
|
import { authToken, bearer } from './helpers';
|
||||||
|
|
||||||
|
const PASSWORD = 'sterk-wachtwoord-123';
|
||||||
|
|
||||||
|
async function signInStatus(app: Hono, email: string, password: string): Promise<number> {
|
||||||
|
const res = await app.request('/api/auth/sign-in/email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
return res.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/admin/users', () => {
|
||||||
|
it('401s without a token, 403s for a worker', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
expect((await app.request('/api/admin/users')).status).toBe(401);
|
||||||
|
const workerTok = await authToken(app, 'users-list-worker@example.com');
|
||||||
|
expect((await app.request('/api/admin/users', { headers: bearer(workerTok) })).status).toBe(
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns each user with role and status', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'users-list-admin@example.com', 'admin');
|
||||||
|
await authToken(app, 'users-list-w@example.com'); // a worker
|
||||||
|
|
||||||
|
const res = await app.request('/api/admin/users', { headers: bearer(adminTok) });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const users = await res.json();
|
||||||
|
const admin = users.find((u: { email: string }) => u.email === 'users-list-admin@example.com');
|
||||||
|
const worker = users.find((u: { email: string }) => u.email === 'users-list-w@example.com');
|
||||||
|
expect(admin.role).toBe('admin');
|
||||||
|
expect(admin.status).toBe('active');
|
||||||
|
expect(worker.role).toBe('worker');
|
||||||
|
expect(worker.status).toBe('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/admin/users', () => {
|
||||||
|
it('creates a user who can then sign in', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'users-create-admin@example.com', 'admin');
|
||||||
|
const res = await app.request('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'created-worker@example.com',
|
||||||
|
name: 'Nieuwe Werker',
|
||||||
|
password: PASSWORD,
|
||||||
|
role: 'worker',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const created = await res.json();
|
||||||
|
expect(created.email).toBe('created-worker@example.com');
|
||||||
|
expect(created.role).toBe('worker');
|
||||||
|
expect(created.status).toBe('active');
|
||||||
|
expect(await signInStatus(app, 'created-worker@example.com', PASSWORD)).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a duplicate email with 409', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'users-dup-admin@example.com', 'admin');
|
||||||
|
const body = JSON.stringify({
|
||||||
|
email: 'dup@example.com',
|
||||||
|
name: 'Dup',
|
||||||
|
password: PASSWORD,
|
||||||
|
role: 'worker',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body }))
|
||||||
|
.status,
|
||||||
|
).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body }))
|
||||||
|
.status,
|
||||||
|
).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a too-short password with 400', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'users-shortpw-admin@example.com', 'admin');
|
||||||
|
const res = await app.request('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'short@example.com',
|
||||||
|
name: 'S',
|
||||||
|
password: 'short',
|
||||||
|
role: 'worker',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403s for a worker', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const workerTok = await authToken(app, 'users-create-worker@example.com');
|
||||||
|
const res = await app.request('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: bearer(workerTok),
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'x@example.com',
|
||||||
|
name: 'X',
|
||||||
|
password: PASSWORD,
|
||||||
|
role: 'worker',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user