Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Signup control** - Admins can set the instance policy to open, individual approval, or no signups, and approve/reject pending signups from the admin panel
- **Deactivate & delete account** - Temporarily deactivate your account (auto-reactivates on next sign-in) or permanently delete it from a new Danger Zone section
- **Deleted User tombstones** - Deleted accounts show as "Deleted User" and their user ID is permanently reserved so it can never be reused
- **Server-scoped moderation workspace** - The Moderation panel now works per server: pick a server and channel from the sidebar to review its messages. Anyone with the Manage Messages permission (or a global moderator) can soft-delete/restore, and server owners/admins (or global admins) can permanently delete. The redundant Moderation tab inside the server admin panel was replaced with a link into this workspace

### ⚙️ Improvements

Expand Down
151 changes: 128 additions & 23 deletions apps/web/src/__tests__/moderation-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,38 @@ env.APPWRITE_ENDPOINT = "http://localhost";
env.APPWRITE_PROJECT_ID = "test-project";
env.APPWRITE_API_KEY = "test-api-key";

vi.mock("../lib/appwrite-roles", () => ({ getUserRoles: vi.fn() }));
vi.mock("../lib/appwrite-audit", () => ({ recordAudit: vi.fn() }));
vi.mock("../lib/appwrite-admin", () => ({
adminSoftDeleteMessage: vi.fn(),
adminRestoreMessage: vi.fn(),
adminDeleteMessage: vi.fn(),
getAdminMessageAuditContext: vi.fn(),
}));
vi.mock("../lib/server-channel-access", () => ({
getServerPermissionsForUser: vi.fn(),
}));
vi.mock("../lib/appwrite-core", () => ({
getEnvConfig: vi.fn().mockReturnValue({
project: "test-project",
databaseId: "db",
collections: {
servers: "servers",
channels: "channels",
messages: "messages",
},
}),
}));
vi.mock("../lib/appwrite-server", () => ({
getServerClient: vi.fn().mockReturnValue({ databases: {}, client: {} }),
}));
vi.mock("next/headers", () => ({
cookies: async () => ({ get: () => ({ value: "session" }) }),
}));

// Mock auth-server helper
vi.mock("../lib/auth-server", () => ({
requireModerator: vi.fn(),
requireAuth: vi.fn(),
checkUserRoles: vi.fn(),
}));

// Mock Appwrite SDK for getServerSession
Expand Down Expand Up @@ -54,30 +71,52 @@ vi.mock("appwrite", () => {
return mod;
});

const { getUserRoles } = await import("../lib/appwrite-roles");
const {
adminSoftDeleteMessage,
adminRestoreMessage,
adminDeleteMessage,
getAdminMessageAuditContext,
} = await import("../lib/appwrite-admin");
const { recordAudit } = await import("../lib/appwrite-audit");
const { requireModerator } = await import("../lib/auth-server");
const { getServerPermissionsForUser } = await import(
"../lib/server-channel-access"
);
const { requireAuth, checkUserRoles } = await import("../lib/auth-server");

function setRole(mod: boolean, admin: boolean) {
(getUserRoles as any).mockResolvedValue({
function setGlobalRoles(mod: boolean, admin: boolean) {
(requireAuth as any).mockResolvedValue({
$id: "moderatorUser",
name: "Mod",
email: "mod@example.com",
});
(checkUserRoles as any).mockResolvedValue({
isModerator: mod,
isAdmin: admin,
});
(requireModerator as any).mockResolvedValue({
user: { $id: "moderatorUser", name: "Mod", email: "mod@example.com" },
roles: { isModerator: mod, isAdmin: admin },
}

function setServerAccess(access: {
isServerOwner?: boolean;
manageMessages?: boolean;
administrator?: boolean;
}) {
(getServerPermissionsForUser as any).mockResolvedValue({
serverId: "server-1",
isServerOwner: access.isServerOwner ?? false,
isMember: true,
permissions: {
manageMessages: access.manageMessages ?? false,
administrator: access.administrator ?? false,
},
roleIds: [],
roles: [],
});
}

beforeEach(async () => {
vi.clearAllMocks();
setRole(true, true);
setGlobalRoles(true, true);
setServerAccess({ manageMessages: true });
(getAdminMessageAuditContext as any).mockResolvedValue({
$id: "m1",
userId: "author-1",
Expand All @@ -88,7 +127,7 @@ beforeEach(async () => {
});

describe("moderation actions", () => {
it("soft delete records audit + metrics", async () => {
it("soft delete records audit + metrics for global admin", async () => {
await actionSoftDelete("m1");
expect(adminSoftDeleteMessage).toHaveBeenCalledWith(
"m1",
Expand All @@ -105,26 +144,61 @@ describe("moderation actions", () => {
}),
);
});

it("soft delete allowed for server moderator with manageMessages", async () => {
setGlobalRoles(false, false);
setServerAccess({ manageMessages: true });
await actionSoftDelete("m2");
expect(adminSoftDeleteMessage).toHaveBeenCalledWith(
"m2",
"moderatorUser",
);
});

it("soft delete allowed for server owner", async () => {
setGlobalRoles(false, false);
setServerAccess({ isServerOwner: true });
await actionSoftDelete("m3");
expect(adminSoftDeleteMessage).toHaveBeenCalledWith(
"m3",
"moderatorUser",
);
});

it("soft delete forbidden without manageMessages or global role", async () => {
setGlobalRoles(false, false);
setServerAccess({});
await expect(actionSoftDelete("m4")).rejects.toThrow("Forbidden");
expect(adminSoftDeleteMessage).not.toHaveBeenCalled();
});

it("soft delete allowed for global moderator even without server perms", async () => {
setGlobalRoles(true, false);
setServerAccess({});
await actionSoftDelete("m5");
expect(adminSoftDeleteMessage).toHaveBeenCalled();
});

it("restore records audit", async () => {
await actionRestore("m2");
expect(adminRestoreMessage).toHaveBeenCalledWith("m2");
await actionRestore("m6");
expect(adminRestoreMessage).toHaveBeenCalledWith("m6");
expect(recordAudit).toHaveBeenCalledWith(
"restore",
"m2",
"m6",
"moderatorUser",
expect.objectContaining({
serverId: "server-1",
targetUserId: "author-1",
}),
);
});
it("hard delete requires admin", async () => {
setRole(true, true);
await actionHardDelete("m3");
expect(adminDeleteMessage).toHaveBeenCalledWith("m3");

it("hard delete allowed for global admin", async () => {
await actionHardDelete("m7");
expect(adminDeleteMessage).toHaveBeenCalledWith("m7");
expect(recordAudit).toHaveBeenCalledWith(
"hard_delete",
"m3",
"m7",
"moderatorUser",
expect.objectContaining({
serverId: "server-1",
Expand All @@ -135,8 +209,39 @@ describe("moderation actions", () => {
}),
);
});
it("hard delete forbidden for non-admin", async () => {
setRole(true, false);
await expect(actionHardDelete("m4")).rejects.toThrow("Forbidden");

it("hard delete allowed for server administrator role", async () => {
setGlobalRoles(false, false);
setServerAccess({ administrator: true, manageMessages: true });
await actionHardDelete("m8");
expect(adminDeleteMessage).toHaveBeenCalledWith("m8");
});
});

it("hard delete allowed for server owner", async () => {
setGlobalRoles(false, false);
setServerAccess({ isServerOwner: true });
await actionHardDelete("m9");
expect(adminDeleteMessage).toHaveBeenCalledWith("m9");
});

it("hard delete forbidden for non-admin server moderator", async () => {
setGlobalRoles(false, false);
setServerAccess({ manageMessages: true });
await expect(actionHardDelete("m10")).rejects.toThrow("Forbidden");
expect(adminDeleteMessage).not.toHaveBeenCalled();
});

it("hard delete forbidden for global moderator without admin", async () => {
setGlobalRoles(true, false);
setServerAccess({ isServerOwner: false, administrator: false });
await expect(actionHardDelete("m11")).rejects.toThrow("Forbidden");
expect(adminDeleteMessage).not.toHaveBeenCalled();
});

it("throws if the message does not exist", async () => {
(getAdminMessageAuditContext as any).mockResolvedValue(null);
await expect(actionSoftDelete("m12")).rejects.toThrow(
"Message not found",
);
});
});
13 changes: 10 additions & 3 deletions apps/web/src/app/moderation/ModerationMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Props = {
initialMessages: ModerationMessage[];
badgeMap: Record<string, string[]>;
isAdmin: boolean;
channelId?: string;
};

function isOptionalString(value: unknown): value is string | undefined {
Expand Down Expand Up @@ -172,6 +173,7 @@ export function ModerationMessageList({
initialMessages,
badgeMap,
isAdmin,
channelId,
}: Props) {
const [messages, setMessages] = useState(initialMessages);
const router = useRouter();
Expand Down Expand Up @@ -247,11 +249,16 @@ export function ModerationMessageList({
prev.filter((m) => m.$id !== payload.$id),
);
} else if (hasCreateEvent) {
// Add new message at the top if it does not already exist
// Add new message at the top if it matches the
// scoped channel and does not already exist
setMessages((prev) =>
prev.some((m) => m.$id === payload.$id)
channelId &&
payload.channelId &&
payload.channelId !== channelId
? prev
: [payload, ...prev],
: prev.some((m) => m.$id === payload.$id)
? prev
: [payload, ...prev],
);
}
},
Expand Down
Loading