refactor: use extracted PermissionsEditor in ChannelSettingsModal
This commit is contained in:
@@ -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 { Modal } from '../ui/Modal';
|
||||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore';
|
||||||
import { api } from '../../api/client';
|
|
||||||
import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions';
|
import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions';
|
||||||
import { Toggle } from '../ui/Toggle';
|
import { Toggle } from '../ui/Toggle';
|
||||||
import type { Role, MemberWithUser } from '@backspace/shared';
|
import { PermissionsEditor } from '../ui/PermissionsEditor';
|
||||||
|
import type { PermissionDef } from '../ui/OverrideEntry';
|
||||||
interface ChannelOverride {
|
|
||||||
channelId: string;
|
|
||||||
targetType: string;
|
|
||||||
targetId: string;
|
|
||||||
allow: string;
|
|
||||||
deny: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Permission Definitions for Channel Overrides ──────────────────────────────
|
// ─── Permission Definitions for Channel Overrides ──────────────────────────────
|
||||||
|
|
||||||
interface PermissionDef {
|
|
||||||
key: keyof typeof PermissionBits;
|
|
||||||
label: string;
|
|
||||||
bit: bigint;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TEXT_CHANNEL_PERMISSIONS: PermissionDef[] = [
|
const TEXT_CHANNEL_PERMISSIONS: PermissionDef[] = [
|
||||||
{ key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL },
|
{ key: 'VIEW_CHANNEL', label: 'View Channel', bit: PermissionBits.VIEW_CHANNEL },
|
||||||
{ key: 'SEND_MESSAGES', label: 'Send Messages', bit: PermissionBits.SEND_MESSAGES },
|
{ 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 },
|
{ 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 (
|
|
||||||
<div className="flex items-center gap-0.5 bg-surface-input rounded-full p-0.5">
|
|
||||||
<button
|
|
||||||
className={btnClass('deny', value === 'deny')}
|
|
||||||
onClick={() => !disabled && onChange(value === 'deny' ? 'neutral' : 'deny')}
|
|
||||||
title="Deny"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={btnClass('neutral', value === 'neutral')}
|
|
||||||
onClick={() => !disabled && onChange('neutral')}
|
|
||||||
title="Neutral (inherit)"
|
|
||||||
>
|
|
||||||
/
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={btnClass('allow', value === 'allow')}
|
|
||||||
onClick={() => !disabled && onChange(value === 'allow' ? 'neutral' : 'allow')}
|
|
||||||
title="Allow"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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 (
|
|
||||||
<div className="rounded-lg bg-white/[0.02] overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-interactive-hover transition-colors"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
||||||
style={{ backgroundColor: color || '#b9bbbe' }}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-txt-primary flex-1 text-left truncate">{label}</span>
|
|
||||||
{!expanded && summary.length > 0 && (
|
|
||||||
<span className="text-[11px] text-txt-tertiary flex-shrink-0">
|
|
||||||
{summary.length} override{summary.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{onRemove && !isEveryone && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
|
||||||
className="p-0.5 text-txt-muted hover:text-accent-rose transition-colors"
|
|
||||||
title="Remove override"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<svg
|
|
||||||
width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
|
|
||||||
className={`text-txt-muted transition-transform flex-shrink-0 ${expanded ? 'rotate-180' : ''}`}
|
|
||||||
>
|
|
||||||
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{expanded && (
|
|
||||||
<div className="px-3 pb-3 space-y-1.5 border-t border-white/[0.04] pt-2">
|
|
||||||
{permDefs.map((perm) => (
|
|
||||||
<div key={perm.key} className="flex items-center justify-between">
|
|
||||||
<span className="text-[13px] text-txt-secondary">{perm.label}</span>
|
|
||||||
<TriStateToggle
|
|
||||||
value={getState(perm.bit)}
|
|
||||||
onChange={(v) => setState(perm.bit, v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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<Map<string, { allow: bigint; deny: bigint }>>(new Map());
|
|
||||||
const [pendingRemovals, setPendingRemovals] = useState<Set<string>>(new Set());
|
|
||||||
const [newOverrides, setNewOverrides] = useState<Map<string, { targetType: string; targetId: string; allow: bigint; deny: bigint }>>(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<HTMLDivElement>(null);
|
|
||||||
const memberDropdownRef = useRef<HTMLDivElement>(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<string, ChannelOverride>();
|
|
||||||
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<string>();
|
|
||||||
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<string>();
|
|
||||||
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<any>[] = [];
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<div className="space-y-4 relative pb-14">
|
|
||||||
{/* Role Overrides */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
|
||||||
Role Overrides
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{roleOverrides.map(({ key, role }) => {
|
|
||||||
const eff = getEffective(key);
|
|
||||||
return (
|
|
||||||
<OverrideEntry
|
|
||||||
key={key}
|
|
||||||
label={role.name}
|
|
||||||
color={role.color}
|
|
||||||
permDefs={permDefs}
|
|
||||||
allow={eff.allow}
|
|
||||||
deny={eff.deny}
|
|
||||||
onChange={(a, d) => handleChange(key, a, d)}
|
|
||||||
onRemove={() => handleRemove(key)}
|
|
||||||
isEveryone={role.id === spaceId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Role */}
|
|
||||||
<div className="mt-2 relative">
|
|
||||||
{!showAddRole ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddRole(true)}
|
|
||||||
className="flex items-center gap-1.5 text-[13px] text-txt-tertiary hover:text-txt-secondary transition-colors"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
|
||||||
</svg>
|
|
||||||
Add Role
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div ref={roleDropdownRef} className="glass rounded-lg overflow-hidden">
|
|
||||||
<div className="p-1.5 max-h-48 overflow-y-auto scrollbar-thin">
|
|
||||||
{availableRoles.length === 0 ? (
|
|
||||||
<div className="px-2.5 py-1.5 text-xs text-txt-muted">No more roles to add</div>
|
|
||||||
) : (
|
|
||||||
availableRoles.map(role => (
|
|
||||||
<button
|
|
||||||
key={role.id}
|
|
||||||
onClick={() => handleAddRole(role.id)}
|
|
||||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-txt-secondary hover:text-txt-primary hover:bg-interactive-hover rounded transition-colors"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
|
||||||
style={{ backgroundColor: role.color || '#b9bbbe' }}
|
|
||||||
/>
|
|
||||||
{role.name}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-white/[0.04] p-1.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddRole(false)}
|
|
||||||
className="w-full text-xs text-txt-muted hover:text-txt-tertiary px-2.5 py-1 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Member Overrides */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
|
||||||
Member Overrides
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{memberOverrides.map(({ key, member }) => {
|
|
||||||
const eff = getEffective(key);
|
|
||||||
return (
|
|
||||||
<OverrideEntry
|
|
||||||
key={key}
|
|
||||||
label={member.user.displayName ?? member.user.username}
|
|
||||||
permDefs={permDefs}
|
|
||||||
allow={eff.allow}
|
|
||||||
deny={eff.deny}
|
|
||||||
onChange={(a, d) => handleChange(key, a, d)}
|
|
||||||
onRemove={() => handleRemove(key)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Member */}
|
|
||||||
<div className="mt-2 relative">
|
|
||||||
{!showAddMember ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAddMember(true)}
|
|
||||||
className="flex items-center gap-1.5 text-[13px] text-txt-tertiary hover:text-txt-secondary transition-colors"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
|
||||||
</svg>
|
|
||||||
Add Member
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div ref={memberDropdownRef} className="glass rounded-lg overflow-hidden">
|
|
||||||
<div className="p-1.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={memberSearch}
|
|
||||||
onChange={(e) => setMemberSearch(e.target.value)}
|
|
||||||
placeholder="Search members..."
|
|
||||||
className="input-search w-full mb-1"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="px-1.5 max-h-48 overflow-y-auto scrollbar-thin">
|
|
||||||
{availableMembers.length === 0 ? (
|
|
||||||
<div className="px-2.5 py-1.5 text-xs text-txt-muted">No members found</div>
|
|
||||||
) : (
|
|
||||||
availableMembers.map(member => (
|
|
||||||
<button
|
|
||||||
key={member.userId}
|
|
||||||
onClick={() => handleAddMember(member.userId)}
|
|
||||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm text-txt-secondary hover:text-txt-primary hover:bg-interactive-hover rounded transition-colors"
|
|
||||||
>
|
|
||||||
<span className="truncate">{member.user.displayName ?? member.user.username}</span>
|
|
||||||
{member.user.displayName && (
|
|
||||||
<span className="text-txt-muted text-xs truncate">@{member.user.username}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-white/[0.04] p-1.5">
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowAddMember(false); setMemberSearch(''); }}
|
|
||||||
className="w-full text-xs text-txt-muted hover:text-txt-tertiary px-2.5 py-1 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{saveError && (
|
|
||||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
|
||||||
{saveError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Save/Discard pill */}
|
|
||||||
{hasChanges && (
|
|
||||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
|
||||||
<div className="flex justify-center pt-3 pb-1">
|
|
||||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 pointer-events-auto animate-slide-up">
|
|
||||||
<button
|
|
||||||
onClick={handleDiscard}
|
|
||||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
|
||||||
>
|
|
||||||
Discard
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? 'Saving...' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Overview Tab ───────────────────────────────────────────────────────────────
|
// ─── Overview Tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function OverviewTab({
|
function OverviewTab({
|
||||||
@@ -815,7 +139,6 @@ export function ChannelSettingsModal() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [overrides, setOverrides] = useState<ChannelOverride[]>([]);
|
|
||||||
|
|
||||||
const isOpen = activeModal === 'channelSettings';
|
const isOpen = activeModal === 'channelSettings';
|
||||||
const channelId = modalData?.channelId as string | undefined;
|
const channelId = modalData?.channelId as string | undefined;
|
||||||
@@ -831,12 +154,11 @@ export function ChannelSettingsModal() {
|
|||||||
setShowDeleteConfirm(false);
|
setShowDeleteConfirm(false);
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
setTab('overview');
|
setTab('overview');
|
||||||
setOverrides([]);
|
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Fetch overrides when modal opens
|
// Fetch overrides for the private toggle (overview tab)
|
||||||
const fetchOverrides = useCallback(() => {
|
const fetchPrivateState = useCallback(() => {
|
||||||
if (!channelId || !currentSpaceId) return;
|
if (!channelId || !currentSpaceId) return;
|
||||||
|
|
||||||
setIsFetching(true);
|
setIsFetching(true);
|
||||||
@@ -846,8 +168,7 @@ export function ChannelSettingsModal() {
|
|||||||
const channelApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
const channelApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||||
|
|
||||||
channelApi.channels.getOverrides(channelId)
|
channelApi.channels.getOverrides(channelId)
|
||||||
.then((data: ChannelOverride[]) => {
|
.then((data: { targetType: string; targetId: string; allow: string; deny: string }[]) => {
|
||||||
setOverrides(data);
|
|
||||||
// Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied
|
// Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied
|
||||||
const everyoneOverride = data.find(
|
const everyoneOverride = data.find(
|
||||||
o => o.targetType === 'role' && o.targetId === currentSpaceId
|
o => o.targetType === 'role' && o.targetId === currentSpaceId
|
||||||
@@ -869,19 +190,20 @@ export function ChannelSettingsModal() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && channelId && currentSpaceId) {
|
if (isOpen && channelId && currentSpaceId) {
|
||||||
fetchOverrides();
|
fetchPrivateState();
|
||||||
} else {
|
} else {
|
||||||
setIsFetching(false);
|
setIsFetching(false);
|
||||||
}
|
}
|
||||||
}, [isOpen, channelId, currentSpaceId, fetchOverrides]);
|
}, [isOpen, channelId, currentSpaceId, fetchPrivateState]);
|
||||||
|
|
||||||
if (!isOpen || !channel || !channelId || !currentSpaceId) return null;
|
if (!isOpen || !channel || !channelId || !currentSpaceId) return null;
|
||||||
|
|
||||||
|
const space = spaces.find(s => s.id === currentSpaceId);
|
||||||
|
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const space = spaces.find(s => s.id === currentSpaceId);
|
|
||||||
const channelApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
const channelApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -899,8 +221,8 @@ export function ChannelSettingsModal() {
|
|||||||
await channelApi.channels.deleteOverride(channelId, 'role', currentSpaceId);
|
await channelApi.channels.deleteOverride(channelId, 'role', currentSpaceId);
|
||||||
setIsPrivate(false);
|
setIsPrivate(false);
|
||||||
}
|
}
|
||||||
// Re-fetch overrides to keep permissions tab in sync
|
// Re-fetch to keep in sync
|
||||||
fetchOverrides();
|
fetchPrivateState();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update channel privacy');
|
setError(err instanceof Error ? err.message : 'Failed to update channel privacy');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -912,7 +234,6 @@ export function ChannelSettingsModal() {
|
|||||||
if (!channelId || !currentSpaceId) return;
|
if (!channelId || !currentSpaceId) return;
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
const space = spaces.find(s => s.id === currentSpaceId);
|
|
||||||
const channelApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
const channelApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||||
await channelApi.channels.delete(channelId);
|
await channelApi.channels.delete(channelId);
|
||||||
closeModal();
|
closeModal();
|
||||||
@@ -963,12 +284,23 @@ export function ChannelSettingsModal() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === 'permissions' && (
|
{tab === 'permissions' && (
|
||||||
<PermissionsTab
|
<PermissionsEditor
|
||||||
channelId={channelId}
|
entityId={channelId}
|
||||||
channelType={channel.type}
|
|
||||||
spaceId={currentSpaceId}
|
spaceId={currentSpaceId}
|
||||||
overrides={overrides}
|
instanceOrigin={space?._instanceOrigin}
|
||||||
onOverridesChange={fetchOverrides}
|
permDefs={channel.type === 'voice' ? VOICE_CHANNEL_PERMISSIONS : TEXT_CHANNEL_PERMISSIONS}
|
||||||
|
getOverrides={() => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user