feat: bans system, voice moderation, and federated space settings fixes
Add ban/unban functionality with BansPanel in space settings, voice moderation context menu (mute/deafen/disconnect), and fix federated space settings panels to use origin-aware API client. Show domain indicators for federated members in MembersPanel.
This commit is contained in:
@@ -9,6 +9,7 @@ import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||
import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
||||
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
|
||||
|
||||
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
||||
@@ -269,13 +270,14 @@ export function SpaceSettingsModal() {
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles'>('overview');
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
|
||||
|
||||
const isOpen = activeModal === 'spaceSettings';
|
||||
const space = spaces.find(s => s.id === currentSpaceId);
|
||||
const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
|
||||
const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE);
|
||||
const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES);
|
||||
const canBanMembers = hasPermissionBit(mySpacePerms, PermissionBits.BAN_MEMBERS);
|
||||
|
||||
if (!space || !currentSpaceId) return null;
|
||||
|
||||
@@ -306,6 +308,11 @@ export function SpaceSettingsModal() {
|
||||
Roles
|
||||
</button>
|
||||
)}
|
||||
{canBanMembers && (
|
||||
<button onClick={() => setTab('bans')} className={tabClass('bans')}>
|
||||
Bans
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -315,6 +322,7 @@ export function SpaceSettingsModal() {
|
||||
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Avatar } from '../../ui/Avatar';
|
||||
import { useSpaceStore, getApiForOrigin } from '../../../stores/spaceStore';
|
||||
|
||||
interface Ban {
|
||||
spaceId: string;
|
||||
userId: string;
|
||||
reason: string | null;
|
||||
bannedBy: string;
|
||||
createdAt: number;
|
||||
user: { id: string; username: string; displayName?: string | null; avatar?: string | null } | null;
|
||||
moderator: { id: string; username: string; displayName?: string | null } | null;
|
||||
}
|
||||
|
||||
interface BansPanelProps {
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export function BansPanel({ spaceId }: BansPanelProps) {
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const space = spaces.find((s) => s.id === spaceId);
|
||||
const spaceApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||
|
||||
const [bans, setBans] = useState<Ban[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadBans = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await spaceApi.spaces.getBans(spaceId);
|
||||
setBans(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load bans');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [spaceId, spaceApi]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBans();
|
||||
}, [loadBans]);
|
||||
|
||||
const handleUnban = async (userId: string) => {
|
||||
try {
|
||||
await spaceApi.spaces.unban(spaceId, userId);
|
||||
setBans((prev) => prev.filter((b) => b.userId !== userId));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to unban user');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-txt-tertiary text-sm">Loading bans...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-txt-tertiary">Banned users cannot rejoin this space until unbanned.</p>
|
||||
|
||||
{bans.length === 0 ? (
|
||||
<div className="text-center py-8 text-txt-tertiary text-sm">No banned users</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Bans ({bans.length})
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||
<div className="space-y-0.5">
|
||||
{bans.map((ban) => {
|
||||
const displayName = ban.user?.displayName ?? ban.user?.username ?? ban.userId;
|
||||
const moderatorName = ban.moderator?.displayName ?? ban.moderator?.username ?? ban.bannedBy;
|
||||
const bannedDate = new Date(ban.createdAt).toLocaleDateString();
|
||||
|
||||
return (
|
||||
<div key={ban.userId} className="flex items-center justify-between p-2 rounded hover:bg-interactive-hover transition-colors">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar
|
||||
src={ban.user?.avatar ?? null}
|
||||
name={displayName}
|
||||
size={32}
|
||||
userId={ban.userId}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{displayName}</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate">
|
||||
Banned by {moderatorName} on {bannedDate}
|
||||
{ban.reason && ` — ${ban.reason}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUnban(ban.userId)}
|
||||
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary hover:bg-surface-base rounded transition-colors flex-shrink-0"
|
||||
>
|
||||
Unban
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar } from '../../ui/Avatar';
|
||||
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||
import { useSpaceStore, getApiForOrigin } from '../../../stores/spaceStore';
|
||||
import { useAuthStore } from '../../../stores/authStore';
|
||||
import { api } from '../../../api/client';
|
||||
import { parseFederatedUsername } from '../../../utils/identity';
|
||||
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
|
||||
import type { MemberWithUser } from '@backspace/shared';
|
||||
|
||||
@@ -19,9 +19,11 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
|
||||
const space = spaces.find((s) => s.id === spaceId);
|
||||
const spaceApi = getApiForOrigin(space?._instanceOrigin ?? '');
|
||||
const myPerms = spacePermissions.get(spaceId);
|
||||
const canManageRoles = hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES);
|
||||
const canKick = hasPermissionBit(myPerms, PermissionBits.KICK_MEMBERS);
|
||||
const canBan = hasPermissionBit(myPerms, PermissionBits.BAN_MEMBERS);
|
||||
|
||||
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
|
||||
const [expandedMemberId, setExpandedMemberId] = useState<string | null>(null);
|
||||
@@ -52,7 +54,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
const roleIds = pendingRoleChanges.get(userId);
|
||||
if (!roleIds) return;
|
||||
try {
|
||||
await api.spaces.updateMember(spaceId, userId, { roleIds: Array.from(roleIds) });
|
||||
await spaceApi.spaces.updateMember(spaceId, userId, { roleIds: Array.from(roleIds) });
|
||||
setPendingRoleChanges((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(userId);
|
||||
@@ -75,13 +77,22 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
|
||||
const handleKick = async (userId: string) => {
|
||||
try {
|
||||
await api.spaces.removeMember(spaceId, userId);
|
||||
await spaceApi.spaces.removeMember(spaceId, userId);
|
||||
await loadSpaceDetail(spaceId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to kick member');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBan = async (userId: string) => {
|
||||
try {
|
||||
await spaceApi.spaces.ban(spaceId, userId);
|
||||
await loadSpaceDetail(spaceId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to ban member');
|
||||
}
|
||||
};
|
||||
|
||||
const canExpandMember = (member: MemberWithUser) =>
|
||||
canManageRoles && member.userId !== currentUser?.id && member.userId !== space.ownerId && assignableRoles.length > 0;
|
||||
|
||||
@@ -99,6 +110,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||
<div className="space-y-0.5">
|
||||
{members.map((member) => {
|
||||
const { domain } = parseFederatedUsername(member.user.username);
|
||||
const displayName = member.user.displayName ?? member.user.username;
|
||||
const isOwner = member.userId === space.ownerId;
|
||||
const memberRoleIds = getMemberRoleIds(member);
|
||||
@@ -127,7 +139,12 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
user={member.user}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{displayName}</div>
|
||||
<div className="text-sm font-medium truncate">
|
||||
{displayName}
|
||||
{domain && (
|
||||
<span className="ml-1 text-[10px] text-txt-tertiary opacity-60">@{domain}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{isOwner && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-rose/20 text-txt-danger font-medium">
|
||||
@@ -151,6 +168,14 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{canBan && member.userId !== currentUser?.id && !isOwner && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleBan(member.userId); }}
|
||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||
>
|
||||
Ban
|
||||
</button>
|
||||
)}
|
||||
{canKick && member.userId !== currentUser?.id && !isOwner && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleKick(member.userId); }}
|
||||
|
||||
Reference in New Issue
Block a user