mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-03 05:22:49 +08:00
feat(auth): two-role permission system (admin/member)
Adds an admin/member role to WebUI auth. /api/users and /api/audit are now gated by a requireAdmin middleware; all other authenticated endpoints accept both roles. Schema migration defaults all existing users to admin to preserve access. POST /api/users defaults new users to member; first-run setup always creates an admin. Adds PATCH /api/users/:id/role with last-admin demotion and deletion guards. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b0b61f8fce
commit
a73f797bcb
18 files changed
+292
-60
No files matched your search
+47
-4
@@ -27,13 +27,14 @@ export function createUsersRouter(
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
const { username, password, role: roleInput } = req.body ?? {};
|
||||
if (!isValidUsername(username) || !isValidPassword(password)) {
|
||||
res.status(400).json({ error: "invalid username or password" });
|
||||
return;
|
||||
}
|
||||
const role: "admin" | "member" = roleInput === "admin" ? "admin" : "member";
|
||||
try {
|
||||
const u = await users.createUser(username, password);
|
||||
const u = await users.createUser(username, password, role);
|
||||
try {
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
@@ -43,8 +44,8 @@ export function createUsersRouter(
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "user.created" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ createdBy: req.user!.id, newUserId: u.id, username }, "User created");
|
||||
res.status(201).json({ id: u.id, username: u.username });
|
||||
logger.info({ createdBy: req.user!.id, newUserId: u.id, username, role }, "User created");
|
||||
res.status(201).json({ id: u.id, username: u.username, role: u.role });
|
||||
} catch (err) {
|
||||
if (err instanceof UsernameTakenError) {
|
||||
res.status(409).json({ error: "username taken" });
|
||||
@@ -66,6 +67,10 @@ export function createUsersRouter(
|
||||
res.status(400).json({ error: "cannot delete self" });
|
||||
return;
|
||||
}
|
||||
if (target.role === "admin" && users.countAdmins() <= 1) {
|
||||
res.status(400).json({ error: "cannot delete last admin" });
|
||||
return;
|
||||
}
|
||||
const deleted = users.deleteUser(targetId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: "not found" });
|
||||
@@ -116,5 +121,43 @@ export function createUsersRouter(
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.patch("/:id/role", (req, res) => {
|
||||
const targetId = req.params.id;
|
||||
const { role: newRole } = req.body ?? {};
|
||||
if (newRole !== "admin" && newRole !== "member") {
|
||||
res.status(400).json({ error: "invalid role" });
|
||||
return;
|
||||
}
|
||||
const target = users.findById(targetId);
|
||||
if (!target) {
|
||||
res.status(404).json({ error: "not found" });
|
||||
return;
|
||||
}
|
||||
if (target.role === newRole) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
if (target.role === "admin" && newRole === "member" && users.countAdmins() <= 1) {
|
||||
res.status(400).json({ error: "cannot demote last admin" });
|
||||
return;
|
||||
}
|
||||
const changed = users.setRole(targetId, newRole);
|
||||
if (!changed) {
|
||||
res.status(404).json({ error: "not found" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
audit.record({
|
||||
actorId: req.user!.id, actorUsername: req.user!.username,
|
||||
targetUserId: target.id, targetUsername: target.username,
|
||||
action: "user.role_changed",
|
||||
});
|
||||
} catch (auditErr) {
|
||||
logger.warn({ err: auditErr, action: "user.role_changed" }, "audit insert failed");
|
||||
}
|
||||
logger.info({ actorId: req.user!.id, targetId, newRole }, "User role changed");
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in new issue
Block a user