From 892164f3a5cbe9e9bafa8711e9cae90bee2cfb13 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:35:38 +0100 Subject: [PATCH] refactor: use extracted PermissionsEditor in ChannelSettingsModal --- .../modals/ChannelSettingsModal.tsx | 724 +----------------- 1 file changed, 28 insertions(+), 696 deletions(-) diff --git a/packages/web/src/components/modals/ChannelSettingsModal.tsx b/packages/web/src/components/modals/ChannelSettingsModal.tsx index f810fb5d..3d5898f7 100644 --- a/packages/web/src/components/modals/ChannelSettingsModal.tsx +++ b/packages/web/src/components/modals/ChannelSettingsModal.tsx @@ -1,29 +1,15 @@ -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +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 { api } from '../../api/client'; import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions'; import { Toggle } from '../ui/Toggle'; -import type { Role, MemberWithUser } from '@backspace/shared'; - -interface ChannelOverride { - channelId: string; - targetType: string; - targetId: string; - allow: string; - deny: string; -} +import { PermissionsEditor } from '../ui/PermissionsEditor'; +import type { PermissionDef } from '../ui/OverrideEntry'; // ─── Permission Definitions for Channel Overrides ────────────────────────────── -interface PermissionDef { - key: keyof typeof PermissionBits; - label: string; - bit: bigint; -} - const TEXT_CHANNEL_PERMISSIONS: PermissionDef[] = [ { key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL }, { key: 'SEND_MESSAGES', label: 'Send Messages', bit: PermissionBits.SEND_MESSAGES }, @@ -44,668 +30,6 @@ const VOICE_CHANNEL_PERMISSIONS: PermissionDef[] = [ { key: 'DISCONNECT_MEMBERS', label: 'Disconnect Members', bit: PermissionBits.DISCONNECT_MEMBERS }, ]; -// ─── Tri-State Toggle ────────────────────────────────────────────────────────── - -type TriState = 'allow' | 'neutral' | 'deny'; - -function TriStateToggle({ - value, - onChange, - disabled, -}: { - value: TriState; - onChange: (v: TriState) => void; - disabled?: boolean; -}) { - const btnClass = (v: TriState, active: boolean) => { - const base = 'w-6 h-6 flex items-center justify-center rounded-full transition-colors text-xs font-bold'; - if (disabled) return `${base} cursor-not-allowed opacity-40`; - if (!active) return `${base} cursor-pointer text-txt-muted hover:text-txt-tertiary`; - switch (v) { - case 'deny': return `${base} cursor-pointer bg-accent-rose/15 text-accent-rose`; - case 'neutral': return `${base} cursor-pointer bg-white/[0.06] text-txt-tertiary`; - case 'allow': return `${base} cursor-pointer bg-accent-primary/15 text-accent-primary`; - } - }; - - return ( -
- - - -
- ); -} - -// ─── Override Entry (expandable row) ──────────────────────────────────────────── - -function OverrideEntry({ - label, - color, - permDefs, - allow, - deny, - onChange, - onRemove, - isEveryone, -}: { - label: string; - color?: string; - permDefs: PermissionDef[]; - allow: bigint; - deny: bigint; - onChange: (allow: bigint, deny: bigint) => void; - onRemove?: () => void; - isEveryone?: boolean; -}) { - const [expanded, setExpanded] = useState(false); - - const getState = (bit: bigint): TriState => { - if ((allow & bit) !== 0n) return 'allow'; - if ((deny & bit) !== 0n) return 'deny'; - return 'neutral'; - }; - - const setState = (bit: bigint, state: TriState) => { - let newAllow = allow & ~bit; - let newDeny = deny & ~bit; - if (state === 'allow') newAllow |= bit; - if (state === 'deny') newDeny |= bit; - onChange(newAllow, newDeny); - }; - - // Compact summary of non-neutral permissions - const summary = permDefs.filter(p => getState(p.bit) !== 'neutral'); - - return ( -
- - )} - - - - - {expanded && ( -
- {permDefs.map((perm) => ( -
- {perm.label} - setState(perm.bit, v)} - /> -
- ))} -
- )} -
- ); -} - -// ─── Permissions Tab ──────────────────────────────────────────────────────────── - -function PermissionsTab({ - channelId, - channelType, - spaceId, - overrides, - onOverridesChange, -}: { - channelId: string; - channelType: 'text' | 'voice'; - spaceId: string; - overrides: ChannelOverride[]; - onOverridesChange: () => void; -}) { - const spaces = useSpaceStore((s) => s.spaces); - const space = spaces.find(s => s.id === spaceId); - const roles = useSpaceStore((s) => s.roles); - const members = useSpaceStore((s) => s.members); - - // Draft state: keyed by "role:id" or "member:id" - const [draftOverrides, setDraftOverrides] = useState>(new Map()); - const [pendingRemovals, setPendingRemovals] = useState>(new Set()); - const [newOverrides, setNewOverrides] = useState>(new Map()); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(''); - - // Add role/member dropdown state - const [showAddRole, setShowAddRole] = useState(false); - const [showAddMember, setShowAddMember] = useState(false); - const [memberSearch, setMemberSearch] = useState(''); - - // Refs + click-outside/Escape for dropdown menus - const roleDropdownRef = useRef(null); - const memberDropdownRef = useRef(null); - - useEffect(() => { - if (!showAddRole) return; - const handleMouseDown = (e: MouseEvent) => { - if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) { - setShowAddRole(false); - } - }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setShowAddRole(false); - }; - document.addEventListener('mousedown', handleMouseDown); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handleMouseDown); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [showAddRole]); - - useEffect(() => { - if (!showAddMember) return; - const handleMouseDown = (e: MouseEvent) => { - if (memberDropdownRef.current && !memberDropdownRef.current.contains(e.target as Node)) { - setShowAddMember(false); - setMemberSearch(''); - } - }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - setShowAddMember(false); - setMemberSearch(''); - } - }; - document.addEventListener('mousedown', handleMouseDown); - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('mousedown', handleMouseDown); - document.removeEventListener('keydown', handleKeyDown); - }; - }, [showAddMember]); - - const permDefs = channelType === 'voice' ? VOICE_CHANNEL_PERMISSIONS : TEXT_CHANNEL_PERMISSIONS; - - // Build map of existing overrides keyed by "role:id" or "member:id" - const existingOverrideMap = useMemo(() => { - const map = new Map(); - for (const o of overrides) { - map.set(`${o.targetType}:${o.targetId}`, o); - } - return map; - }, [overrides]); - - // Roles that already have overrides - const existingRoleIds = useMemo(() => { - const set = new Set(); - for (const o of overrides) { - if (o.targetType === 'role') set.add(o.targetId); - } - for (const [key] of newOverrides) { - if (key.startsWith('role:')) set.add(key.slice(5)); - } - return set; - }, [overrides, newOverrides]); - - // Members that already have overrides - const existingMemberIds = useMemo(() => { - const set = new Set(); - for (const o of overrides) { - if (o.targetType === 'member') set.add(o.targetId); - } - for (const [key] of newOverrides) { - if (key.startsWith('member:')) set.add(key.slice(7)); - } - return set; - }, [overrides, newOverrides]); - - // Available roles to add (not already in overrides) - const availableRoles = useMemo(() => - roles.filter(r => !existingRoleIds.has(r.id) && !pendingRemovals.has(`role:${r.id}`)), - [roles, existingRoleIds, pendingRemovals]); - - // Available members to add (not already in overrides), filtered by search - const availableMembers = useMemo(() => { - const filtered = members.filter(m => - !existingMemberIds.has(m.userId) && - !pendingRemovals.has(`member:${m.userId}`) - ); - if (!memberSearch.trim()) return filtered.slice(0, 20); - const q = memberSearch.toLowerCase(); - return filtered.filter(m => - m.user.username.toLowerCase().includes(q) || - (m.user.displayName?.toLowerCase().includes(q)) - ).slice(0, 20); - }, [members, existingMemberIds, pendingRemovals, memberSearch]); - - // Get effective allow/deny for a key — considers drafts, new overrides, and originals - const getEffective = useCallback((key: string): { allow: bigint; deny: bigint } => { - if (newOverrides.has(key)) { - const n = newOverrides.get(key)!; - return { allow: n.allow, deny: n.deny }; - } - if (draftOverrides.has(key)) return draftOverrides.get(key)!; - const orig = existingOverrideMap.get(key); - if (orig) return { allow: stringToPermissions(orig.allow), deny: stringToPermissions(orig.deny) }; - return { allow: 0n, deny: 0n }; - }, [draftOverrides, newOverrides, existingOverrideMap]); - - // Update handler for an override entry - const handleChange = useCallback((key: string, allow: bigint, deny: bigint) => { - if (newOverrides.has(key)) { - setNewOverrides(prev => { - const next = new Map(prev); - const entry = next.get(key)!; - next.set(key, { ...entry, allow, deny }); - return next; - }); - } else { - setDraftOverrides(prev => { - const next = new Map(prev); - next.set(key, { allow, deny }); - return next; - }); - } - }, [newOverrides]); - - // Remove handler - const handleRemove = useCallback((key: string) => { - if (newOverrides.has(key)) { - setNewOverrides(prev => { - const next = new Map(prev); - next.delete(key); - return next; - }); - } else { - setPendingRemovals(prev => { - const next = new Set(prev); - next.add(key); - return next; - }); - // Remove from drafts too - setDraftOverrides(prev => { - const next = new Map(prev); - next.delete(key); - return next; - }); - } - }, [newOverrides]); - - // Add role override - const handleAddRole = useCallback((roleId: string) => { - const key = `role:${roleId}`; - setNewOverrides(prev => { - const next = new Map(prev); - next.set(key, { targetType: 'role', targetId: roleId, allow: 0n, deny: 0n }); - return next; - }); - // If it was pending removal, unmark it - setPendingRemovals(prev => { - const next = new Set(prev); - next.delete(key); - return next; - }); - setShowAddRole(false); - }, []); - - // Add member override - const handleAddMember = useCallback((userId: string) => { - const key = `member:${userId}`; - setNewOverrides(prev => { - const next = new Map(prev); - next.set(key, { targetType: 'member', targetId: userId, allow: 0n, deny: 0n }); - return next; - }); - setPendingRemovals(prev => { - const next = new Set(prev); - next.delete(key); - return next; - }); - setShowAddMember(false); - setMemberSearch(''); - }, []); - - const hasChanges = draftOverrides.size > 0 || newOverrides.size > 0 || pendingRemovals.size > 0; - - const handleDiscard = useCallback(() => { - setDraftOverrides(new Map()); - setNewOverrides(new Map()); - setPendingRemovals(new Set()); - setSaveError(''); - }, []); - - const handleSave = useCallback(async () => { - setSaving(true); - setSaveError(''); - - const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); - - try { - const promises: Promise[] = []; - - // Delete removed overrides - for (const key of pendingRemovals) { - const parts = key.split(':'); - promises.push(channelApi.channels.deleteOverride(channelId, parts[0]!, parts[1]!)); - } - - // Update modified existing overrides - for (const [key, { allow, deny }] of draftOverrides) { - if (pendingRemovals.has(key)) continue; - const parts = key.split(':'); - promises.push(channelApi.channels.putOverride(channelId, { - targetType: parts[0]!, - targetId: parts[1]!, - allow: permissionsToString(allow), - deny: permissionsToString(deny), - })); - } - - // Create new overrides - for (const [, { targetType, targetId, allow, deny }] of newOverrides) { - promises.push(channelApi.channels.putOverride(channelId, { - targetType, - targetId, - allow: permissionsToString(allow), - deny: permissionsToString(deny), - })); - } - - const results = await Promise.allSettled(promises); - const failures = results.filter(r => r.status === 'rejected'); - if (failures.length > 0) { - const first = failures[0] as PromiseRejectedResult; - setSaveError(first.reason?.message || `${failures.length} override(s) failed to save`); - } - - // Reset draft state and re-fetch overrides - setDraftOverrides(new Map()); - setNewOverrides(new Map()); - setPendingRemovals(new Set()); - onOverridesChange(); - } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to save overrides'); - } finally { - setSaving(false); - } - }, [channelId, draftOverrides, newOverrides, pendingRemovals, space, onOverridesChange]); - - // Build ordered lists of role and member overrides - const roleOverrides = useMemo(() => { - const items: { key: string; role: Role; isNew: boolean }[] = []; - - // Existing overrides (excluding pending removals) - for (const o of overrides) { - if (o.targetType !== 'role') continue; - const key = `role:${o.targetId}`; - if (pendingRemovals.has(key)) continue; - const role = roles.find(r => r.id === o.targetId); - if (!role) continue; - items.push({ key, role, isNew: false }); - } - - // New overrides - for (const [key, entry] of newOverrides) { - if (!key.startsWith('role:')) continue; - const role = roles.find(r => r.id === entry.targetId); - if (!role) continue; - if (items.some(i => i.key === key)) continue; - items.push({ key, role, isNew: true }); - } - - // Sort: @everyone first, then by position - items.sort((a, b) => { - if (a.role.id === spaceId) return -1; - if (b.role.id === spaceId) return 1; - return (a.role.position ?? 0) - (b.role.position ?? 0); - }); - - return items; - }, [overrides, newOverrides, pendingRemovals, roles, spaceId]); - - const memberOverrides = useMemo(() => { - const items: { key: string; member: MemberWithUser; isNew: boolean }[] = []; - - for (const o of overrides) { - if (o.targetType !== 'member') continue; - const key = `member:${o.targetId}`; - if (pendingRemovals.has(key)) continue; - const member = members.find(m => m.userId === o.targetId); - if (!member) continue; - items.push({ key, member, isNew: false }); - } - - for (const [key, entry] of newOverrides) { - if (!key.startsWith('member:')) continue; - const member = members.find(m => m.userId === entry.targetId); - if (!member) continue; - if (items.some(i => i.key === key)) continue; - items.push({ key, member, isNew: true }); - } - - return items; - }, [overrides, newOverrides, pendingRemovals, members]); - - return ( -
- {/* Role Overrides */} -
-
- Role Overrides -
-
- {roleOverrides.map(({ key, role }) => { - const eff = getEffective(key); - return ( - handleChange(key, a, d)} - onRemove={() => handleRemove(key)} - isEveryone={role.id === spaceId} - /> - ); - })} -
- - {/* Add Role */} -
- {!showAddRole ? ( - - ) : ( -
-
- {availableRoles.length === 0 ? ( -
No more roles to add
- ) : ( - availableRoles.map(role => ( - - )) - )} -
-
- -
-
- )} -
-
- - {/* Member Overrides */} -
-
- Member Overrides -
-
- {memberOverrides.map(({ key, member }) => { - const eff = getEffective(key); - return ( - handleChange(key, a, d)} - onRemove={() => handleRemove(key)} - /> - ); - })} -
- - {/* Add Member */} -
- {!showAddMember ? ( - - ) : ( -
-
- setMemberSearch(e.target.value)} - placeholder="Search members..." - className="input-search w-full mb-1" - autoFocus - /> -
-
- {availableMembers.length === 0 ? ( -
No members found
- ) : ( - availableMembers.map(member => ( - - )) - )} -
-
- -
-
- )} -
-
- - {/* Error */} - {saveError && ( -
- {saveError} -
- )} - - {/* Save/Discard pill */} - {hasChanges && ( -
-
-
- - -
-
-
- )} -
- ); -} - // ─── Overview Tab ─────────────────────────────────────────────────────────────── function OverviewTab({ @@ -815,7 +139,6 @@ export function ChannelSettingsModal() { const [error, setError] = useState(''); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const [overrides, setOverrides] = useState([]); const isOpen = activeModal === 'channelSettings'; const channelId = modalData?.channelId as string | undefined; @@ -831,12 +154,11 @@ export function ChannelSettingsModal() { setShowDeleteConfirm(false); setIsDeleting(false); setTab('overview'); - setOverrides([]); } }, [isOpen]); - // Fetch overrides when modal opens - const fetchOverrides = useCallback(() => { + // Fetch overrides for the private toggle (overview tab) + const fetchPrivateState = useCallback(() => { if (!channelId || !currentSpaceId) return; setIsFetching(true); @@ -846,8 +168,7 @@ export function ChannelSettingsModal() { const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); channelApi.channels.getOverrides(channelId) - .then((data: ChannelOverride[]) => { - setOverrides(data); + .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 @@ -869,19 +190,20 @@ export function ChannelSettingsModal() { useEffect(() => { if (isOpen && channelId && currentSpaceId) { - fetchOverrides(); + fetchPrivateState(); } else { setIsFetching(false); } - }, [isOpen, channelId, currentSpaceId, fetchOverrides]); + }, [isOpen, channelId, currentSpaceId, fetchPrivateState]); if (!isOpen || !channel || !channelId || !currentSpaceId) return null; + const space = spaces.find(s => s.id === currentSpaceId); + const handleToggle = async () => { setError(''); setIsLoading(true); - const space = spaces.find(s => s.id === currentSpaceId); const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); try { @@ -899,8 +221,8 @@ export function ChannelSettingsModal() { await channelApi.channels.deleteOverride(channelId, 'role', currentSpaceId); setIsPrivate(false); } - // Re-fetch overrides to keep permissions tab in sync - fetchOverrides(); + // Re-fetch to keep in sync + fetchPrivateState(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to update channel privacy'); } finally { @@ -912,7 +234,6 @@ export function ChannelSettingsModal() { if (!channelId || !currentSpaceId) return; setIsDeleting(true); try { - const space = spaces.find(s => s.id === currentSpaceId); const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); await channelApi.channels.delete(channelId); closeModal(); @@ -963,12 +284,23 @@ export function ChannelSettingsModal() { /> )} {tab === 'permissions' && ( - { + const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); + return channelApi.channels.getOverrides(channelId); + }} + putOverride={(data) => { + const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); + return channelApi.channels.putOverride(channelId, data); + }} + deleteOverride={(targetType, targetId) => { + const channelApi = getApiForOrigin(space?._instanceOrigin ?? ''); + return channelApi.channels.deleteOverride(channelId, targetType, targetId); + }} /> )}