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:
saopig1andClaude Opus 4.7 committed 2026-05-27 15:35:15 +08:00
1 parent b0b61f8fce
commit a73f797bcb
18 files changed
+292 -60

No files matched your search

+67 -2
View File
@@ -36,10 +36,10 @@ describe("users router", () => {
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
app = makeApp(botDb, users, sessions);
const alice = await users.createUser("alice", "pw-alice");
const alice = await users.createUser("alice", "pw-alice", "admin");
aliceId = alice.id;
aliceCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(alice.id).token}`;
const bob = await users.createUser("bob", "pw-bob-bob");
const bob = await users.createUser("bob", "pw-bob-bob", "member");
bobId = bob.id;
});
@@ -189,4 +189,69 @@ describe("users router", () => {
// Bob's session should be dead
expect(sessions.validateAndTouch(bobToken)).toBeNull();
});
it("POST / defaults new user to role=member when role omitted", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "carol", password: "pw-carol-pw" });
expect(res.status).toBe(201);
expect(res.body.role).toBe("member");
});
it("POST / accepts role=admin", async () => {
const res = await request(app)
.post("/api/users")
.set("Cookie", aliceCookie)
.send({ username: "carol", password: "pw-carol-pw", role: "admin" });
expect(res.status).toBe(201);
expect(res.body.role).toBe("admin");
expect(users.countAdmins()).toBe(2);
});
it("PATCH /:id/role can change role between admin and member", async () => {
const res = await request(app)
.patch(`/api/users/${bobId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "admin" });
expect(res.status).toBe(204);
expect(users.findById(bobId)!.role).toBe("admin");
});
it("PATCH /:id/role blocks demoting the last admin", async () => {
// alice is the only admin. Demoting her would leave 0 admins. Block.
const res = await request(app)
.patch(`/api/users/${aliceId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "member" });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "cannot demote last admin" });
});
it("PATCH /:id/role allows demoting an admin when other admins exist", async () => {
// Promote bob first
users.setRole(bobId, "admin");
// Now both are admins. Demoting alice should work.
const res = await request(app)
.patch(`/api/users/${aliceId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "member" });
expect(res.status).toBe(204);
});
it("PATCH /:id/role 400 on invalid role", async () => {
const res = await request(app)
.patch(`/api/users/${bobId}/role`)
.set("Cookie", aliceCookie)
.send({ role: "superuser" });
expect(res.status).toBe(400);
});
it("PATCH /:id/role 404 on unknown user", async () => {
const res = await request(app)
.patch(`/api/users/not-a-real-id/role`)
.set("Cookie", aliceCookie)
.send({ role: "admin" });
expect(res.status).toBe(404);
});
});