diff --git a/packages/web/src/components/modals/CategorySettingsModal.tsx b/packages/web/src/components/modals/CategorySettingsModal.tsx
new file mode 100644
index 00000000..8589a565
--- /dev/null
+++ b/packages/web/src/components/modals/CategorySettingsModal.tsx
@@ -0,0 +1,387 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { Modal } from '../ui/Modal';
+import { ConfirmDialog } from '../ui/ConfirmDialog';
+import { useUIStore } from '../../stores/uiStore';
+import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore';
+import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions';
+import { Toggle } from '../ui/Toggle';
+import { PermissionsEditor } from '../ui/PermissionsEditor';
+import type { PermissionDef } from '../ui/OverrideEntry';
+
+// ─── Permission Definitions for Category Overrides ──────────────────────────────
+
+const CATEGORY_PERMISSIONS: PermissionDef[] = [
+ { key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL },
+ { key: 'SEND_MESSAGES', label: 'Send Messages', bit: PermissionBits.SEND_MESSAGES },
+ { key: 'MANAGE_MESSAGES', label: 'Manage Messages', bit: PermissionBits.MANAGE_MESSAGES },
+ { key: 'ATTACH_FILES', label: 'Attach Files', bit: PermissionBits.ATTACH_FILES },
+ { key: 'READ_MESSAGE_HISTORY', label: 'Read Message History', bit: PermissionBits.READ_MESSAGE_HISTORY },
+ { key: 'ADD_REACTIONS', label: 'Add Reactions', bit: PermissionBits.ADD_REACTIONS },
+ { key: 'CONNECT', label: 'Connect', bit: PermissionBits.CONNECT },
+ { key: 'SPEAK', label: 'Speak', bit: PermissionBits.SPEAK },
+ { key: 'STREAM', label: 'Stream', bit: PermissionBits.STREAM },
+ { key: 'MUTE_MEMBERS', label: 'Mute Members', bit: PermissionBits.MUTE_MEMBERS },
+ { key: 'DEAFEN_MEMBERS', label: 'Deafen Members', bit: PermissionBits.DEAFEN_MEMBERS },
+ { key: 'MOVE_MEMBERS', label: 'Move Members', bit: PermissionBits.MOVE_MEMBERS },
+ { key: 'DISCONNECT_MEMBERS', label: 'Disconnect Members', bit: PermissionBits.DISCONNECT_MEMBERS },
+];
+
+// ─── Overview Tab ───────────────────────────────────────────────────────────────
+
+function OverviewTab({
+ categoryId,
+ categoryName,
+ isPrivate,
+ isFetching,
+ isLoading,
+ error,
+ canManageChannels,
+ canManageRoles,
+ onTogglePrivate,
+ onDeleteCategory,
+ onRename,
+}: {
+ categoryId: string;
+ categoryName: string;
+ isPrivate: boolean;
+ isFetching: boolean;
+ isLoading: boolean;
+ error: string;
+ canManageChannels: boolean;
+ canManageRoles: boolean;
+ onTogglePrivate: () => void;
+ onDeleteCategory: () => void;
+ onRename: (name: string) => void;
+}) {
+ const [editName, setEditName] = useState(categoryName);
+ const [isSavingName, setIsSavingName] = useState(false);
+
+ // Sync edit name when category name changes externally
+ useEffect(() => {
+ setEditName(categoryName);
+ }, [categoryName]);
+
+ const handleNameBlur = () => {
+ const trimmed = editName.trim();
+ if (trimmed && trimmed !== categoryName) {
+ setIsSavingName(true);
+ onRename(trimmed);
+ // The parent will update the store; we just reset the saving state after a short delay
+ setTimeout(() => setIsSavingName(false), 500);
+ } else {
+ setEditName(categoryName);
+ }
+ };
+
+ const handleNameKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ (e.target as HTMLInputElement).blur();
+ } else if (e.key === 'Escape') {
+ setEditName(categoryName);
+ (e.target as HTMLInputElement).blur();
+ }
+ };
+
+ return (
+
+
+
+ {canManageChannels ? (
+
+
+
setEditName(e.target.value)}
+ onBlur={handleNameBlur}
+ onKeyDown={handleNameKeyDown}
+ disabled={isSavingName}
+ className="input-standard flex-1 py-1.5 px-2 text-sm"
+ maxLength={100}
+ />
+
+ ) : (
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
Private Category
+
+ Only selected members and roles will be able to view channels in this category.
+
+
+
+
+
+
+
+
+ {isPrivate && !isFetching && (
+
+
+
+ This category is hidden from members without explicit access. Channels inside inherit this restriction unless they explicitly override it.
+
+
+ )}
+
+ {canManageChannels && (
+
+
+
+
+ )}
+
+ );
+}
+
+// ─── Main Modal ─────────────────────────────────────────────────────────────────
+
+export function CategorySettingsModal() {
+ const activeModal = useUIStore((s) => s.activeModal);
+ const modalData = useUIStore((s) => s.modalData);
+ const closeModal = useUIStore((s) => s.closeModal);
+ const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
+ const categories = useSpaceStore((s) => s.categories);
+ const spaces = useSpaceStore((s) => s.spaces);
+ const spacePermissions = useSpaceStore((s) => s.spacePermissions);
+
+ const [tab, setTab] = useState<'overview' | 'permissions'>('overview');
+ const [isPrivate, setIsPrivate] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isFetching, setIsFetching] = useState(true);
+ const [error, setError] = useState('');
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const isOpen = activeModal === 'categorySettings';
+ const categoryId = modalData?.categoryId as string | undefined;
+ const category = categories.find(c => c.id === categoryId);
+
+ const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
+ const canManageChannels = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_CHANNELS);
+ const canManageRoles = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES);
+
+ // Reset state when modal closes
+ useEffect(() => {
+ if (!isOpen) {
+ setShowDeleteConfirm(false);
+ setIsDeleting(false);
+ setTab('overview');
+ }
+ }, [isOpen]);
+
+ // Fetch overrides for the private toggle (overview tab)
+ const fetchPrivateState = useCallback(() => {
+ if (!categoryId || !currentSpaceId) return;
+
+ setIsFetching(true);
+ setError('');
+
+ const space = spaces.find(s => s.id === currentSpaceId);
+ const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
+
+ catApi.categories.getOverrides(categoryId)
+ .then((data: { targetType: string; targetId: string; allow: string; deny: string }[]) => {
+ // Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied
+ const everyoneOverride = data.find(
+ o => o.targetType === 'role' && o.targetId === currentSpaceId
+ );
+ if (everyoneOverride) {
+ const denyBits = stringToPermissions(everyoneOverride.deny);
+ setIsPrivate((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n);
+ } else {
+ setIsPrivate(false);
+ }
+ })
+ .catch((err: Error) => {
+ setError(err.message || 'Failed to load category overrides');
+ })
+ .finally(() => {
+ setIsFetching(false);
+ });
+ }, [categoryId, currentSpaceId, spaces]);
+
+ useEffect(() => {
+ if (isOpen && categoryId && currentSpaceId) {
+ fetchPrivateState();
+ } else {
+ setIsFetching(false);
+ }
+ }, [isOpen, categoryId, currentSpaceId, fetchPrivateState]);
+
+ if (!isOpen || !category || !categoryId || !currentSpaceId) return null;
+
+ const space = spaces.find(s => s.id === currentSpaceId);
+
+ const handleToggle = async () => {
+ setError('');
+ setIsLoading(true);
+
+ const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
+
+ try {
+ if (!isPrivate) {
+ // Make private: deny VIEW_CHANNEL for @everyone role
+ await catApi.categories.putOverride(categoryId, {
+ targetType: 'role',
+ targetId: currentSpaceId,
+ allow: '0',
+ deny: permissionsToString(PermissionBits.VIEW_CHANNEL),
+ });
+ setIsPrivate(true);
+ } else {
+ // Make public: remove the @everyone VIEW_CHANNEL deny override
+ await catApi.categories.deleteOverride(categoryId, 'role', currentSpaceId);
+ setIsPrivate(false);
+ }
+ // Re-fetch to keep in sync
+ fetchPrivateState();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to update category privacy');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleRename = async (name: string) => {
+ setError('');
+ try {
+ await useSpaceStore.getState().updateCategory(categoryId, { name });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to rename category');
+ }
+ };
+
+ const handleDeleteCategory = async () => {
+ if (!categoryId) return;
+ setIsDeleting(true);
+ try {
+ await useSpaceStore.getState().deleteCategory(categoryId);
+ closeModal();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to delete category');
+ setIsDeleting(false);
+ }
+ };
+
+ const showTabs = canManageRoles;
+
+ const tabClass = (t: typeof tab) =>
+ `w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${
+ tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
+ }`;
+
+ return (
+ <>
+
+ {showTabs ? (
+
+ {/* Tabs */}
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {tab === 'overview' && (
+
setShowDeleteConfirm(true)}
+ onRename={handleRename}
+ />
+ )}
+ {tab === 'permissions' && (
+ {
+ const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
+ return catApi.categories.getOverrides(categoryId);
+ }}
+ putOverride={(data) => {
+ const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
+ return catApi.categories.putOverride(categoryId, data);
+ }}
+ deleteOverride={(targetType, targetId) => {
+ const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
+ return catApi.categories.deleteOverride(categoryId, targetType, targetId);
+ }}
+ />
+ )}
+
+
+ ) : (
+ setShowDeleteConfirm(true)}
+ onRename={handleRename}
+ />
+ )}
+
+
+ setShowDeleteConfirm(false)}
+ onConfirm={handleDeleteCategory}
+ title={`Delete "${category.name}"?`}
+ description={<>
+ This will permanently delete the {category.name} category. Channels inside it will be moved to the top level. This action cannot be undone.
+ >}
+ confirmLabel="Delete Category"
+ variant="danger"
+ loading={isDeleting}
+ />
+ >
+ );
+}