diff --git a/PIVOT.md b/PIVOT.md index 6179bfe..7fd347b 100644 --- a/PIVOT.md +++ b/PIVOT.md @@ -258,13 +258,18 @@ Three buckets. Execute in order: deletes first on a branch, get to a minimal cha - `packages/db/src/schemas/user-privacy-settings.ts` + `apps/api/src/routes/v1/privacy-settings/` (peer-to-peer privacy controls don't apply inside a tenant) - `realtime/src/services/blocks.ts` block enforcement in DMs (the `user-blocks` table itself stays for now per maintainer call — UI hidden) -**Per-guild role / permission system** (collapse to `member | admin | owner`): -- `packages/db/src/schemas/guild-roles.ts` (role definitions + permission strings) -- `packages/db/src/schemas/guild-bans.ts` (bans + timeouts — remove-from-workspace is enough) -- `communication_timeout` field on `guild-members.ts` -- Role/ban/timeout endpoints in `apps/api/src/routes/v1/guilds/` -- Roles / bans / moderation panes in `apps/web/src/components/guild/` -- Role-permission helpers in `packages/auth/src/lib/permissions.ts` +**Banishment and timeouts — gone for good:** +- `packages/db/src/schemas/guild-bans.ts` +- `communicationDisabledUntil` / `communicationDisabledBy` / `communicationDisabledReason` fields on `guild-members.ts` +- `banGuildMember`, `timeoutGuildMember`, `clearGuildMemberTimeout` endpoints in `apps/api/src/routes/v1/guilds/` +- Ban / timeout UI in `apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx` +- `isCommunicationDisabled` / `assertMemberCanCommunicate` helpers in `apps/api/src/lib/permissions.ts` + +**Granular permission system — KEPT** (decision reversed 2026-05-28): +- The better-auth `createAccessControl` system in `packages/auth/src/lib/permissions.ts` stays. `guild-roles.ts` schema stays. Dynamic per-guild role grants stay. `assertGuildPermission(actor, guild, { channel: ["update"] })` pattern stays — it scales better than `if role === "admin"` sprinkled in handlers. +- Trims to the system for Lor scope: drop `announcement` statement (no announcement channels), drop `ban`/`timeout` actions from `guildMember` (features removed), drop the `warden` role (no moderator tier; teams can define their own moderator role via the dynamic `guild_role` table), drop "Citizen" label → "Member." +- Final core roles: `owner`, `admin`, `member`. Assignable via API: `["admin", "member"]`. +- `role` column on `guild_member` is plain `text` (no DB enum constraint) — better-auth pattern, allows dynamic role names. **Channel types we don't need:** - `announcement` (Decrees) — B2B teams don't broadcast like communities diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts index 8d4b02e..32fefdd 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -111,20 +111,3 @@ export function assertCanManageGuildMember( targetAuthority, } } - -export function isCommunicationDisabled( - member: Pick -) { - if (!member.communicationDisabledUntil) return false - return member.communicationDisabledUntil.getTime() > Date.now() -} - -export function assertMemberCanCommunicate( - member: Pick -) { - if (!isCommunicationDisabled(member)) return - - throw new HTTPException(HttpStatusCodes.FORBIDDEN, { - message: "You are temporarily timed out and cannot send messages", - }) -} diff --git a/apps/api/src/routes/v1/guilds/handlers.ts b/apps/api/src/routes/v1/guilds/handlers.ts index 71d34d2..d828b5d 100644 --- a/apps/api/src/routes/v1/guilds/handlers.ts +++ b/apps/api/src/routes/v1/guilds/handlers.ts @@ -16,12 +16,9 @@ import { import { getRedisClient } from "@/lib/redis" import type { AppRouteHandler } from "@/lib/types/app-types" import type { - BanGuildMemberRoute, - ClearGuildMemberTimeoutRoute, KickGuildMemberRoute, ListGuildMembersRoute, SearchMessagesRoute, - TimeoutGuildMemberRoute, UpdateGuildMemberRoleRoute, UpdateGuildRoute, } from "@/routes/v1/guilds/routes" @@ -65,8 +62,6 @@ function toGuildMemberPresence( displayUsername: string | null image: string | null role: string - communicationDisabledUntil: Date | null - communicationDisabledReason: string | null }, ownerId: string, onlineUserIds: Set @@ -82,9 +77,6 @@ function toGuildMemberPresence( status: onlineUserIds.has(member.userId) ? ("online" as const) : ("offline" as const), - communicationDisabledUntil: - member.communicationDisabledUntil?.toISOString() ?? null, - communicationDisabledReason: member.communicationDisabledReason, } } @@ -93,9 +85,6 @@ async function getGuildMemberRow(guildId: string, userId: string) { .select({ userId: schema.guildMember.userId, role: schema.guildMember.role, - communicationDisabledUntil: schema.guildMember.communicationDisabledUntil, - communicationDisabledReason: - schema.guildMember.communicationDisabledReason, name: schema.user.name, username: schema.user.username, displayUsername: schema.user.displayUsername, @@ -122,9 +111,6 @@ export const listGuildMembers: AppRouteHandler = async ( .select({ userId: schema.guildMember.userId, role: schema.guildMember.role, - communicationDisabledUntil: schema.guildMember.communicationDisabledUntil, - communicationDisabledReason: - schema.guildMember.communicationDisabledReason, name: schema.user.name, username: schema.user.username, displayUsername: schema.user.displayUsername, @@ -253,226 +239,6 @@ export const kickGuildMember: AppRouteHandler = async ( return c.json({ success: true as const }, HttpStatusCodes.OK) } -export const banGuildMember: AppRouteHandler = async ( - c -) => { - const guild = c.var.guild - const actor = c.var.member - const { userId } = c.req.valid("param") - const { reason, expiresAt } = c.req.valid("json") - - assertGuildPermission(actor, guild, { - guildMember: ["ban"], - }) - - const target = await getGuildMemberRow(guild.id, userId) - - if (!target) { - return c.json( - { success: false, message: "Guild member not found" }, - HttpStatusCodes.NOT_FOUND - ) - } - - assertCanManageGuildMember(actor, target, guild) - - const expiresAtDate = expiresAt ? new Date(expiresAt) : null - const banTimestamp = new Date() - - const ban = await db.transaction(async (tx) => { - const insertedBan = await tx - .insert(schema.guildBan) - .values({ - createdAt: banTimestamp, - guildId: guild.id, - userId, - bannedBy: actor.userId, - reason: reason ?? null, - expiresAt: expiresAtDate, - revokedAt: null, - revokeReason: null, - }) - .onConflictDoUpdate({ - target: [schema.guildBan.guildId, schema.guildBan.userId], - set: { - createdAt: banTimestamp, - bannedBy: actor.userId, - reason: reason ?? null, - expiresAt: expiresAtDate, - revokedAt: null, - revokeReason: null, - }, - }) - .returning({ - userId: schema.guildBan.userId, - guildId: schema.guildBan.guildId, - bannedBy: schema.guildBan.bannedBy, - reason: schema.guildBan.reason, - expiresAt: schema.guildBan.expiresAt, - createdAt: schema.guildBan.createdAt, - revokedAt: schema.guildBan.revokedAt, - }) - .then((rows) => rows[0]) - - await tx - .delete(schema.guildMember) - .where( - and( - eq(schema.guildMember.guildId, guild.id), - eq(schema.guildMember.userId, userId) - ) - ) - - return insertedBan - }) - - if (!ban) { - return c.json( - { success: false, message: "Failed to create guild ban" }, - HttpStatusCodes.INTERNAL_SERVER_ERROR - ) - } - - return c.json( - { - success: true as const, - ban: { - ...ban, - reason: ban.reason ?? null, - expiresAt: ban.expiresAt?.toISOString() ?? null, - createdAt: ban.createdAt.toISOString(), - revokedAt: ban.revokedAt?.toISOString() ?? null, - }, - }, - HttpStatusCodes.OK - ) -} - -export const timeoutGuildMember: AppRouteHandler< - TimeoutGuildMemberRoute -> = async (c) => { - const guild = c.var.guild - const actor = c.var.member - const { userId } = c.req.valid("param") - const { durationMinutes, reason } = c.req.valid("json") - - assertGuildPermission(actor, guild, { - guildMember: ["timeout"], - }) - - const target = await getGuildMemberRow(guild.id, userId) - - if (!target) { - return c.json( - { success: false, message: "Guild member not found" }, - HttpStatusCodes.NOT_FOUND - ) - } - - assertCanManageGuildMember(actor, target, guild) - - const communicationDisabledUntil = new Date( - Date.now() + durationMinutes * 60 * 1000 - ) - - await db - .update(schema.guildMember) - .set({ - communicationDisabledUntil, - communicationDisabledBy: actor.userId, - communicationDisabledReason: reason ?? null, - }) - .where( - and( - eq(schema.guildMember.guildId, guild.id), - eq(schema.guildMember.userId, userId) - ) - ) - - const updatedMember = await getGuildMemberRow(guild.id, userId) - - if (!updatedMember) { - return c.json( - { success: false, message: "Guild member not found" }, - HttpStatusCodes.NOT_FOUND - ) - } - - const onlineUserIds = await listOnlineUserIds([updatedMember.userId]) - - return c.json( - { - success: true as const, - member: toGuildMemberPresence( - updatedMember, - guild.ownerId, - onlineUserIds - ), - }, - HttpStatusCodes.OK - ) -} - -export const clearGuildMemberTimeout: AppRouteHandler< - ClearGuildMemberTimeoutRoute -> = async (c) => { - const guild = c.var.guild - const actor = c.var.member - const { userId } = c.req.valid("param") - - assertGuildPermission(actor, guild, { - guildMember: ["timeout"], - }) - - const target = await getGuildMemberRow(guild.id, userId) - - if (!target) { - return c.json( - { success: false, message: "Guild member not found" }, - HttpStatusCodes.NOT_FOUND - ) - } - - assertCanManageGuildMember(actor, target, guild) - - await db - .update(schema.guildMember) - .set({ - communicationDisabledUntil: null, - communicationDisabledBy: null, - communicationDisabledReason: null, - }) - .where( - and( - eq(schema.guildMember.guildId, guild.id), - eq(schema.guildMember.userId, userId) - ) - ) - - const updatedMember = await getGuildMemberRow(guild.id, userId) - - if (!updatedMember) { - return c.json( - { success: false, message: "Guild member not found" }, - HttpStatusCodes.NOT_FOUND - ) - } - - const onlineUserIds = await listOnlineUserIds([updatedMember.userId]) - - return c.json( - { - success: true as const, - member: toGuildMemberPresence( - updatedMember, - guild.ownerId, - onlineUserIds - ), - }, - HttpStatusCodes.OK - ) -} - // ── Guild Settings ───────────────────────────────────── export const updateGuild: AppRouteHandler = async (c) => { diff --git a/apps/api/src/routes/v1/guilds/index.ts b/apps/api/src/routes/v1/guilds/index.ts index d94aa01..adc52de 100644 --- a/apps/api/src/routes/v1/guilds/index.ts +++ b/apps/api/src/routes/v1/guilds/index.ts @@ -8,8 +8,5 @@ const guildsRouter = createRouter() .openapi(routes.updateGuild, handlers.updateGuild) .openapi(routes.updateGuildMemberRole, handlers.updateGuildMemberRole) .openapi(routes.kickGuildMember, handlers.kickGuildMember) - .openapi(routes.banGuildMember, handlers.banGuildMember) - .openapi(routes.timeoutGuildMember, handlers.timeoutGuildMember) - .openapi(routes.clearGuildMemberTimeout, handlers.clearGuildMemberTimeout) export default guildsRouter diff --git a/apps/api/src/routes/v1/guilds/routes.ts b/apps/api/src/routes/v1/guilds/routes.ts index f2874e5..caa2320 100644 --- a/apps/api/src/routes/v1/guilds/routes.ts +++ b/apps/api/src/routes/v1/guilds/routes.ts @@ -9,16 +9,12 @@ import { } from "@/lib/helpers/openapi/schemas" import { guildAuthMiddleware } from "@/middleware/guild-auth" import { - banGuildMemberRequestSchema, - banGuildMemberResponseSchema, guildMemberParamsSchema, guildSlugParamsSchema, listGuildMembersResponseSchema, moderateGuildMemberResponseSchema, searchMessagesQuerySchema, searchMessagesResponseSchema, - timeoutGuildMemberRequestSchema, - timeoutGuildMemberResponseSchema, updateGuildMemberRoleRequestSchema, updateGuildMemberRoleResponseSchema, updateGuildRequestSchema, @@ -84,7 +80,7 @@ export const kickGuildMember = createRoute({ method: "post", summary: "Kick a guild member", description: - "Removes a member from the guild. Requires member kick permission and sufficient hierarchy.", + "Removes a member from the guild. Requires admin or owner role; the owner cannot be kicked, and admins cannot kick other admins.", tags: ["Guilds"], middleware: [guildAuthMiddleware] as const, request: { @@ -104,89 +100,6 @@ export const kickGuildMember = createRoute({ export type KickGuildMemberRoute = typeof kickGuildMember -export const banGuildMember = createRoute({ - path: "/guilds/{guildSlug}/members/{userId}/ban", - method: "post", - summary: "Ban a guild member", - description: - "Bans a member from the guild and removes their active membership. Requires member ban permission and sufficient hierarchy.", - tags: ["Guilds"], - middleware: [guildAuthMiddleware] as const, - request: { - params: guildMemberParamsSchema, - body: jsonContent({ - schema: banGuildMemberRequestSchema, - description: "Ban metadata", - }), - }, - responses: { - [HttpStatusCodes.OK]: jsonContent({ - schema: banGuildMemberResponseSchema, - description: "Member banned", - }), - [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, - [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, - [HttpStatusCodes.NOT_FOUND]: notFoundSchema, - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, - }, -}) - -export type BanGuildMemberRoute = typeof banGuildMember - -export const timeoutGuildMember = createRoute({ - path: "/guilds/{guildSlug}/members/{userId}/timeout", - method: "post", - summary: "Time out a guild member", - description: - "Temporarily disables a guild member's ability to communicate. Requires member timeout permission and sufficient hierarchy.", - tags: ["Guilds"], - middleware: [guildAuthMiddleware] as const, - request: { - params: guildMemberParamsSchema, - body: jsonContent({ - schema: timeoutGuildMemberRequestSchema, - description: "Timeout duration and optional reason", - }), - }, - responses: { - [HttpStatusCodes.OK]: jsonContent({ - schema: timeoutGuildMemberResponseSchema, - description: "Timed out guild member", - }), - [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, - [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, - [HttpStatusCodes.NOT_FOUND]: notFoundSchema, - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, - }, -}) - -export type TimeoutGuildMemberRoute = typeof timeoutGuildMember - -export const clearGuildMemberTimeout = createRoute({ - path: "/guilds/{guildSlug}/members/{userId}/timeout", - method: "delete", - summary: "Clear a guild member timeout", - description: - "Restores a timed out member's ability to communicate. Requires member timeout permission and sufficient hierarchy.", - tags: ["Guilds"], - middleware: [guildAuthMiddleware] as const, - request: { - params: guildMemberParamsSchema, - }, - responses: { - [HttpStatusCodes.OK]: jsonContent({ - schema: timeoutGuildMemberResponseSchema, - description: "Updated guild member", - }), - [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, - [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, - [HttpStatusCodes.NOT_FOUND]: notFoundSchema, - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, - }, -}) - -export type ClearGuildMemberTimeoutRoute = typeof clearGuildMemberTimeout - export const searchMessages = createRoute({ path: "/guilds/{guildSlug}/search", method: "get", diff --git a/apps/api/src/routes/v1/guilds/schema.ts b/apps/api/src/routes/v1/guilds/schema.ts index 645d33c..ac64940 100644 --- a/apps/api/src/routes/v1/guilds/schema.ts +++ b/apps/api/src/routes/v1/guilds/schema.ts @@ -18,8 +18,6 @@ export const guildMemberPresenceSchema = z.object({ role: z.string(), isOwner: z.boolean(), status: z.enum(["online", "offline"]), - communicationDisabledUntil: z.string().datetime().nullable(), - communicationDisabledReason: z.string().nullable(), }) export const listGuildMembersResponseSchema = z.object({ @@ -44,64 +42,15 @@ export const guildMemberParamsSchema = guildSlugParamsSchema.extend({ }), }) -export const updateGuildMemberRoleRequestSchema = z.object({ - role: z.enum(assignableGuildRoles), -}) - -export const updateGuildMemberRoleResponseSchema = z.object({ - success: z.literal(true), - member: guildMemberPresenceSchema, -}) - export const moderateGuildMemberResponseSchema = z.object({ success: z.literal(true), }) -export const guildBanSchema = z.object({ - userId: z.string().uuid(), - guildId: z.string().uuid(), - bannedBy: z.string().uuid(), - reason: z.string().nullable(), - expiresAt: z.string().datetime().nullable(), - createdAt: z.string().datetime(), - revokedAt: z.string().datetime().nullable(), -}) - -const optionalFutureExpiresAtSchema = z - .string() - .datetime() - .nullable() - .optional() - .refine( - (value) => { - if (value == null) return true - return new Date(value).getTime() > Date.now() - }, - { - message: "expiresAt must be in the future", - } - ) - -export const banGuildMemberRequestSchema = z.object({ - reason: z.string().trim().min(1).max(255).nullable().optional(), - expiresAt: optionalFutureExpiresAtSchema, -}) - -export const banGuildMemberResponseSchema = z.object({ - success: z.literal(true), - ban: guildBanSchema, -}) - -export const timeoutGuildMemberRequestSchema = z.object({ - durationMinutes: z - .number() - .int() - .min(1) - .max(60 * 24 * 28), - reason: z.string().trim().min(1).max(255).nullable().optional(), +export const updateGuildMemberRoleRequestSchema = z.object({ + role: z.enum(assignableGuildRoles), }) -export const timeoutGuildMemberResponseSchema = z.object({ +export const updateGuildMemberRoleResponseSchema = z.object({ success: z.literal(true), member: guildMemberPresenceSchema, }) diff --git a/apps/api/src/routes/v1/invites/handlers.ts b/apps/api/src/routes/v1/invites/handlers.ts index 7599b09..d2e20f7 100644 --- a/apps/api/src/routes/v1/invites/handlers.ts +++ b/apps/api/src/routes/v1/invites/handlers.ts @@ -341,28 +341,6 @@ export const acceptInvite: AppRouteHandler = async (c) => { ) } - // Check if banned (outside transaction — read-only check) - const activeBan = await db - .select({ id: schema.guildBan.id }) - .from(schema.guildBan) - .where( - and( - eq(schema.guildBan.guildId, invite.guildId), - eq(schema.guildBan.userId, user.id), - sql`${schema.guildBan.revokedAt} IS NULL`, - sql`(${schema.guildBan.expiresAt} IS NULL OR ${schema.guildBan.expiresAt} > NOW())` - ) - ) - .limit(1) - .then((rows) => rows[0]) - - if (activeBan) { - return c.json( - { success: false, message: "You are banished from this guild" }, - HttpStatusCodes.FORBIDDEN - ) - } - // Join the guild in a transaction with race-condition protection const result = await db.transaction(async (tx) => { // Check if already a member (inside transaction) diff --git a/apps/api/src/routes/v1/uploads/handlers.ts b/apps/api/src/routes/v1/uploads/handlers.ts index 5df0e20..c0ab63a 100644 --- a/apps/api/src/routes/v1/uploads/handlers.ts +++ b/apps/api/src/routes/v1/uploads/handlers.ts @@ -1,19 +1,11 @@ import { PutObjectCommand } from "@aws-sdk/client-s3" import { getSignedUrl } from "@aws-sdk/s3-request-presigner" -import { - type GuildRole, - guildAuthorityHasPermissions, - isGuildRole, -} from "@repo/auth/permissions" import { db } from "@repo/db" import { channel, channelMember, guild, guildMember } from "@repo/db/schema" import { env } from "@repo/env/server" import { and, eq } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" -import { - assertGuildPermission, - assertMemberCanCommunicate, -} from "@/lib/permissions" +import { assertGuildPermission } from "@/lib/permissions" import { s3Client } from "@/lib/s3" import type { AppRouteHandler } from "@/lib/types/app-types" import type { @@ -62,7 +54,6 @@ export const presign: AppRouteHandler = async (c) => { id: guildMember.id, role: guildMember.role, userId: guildMember.userId, - communicationDisabledUntil: guildMember.communicationDisabledUntil, }) .from(guildMember) .where( @@ -81,17 +72,8 @@ export const presign: AppRouteHandler = async (c) => { ) } - assertMemberCanCommunicate(member) - - // Block uploads in announcement channels for users without permission + // Block uploads in announcement channels for non-admins/owners if (ch.type === "announcement") { - if (!isGuildRole(member.role)) { - return c.json( - { success: false, message: "Forbidden" }, - HttpStatusCodes.FORBIDDEN - ) - } - const guildRecord = await db .select({ ownerId: guild.ownerId }) .from(guild) @@ -99,21 +81,16 @@ export const presign: AppRouteHandler = async (c) => { .limit(1) .then((rows) => rows[0]) - if ( - !guildRecord || - !guildAuthorityHasPermissions( - { - role: member.role as GuildRole, - isOwner: guildRecord.ownerId === member.userId, - }, - { announcement: ["send"] } - ) - ) { + if (!guildRecord) { return c.json( { success: false, message: "Forbidden" }, HttpStatusCodes.FORBIDDEN ) } + + assertGuildPermission(member, guildRecord, { + channel: ["update"], + }) } } else if ( DM_CHANNEL_TYPES.includes(ch.type as (typeof DM_CHANNEL_TYPES)[number]) diff --git a/apps/realtime/src/services/channel-access.ts b/apps/realtime/src/services/channel-access.ts index 122e862..f81d60a 100644 --- a/apps/realtime/src/services/channel-access.ts +++ b/apps/realtime/src/services/channel-access.ts @@ -7,8 +7,6 @@ export type AccessibleChannel = { guildId: string | null memberRole: string | null memberIsOwner: boolean - communicationDisabledUntil: Date | null - communicationDisabledReason: string | null } export async function assertUserCanAccessChannel( @@ -39,10 +37,6 @@ export async function assertUserCanAccessChannel( const memberRecord = await db .select({ role: schema.guildMember.role, - communicationDisabledUntil: - schema.guildMember.communicationDisabledUntil, - communicationDisabledReason: - schema.guildMember.communicationDisabledReason, ownerId: schema.guild.ownerId, }) .from(schema.guildMember) @@ -64,8 +58,6 @@ export async function assertUserCanAccessChannel( ...channelRecord, memberRole: memberRecord.role, memberIsOwner: memberRecord.ownerId === userId, - communicationDisabledUntil: memberRecord.communicationDisabledUntil, - communicationDisabledReason: memberRecord.communicationDisabledReason, } } @@ -89,7 +81,5 @@ export async function assertUserCanAccessChannel( ...channelRecord, memberRole: null, memberIsOwner: false, - communicationDisabledUntil: null, - communicationDisabledReason: null, } } diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts index 7c1a862..2eb4e6e 100644 --- a/apps/realtime/src/services/messages.ts +++ b/apps/realtime/src/services/messages.ts @@ -17,16 +17,6 @@ import { assertUserCanAccessChannel, } from "./channel-access" -function assertChannelCommunicationAllowed(channel: AccessibleChannel) { - if (!channel.guildId) return - if (!channel.communicationDisabledUntil) return - if (channel.communicationDisabledUntil.getTime() <= Date.now()) return - - throw new Error( - "You are temporarily timed out and cannot perform this action" - ) -} - type CreateMessageInput = { userId: string payload: SendMessagePayload @@ -71,7 +61,6 @@ export async function createMessage(input: CreateMessageInput) { } const channelRecord = input.accessibleChannel - assertChannelCommunicationAllowed(channelRecord) // Block sending in announcement channels for users without permission if (channelRecord.type === "announcement" && channelRecord.guildId) { @@ -81,11 +70,11 @@ export async function createMessage(input: CreateMessageInput) { !isGuildRole(role) || !guildAuthorityHasPermissions( { role: role as GuildRole, isOwner: channelRecord.memberIsOwner }, - { announcement: ["send"] } + { channel: ["create"] } ) ) { throw new Error( - "Only owners, admins, and wardens can post in decree channels" + "Only admins and owners can post in announcement channels" ) } } @@ -235,7 +224,6 @@ export async function deleteMessage( input.userId, input.payload.channelId ) - assertChannelCommunicationAllowed(channelRecord) const messageRecord = await db .select({ @@ -286,7 +274,6 @@ export async function editMessage( input.userId, input.payload.channelId ) - assertChannelCommunicationAllowed(channelRecord) const messageRecord = await db .select({ @@ -332,7 +319,6 @@ export async function toggleMessageReaction(input: ToggleMessageReactionInput) { input.userId, input.payload.channelId ) - assertChannelCommunicationAllowed(channelRecord) const messageRecord = await db .select({ diff --git a/apps/realtime/src/services/rate-limit.ts b/apps/realtime/src/services/rate-limit.ts index 7b2a89c..032c47f 100644 --- a/apps/realtime/src/services/rate-limit.ts +++ b/apps/realtime/src/services/rate-limit.ts @@ -6,6 +6,13 @@ type RedisClient = ReturnType const WINDOW_SECONDS = 60 const KEY_TTL_SECONDS = 90 +// The DB role column is plain text — unknown values fall back to the +// `member` tier (the safest/strictest rate limit). +function getRoleRateLimit(role: string): number { + if (isGuildRole(role)) return getGuildMessageRateLimit(role) + return getGuildMessageRateLimit("member") +} + function getMessageRateLimitKey( guildId: string, userId: string, @@ -28,10 +35,6 @@ export async function enforceGuildMessageRateLimit( role: string } ) { - if (!isGuildRole(input.role)) { - throw new Error(`Unknown guild role: ${input.role}`) - } - const now = Date.now() const key = getMessageRateLimitKey(input.guildId, input.userId, now) const nextCount = await redis.incr(key) @@ -40,7 +43,7 @@ export async function enforceGuildMessageRateLimit( await redis.expire(key, KEY_TTL_SECONDS) } - const limit = getGuildMessageRateLimit(input.role) + const limit = getRoleRateLimit(input.role) if (nextCount <= limit) return throw new Error( diff --git a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx index a845050..ad7d810 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -16,7 +16,6 @@ import { } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import { authClient } from "@repo/auth/client" -import type { GuildRole } from "@repo/auth/permissions" import { Button } from "@repo/ui/components/button" import { DropdownMenu, @@ -155,20 +154,46 @@ export function ChannelList() { const { data: activeMember } = useQuery({ queryKey: ["active-guild-member", guildSlug], - queryFn: async () => { + queryFn: async (): Promise<{ userId: string; role: string } | null> => { const res = await authClient.organization.getActiveMember() return res.data + ? { + userId: res.data.userId as string, + role: res.data.role as string, + } + : null }, enabled: !!guildSlug, }) - const canCreate = activeMember?.role - ? canCreateChannels(activeMember.role as GuildRole) + + const { data: guildMembersData } = useQuery({ + queryKey: ["guild-members", guildSlug], + queryFn: async () => { + const res = await apiClient.v1.guilds[":guildSlug"].members.$get({ + param: { guildSlug: guildSlug as string }, + }) + if (!res.ok) throw new Error("Failed to fetch guild members") + return res.json() + }, + enabled: !!guildSlug, + }) + + const permissionCtx = + activeMember && guildMembersData?.ownerId + ? { + actor: activeMember, + guild: { ownerId: guildMembersData.ownerId }, + } + : null + + const canCreate = permissionCtx + ? canCreateChannels(permissionCtx.actor, permissionCtx.guild) : false - const canManage = activeMember?.role - ? canManageChannels(activeMember.role as GuildRole) + const canManage = permissionCtx + ? canManageChannels(permissionCtx.actor, permissionCtx.guild) : false - const canDelete = activeMember?.role - ? canDeleteChannels(activeMember.role as GuildRole) + const canDelete = permissionCtx + ? canDeleteChannels(permissionCtx.actor, permissionCtx.guild) : false const [createDialogOpen, setCreateDialogOpen] = useState(false) diff --git a/apps/web/src/components/sidebar/channel-panel/guild-header.tsx b/apps/web/src/components/sidebar/channel-panel/guild-header.tsx index 26e1117..ce887ec 100644 --- a/apps/web/src/components/sidebar/channel-panel/guild-header.tsx +++ b/apps/web/src/components/sidebar/channel-panel/guild-header.tsx @@ -1,5 +1,4 @@ import { authClient } from "@repo/auth/client" -import { isGuildRole, roleHasPermissions } from "@repo/auth/permissions" import { DropdownMenu, DropdownMenuContent, @@ -14,7 +13,7 @@ import { useMemo, useState } from "react" import { GuildSettingsDialog } from "@/components/guild/guild-settings-dialog" import { CreateInviteDialog } from "@/components/invite/create-invite-dialog" import { ManageInvitesDialog } from "@/components/invite/manage-invites-dialog" -import { canKickGuildMembers } from "@/lib/permissions" +import { canKickGuildMembers, isAdminOrOwner } from "@/lib/permissions" export function GuildHeader() { const { guildSlug } = useParams({ strict: false }) @@ -33,33 +32,42 @@ export function GuildHeader() { const { data: activeMember } = useQuery({ queryKey: ["active-guild-member", guildSlug], - queryFn: async () => { + queryFn: async (): Promise<{ userId: string; role: string } | null> => { const res = await authClient.organization.getActiveMember() if (res.error) { if (res.error.status === 403) return null throw res.error } return res.data + ? { + userId: res.data.userId as string, + role: res.data.role as string, + } + : null }, enabled: !!guildSlug, }) - const memberRole = - typeof activeMember?.role === "string" && isGuildRole(activeMember.role) - ? activeMember.role - : null - - const canManageInvites = - memberRole !== null && canKickGuildMembers(memberRole) - const canEditGuild = - memberRole !== null && - roleHasPermissions(memberRole, { organization: ["update"] }) - const activeGuild = useMemo( () => guilds?.find((g) => g.slug === guildSlug) ?? null, [guilds, guildSlug] ) + const permissionCtx = + activeMember && activeGuild?.ownerId + ? { + actor: activeMember, + guild: { ownerId: activeGuild.ownerId }, + } + : null + + const canManageInvites = permissionCtx + ? canKickGuildMembers(permissionCtx.actor, permissionCtx.guild) + : false + const canEditGuild = permissionCtx + ? isAdminOrOwner(permissionCtx.actor, permissionCtx.guild) + : false + const guildName = activeGuild?.name const title = isPending ? "Loading..." : (guildName ?? "Guild not found") diff --git a/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx b/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx index 30cf56f..e81e0a5 100644 --- a/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx +++ b/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx @@ -2,7 +2,7 @@ import { authClient } from "@repo/auth/client" import { type AssignableGuildRole, assignableGuildRoles, - type GuildRole, + formatGuildRole, isGuildRole, } from "@repo/auth/permissions" import type { @@ -26,7 +26,6 @@ import { DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, - DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -48,14 +47,7 @@ import type { GuildMemberPresence, ListGuildMembersResponse, } from "@/lib/api-types" -import { - canBanGuildMembers, - canKickGuildMembers, - canManageGuildMember, - canTimeoutGuildMembers, - canUpdateGuildMemberRoles, - formatGuildRole, -} from "@/lib/permissions" +import { canKickGuildMembers, canManageGuildMember } from "@/lib/permissions" import { useRightSidebar } from "./right-sidebar-context" import type { GuildMembersSidebarView } from "./right-sidebar-types" @@ -70,32 +62,14 @@ const statusLabel: Record = { } function formatRole(role: GuildMemberPresence["role"]) { - if (!role || !isGuildRole(role)) return "Citizen" + if (!role || !isGuildRole(role)) return "Member" return formatGuildRole(role) } -function isMemberTimedOut(member: GuildMemberPresence) { - if (!member.communicationDisabledUntil) return false - return new Date(member.communicationDisabledUntil).getTime() > Date.now() -} - -function formatTimeoutLabel(member: GuildMemberPresence) { - if (!member.communicationDisabledUntil) return null - const timeoutDate = new Date(member.communicationDisabledUntil) - if (Number.isNaN(timeoutDate.getTime())) return null - return `Timed out until ${timeoutDate.toLocaleString()}` -} - -const timeoutOptions = [ - { label: "10 minutes", durationMinutes: 10 }, - { label: "1 hour", durationMinutes: 60 }, - { label: "1 day", durationMinutes: 60 * 24 }, -] as const - -type ModerationDialogState = - | { type: "kick"; member: GuildMemberPresence } - | { type: "ban"; member: GuildMemberPresence } - | null +type ModerationDialogState = { + type: "kick" + member: GuildMemberPresence +} | null function MembersSkeleton() { return ( @@ -120,55 +94,39 @@ function MembersSkeleton() { function MemberRow({ member, currentUserId, - currentRole, - currentIsOwner, + currentMember, + ownerId, onRoleChange, onKick, - onBan, - onTimeout, - onClearTimeout, isBusy, }: { member: GuildMemberPresence currentUserId: string | null - currentRole: GuildRole | null - currentIsOwner: boolean + currentMember: { userId: string; role: string } | null + ownerId: string | null onRoleChange: (member: GuildMemberPresence, role: AssignableGuildRole) => void onKick: (member: GuildMemberPresence) => void - onBan: (member: GuildMemberPresence) => void - onTimeout: (member: GuildMemberPresence, durationMinutes: number) => void - onClearTimeout: (member: GuildMemberPresence) => void isBusy: boolean }) { const targetRole = isGuildRole(member.role) ? member.role : null + const guildCtx = ownerId ? { ownerId } : null + const canManageTarget = - currentRole && targetRole + currentMember && guildCtx && currentUserId !== member.userId ? canManageGuildMember( - currentRole, - targetRole, - currentIsOwner, - member.isOwner - ) && currentUserId !== member.userId + currentMember, + { userId: member.userId, role: member.role }, + guildCtx + ) : false - const canUpdateRole = - currentRole && targetRole - ? canUpdateGuildMemberRoles(currentRole) && canManageTarget - : false + const canUpdateRole = canManageTarget && !!targetRole const canKick = - currentRole && targetRole - ? canKickGuildMembers(currentRole) && canManageTarget - : false - const canBan = - currentRole && targetRole - ? canBanGuildMembers(currentRole) && canManageTarget - : false - const canTimeout = - currentRole && targetRole - ? canTimeoutGuildMembers(currentRole) && canManageTarget + currentMember && guildCtx + ? canKickGuildMembers(currentMember, guildCtx) && canManageTarget : false - const showActions = canUpdateRole || canKick || canBan || canTimeout - const timeoutLabel = formatTimeoutLabel(member) + + const showActions = canUpdateRole || canKick return (
@@ -193,13 +151,8 @@ function MemberRow({
- {formatRole(member.role)} + {member.isOwner ? "Owner" : formatRole(member.role)}
- {timeoutLabel && ( -
- {timeoutLabel} -
- )}
@@ -252,47 +205,11 @@ function MemberRow({ )} - {canTimeout && ( - - Timeout - - {timeoutOptions.map((option) => ( - - onTimeout(member, option.durationMinutes) - } - > - {option.label} - - ))} - {isMemberTimedOut(member) && ( - <> - - onClearTimeout(member)} - > - Clear timeout - - - )} - - - )} - {(canKick || canBan) && } {canKick && ( onKick(member)}> Kick member )} - {canBan && ( - onBan(member)} - > - Ban member - - )} )} @@ -350,11 +267,11 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { const currentUserId = session?.user?.id ?? null const activeMemberRole = typeof activeMember?.role === "string" ? activeMember.role : null - const currentRole = - !hasActiveMemberError && activeMemberRole && isGuildRole(activeMemberRole) - ? activeMemberRole + const currentMember = + !hasActiveMemberError && currentUserId && activeMemberRole + ? { userId: currentUserId, role: activeMemberRole } : null - const currentIsOwner = data?.ownerId === currentUserId + const ownerId = data?.ownerId ?? null useEffect(() => { if (!hasActiveMemberError) return @@ -419,81 +336,7 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { }, }) - const banMutation = useMutation({ - mutationFn: async (userId: string) => { - const res = await apiClient.v1.guilds[":guildSlug"].members[ - ":userId" - ].ban.$post({ - param: { guildSlug: view.guildSlug, userId }, - json: { reason: null, expiresAt: null }, - }) - - if (!res.ok) { - throw new Error("Failed to ban member") - } - }, - onSuccess: async () => { - await invalidateMembers() - setModerationDialog(null) - toast.success("Member banned") - }, - onError: () => { - toast.error("Failed to ban member") - }, - }) - - const timeoutMutation = useMutation({ - mutationFn: async (input: { userId: string; durationMinutes: number }) => { - const res = await apiClient.v1.guilds[":guildSlug"].members[ - ":userId" - ].timeout.$post({ - param: { guildSlug: view.guildSlug, userId: input.userId }, - json: { - durationMinutes: input.durationMinutes, - reason: null, - }, - }) - - if (!res.ok) { - throw new Error("Failed to time out member") - } - }, - onSuccess: async () => { - await invalidateMembers() - toast.success("Timeout applied") - }, - onError: () => { - toast.error("Failed to apply timeout") - }, - }) - - const clearTimeoutMutation = useMutation({ - mutationFn: async (userId: string) => { - const res = await apiClient.v1.guilds[":guildSlug"].members[ - ":userId" - ].timeout.$delete({ - param: { guildSlug: view.guildSlug, userId }, - }) - - if (!res.ok) { - throw new Error("Failed to clear timeout") - } - }, - onSuccess: async () => { - await invalidateMembers() - toast.success("Timeout cleared") - }, - onError: () => { - toast.error("Failed to clear timeout") - }, - }) - - const isMutating = - updateRoleMutation.isPending || - kickMutation.isPending || - banMutation.isPending || - timeoutMutation.isPending || - clearTimeoutMutation.isPending + const isMutating = updateRoleMutation.isPending || kickMutation.isPending const handleRoleChange = ( member: GuildMemberPresence, @@ -507,30 +350,12 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { setModerationDialog({ type: "kick", member }) } - const handleBan = (member: GuildMemberPresence) => { - setModerationDialog({ type: "ban", member }) - } - - const handleTimeout = ( - member: GuildMemberPresence, - durationMinutes: number - ) => { - timeoutMutation.mutate({ userId: member.userId, durationMinutes }) - } - - const handleClearTimeout = (member: GuildMemberPresence) => { - clearTimeoutMutation.mutate(member.userId) - } - const handleConfirmModeration = () => { if (!moderationDialog) return if (moderationDialog.type === "kick") { kickMutation.mutate(moderationDialog.member.userId) - return } - - banMutation.mutate(moderationDialog.member.userId) } useEffect(() => { @@ -618,19 +443,13 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { const onlineMembers = members.filter((member) => member.status !== "offline") const offlineMembers = members.filter((member) => member.status === "offline") const isModerationDialogOpen = moderationDialog !== null - const moderationDialogTitle = - moderationDialog?.type === "kick" ? "Kick member" : "Ban member" - const moderationDialogDescription = - moderationDialog?.type === "kick" - ? `Are you sure you want to kick ${moderationDialog.member.name} from this guild? They can rejoin if invited again.` - : moderationDialog - ? `Are you sure you want to ban ${moderationDialog.member.name} from this guild? They will be removed immediately and blocked from rejoining.` - : "" + const moderationDialogTitle = "Kick member" + const moderationDialogDescription = moderationDialog + ? `Are you sure you want to kick ${moderationDialog.member.name} from this guild? They can rejoin if invited again.` + : "" const isModerationSubmitting = - (moderationDialog?.type === "kick" && kickMutation.isPending) || - (moderationDialog?.type === "ban" && banMutation.isPending) - const moderationActionLabel = - moderationDialog?.type === "kick" ? "Kick member" : "Ban member" + moderationDialog?.type === "kick" && kickMutation.isPending + const moderationActionLabel = "Kick member" return ( <> @@ -680,13 +499,10 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { key={member.userId} member={member} currentUserId={currentUserId} - currentRole={currentRole} - currentIsOwner={currentIsOwner} + currentMember={currentMember} + ownerId={ownerId} onRoleChange={handleRoleChange} onKick={handleKick} - onBan={handleBan} - onTimeout={handleTimeout} - onClearTimeout={handleClearTimeout} isBusy={isMutating} /> ))} @@ -705,13 +521,10 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { key={member.userId} member={member} currentUserId={currentUserId} - currentRole={currentRole} - currentIsOwner={currentIsOwner} + currentMember={currentMember} + ownerId={ownerId} onRoleChange={handleRoleChange} onKick={handleKick} - onBan={handleBan} - onTimeout={handleTimeout} - onClearTimeout={handleClearTimeout} isBusy={isMutating} /> ))} diff --git a/apps/web/src/lib/permissions.ts b/apps/web/src/lib/permissions.ts index 0c318d7..5262eff 100644 --- a/apps/web/src/lib/permissions.ts +++ b/apps/web/src/lib/permissions.ts @@ -1,68 +1,109 @@ import { canManageGuildAuthority, - formatGuildRole, - type GuildRole, - roleHasPermissions, + formatGuildRole as formatGuildRoleHelper, + type GuildAuthority, + guildAuthorityHasPermissions, + isGuildRole, + type PermissionRequest, } from "@repo/auth/permissions" -export function canCreateChannels(role: GuildRole): boolean { - return roleHasPermissions(role, { - channel: ["create"], - }) +function toAuthority( + member: { userId: string; role: string }, + guild: { ownerId: string } +): GuildAuthority | null { + if (!isGuildRole(member.role)) return null + return { + role: member.role, + isOwner: guild.ownerId === member.userId, + } } -export function canManageChannels(role: GuildRole): boolean { - return roleHasPermissions(role, { - channel: ["update"], - }) +export function isOwner( + member: { userId: string }, + guild: { ownerId: string } +): boolean { + return member.userId === guild.ownerId } -export function canDeleteChannels(role: GuildRole): boolean { - return roleHasPermissions(role, { - channel: ["delete"], - }) +export function isAdmin(member: { role: string }): boolean { + return member.role === "admin" } -export function canUpdateGuildMemberRoles(role: GuildRole): boolean { - return roleHasPermissions(role, { - guildMember: ["role:update"], - }) +export function isAdminOrOwner( + member: { userId: string; role: string }, + guild: { ownerId: string } +): boolean { + return isOwner(member, guild) || isAdmin(member) } -export function canKickGuildMembers(role: GuildRole): boolean { - return roleHasPermissions(role, { - guildMember: ["kick"], - }) +export function formatGuildRole(role: string): string { + if (isGuildRole(role)) return formatGuildRoleHelper(role) + return "Member" } -export function canBanGuildMembers(role: GuildRole): boolean { - return roleHasPermissions(role, { - guildMember: ["ban"], - }) +function hasPermissions( + member: { userId: string; role: string }, + guild: { ownerId: string }, + requestedPermissions: PermissionRequest +): boolean { + const authority = toAuthority(member, guild) + if (!authority) return false + return guildAuthorityHasPermissions(authority, requestedPermissions) } -export function canTimeoutGuildMembers(role: GuildRole): boolean { - return roleHasPermissions(role, { - guildMember: ["timeout"], - }) +export function canManageChannels( + member: { userId: string; role: string }, + guild: { ownerId: string } +): boolean { + return hasPermissions(member, guild, { channel: ["update"] }) } -export function canManageGuildMember( - actorRole: GuildRole, - targetRole: GuildRole, - actorIsOwner = false, - targetIsOwner = false -) { - return canManageGuildAuthority( - { role: actorRole, isOwner: actorIsOwner }, - { role: targetRole, isOwner: targetIsOwner } - ) +export function canCreateChannels( + member: { userId: string; role: string }, + guild: { ownerId: string } +): boolean { + return hasPermissions(member, guild, { channel: ["create"] }) +} + +export function canDeleteChannels( + member: { userId: string; role: string }, + guild: { ownerId: string } +): boolean { + return hasPermissions(member, guild, { channel: ["delete"] }) +} + +export function canPinMessages( + member: { userId: string; role: string }, + guild: { ownerId: string } +): boolean { + return hasPermissions(member, guild, { message: ["pin"] }) } -export function canSendInAnnouncement(role: GuildRole): boolean { - return roleHasPermissions(role, { - announcement: ["send"], - }) +export function canSendInAnnouncement( + member: { userId: string; role: string }, + guild: { ownerId: string } +): boolean { + // The `announcement` statement was removed in the Lor pivot. Posting in + // announcement channels is gated by the same tier as channel creation + // (admin/owner). + return hasPermissions(member, guild, { channel: ["create"] }) } -export { formatGuildRole } +export function canKickGuildMembers( + member: { userId: string; role: string }, + guild: { ownerId: string } +): boolean { + return hasPermissions(member, guild, { guildMember: ["kick"] }) +} + +export function canManageGuildMember( + actor: { userId: string; role: string }, + target: { userId: string; role: string }, + guild: { ownerId: string } +): boolean { + if (actor.userId === target.userId) return false + const actorAuthority = toAuthority(actor, guild) + const targetAuthority = toAuthority(target, guild) + if (!actorAuthority || !targetAuthority) return false + return canManageGuildAuthority(actorAuthority, targetAuthority) +} diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index 309e0af..ec00c70 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -1,9 +1,4 @@ import { authClient } from "@repo/auth/client" -import { - type GuildRole, - isGuildRole, - roleHasPermissions, -} from "@repo/auth/permissions" import { useIsMobile } from "@repo/ui/hooks/use-mobile" import { useInfiniteQuery, @@ -31,7 +26,7 @@ import { useMessageSending } from "@/hooks/use-message-sending" import { useReplyState } from "@/hooks/use-reply-state" import { useTypingIndicator } from "@/hooks/use-typing-indicator" import { apiClient } from "@/lib/api-client" -import { canSendInAnnouncement } from "@/lib/permissions" +import { canPinMessages, canSendInAnnouncement } from "@/lib/permissions" type ChannelSearchParams = { msgId?: string @@ -192,16 +187,25 @@ function ChannelView() { }, }) - const canPin = + const activeMemberCtx = typeof activeMember?.role === "string" && - isGuildRole(activeMember.role) && - roleHasPermissions(activeMember.role as GuildRole, { message: ["pin"] }) + typeof activeMember.userId === "string" && + guildMembersData?.ownerId + ? { + actor: { userId: activeMember.userId, role: activeMember.role }, + guild: { ownerId: guildMembersData.ownerId }, + } + : null + + const canPin = activeMemberCtx + ? canPinMessages(activeMemberCtx.actor, activeMemberCtx.guild) + : false const canSendMessages = data?.type !== "announcement" || - (typeof activeMember?.role === "string" && - isGuildRole(activeMember.role) && - canSendInAnnouncement(activeMember.role as GuildRole)) + (activeMemberCtx + ? canSendInAnnouncement(activeMemberCtx.actor, activeMemberCtx.guild) + : false) const { handleTogglePin } = useMessagePinning({ socket, diff --git a/packages/auth/src/lib/auth-client.ts b/packages/auth/src/lib/auth-client.ts index 6e2d721..90612af 100644 --- a/packages/auth/src/lib/auth-client.ts +++ b/packages/auth/src/lib/auth-client.ts @@ -9,7 +9,7 @@ import { } from "better-auth/client/plugins" import { createAuthClient } from "better-auth/react" import type { auth } from "./auth.js" -import { ac, admin, member, owner, warden } from "./permissions" +import { ac, admin, member, owner } from "./permissions" export const authClient = createAuthClient({ baseURL: env.NEXT_PUBLIC_API_URL, @@ -19,7 +19,6 @@ export const authClient = createAuthClient({ roles: { owner, admin, - warden, member, }, schema: inferOrgAdditionalFields(), diff --git a/packages/auth/src/lib/auth.ts b/packages/auth/src/lib/auth.ts index 3cd0882..4620464 100644 --- a/packages/auth/src/lib/auth.ts +++ b/packages/auth/src/lib/auth.ts @@ -12,7 +12,6 @@ import { admin as adminRole, member as memberRole, owner as ownerRole, - warden, } from "./permissions" const redis = new Redis(env.REDIS_URL) @@ -213,7 +212,6 @@ export const auth = betterAuth({ roles: { owner: ownerRole, admin: adminRole, - warden, member: memberRole, }, schema: { diff --git a/packages/auth/src/lib/permissions.ts b/packages/auth/src/lib/permissions.ts index f244944..7ef2944 100644 --- a/packages/auth/src/lib/permissions.ts +++ b/packages/auth/src/lib/permissions.ts @@ -10,8 +10,7 @@ const statement = { ...defaultStatements, channel: ["create", "update", "delete"], message: ["delete", "pin"], // delete/pin others' messages (own messages are always deletable) - guildMember: ["kick", "ban", "timeout", "role:update"], - announcement: ["send"], + guildMember: ["kick", "role:update"], } as const const ac = createAccessControl(statement) @@ -19,59 +18,44 @@ const ac = createAccessControl(statement) const owner = ac.newRole({ channel: ["create", "update", "delete"], message: ["delete", "pin"], - guildMember: ["kick", "ban", "timeout", "role:update"], - announcement: ["send"], + guildMember: ["kick", "role:update"], ...ownerAc.statements, }) const admin = ac.newRole({ channel: ["create", "update", "delete"], message: ["delete", "pin"], - guildMember: ["kick", "ban", "timeout", "role:update"], - announcement: ["send"], + guildMember: ["kick", "role:update"], ...adminAc.statements, }) -// Warden (moderator) — can create/update channels and moderate messages/members -const warden = ac.newRole({ - channel: ["create", "update"], - message: ["delete", "pin"], - guildMember: ["kick", "ban", "timeout"], - announcement: ["send"], - ...memberAc.statements, -}) - -// Member (citizen) — basic access only, displayed as "Citizen" in UI +// Member — basic access only const member = ac.newRole({ ...memberAc.statements, }) -const roles = { owner, admin, warden, member } +const roles = { owner, admin, member } const guildRoleLabels = { owner: "Owner", admin: "Admin", - warden: "Warden", - member: "Citizen", + member: "Member", } as const satisfies Record const guildRolePositions = { owner: 0, admin: 10, - warden: 20, - member: 30, + member: 20, } as const satisfies Record const guildMessageRateLimitsPerMinute = { owner: 120, admin: 120, - warden: 60, member: 30, } as const satisfies Record const assignableGuildRoles = [ "admin", - "warden", "member", ] as const satisfies ReadonlyArray> @@ -179,5 +163,4 @@ export { owner, roles, statement, - warden, } diff --git a/packages/db/src/schemas/guild-bans.ts b/packages/db/src/schemas/guild-bans.ts deleted file mode 100644 index 9a50a9c..0000000 --- a/packages/db/src/schemas/guild-bans.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { relations } from "drizzle-orm" -import { - index, - pgTable, - text, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core" -import { guild } from "./guilds" -import { user } from "./users" - -export const guildBan = pgTable( - "guild_ban", - { - id: uuid("id").defaultRandom().primaryKey(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .defaultNow() - .$onUpdate(() => new Date()) - .notNull(), - guildId: uuid("guild_id") - .notNull() - .references(() => guild.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - bannedBy: uuid("banned_by") - .notNull() - .references(() => user.id, { onDelete: "restrict" }), - reason: varchar("reason", { length: 255 }), - expiresAt: timestamp("expires_at"), - revokedAt: timestamp("revoked_at"), - revokeReason: text("revoke_reason"), - }, - (table) => [ - uniqueIndex("guildBan_guild_user_uidx").on(table.guildId, table.userId), - index("guildBan_guild_idx").on(table.guildId), - index("guildBan_user_idx").on(table.userId), - ] -) - -export const guildBanRelations = relations(guildBan, ({ one }) => ({ - guild: one(guild, { - fields: [guildBan.guildId], - references: [guild.id], - }), - user: one(user, { - relationName: "guildBanUser", - fields: [guildBan.userId], - references: [user.id], - }), - bannedByUser: one(user, { - relationName: "guildBanModerator", - fields: [guildBan.bannedBy], - references: [user.id], - }), -})) diff --git a/packages/db/src/schemas/guild-members.ts b/packages/db/src/schemas/guild-members.ts index f48348f..9a9ba2a 100644 --- a/packages/db/src/schemas/guild-members.ts +++ b/packages/db/src/schemas/guild-members.ts @@ -1,15 +1,16 @@ import { relations } from "drizzle-orm" +import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" import { - index, - pgTable, - text, - timestamp, - uuid, - varchar, -} from "drizzle-orm/pg-core" + createInsertSchema, + createSelectSchema, + createUpdateSchema, +} from "drizzle-zod" import { guild } from "./guilds" import { user } from "./users" +export const GUILD_MEMBER_ROLES = ["member", "admin"] as const +export type GuildMemberRole = (typeof GUILD_MEMBER_ROLES)[number] + export const guildMember = pgTable( "guild_member", { @@ -21,14 +22,6 @@ export const guildMember = pgTable( .notNull() .references(() => user.id, { onDelete: "cascade" }), role: text("role").default("member").notNull(), - communicationDisabledUntil: timestamp("communication_disabled_until"), - communicationDisabledBy: uuid("communication_disabled_by").references( - () => user.id, - { onDelete: "set null" } - ), - communicationDisabledReason: varchar("communication_disabled_reason", { - length: 255, - }), createdAt: timestamp("created_at").notNull(), }, (table) => [ @@ -43,13 +36,12 @@ export const guildMemberRelations = relations(guildMember, ({ one }) => ({ references: [guild.id], }), user: one(user, { - relationName: "guildMembershipUser", fields: [guildMember.userId], references: [user.id], }), - communicationDisabledByUser: one(user, { - relationName: "guildMemberModerator", - fields: [guildMember.communicationDisabledBy], - references: [user.id], - }), })) + +// Zod schemas +export const selectGuildMemberSchema = createSelectSchema(guildMember) +export const insertGuildMemberSchema = createInsertSchema(guildMember) +export const updateGuildMemberSchema = createUpdateSchema(guildMember) diff --git a/packages/db/src/schemas/guilds.ts b/packages/db/src/schemas/guilds.ts index 543b019..3f02718 100644 --- a/packages/db/src/schemas/guilds.ts +++ b/packages/db/src/schemas/guilds.ts @@ -11,7 +11,6 @@ import { createSelectSchema, createUpdateSchema, } from "drizzle-zod" -import { guildBan } from "./guild-bans" import { guildInvite } from "./guild-invites" import { guildMember } from "./guild-members" import { guildRole } from "./guild-roles" @@ -26,7 +25,7 @@ export const guild = pgTable( slug: text("slug").notNull().unique(), logo: text("logo"), createdAt: timestamp("created_at").notNull(), - ownerId: uuid("owner_id") // this is the source of truth for the owner of the guild, the guildMember who owns this guild will also have role === "owner" so we will need to keep these in sync + ownerId: uuid("owner_id") // source of truth for guild ownership — derive owner status by comparing user.id against guild.ownerId, NOT from guild_member.role .notNull() .references(() => user.id, { onDelete: "restrict" }), // don't delete guild if owner deletes account metadata: text("metadata"), @@ -39,9 +38,8 @@ export const guildRelations = relations(guild, ({ one, many }) => ({ fields: [guild.ownerId], references: [user.id], }), - guildBans: many(guildBan), - guildRoles: many(guildRole), guildMembers: many(guildMember), + guildRoles: many(guildRole), invitations: many(invitation), guildInvites: many(guildInvite), })) diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts index 08d0260..e26ded4 100644 --- a/packages/db/src/schemas/index.ts +++ b/packages/db/src/schemas/index.ts @@ -1,7 +1,6 @@ export * from "./accounts" export * from "./channel-read-states" export * from "./channels" -export * from "./guild-bans" export * from "./guild-invites" export * from "./guild-members" export * from "./guild-roles" diff --git a/packages/db/src/schemas/users.ts b/packages/db/src/schemas/users.ts index 3a47ab1..a018590 100644 --- a/packages/db/src/schemas/users.ts +++ b/packages/db/src/schemas/users.ts @@ -8,7 +8,6 @@ import { varchar, } from "drizzle-orm/pg-core" import { account } from "./accounts" -import { guildBan } from "./guild-bans" import { guildMember } from "./guild-members" import { guild } from "./guilds" import { invitation } from "./invitations" @@ -42,18 +41,7 @@ export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), guilds: many(guild), // can be owners of many guilds - guildBans: many(guildBan, { - relationName: "guildBanUser", - }), - issuedGuildBans: many(guildBan, { - relationName: "guildBanModerator", - }), - guildMembers: many(guildMember, { - relationName: "guildMembershipUser", - }), - moderatedGuildMembers: many(guildMember, { - relationName: "guildMemberModerator", - }), + guildMembers: many(guildMember), invitations: many(invitation), twoFactors: many(twoFactor), }))