diff --git a/apps/api/src/routes/v1/guilds/handlers.ts b/apps/api/src/routes/v1/guilds/handlers.ts index 319e821..71d34d2 100644 --- a/apps/api/src/routes/v1/guilds/handlers.ts +++ b/apps/api/src/routes/v1/guilds/handlers.ts @@ -3,8 +3,10 @@ import { getGuildRolePosition, } from "@repo/auth/permissions" import { and, count, db, desc, eq, ilike, inArray, schema } from "@repo/db" +import { env } from "@repo/env/server" import { PRESENCE_ONLINE_USERS_SET_KEY } from "@repo/realtime-types" import { asc } from "drizzle-orm" +import { HTTPException } from "hono/http-exception" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" import { logger } from "@/lib/logger" import { @@ -21,6 +23,7 @@ import type { SearchMessagesRoute, TimeoutGuildMemberRoute, UpdateGuildMemberRoleRoute, + UpdateGuildRoute, } from "@/routes/v1/guilds/routes" const PRESENCE_MEMBERSHIP_CHUNK_SIZE = 250 @@ -470,6 +473,76 @@ export const clearGuildMemberTimeout: AppRouteHandler< ) } +// ── Guild Settings ───────────────────────────────────── + +export const updateGuild: AppRouteHandler = async (c) => { + const guild = c.var.guild + const actor = c.var.member + + assertGuildPermission(actor, guild, { + organization: ["update"], + }) + + const body = c.req.valid("json") + + const guildIconPrefix = `${env.S3_PUBLIC_URL.replace(/\/$/, "")}/guild-icons/${guild.id}/` + if (body.logo && !body.logo.startsWith(guildIconPrefix)) { + throw new HTTPException(HttpStatusCodes.BAD_REQUEST, { + message: "Invalid logo URL", + }) + } + + const updates: Record = {} + if (body.name !== undefined) updates.name = body.name + if (body.logo !== undefined) updates.logo = body.logo + + if (Object.keys(updates).length === 0) { + return c.json( + { + success: true as const, + guild: { + id: guild.id, + name: guild.name, + slug: guild.slug, + logo: guild.logo, + }, + }, + HttpStatusCodes.OK + ) + } + + const [updated] = await db + .update(schema.guild) + .set(updates) + .where(eq(schema.guild.id, guild.id)) + .returning({ + id: schema.guild.id, + name: schema.guild.name, + slug: schema.guild.slug, + logo: schema.guild.logo, + }) + + if (!updated) { + return c.json( + { success: false, message: "Guild not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + return c.json( + { + success: true as const, + guild: { + id: updated.id, + name: updated.name, + slug: updated.slug, + logo: updated.logo, + }, + }, + HttpStatusCodes.OK + ) +} + // ── Search ────────────────────────────────────────────── export const searchMessages: AppRouteHandler = async ( diff --git a/apps/api/src/routes/v1/guilds/index.ts b/apps/api/src/routes/v1/guilds/index.ts index 50442dd..d94aa01 100644 --- a/apps/api/src/routes/v1/guilds/index.ts +++ b/apps/api/src/routes/v1/guilds/index.ts @@ -5,6 +5,7 @@ import * as routes from "@/routes/v1/guilds/routes" const guildsRouter = createRouter() .openapi(routes.listGuildMembers, handlers.listGuildMembers) .openapi(routes.searchMessages, handlers.searchMessages) + .openapi(routes.updateGuild, handlers.updateGuild) .openapi(routes.updateGuildMemberRole, handlers.updateGuildMemberRole) .openapi(routes.kickGuildMember, handlers.kickGuildMember) .openapi(routes.banGuildMember, handlers.banGuildMember) diff --git a/apps/api/src/routes/v1/guilds/routes.ts b/apps/api/src/routes/v1/guilds/routes.ts index d4358d8..f2874e5 100644 --- a/apps/api/src/routes/v1/guilds/routes.ts +++ b/apps/api/src/routes/v1/guilds/routes.ts @@ -21,6 +21,8 @@ import { timeoutGuildMemberResponseSchema, updateGuildMemberRoleRequestSchema, updateGuildMemberRoleResponseSchema, + updateGuildRequestSchema, + updateGuildResponseSchema, } from "./schema" export const listGuildMembers = createRoute({ @@ -210,3 +212,31 @@ export const searchMessages = createRoute({ }) export type SearchMessagesRoute = typeof searchMessages + +export const updateGuild = createRoute({ + path: "/guilds/{guildSlug}", + method: "patch", + summary: "Update guild settings", + description: "Updates guild name and/or logo. Requires admin or owner role.", + tags: ["Guilds"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildSlugParamsSchema, + body: jsonContent({ + schema: updateGuildRequestSchema, + description: "Guild fields to update", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: updateGuildResponseSchema, + description: "Updated guild", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type UpdateGuildRoute = typeof updateGuild diff --git a/apps/api/src/routes/v1/guilds/schema.ts b/apps/api/src/routes/v1/guilds/schema.ts index 5401a32..645d33c 100644 --- a/apps/api/src/routes/v1/guilds/schema.ts +++ b/apps/api/src/routes/v1/guilds/schema.ts @@ -106,6 +106,27 @@ export const timeoutGuildMemberResponseSchema = z.object({ member: guildMemberPresenceSchema, }) +// ── Guild Settings ───────────────────────────────────── + +export const updateGuildRequestSchema = z + .object({ + name: z.string().trim().min(1).max(100).optional(), + logo: z.string().url().nullable().optional(), + }) + .refine((data) => data.name !== undefined || data.logo !== undefined, { + message: "At least one field (name or logo) must be provided", + }) + +export const updateGuildResponseSchema = z.object({ + success: z.literal(true), + guild: z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + logo: z.string().nullable(), + }), +}) + // ── Search ────────────────────────────────────────────── export const searchMessagesQuerySchema = paginationQuerySchema.extend({ diff --git a/apps/api/src/routes/v1/uploads/handlers.ts b/apps/api/src/routes/v1/uploads/handlers.ts index 7c74774..5df0e20 100644 --- a/apps/api/src/routes/v1/uploads/handlers.ts +++ b/apps/api/src/routes/v1/uploads/handlers.ts @@ -1,15 +1,31 @@ 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, guildMember } from "@repo/db/schema" +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 { assertMemberCanCommunicate } from "@/lib/permissions" +import { + assertGuildPermission, + assertMemberCanCommunicate, +} from "@/lib/permissions" import { s3Client } from "@/lib/s3" import type { AppRouteHandler } from "@/lib/types/app-types" -import type { AvatarPresignRoute, PresignRoute } from "./routes" -import { MAX_AVATAR_SIZE, PRESIGNED_URL_EXPIRY_SECONDS } from "./schema" +import type { + AvatarPresignRoute, + GuildIconPresignRoute, + PresignRoute, +} from "./routes" +import { + MAX_AVATAR_SIZE, + MAX_GUILD_ICON_SIZE, + PRESIGNED_URL_EXPIRY_SECONDS, +} from "./schema" const DM_CHANNEL_TYPES = ["dm", "group_dm"] as const @@ -44,6 +60,8 @@ export const presign: AppRouteHandler = async (c) => { const member = await db .select({ id: guildMember.id, + role: guildMember.role, + userId: guildMember.userId, communicationDisabledUntil: guildMember.communicationDisabledUntil, }) .from(guildMember) @@ -64,6 +82,39 @@ export const presign: AppRouteHandler = async (c) => { } assertMemberCanCommunicate(member) + + // Block uploads in announcement channels for users without permission + 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) + .where(eq(guild.id, ch.guildId)) + .limit(1) + .then((rows) => rows[0]) + + if ( + !guildRecord || + !guildAuthorityHasPermissions( + { + role: member.role as GuildRole, + isOwner: guildRecord.ownerId === member.userId, + }, + { announcement: ["send"] } + ) + ) { + return c.json( + { success: false, message: "Forbidden" }, + HttpStatusCodes.FORBIDDEN + ) + } + } } else if ( DM_CHANNEL_TYPES.includes(ch.type as (typeof DM_CHANNEL_TYPES)[number]) ) { @@ -143,3 +194,68 @@ export const avatarPresign: AppRouteHandler = async (c) => { return c.json({ uploadUrl, fileUrl }, HttpStatusCodes.OK) } + +export const guildIconPresign: AppRouteHandler = async ( + c +) => { + const user = c.var.user + const { guildId, filename, contentType, size } = c.req.valid("json") + + if (size > MAX_GUILD_ICON_SIZE) { + return c.json( + { success: false, message: "File too large" }, + HttpStatusCodes.REQUEST_TOO_LONG + ) + } + + // Verify guild exists and user has update permission + const guildRecord = await db + .select({ ownerId: guild.ownerId }) + .from(guild) + .where(eq(guild.id, guildId)) + .limit(1) + .then((rows) => rows[0]) + + if (!guildRecord) { + return c.json( + { success: false, message: "Forbidden" }, + HttpStatusCodes.FORBIDDEN + ) + } + + const member = await db + .select({ role: guildMember.role, userId: guildMember.userId }) + .from(guildMember) + .where( + and(eq(guildMember.guildId, guildId), eq(guildMember.userId, user.id)) + ) + .limit(1) + .then((rows) => rows[0]) + + if (!member) { + return c.json( + { success: false, message: "Forbidden" }, + HttpStatusCodes.FORBIDDEN + ) + } + + assertGuildPermission(member, guildRecord, { organization: ["update"] }) + + const sanitizedFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_") + const key = `guild-icons/${guildId}/${crypto.randomUUID()}/${sanitizedFilename}` + + const command = new PutObjectCommand({ + Bucket: env.S3_BUCKET_NAME, + Key: key, + ContentType: contentType, + ContentLength: size, + }) + + const uploadUrl = await getSignedUrl(s3Client, command, { + expiresIn: PRESIGNED_URL_EXPIRY_SECONDS, + }) + + const fileUrl = `${env.S3_PUBLIC_URL.replace(/\/$/, "")}/${key}` + + return c.json({ uploadUrl, fileUrl }, HttpStatusCodes.OK) +} diff --git a/apps/api/src/routes/v1/uploads/index.ts b/apps/api/src/routes/v1/uploads/index.ts index 692df02..c3b1237 100644 --- a/apps/api/src/routes/v1/uploads/index.ts +++ b/apps/api/src/routes/v1/uploads/index.ts @@ -5,5 +5,6 @@ import * as routes from "./routes" const uploadsRouter = createRouter() .openapi(routes.presign, handlers.presign) .openapi(routes.avatarPresign, handlers.avatarPresign) + .openapi(routes.guildIconPresign, handlers.guildIconPresign) export default uploadsRouter diff --git a/apps/api/src/routes/v1/uploads/routes.ts b/apps/api/src/routes/v1/uploads/routes.ts index 8a943ce..700dbbb 100644 --- a/apps/api/src/routes/v1/uploads/routes.ts +++ b/apps/api/src/routes/v1/uploads/routes.ts @@ -11,6 +11,8 @@ import { sessionAuthMiddleware } from "@/middleware/session-auth" import { avatarPresignRequestSchema, avatarPresignResponseSchema, + guildIconPresignRequestSchema, + guildIconPresignResponseSchema, presignRequestSchema, presignResponseSchema, } from "./schema" @@ -69,3 +71,31 @@ export const avatarPresign = createRoute({ }) export type AvatarPresignRoute = typeof avatarPresign + +export const guildIconPresign = createRoute({ + path: "/uploads/guild-icon/presign", + method: "post", + summary: "Request a presigned URL for guild icon upload", + description: + "Returns a presigned URL for uploading a guild icon to S3-compatible storage.", + tags: ["Uploads"], + middleware: [sessionAuthMiddleware] as const, + request: { + body: jsonContent({ + schema: guildIconPresignRequestSchema, + description: "Guild icon file metadata", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: guildIconPresignResponseSchema, + description: "Presigned URL for guild icon upload", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.REQUEST_TOO_LONG]: payloadTooLargeSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type GuildIconPresignRoute = typeof guildIconPresign diff --git a/apps/api/src/routes/v1/uploads/schema.ts b/apps/api/src/routes/v1/uploads/schema.ts index 22b9b2b..10bdfcd 100644 --- a/apps/api/src/routes/v1/uploads/schema.ts +++ b/apps/api/src/routes/v1/uploads/schema.ts @@ -47,3 +47,30 @@ export const avatarPresignResponseSchema = z.object({ uploadUrl: z.string().url(), fileUrl: z.string().url(), }) + +// ── Guild Icon ───────────────────────────────────────── + +const GUILD_ICON_MIME_TYPES = [ + "image/jpeg", + "image/png", + "image/webp", + "image/svg+xml", +] as const + +export const MAX_GUILD_ICON_SIZE = 2 * 1024 * 1024 // 2 MB + +export const guildIconPresignRequestSchema = z.object({ + guildId: z.string().uuid(), + filename: z.string().min(1).max(256), + contentType: z + .string() + .refine((ct) => (GUILD_ICON_MIME_TYPES as readonly string[]).includes(ct), { + message: "Unsupported image type", + }), + size: z.number().int().min(1).max(MAX_GUILD_ICON_SIZE), +}) + +export const guildIconPresignResponseSchema = z.object({ + uploadUrl: z.string().url(), + fileUrl: z.string().url(), +}) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index a81711f..454e0e2 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -18,7 +18,8 @@ "minWidth": 800, "minHeight": 600, "resizable": true, - "fullscreen": false + "fullscreen": false, + "dragDropEnabled": false } ], "security": { diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts index f572782..7c1a862 100644 --- a/apps/realtime/src/services/messages.ts +++ b/apps/realtime/src/services/messages.ts @@ -1,3 +1,8 @@ +import { + type GuildRole, + guildAuthorityHasPermissions, + isGuildRole, +} from "@repo/auth/permissions" import { and, count, db, eq, schema } from "@repo/db" import type { DeleteMessagePayload, @@ -68,6 +73,23 @@ 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) { + const role = channelRecord.memberRole + if ( + !role || + !isGuildRole(role) || + !guildAuthorityHasPermissions( + { role: role as GuildRole, isOwner: channelRecord.memberIsOwner }, + { announcement: ["send"] } + ) + ) { + throw new Error( + "Only owners, admins, and wardens can post in decree channels" + ) + } + } + let hasReply = !!input.payload.referencedMessageId const messageWithAuthor = await db.transaction(async (tx) => { diff --git a/apps/web/src/components/chat/composer/message-input.tsx b/apps/web/src/components/chat/composer/message-input.tsx index 6a6d292..551573b 100644 --- a/apps/web/src/components/chat/composer/message-input.tsx +++ b/apps/web/src/components/chat/composer/message-input.tsx @@ -88,6 +88,8 @@ const SLASH_COMMANDS: SlashCommandItem[] = [ interface MessageInputProps { context: ChatContext + disabled?: boolean + disabledReason?: string onSend: ( content: string, options?: { @@ -401,6 +403,8 @@ function getActiveCodeBlockPos(editor: { export function MessageInput({ context, + disabled, + disabledReason, onSend, isSending, currentUserId, @@ -703,6 +707,19 @@ export function MessageInput({ [addFiles] ) + if (disabled) { + return ( +
+
+ + {disabledReason ?? + "You do not have permission to send messages in this channel"} + +
+
+ ) + } + return (
MAX_GUILD_ICON_BYTES) { + return "Icon must be under 2 MB" + } + return null +} + +type Guild = { + id: string + name: string + slug: string + logo?: string | null +} + +export function GuildSettingsDialog({ + open, + onOpenChange, + guild, +}: { + open: boolean + onOpenChange: (open: boolean) => void + guild: Guild +}) { + const queryClient = useQueryClient() + const [name, setName] = useState(guild.name) + const [iconPreview, setIconPreview] = useState(null) + const [iconFile, setIconFile] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const fileInputRef = useRef(null) + const iconPreviewRef = useRef(null) + const dragCountRef = useRef(0) + + useEffect(() => { + setName(guild.name) + setIconFile(null) + if (iconPreviewRef.current) { + URL.revokeObjectURL(iconPreviewRef.current) + iconPreviewRef.current = null + } + setIconPreview(null) + }, [guild, open]) + + useEffect(() => { + return () => { + if (iconPreviewRef.current) URL.revokeObjectURL(iconPreviewRef.current) + } + }, []) + + const setIconFromFile = useCallback((file: File) => { + const error = validateIconFile(file) + if (error) { + toast.error(error) + return + } + setIconFile(file) + if (iconPreviewRef.current) URL.revokeObjectURL(iconPreviewRef.current) + const url = URL.createObjectURL(file) + iconPreviewRef.current = url + setIconPreview(url) + }, []) + + const handleIconSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) setIconFromFile(file) + }, + [setIconFromFile] + ) + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCountRef.current += 1 + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCountRef.current -= 1 + if (dragCountRef.current === 0) setIsDragging(false) + }, []) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCountRef.current = 0 + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) setIconFromFile(file) + }, + [setIconFromFile] + ) + + const uploadIcon = useCallback( + async (file: File): Promise => { + const res = await apiClient.v1.uploads["guild-icon"].presign.$post({ + json: { + guildId: guild.id, + filename: file.name, + contentType: file.type, + size: file.size, + }, + }) + + if (!res.ok) throw new Error("Failed to get upload URL") + + const { uploadUrl, fileUrl } = await res.json() + + const uploadRes = await fetch(uploadUrl, { + method: "PUT", + body: file, + headers: { "Content-Type": file.type }, + }) + + if (!uploadRes.ok) throw new Error("Failed to upload icon") + + return fileUrl + }, + [guild.id] + ) + + const handleSave = useCallback(async () => { + setIsSaving(true) + try { + let logoUrl: string | null | undefined + if (iconFile) { + logoUrl = await uploadIcon(iconFile) + } + + const res = await apiClient.v1.guilds[":guildSlug"].$patch({ + param: { guildSlug: guild.slug }, + json: { + ...(name.trim() !== guild.name ? { name: name.trim() } : {}), + ...(logoUrl !== undefined ? { logo: logoUrl } : {}), + }, + }) + + if (!res.ok) throw new Error("Failed to update guild") + + setIconFile(null) + if (iconPreview) { + URL.revokeObjectURL(iconPreview) + iconPreviewRef.current = null + setIconPreview(null) + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["guilds"] }), + queryClient.invalidateQueries({ queryKey: ["active-guild"] }), + ]) + + toast.success("Guild updated") + onOpenChange(false) + } catch { + toast.error("Failed to update guild") + } finally { + setIsSaving(false) + } + }, [ + guild, + name, + iconFile, + iconPreview, + uploadIcon, + queryClient, + onOpenChange, + ]) + + const hasChanges = name.trim() !== guild.name || iconFile !== null + const isValid = name.trim().length > 0 + + const displayIcon = iconPreview ?? guild.logo + const initials = guild.name + .split(" ") + .map((w) => w[0]) + .join("") + .slice(0, 2) + + return ( + + + + Guild Settings + + +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: drop zone for icon upload */} +
+ +
+ + + + or drag & drop + +
+
+ +
+ + setName(e.target.value)} + maxLength={100} + placeholder="My Awesome Guild" + /> +
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx b/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx index 244c818..a1ad746 100644 --- a/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx +++ b/apps/web/src/components/sidebar/channel-panel/channel-panel.tsx @@ -6,12 +6,15 @@ import { UserBar } from "./user-bar" export function ChannelPanel() { return ( -
+
- - - +
+ + +
+ +
) diff --git a/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx b/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx index 1dfd806..fc33e8b 100644 --- a/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx +++ b/apps/web/src/components/sidebar/channel-panel/create-channel-dialog.tsx @@ -23,7 +23,7 @@ import { apiClient } from "@/lib/api-client" const channelTypes = [ { value: "text", label: "Text Channel", icon: Hash }, - { value: "announcement", label: "Announcement", icon: Megaphone }, + { value: "announcement", label: "Decree", icon: Megaphone }, ] as const export function CreateChannelDialog({ 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 c92f275..26e1117 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,5 @@ import { authClient } from "@repo/auth/client" -import { isGuildRole } from "@repo/auth/permissions" +import { isGuildRole, roleHasPermissions } from "@repo/auth/permissions" import { DropdownMenu, DropdownMenuContent, @@ -9,8 +9,9 @@ import { } from "@repo/ui/components/dropdown-menu" import { useQuery } from "@tanstack/react-query" import { useParams } from "@tanstack/react-router" -import { ChevronDown, Link, UserPlus } from "lucide-react" +import { ChevronDown, Link, Settings, UserPlus } from "lucide-react" 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" @@ -19,6 +20,7 @@ export function GuildHeader() { const { guildSlug } = useParams({ strict: false }) const [inviteDialogOpen, setInviteDialogOpen] = useState(false) const [manageInvitesOpen, setManageInvitesOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) const { data: guilds, isPending } = useQuery({ queryKey: ["guilds"], @@ -42,21 +44,31 @@ export function GuildHeader() { enabled: !!guildSlug, }) + const memberRole = + typeof activeMember?.role === "string" && isGuildRole(activeMember.role) + ? activeMember.role + : null + const canManageInvites = - typeof activeMember?.role === "string" && - isGuildRole(activeMember.role) && - canKickGuildMembers(activeMember.role) + memberRole !== null && canKickGuildMembers(memberRole) + const canEditGuild = + memberRole !== null && + roleHasPermissions(memberRole, { organization: ["update"] }) - const guildName = useMemo( - () => guilds?.find((g) => g.slug === guildSlug)?.name, + const activeGuild = useMemo( + () => guilds?.find((g) => g.slug === guildSlug) ?? null, [guilds, guildSlug] ) + const guildName = activeGuild?.name + const title = isPending ? "Loading..." : (guildName ?? "Guild not found") - if (!canManageInvites) { + const showDropdown = canManageInvites || canEditGuild + + if (!showDropdown) { return ( -
+

{title}

@@ -70,7 +82,7 @@ export function GuildHeader() { - setInviteDialogOpen(true)}> - - Invite People - - setManageInvitesOpen(true)}> - - Manage Invites - + {canManageInvites && ( + <> + setInviteDialogOpen(true)}> + + Invite People + + setManageInvitesOpen(true)}> + + Manage Invites + + + )} + {canEditGuild && ( + <> + {canManageInvites && } + setSettingsOpen(true)}> + + Guild Settings + + + )} @@ -98,6 +123,13 @@ export function GuildHeader() { open={manageInvitesOpen} onOpenChange={setManageInvitesOpen} /> + {canEditGuild && activeGuild && ( + + )} ) } diff --git a/apps/web/src/components/sidebar/channel-panel/search-bar.tsx b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx index a41ae31..95b148b 100644 --- a/apps/web/src/components/sidebar/channel-panel/search-bar.tsx +++ b/apps/web/src/components/sidebar/channel-panel/search-bar.tsx @@ -129,7 +129,7 @@ export function SearchBar({ mode = "guild" }: { mode?: "guild" | "dm" }) { } return ( -
+
+
diff --git a/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx b/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx index 9241810..7f3295f 100644 --- a/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx +++ b/apps/web/src/components/sidebar/guild-bar/guild-bar.tsx @@ -40,9 +40,13 @@ function GuildIcon({
{logo ? ( @@ -69,12 +73,12 @@ export function GuildBar() { }, }) return ( -
+
{/* Home / DMs button */}