Files
tool-evaluator/artifacts/api-server/src/routes/users.ts
T
opencode 743b177c89
Build & Push Docker Image / build (push) Successful in 2m21s
fix(auth): password min length 6 + show/hide toggle on password inputs
2026-08-03 08:01:52 +02:00

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;