161 lines
4.8 KiB
TypeScript
161 lines
4.8 KiB
TypeScript
import { query, mutation } from "./_generated/server";
|
|
import { v } from "convex/values";
|
|
import type { GenericQueryCtx } from "convex/server";
|
|
import type { DataModel, Id } from "./_generated/dataModel";
|
|
import { hasPermission, getRolesForUser } from "./roles";
|
|
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
|
import { getPublicStorageUrl } from "./storageUrl";
|
|
|
|
/**
|
|
* Returns true if the user is currently banned. Callers should
|
|
* surface this as an explicit `{ error: "Banned" }` at auth time or
|
|
* throw from sensitive mutations like `messages.send`.
|
|
*/
|
|
export async function isBanned(
|
|
ctx: GenericQueryCtx<DataModel>,
|
|
userId: Id<"userProfiles">,
|
|
): Promise<boolean> {
|
|
const row = await ctx.db
|
|
.query("bans")
|
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
|
.first();
|
|
return !!row;
|
|
}
|
|
|
|
export const isUserBanned = query({
|
|
args: { userId: v.id("userProfiles") },
|
|
returns: v.boolean(),
|
|
handler: async (ctx, args) => isBanned(ctx, args.userId),
|
|
});
|
|
|
|
// List all bans with actor + target enrichment for the Bans tab.
|
|
export const list = query({
|
|
args: { actorId: v.id("userProfiles") },
|
|
returns: v.array(v.any()),
|
|
handler: async (ctx, args) => {
|
|
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
|
|
throw new Error("You don't have permission to view bans.");
|
|
}
|
|
const bans = await ctx.db.query("bans").collect();
|
|
const out = [];
|
|
for (const b of bans) {
|
|
const user = await ctx.db.get(b.userId);
|
|
const actor = await ctx.db.get(b.bannedBy);
|
|
let avatarUrl: string | null = null;
|
|
if (user?.avatarStorageId) {
|
|
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
|
|
}
|
|
out.push({
|
|
_id: b._id,
|
|
userId: b.userId,
|
|
bannedBy: b.bannedBy,
|
|
reason: b.reason ?? null,
|
|
createdAt: b.createdAt,
|
|
user: user
|
|
? {
|
|
username: user.username,
|
|
displayName: user.displayName,
|
|
avatarUrl,
|
|
}
|
|
: null,
|
|
actor: actor
|
|
? { username: actor.username, displayName: actor.displayName }
|
|
: null,
|
|
});
|
|
}
|
|
out.sort((a, b) => b.createdAt - a.createdAt);
|
|
return out;
|
|
},
|
|
});
|
|
|
|
export const ban = mutation({
|
|
args: {
|
|
actorId: v.id("userProfiles"),
|
|
userId: v.id("userProfiles"),
|
|
reason: v.optional(v.string()),
|
|
},
|
|
returns: v.object({ success: v.boolean() }),
|
|
handler: async (ctx, args) => {
|
|
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
|
|
throw new Error("You don't have permission to ban members.");
|
|
}
|
|
if (args.actorId === args.userId) {
|
|
throw new Error("You can't ban yourself.");
|
|
}
|
|
const target = await ctx.db.get(args.userId);
|
|
if (!target) throw new Error("User not found.");
|
|
|
|
// Refuse to ban isAdmin or Owner-role bearers. Single-server
|
|
// deployment can't afford to lock itself out of administration.
|
|
if (target.isAdmin) {
|
|
throw new Error("Server admins can't be banned.");
|
|
}
|
|
const targetRoles = await getRolesForUser(ctx, args.userId);
|
|
if (targetRoles.some((r) => r.name === "Owner")) {
|
|
throw new Error("The Owner can't be banned.");
|
|
}
|
|
|
|
const existing = await ctx.db
|
|
.query("bans")
|
|
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
.first();
|
|
if (existing) {
|
|
// Update reason + actor if re-banning.
|
|
await ctx.db.patch(existing._id, {
|
|
bannedBy: args.actorId,
|
|
reason: args.reason,
|
|
createdAt: Date.now(),
|
|
});
|
|
} else {
|
|
await ctx.db.insert("bans", {
|
|
userId: args.userId,
|
|
bannedBy: args.actorId,
|
|
reason: args.reason,
|
|
createdAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
await logAudit(ctx, {
|
|
actorId: args.actorId,
|
|
action: AUDIT_ACTIONS.BAN_ADD,
|
|
targetType: "user",
|
|
targetId: args.userId,
|
|
targetName: target.displayName ?? target.username,
|
|
metadata: args.reason ? { reason: args.reason } : undefined,
|
|
});
|
|
|
|
return { success: true };
|
|
},
|
|
});
|
|
|
|
export const unban = mutation({
|
|
args: {
|
|
actorId: v.id("userProfiles"),
|
|
userId: v.id("userProfiles"),
|
|
},
|
|
returns: v.object({ success: v.boolean() }),
|
|
handler: async (ctx, args) => {
|
|
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
|
|
throw new Error("You don't have permission to unban members.");
|
|
}
|
|
const row = await ctx.db
|
|
.query("bans")
|
|
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
.first();
|
|
if (!row) return { success: true };
|
|
|
|
const target = await ctx.db.get(args.userId);
|
|
await ctx.db.delete(row._id);
|
|
|
|
await logAudit(ctx, {
|
|
actorId: args.actorId,
|
|
action: AUDIT_ACTIONS.BAN_REMOVE,
|
|
targetType: "user",
|
|
targetId: args.userId,
|
|
targetName: target?.displayName ?? target?.username,
|
|
});
|
|
|
|
return { success: true };
|
|
},
|
|
});
|