207 lines
5.8 KiB
TypeScript
207 lines
5.8 KiB
TypeScript
import { Router, type IRouter } from "express";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import bcrypt from "bcryptjs";
|
|
import { db, usersTable } from "@workspace/db";
|
|
import { requireAdmin } from "../middleware/auth";
|
|
import { writeAuditLog } from "../lib/audit";
|
|
import { passwordRateLimit } from "../lib/rate-limit";
|
|
import { z } from "zod";
|
|
|
|
const router: IRouter = Router();
|
|
|
|
const Tier = z.enum(["free", "premium", "enterprise"]);
|
|
|
|
const UserCreateSchema = z.object({
|
|
username: z.string().min(2),
|
|
password: z.string().min(6),
|
|
email: z.string().optional(),
|
|
role: z.enum(["admin", "user"]).optional().default("user"),
|
|
tier: Tier.optional().default("free"),
|
|
});
|
|
|
|
const UserUpdateSchema = z.object({
|
|
role: z.enum(["admin", "user"]).optional(),
|
|
tier: Tier.optional(),
|
|
});
|
|
|
|
const SetPasswordSchema = z.object({
|
|
password: z.string().min(6),
|
|
});
|
|
|
|
router.patch("/users/:id/password", requireAdmin, passwordRateLimit, async (req, res): Promise<void> => {
|
|
const id = parseInt(String(req.params.id), 10);
|
|
if (isNaN(id)) {
|
|
res.status(400).json({ error: "Invalid user id" });
|
|
return;
|
|
}
|
|
|
|
const parsed = SetPasswordSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.message });
|
|
return;
|
|
}
|
|
|
|
const [target] = await db
|
|
.select({ id: usersTable.id, authProvider: usersTable.authProvider, username: usersTable.username })
|
|
.from(usersTable)
|
|
.where(eq(usersTable.id, id))
|
|
.limit(1);
|
|
|
|
if (!target) {
|
|
res.status(404).json({ error: "User not found" });
|
|
return;
|
|
}
|
|
|
|
if (target.authProvider !== "local") {
|
|
res.status(422).json({ error: "oidc" });
|
|
return;
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
|
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, id));
|
|
|
|
await writeAuditLog(req, "user", id, "set_password", { username: target.username });
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
|
const users = await db
|
|
.select({
|
|
id: usersTable.id,
|
|
username: usersTable.username,
|
|
email: usersTable.email,
|
|
role: usersTable.role,
|
|
tier: usersTable.tier,
|
|
authProvider: usersTable.authProvider,
|
|
createdAt: usersTable.createdAt,
|
|
})
|
|
.from(usersTable)
|
|
.orderBy(usersTable.createdAt);
|
|
res.json(users);
|
|
});
|
|
|
|
router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
|
const parsed = UserCreateSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.message });
|
|
return;
|
|
}
|
|
|
|
const existing = await db
|
|
.select({ id: usersTable.id })
|
|
.from(usersTable)
|
|
.where(eq(usersTable.username, parsed.data.username))
|
|
.limit(1);
|
|
|
|
if (existing.length > 0) {
|
|
res.status(409).json({ error: "Username already exists" });
|
|
return;
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
|
const [user] = await db
|
|
.insert(usersTable)
|
|
.values({
|
|
username: parsed.data.username,
|
|
passwordHash,
|
|
email: parsed.data.email ?? null,
|
|
role: parsed.data.role ?? "user",
|
|
tier: parsed.data.tier ?? "free",
|
|
})
|
|
.returning({
|
|
id: usersTable.id,
|
|
username: usersTable.username,
|
|
email: usersTable.email,
|
|
role: usersTable.role,
|
|
tier: usersTable.tier,
|
|
createdAt: usersTable.createdAt,
|
|
});
|
|
|
|
await writeAuditLog(req, "user", user.id, "create", { username: user.username, role: user.role, tier: user.tier });
|
|
res.status(201).json(user);
|
|
});
|
|
|
|
router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
|
const id = parseInt(String(req.params.id), 10);
|
|
if (isNaN(id)) {
|
|
res.status(400).json({ error: "Invalid user id" });
|
|
return;
|
|
}
|
|
|
|
const parsed = UserUpdateSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: parsed.error.message });
|
|
return;
|
|
}
|
|
|
|
const [target] = await db
|
|
.select({ id: usersTable.id, role: usersTable.role })
|
|
.from(usersTable)
|
|
.where(eq(usersTable.id, id))
|
|
.limit(1);
|
|
|
|
if (!target) {
|
|
res.status(404).json({ error: "User not found" });
|
|
return;
|
|
}
|
|
|
|
if (target.role === "admin" && parsed.data.role === "user") {
|
|
if (req.session.user?.sub === String(id)) {
|
|
res.status(400).json({ error: "Cannot demote your own account" });
|
|
return;
|
|
}
|
|
const [{ count }] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(usersTable)
|
|
.where(eq(usersTable.role, "admin"));
|
|
if (count <= 1) {
|
|
res.status(400).json({ error: "Cannot demote the last admin" });
|
|
return;
|
|
}
|
|
}
|
|
|
|
const updateData: Record<string, unknown> = {};
|
|
if (parsed.data.role !== undefined) updateData.role = parsed.data.role;
|
|
if (parsed.data.tier !== undefined) updateData.tier = parsed.data.tier;
|
|
|
|
const [user] = await db
|
|
.update(usersTable)
|
|
.set(updateData)
|
|
.where(eq(usersTable.id, id))
|
|
.returning({
|
|
id: usersTable.id,
|
|
username: usersTable.username,
|
|
email: usersTable.email,
|
|
role: usersTable.role,
|
|
tier: usersTable.tier,
|
|
createdAt: usersTable.createdAt,
|
|
});
|
|
|
|
await writeAuditLog(req, "user", id, "update", updateData);
|
|
res.json(user);
|
|
});
|
|
|
|
router.delete("/users/:id", requireAdmin, async (req, res): Promise<void> => {
|
|
const id = parseInt(String(req.params.id), 10);
|
|
if (isNaN(id)) {
|
|
res.status(400).json({ error: "Invalid user id" });
|
|
return;
|
|
}
|
|
|
|
if (req.session.user?.sub === String(id)) {
|
|
res.status(400).json({ error: "Cannot delete your own account" });
|
|
return;
|
|
}
|
|
|
|
const [user] = await db.delete(usersTable).where(eq(usersTable.id, id)).returning();
|
|
if (!user) {
|
|
res.status(404).json({ error: "User not found" });
|
|
return;
|
|
}
|
|
|
|
await writeAuditLog(req, "user", id, "delete", { username: user.username });
|
|
res.sendStatus(204);
|
|
});
|
|
|
|
export default router;
|