From 530e7af4d961b1509057d0e92b8151a8a2b21717 Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Tue, 10 Mar 2026 07:44:14 -0700 Subject: [PATCH 1/6] feat: add role-based permissions system with channel edit/delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Define guild roles (owner, admin, warden, member) using Better Auth's createAccessControl with type-safe permission statements - Add channel update (PATCH) and delete (DELETE) API endpoints with permission checks on all mutating channel endpoints - Add edit/delete channel dialogs gated behind role permissions - Warden can create/edit channels but only owner/admin can delete - Gate drag-and-drop reordering and context menu behind canManage check - Fix auth route wildcard pattern (** → *) for Hono compatibility - Invalidate active guild member query on guild switch for correct roles - Display "Citizen" for member role and "Warden" for warden in UI --- CLAUDE.md | 3 + ROADMAP.md | 3 +- apps/api/src/app.ts | 4 +- apps/api/src/lib/permissions.ts | 45 +++++ apps/api/src/routes/v1/channels/handlers.ts | 53 +++++ apps/api/src/routes/v1/channels/index.ts | 2 + apps/api/src/routes/v1/channels/routes.ts | 55 +++++ apps/api/src/routes/v1/channels/schema.ts | 16 +- .../sidebar/channel-panel/channel-list.tsx | 189 ++++++++++++------ .../channel-panel/delete-channel-dialog.tsx | 82 ++++++++ .../channel-panel/edit-channel-dialog.tsx | 125 ++++++++++++ .../right-panel/guild-members-panel.tsx | 11 +- apps/web/src/lib/permissions.ts | 16 ++ .../src/routes/_authenticated/$guildSlug.tsx | 12 +- packages/auth/package.json | 3 +- packages/auth/src/lib/auth-client.ts | 8 + packages/auth/src/lib/auth.ts | 14 ++ packages/auth/src/lib/permissions.ts | 45 +++++ packages/ui/src/components/alert-dialog.tsx | 13 +- 19 files changed, 627 insertions(+), 72 deletions(-) create mode 100644 apps/api/src/lib/permissions.ts create mode 100644 apps/web/src/components/sidebar/channel-panel/delete-channel-dialog.tsx create mode 100644 apps/web/src/components/sidebar/channel-panel/edit-channel-dialog.tsx create mode 100644 apps/web/src/lib/permissions.ts create mode 100644 packages/auth/src/lib/permissions.ts diff --git a/CLAUDE.md b/CLAUDE.md index 514d31c..ae6804b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,3 +74,6 @@ All CSS lives in `packages/ui`. Apps do NOT have their own `globals.css`. ./lib/* → ./src/lib/*.ts ./hooks/* → ./src/hooks/*.ts ``` + +PS. If u add/edit routes in the API, make sure to build the API Client as the frontend relies on this being built to be up to date. +Otherwise you will receive errors when type checking diff --git a/ROADMAP.md b/ROADMAP.md index c056629..db36c7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,7 +26,7 @@ - [x] Message editing UI - [x] User profiles (bio, custom status, avatar upload) - [ ] Channel edit/delete -- [x] Settings pages +- [x] User settings page ## Phase 2 — Permissions & Moderation @@ -57,6 +57,7 @@ - [ ] Thread support - [ ] Notification preferences - [ ] Error handling & loading state improvements +- [ ] Other settings pages ## Phase 6 — Infrastructure diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6667ffa..06ed05d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -19,7 +19,9 @@ app.use( }) ) -app.on(["GET", "POST"], "/api/auth/**", (c) => auth.handler(c.req.raw)) +app.on(["POST", "GET"], "/api/auth/*", (c) => { + return auth.handler(c.req.raw) +}) configureOpenAPI(app) diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts new file mode 100644 index 0000000..c46b400 --- /dev/null +++ b/apps/api/src/lib/permissions.ts @@ -0,0 +1,45 @@ +import { auth } from "@repo/auth" +import type { statement } from "@repo/auth/permissions" +import { HTTPException } from "hono/http-exception" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" + +// ── Type-Safe Permission Types ────────────────────────────────────── + +export type StatementKey = keyof typeof statement + +export type PermissionForStatement = + (typeof statement)[T][number] + +// ── Permission Check ────────────────────────────────────── + +/** + * Checks if the current user has the specified permissions in their active guild. + * Uses better-auth's hasPermission API. + * + * Throws an HTTPException with 403 if the user lacks the required permissions. + * + * @example + * const allowed = await checkPermission(c.req.raw.headers, "channel", ["update"]) + * if (!allowed) throw new HTTPException(403) + */ +export async function checkPermission< + TResource extends StatementKey, + TPermissions extends readonly PermissionForStatement[], +>(headers: Headers, resource: TResource, permissions: TPermissions) { + const result = await auth.api.hasPermission({ + headers, + body: { + permissions: { + [resource]: [...permissions], + }, + }, + }) + + if (!result.success) { + throw new HTTPException(HttpStatusCodes.FORBIDDEN, { + message: `You do not have permission to ${permissions.join("/")} ${resource}`, + }) + } + + return true +} diff --git a/apps/api/src/routes/v1/channels/handlers.ts b/apps/api/src/routes/v1/channels/handlers.ts index b1dff78..22aef92 100644 --- a/apps/api/src/routes/v1/channels/handlers.ts +++ b/apps/api/src/routes/v1/channels/handlers.ts @@ -2,14 +2,17 @@ import { db } from "@repo/db" import { channel } from "@repo/db/schema" import { and, asc, eq, inArray } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import { checkPermission } from "@/lib/permissions" import { fetchMessagePage } from "@/lib/queries/messages" import type { AppRouteHandler } from "@/lib/types/app-types" import type { CreateChannelRoute, + DeleteChannelRoute, GetChannelRoute, ListChannelMessagesRoute, ListChannelsRoute, ReorderChannelsRoute, + UpdateChannelRoute, } from "./routes" export const listChannels: AppRouteHandler = async (c) => { @@ -55,6 +58,8 @@ export const listChannels: AppRouteHandler = async (c) => { } export const createChannel: AppRouteHandler = async (c) => { + await checkPermission(c.req.raw.headers, "channel", ["create"]) + const guild = c.var.guild const body = c.req.valid("json") @@ -80,6 +85,8 @@ export const createChannel: AppRouteHandler = async (c) => { export const reorderChannels: AppRouteHandler = async ( c ) => { + await checkPermission(c.req.raw.headers, "channel", ["update"]) + const guild = c.var.guild const { channels: updates } = c.req.valid("json") @@ -134,6 +141,52 @@ export const getChannel: AppRouteHandler = async (c) => { return c.json(ch, HttpStatusCodes.OK) } +export const updateChannel: AppRouteHandler = async (c) => { + await checkPermission(c.req.raw.headers, "channel", ["update"]) + + const guild = c.var.guild + const { channelId } = c.req.valid("param") + const body = c.req.valid("json") + + const updated = await db + .update(channel) + .set(body) + .where(and(eq(channel.id, channelId), eq(channel.guildId, guild.id))) + .returning() + .then((rows) => rows[0]) + + if (!updated) { + return c.json( + { success: false, message: "Channel not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + return c.json(updated, HttpStatusCodes.OK) +} + +export const deleteChannel: AppRouteHandler = async (c) => { + await checkPermission(c.req.raw.headers, "channel", ["delete"]) + + const guild = c.var.guild + const { channelId } = c.req.valid("param") + + const deleted = await db + .delete(channel) + .where(and(eq(channel.id, channelId), eq(channel.guildId, guild.id))) + .returning({ id: channel.id }) + .then((rows) => rows[0]) + + if (!deleted) { + return c.json( + { success: false, message: "Channel not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + return c.json({ success: true }, HttpStatusCodes.OK) +} + export const listChannelMessages: AppRouteHandler< ListChannelMessagesRoute > = async (c) => { diff --git a/apps/api/src/routes/v1/channels/index.ts b/apps/api/src/routes/v1/channels/index.ts index 45264c8..5949d28 100644 --- a/apps/api/src/routes/v1/channels/index.ts +++ b/apps/api/src/routes/v1/channels/index.ts @@ -7,6 +7,8 @@ const channelsRouter = createRouter() .openapi(routes.createChannel, handlers.createChannel) .openapi(routes.reorderChannels, handlers.reorderChannels) .openapi(routes.getChannel, handlers.getChannel) + .openapi(routes.updateChannel, handlers.updateChannel) + .openapi(routes.deleteChannel, handlers.deleteChannel) .openapi(routes.listChannelMessages, handlers.listChannelMessages) export default channelsRouter diff --git a/apps/api/src/routes/v1/channels/routes.ts b/apps/api/src/routes/v1/channels/routes.ts index 2040321..6608662 100644 --- a/apps/api/src/routes/v1/channels/routes.ts +++ b/apps/api/src/routes/v1/channels/routes.ts @@ -13,12 +13,15 @@ import { channelResponseSchema, createChannelRequestSchema, createChannelResponseSchema, + deleteChannelResponseSchema, guildSlugParamsSchema, listChannelsResponseSchema, listMessagesQuerySchema, listMessagesResponseSchema, reorderChannelsRequestSchema, reorderChannelsResponseSchema, + updateChannelRequestSchema, + updateChannelResponseSchema, } from "./schema" export const listChannels = createRoute({ @@ -138,8 +141,60 @@ export const listChannelMessages = createRoute({ }, }) +export const updateChannel = createRoute({ + path: "/guilds/{guildSlug}/channels/{channelId}", + method: "patch", + summary: "Update a channel", + description: + "Updates a channel's name, topic, or other properties. Requires channel:update permission.", + tags: ["Channels"], + middleware: [guildAuthMiddleware] as const, + request: { + params: channelParamsSchema, + body: jsonContent({ + schema: updateChannelRequestSchema, + description: "Channel fields to update", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: updateChannelResponseSchema, + description: "Updated channel", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export const deleteChannel = createRoute({ + path: "/guilds/{guildSlug}/channels/{channelId}", + method: "delete", + summary: "Delete a channel", + description: + "Permanently deletes a channel and all its messages. Requires channel:delete permission.", + tags: ["Channels"], + middleware: [guildAuthMiddleware] as const, + request: { + params: channelParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: deleteChannelResponseSchema, + description: "Channel deleted", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + export type ListChannelsRoute = typeof listChannels export type CreateChannelRoute = typeof createChannel export type ReorderChannelsRoute = typeof reorderChannels export type GetChannelRoute = typeof getChannel +export type UpdateChannelRoute = typeof updateChannel +export type DeleteChannelRoute = typeof deleteChannel export type ListChannelMessagesRoute = typeof listChannelMessages diff --git a/apps/api/src/routes/v1/channels/schema.ts b/apps/api/src/routes/v1/channels/schema.ts index 1af581c..6a6106d 100644 --- a/apps/api/src/routes/v1/channels/schema.ts +++ b/apps/api/src/routes/v1/channels/schema.ts @@ -1,5 +1,9 @@ import { z } from "@hono/zod-openapi" -import { insertChannelSchema, selectChannelSchema } from "@repo/db/schema" +import { + insertChannelSchema, + selectChannelSchema, + updateChannelSchema, +} from "@repo/db/schema" import { listMessagesQuerySchema, listMessagesResponseSchema, @@ -44,6 +48,16 @@ export const createChannelRequestSchema = insertChannelSchema export const createChannelResponseSchema = selectChannelSchema +// ── Update / Delete ────────────────────────────────────────── + +export const updateChannelRequestSchema = updateChannelSchema + +export const updateChannelResponseSchema = selectChannelSchema + +export const deleteChannelResponseSchema = z.object({ + success: z.literal(true), +}) + // ── Messages ────────────────────────────────────────── export { 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 64602c7..9a33bdf 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-list.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-list.tsx @@ -15,6 +15,8 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" +import { authClient } from "@repo/auth/client" +import type { GuildRole } from "@repo/auth/permissions" import { DropdownMenu, DropdownMenuContent, @@ -38,6 +40,9 @@ import { AnimatePresence, motion } from "motion/react" import { useCallback, useState } from "react" import { apiClient } from "@/lib/api-client" import type { Channel, ListChannelsResponse } from "@/lib/api-types" +import { canDeleteChannels, canManageChannels } from "@/lib/permissions" +import { DeleteChannelDialog } from "./delete-channel-dialog" +import { EditChannelDialog } from "./edit-channel-dialog" const channelIcons = { text: Hash, @@ -137,6 +142,21 @@ export function ChannelList() { }, }) + const { data: activeMember } = useQuery({ + queryKey: ["active-guild-member", guildSlug], + queryFn: async () => { + const res = await authClient.organization.getActiveMember() + return res.data + }, + enabled: !!guildSlug, + }) + const canManage = activeMember?.role + ? canManageChannels(activeMember.role as GuildRole) + : false + const canDelete = activeMember?.role + ? canDeleteChannels(activeMember.role as GuildRole) + : false + const [activeItem, setActiveItem] = useState<{ channel: Channel isCategory: boolean @@ -355,15 +375,16 @@ export function ChannelList() { ch.id)} strategy={verticalListSortingStrategy} + disabled={!canManage} >
{data.uncategorized.map((ch) => ( navigate({ to: "/$guildSlug/$channelId", @@ -383,6 +404,7 @@ export function ChannelList() { cat.id)} strategy={verticalListSortingStrategy} + disabled={!canManage} > {data.categories.map((cat) => ( navigate({ to: "/$guildSlug/$channelId", @@ -432,6 +456,8 @@ function SortableCategorySection({ channels, draggingCategory, activeChannelId, + canManage, + canDelete, onChannelClick, }: { id: string @@ -439,6 +465,8 @@ function SortableCategorySection({ channels: Channel[] draggingCategory: boolean activeChannelId?: string + canManage: boolean + canDelete: boolean onChannelClick?: (channelId: string) => void }) { const [collapsed, setCollapsed] = useState(false) @@ -449,7 +477,7 @@ function SortableCategorySection({ transform, transition, isDragging, - } = useSortable({ id }) + } = useSortable({ id, disabled: !canManage }) const style = { transform: CSS.Translate.toString(transform), @@ -462,8 +490,7 @@ function SortableCategorySection({