Skip to content

Commit 9f1f2b3

Browse files
committed
Add moderator role model and resource sharing access controls
1 parent 61e53fd commit 9f1f2b3

18 files changed

Lines changed: 617 additions & 159 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
PRAGMA foreign_keys = OFF;
2+
3+
ALTER TABLE users ADD COLUMN is_moderator INTEGER NOT NULL DEFAULT 0;
4+
5+
-- Legacy visibility default for existing resources: treat as shared.
6+
UPDATE sites SET visibility = 'public_write' WHERE visibility = 'private';
7+
UPDATE simulations SET visibility = 'public_write' WHERE visibility = 'private';
8+
9+
PRAGMA foreign_keys = ON;

db/schema.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS users (
1616
avatar_bytes INTEGER,
1717
avatar_content_type TEXT,
1818
is_admin INTEGER NOT NULL DEFAULT 0,
19+
is_moderator INTEGER NOT NULL DEFAULT 0,
1920
is_approved INTEGER NOT NULL DEFAULT 0,
2021
approved_at TEXT,
2122
approved_by_user_id TEXT,

docs/BACKLOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ State: stabilization pass (no net-new product features unless explicitly approve
2424
- [x] What does the reject button currently do?
2525
- [x] Show profile pictures to other users when you click open the user popover. Also show a small one next to any user name in the UI
2626
- [x] User should be able to select if e-mail should be visible to everyone or only admins
27-
- [ ] access rights on sites and simulations. public, public-read only, private
27+
- [x] access rights on sites and simulations. private, public, shared (+ collaborator edit grants)
2828
- [ ] explore if the route option actually makes sense anymore
2929
- [ ] explore if we can take buildings into account
3030

@@ -58,6 +58,8 @@ State: stabilization pass (no net-new product features unless explicitly approve
5858
- Progress: added in-app metadata repair utility for created/last-edited backfill from ownership/change history.
5959
- Progress: added admin ownership operations endpoint/UI (single + bulk reassign) with in-app audit view.
6060
- [x] Add user moderation actions and review queue ergonomics
61+
- Progress: role model expanded to `Admin/Moderator/User/Pending`; user role changes now use dropdown role assignment (with moderator constraints).
62+
- Progress: pending approvals/notifications now target moderator/admin reviewers.
6163
- [x] Add simulation/site ownership repair tools in UI
6264
- Progress: ownership-related display gaps now repaired via metadata repair + fallback mapping; explicit owner reassignment UI now available for admins.
6365
- [x] Add admin-safe bulk operations with confirmations and logs

functions/_lib/access.test.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
canAssignRole,
34
canDeleteUserAccount,
45
canListUsers,
5-
canUpdateUserApproval,
6-
canUpdateUserRole,
6+
canSetPendingOrUser,
77
deriveAccountState,
8+
deriveUserRole,
89
type AccessUserLike,
910
} from "./access";
1011

1112
const makeUser = (patch: Partial<AccessUserLike>): AccessUserLike => ({
1213
id: patch.id ?? "u1",
1314
isAdmin: patch.isAdmin ?? false,
15+
isModerator: patch.isModerator ?? false,
1416
isApproved: patch.isApproved ?? false,
1517
approvedAt: patch.approvedAt ?? null,
1618
approvedByUserId: patch.approvedByUserId ?? null,
@@ -21,29 +23,40 @@ describe("access matrix", () => {
2123
expect(deriveAccountState(makeUser({ isApproved: false, approvedAt: null }))).toBe("pending");
2224
expect(deriveAccountState(makeUser({ isApproved: true }))).toBe("approved");
2325
expect(deriveAccountState(makeUser({ isAdmin: true, isApproved: false }))).toBe("approved");
26+
expect(deriveAccountState(makeUser({ isModerator: true, isApproved: false }))).toBe("approved");
2427
expect(deriveAccountState(makeUser({ isApproved: false, approvedAt: "2026-01-01T00:00:00Z" }))).toBe(
2528
"revoked",
2629
);
2730
});
2831

29-
it("enforces admin-only list and disallows self-moderation", () => {
32+
it("enforces admin/moderator list and role assignment limits", () => {
3033
const admin = makeUser({ id: "admin", isAdmin: true, isApproved: true });
34+
const moderator = makeUser({ id: "mod", isModerator: true, isApproved: true });
3135
const normal = makeUser({ id: "user", isAdmin: false, isApproved: true });
36+
const pending = makeUser({ id: "pending", isApproved: false });
3237

3338
expect(canListUsers(admin)).toBe(true);
39+
expect(canListUsers(moderator)).toBe(true);
3440
expect(canListUsers(normal)).toBe(false);
3541

36-
expect(canUpdateUserRole(admin, "user")).toBe(true);
37-
expect(canUpdateUserRole(admin, "admin")).toBe(false);
38-
expect(canUpdateUserRole(normal, "admin")).toBe(false);
42+
expect(deriveUserRole(admin)).toBe("admin");
43+
expect(deriveUserRole(moderator)).toBe("moderator");
44+
expect(deriveUserRole(normal)).toBe("user");
45+
expect(deriveUserRole(pending)).toBe("pending");
3946

40-
expect(canUpdateUserApproval(admin, "user")).toBe(true);
41-
expect(canUpdateUserApproval(admin, "admin")).toBe(false);
42-
expect(canUpdateUserApproval(normal, "admin")).toBe(false);
47+
expect(canAssignRole(admin, normal, "moderator")).toBe(true);
48+
expect(canAssignRole(admin, admin, "user")).toBe(false);
49+
expect(canAssignRole(moderator, normal, "pending")).toBe(true);
50+
expect(canAssignRole(moderator, normal, "admin")).toBe(false);
51+
expect(canAssignRole(moderator, admin, "pending")).toBe(false);
52+
expect(canAssignRole(normal, pending, "user")).toBe(false);
53+
54+
expect(canSetPendingOrUser(admin, normal)).toBe(true);
55+
expect(canSetPendingOrUser(moderator, normal)).toBe(true);
56+
expect(canSetPendingOrUser(moderator, admin)).toBe(false);
4357

4458
expect(canDeleteUserAccount(admin, "user")).toBe(true);
4559
expect(canDeleteUserAccount(admin, "admin")).toBe(false);
4660
expect(canDeleteUserAccount(normal, "admin")).toBe(false);
4761
});
4862
});
49-

functions/_lib/access.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,58 @@
11
export type AccessUserLike = {
22
id: string;
33
isAdmin: boolean;
4+
isModerator?: boolean;
45
isApproved: boolean;
56
approvedAt?: string | null;
67
approvedByUserId?: string | null;
78
};
89

910
export type AccountState = "pending" | "approved" | "revoked";
11+
export type UserRole = "admin" | "moderator" | "user" | "pending";
1012

1113
export const deriveAccountState = (user: AccessUserLike): AccountState => {
12-
if (user.isAdmin || user.isApproved) return "approved";
14+
if (user.isAdmin || user.isModerator || user.isApproved) return "approved";
1315
if (user.approvedAt || user.approvedByUserId) return "revoked";
1416
return "pending";
1517
};
1618

17-
export const canListUsers = (user: AccessUserLike): boolean => user.isAdmin;
19+
export const deriveUserRole = (user: AccessUserLike): UserRole => {
20+
if (user.isAdmin) return "admin";
21+
if (user.isModerator) return "moderator";
22+
if (user.isApproved) return "user";
23+
return "pending";
24+
};
25+
26+
export const canListUsers = (user: AccessUserLike): boolean => user.isAdmin || Boolean(user.isModerator);
27+
28+
const canModeratePendingOrUser = (actor: AccessUserLike, target: AccessUserLike): boolean => {
29+
if (actor.id === target.id) return false;
30+
if (actor.isAdmin) return true;
31+
if (!actor.isModerator) return false;
32+
if (target.isAdmin || target.isModerator) return false;
33+
return true;
34+
};
1835

1936
export const canUpdateUserRole = (actor: AccessUserLike, targetUserId: string): boolean =>
2037
actor.isAdmin && actor.id !== targetUserId;
2138

2239
export const canUpdateUserApproval = (actor: AccessUserLike, targetUserId: string): boolean =>
23-
actor.isAdmin && actor.id !== targetUserId;
40+
(actor.isAdmin || Boolean(actor.isModerator)) && actor.id !== targetUserId;
41+
42+
export const canAssignRole = (
43+
actor: AccessUserLike,
44+
target: AccessUserLike,
45+
nextRole: UserRole,
46+
): boolean => {
47+
if (actor.id === target.id) return false;
48+
if (actor.isAdmin) return true;
49+
if (!actor.isModerator) return false;
50+
if (target.isAdmin || target.isModerator) return false;
51+
return nextRole === "pending" || nextRole === "user";
52+
};
53+
54+
export const canSetPendingOrUser = (actor: AccessUserLike, target: AccessUserLike): boolean =>
55+
canModeratePendingOrUser(actor, target);
2456

2557
export const canDeleteUserAccount = (actor: AccessUserLike, targetUserId: string): boolean =>
2658
actor.isAdmin && actor.id !== targetUserId;
27-

functions/_lib/db.identity.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ const makeCandidate = (patch: Partial<Candidate>): Candidate => ({
1212
idp_email: patch.idp_email ?? null,
1313
idp_email_verified: patch.idp_email_verified ?? 0,
1414
avatar_url: patch.avatar_url ?? null,
15+
email_public: patch.email_public ?? 1,
16+
avatar_object_key: patch.avatar_object_key ?? null,
17+
avatar_thumb_key: patch.avatar_thumb_key ?? null,
18+
avatar_hash: patch.avatar_hash ?? null,
19+
avatar_bytes: patch.avatar_bytes ?? null,
20+
avatar_content_type: patch.avatar_content_type ?? null,
1521
is_admin: patch.is_admin ?? 0,
22+
is_moderator: patch.is_moderator ?? 0,
1623
is_approved: patch.is_approved ?? 0,
1724
approved_at: patch.approved_at ?? null,
1825
approved_by_user_id: patch.approved_by_user_id ?? null,

0 commit comments

Comments
 (0)