From 31db1a7bd329d06b444bfa2322d437644c2ef721 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 9 Mar 2026 00:37:28 +0100 Subject: [PATCH] feat: refactor space settings into panel components with roles management Restructure SpaceSettings modal from 778-line monolith into thin orchestrator with extracted panel components. Add full role CRUD with permission editor (create, edit name/color/permissions, delete). Backend: expose role permissions in GET response, accept permissions in POST/PATCH role endpoints with BigInt validation, broadcast pushReadyPayload to all space members on role mutations. --- packages/server/src/routes/spaces.ts | 64 ++- packages/web/src/api/client.ts | 16 + .../src/components/modals/SpaceSettings.tsx | 300 ++----------- .../spaceSettingsPanels/MembersPanel.tsx | 185 ++++++++ .../spaceSettingsPanels/OverviewPanel.tsx | 274 ++++++++++++ .../modals/spaceSettingsPanels/RolesPanel.tsx | 396 ++++++++++++++++++ 6 files changed, 954 insertions(+), 281 deletions(-) create mode 100644 packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx create mode 100644 packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx create mode 100644 packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 747d64df..1da4f92d 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -243,6 +243,8 @@ export async function spaceRoutes(app: FastifyInstance): Promise { } } + const canManageRoles = (spacePerms & PermissionBits.MANAGE_ROLES) !== 0n; + const result: SpaceWithChannelsAndMembers = { ...rowToSpace(server), channels: visibleChannels, @@ -253,6 +255,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise { name: r.name, color: r.color ?? '#b9bbbe', position: r.position ?? 0, + permissions: canManageRoles ? (r.permissions ?? '0') : undefined, createdAt: r.createdAt, })), myPermissions: permissionsToString(spacePerms), @@ -289,7 +292,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise { } if (icon !== undefined) { - updates.icon = icon; + updates.icon = icon || null; } if (visibility !== undefined) { @@ -764,17 +767,28 @@ export async function spaceRoutes(app: FastifyInstance): Promise { // Role Management // POST /api/spaces/:id/roles - Create a new role - app.post<{ Params: { id: string }; Body: { name: string; color?: string } }>('/api/spaces/:id/roles', { + app.post<{ Params: { id: string }; Body: { name: string; color?: string; permissions?: string } }>('/api/spaces/:id/roles', { preHandler: authenticate, }, async (request, reply) => { const { id } = request.params; - const { name, color } = request.body; + const { name, color, permissions } = request.body; const db = getDb(); if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } + // Validate permissions string is a valid bigint if provided + let permStr: string | undefined; + if (permissions !== undefined) { + try { + BigInt(permissions); + permStr = permissions; + } catch { + return reply.code(400).send({ error: 'Invalid permissions value', statusCode: 400 }); + } + } + const roleId = generateSnowflake(); db.insert(schema.roles).values({ id: roleId, @@ -782,27 +796,60 @@ export async function spaceRoutes(app: FastifyInstance): Promise { name: name || 'new role', color: color || '#b9bbbe', position: 0, + permissions: permStr ?? null, createdAt: Date.now(), }).run(); const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get(); + + // Broadcast updated state to all space members + const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all(); + for (const m of memberRows) { + connectionManager.pushReadyPayload(m.userId); + } + return reply.code(201).send(role); }); // PATCH /api/spaces/:id/roles/:roleId - Update a role - app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number } }>('/api/spaces/:id/roles/:roleId', { + app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number; permissions?: string } }>('/api/spaces/:id/roles/:roleId', { preHandler: authenticate, }, async (request, reply) => { const { id, roleId } = request.params; - const updates = request.body; + const { name, color, position, permissions } = request.body; const db = getDb(); if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) { return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); } + const updates: Partial = {}; + if (name !== undefined) updates.name = name; + if (color !== undefined) updates.color = color; + if (position !== undefined) updates.position = position; + + if (permissions !== undefined) { + try { + BigInt(permissions); + updates.permissions = permissions; + } catch { + return reply.code(400).send({ error: 'Invalid permissions value', statusCode: 400 }); + } + } + + if (Object.keys(updates).length === 0) { + return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); + } + db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run(); const updated = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get(); + + // Broadcast updated state to all space members + const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all(); + for (const m of memberRows) { + connectionManager.pushReadyPayload(m.userId); + } + return reply.code(200).send(updated); }); @@ -828,6 +875,13 @@ export async function spaceRoutes(app: FastifyInstance): Promise { ).run(); db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run(); + + // Broadcast updated state to all space members + const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all(); + for (const m of memberRows) { + connectionManager.pushReadyPayload(m.userId); + } + return reply.code(200).send({ success: true }); }); diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 9b34f70e..fd98ceb5 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -30,6 +30,7 @@ import type { VerifyPasswordResponse, ExploreSpace, JoinRequest, + Role, } from '@backspace/shared'; export class BackspaceApiClient { @@ -117,6 +118,12 @@ export class BackspaceApiClient { info: () => Promise; }; + readonly roles: { + create: (spaceId: string, data: { name: string; color?: string; permissions?: string }) => Promise; + update: (spaceId: string, roleId: string, data: { name?: string; color?: string; position?: number; permissions?: string }) => Promise; + delete: (spaceId: string, roleId: string) => Promise<{ success: boolean }>; + }; + readonly explore: { list: (q?: string, limit?: number, offset?: number) => Promise<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>; publicJoin: (spaceId: string) => Promise; @@ -299,6 +306,15 @@ export class BackspaceApiClient { info: () => request('GET', '/instance/info', undefined, false), }; + this.roles = { + create: (spaceId: string, data: { name: string; color?: string; permissions?: string }) => + request('POST', `/spaces/${spaceId}/roles`, data), + update: (spaceId: string, roleId: string, data: { name?: string; color?: string; position?: number; permissions?: string }) => + request('PATCH', `/spaces/${spaceId}/roles/${roleId}`, data), + delete: (spaceId: string, roleId: string) => + request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/roles/${roleId}`), + }; + this.explore = { list: (q?: string, limit = 50, offset = 0) => { const params = new URLSearchParams(); diff --git a/packages/web/src/components/modals/SpaceSettings.tsx b/packages/web/src/components/modals/SpaceSettings.tsx index f9c77751..e4220963 100644 --- a/packages/web/src/components/modals/SpaceSettings.tsx +++ b/packages/web/src/components/modals/SpaceSettings.tsx @@ -1,13 +1,14 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { Modal } from '../ui/Modal'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore } from '../../stores/spaceStore'; -import { useAuthStore } from '../../stores/authStore'; import { useSettingsStore } from '../../stores/settingsStore'; import { Avatar } from '../ui/Avatar'; import { api } from '../../api/client'; -import { useNavigate } from 'react-router-dom'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; +import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel'; +import { MembersPanel } from './spaceSettingsPanels/MembersPanel'; +import { RolesPanel } from './spaceSettingsPanels/RolesPanel'; import type { InstanceStreamingLimits, SpaceVisibility, JoinRequest } from '@backspace/shared'; const VALID_RESOLUTIONS = [540, 720, 1080] as const; @@ -464,157 +465,47 @@ export function SpaceSettingsModal() { const closeModal = useUIStore((s) => s.closeModal); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const spaces = useSpaceStore((s) => s.spaces); - const members = useSpaceStore((s) => s.members); - const roles = useSpaceStore((s) => s.roles); - const updateSpace = useSpaceStore((s) => s.updateSpace); - const deleteSpace = useSpaceStore((s) => s.deleteSpace); - const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail); - const currentUser = useAuthStore((s) => s.user); const isAdmin = useSettingsStore((s) => s.isAdmin); - const navigate = useNavigate(); - - const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'streaming'>('overview'); - const [spaceName, setSpaceName] = useState(''); - const [error, setError] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [confirmDelete, setConfirmDelete] = useState(false); - const [pendingRoleChanges, setPendingRoleChanges] = useState>>(new Map()); - const spacePermissions = useSpaceStore((s) => s.spacePermissions); + const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'streaming'>('overview'); + const isOpen = activeModal === 'spaceSettings'; const space = spaces.find(s => s.id === currentSpaceId); - const isOwnerUser = space?.ownerId === currentUser?.id; const mySpacePerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined; const canManageSpace = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_SPACE); const canManageRoles = hasPermissionBit(mySpacePerms, PermissionBits.MANAGE_ROLES); - // Assignable roles: exclude @everyone (where role.id === spaceId) - const assignableRoles = roles.filter(r => r.id !== currentSpaceId); - - React.useEffect(() => { - if (space) { - setSpaceName(space.name); - } - }, [space]); - if (!space || !currentSpaceId) return null; - const handleSave = async () => { - setError(''); - setIsLoading(true); - try { - await updateSpace(currentSpaceId, { name: spaceName.trim() }); - setIsLoading(false); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update space'); - setIsLoading(false); - } - }; - - const handleDelete = async () => { - if (!confirmDelete) { - setConfirmDelete(true); - return; - } - try { - await deleteSpace(currentSpaceId); - closeModal(); - navigate('/channels/@me'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete space'); - } - }; - - const getMemberRoleIds = (member: typeof members[number]): Set => { - // Check for pending (unsaved) changes first - const pending = pendingRoleChanges.get(member.userId); - if (pending) return pending; - return new Set(member.roles?.map(r => r.id) ?? []); - }; - - const handleRoleToggle = (userId: string, roleId: string, currentRoleIds: Set) => { - const updated = new Set(currentRoleIds); - if (updated.has(roleId)) { - updated.delete(roleId); - } else { - updated.add(roleId); - } - setPendingRoleChanges(prev => new Map(prev).set(userId, updated)); - }; - - const handleSaveRoles = async (userId: string) => { - const roleIds = pendingRoleChanges.get(userId); - if (!roleIds) return; - - try { - await api.spaces.updateMember(currentSpaceId, userId, { roleIds: Array.from(roleIds) }); - setPendingRoleChanges(prev => { - const next = new Map(prev); - next.delete(userId); - return next; - }); - await loadSpaceDetail(currentSpaceId); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to update roles'); - } - }; - - const handleCancelRoleChange = (userId: string) => { - setPendingRoleChanges(prev => { - const next = new Map(prev); - next.delete(userId); - return next; - }); - }; - - const handleKick = async (userId: string) => { - try { - await api.spaces.removeMember(currentSpaceId, userId); - await loadSpaceDetail(currentSpaceId); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to kick member'); - } - }; + const tabClass = (t: typeof tab) => + `w-full text-left px-3 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 (
{/* Tabs */}
- {canManageSpace && ( - )} - + {canManageRoles && ( + + )} {isAdmin && ( - )} @@ -622,154 +513,11 @@ export function SpaceSettingsModal() { {/* Content */}
- {error && ( -
{error}
- )} - - {tab === 'overview' && ( -
-
- - setSpaceName(e.target.value)} - className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary" - disabled={!canManageSpace} - /> -
- - {canManageSpace && ( - <> - - -
-

Danger Zone

- -
- - )} -
- )} - - {tab === 'discovery' && canManageSpace && currentSpaceId && ( - - )} - - {tab === 'members' && ( -
- {members.map((member) => { - const displayName = member.user.displayName ?? member.user.username; - const isOwner = member.userId === space.ownerId; - const memberRoleIds = getMemberRoleIds(member); - const hasPendingChanges = pendingRoleChanges.has(member.userId); - - return ( -
-
-
- -
-
{displayName}
-
- {isOwner && ( - - Owner - - )} - {member.roles?.filter(r => r.id !== currentSpaceId).map(r => ( - - {r.name} - - ))} - {!isOwner && (!member.roles || member.roles.filter(r => r.id !== currentSpaceId).length === 0) && ( - No roles - )} -
-
-
- - {canManageRoles && member.userId !== currentUser?.id && !isOwner && ( -
- -
- )} -
- - {/* Role checkboxes — shown for non-self, non-owner members when user can manage roles */} - {canManageRoles && member.userId !== currentUser?.id && !isOwner && assignableRoles.length > 0 && ( -
- {assignableRoles.map(role => ( - - ))} - {hasPendingChanges && ( -
- - -
- )} -
- )} -
- ); - })} -
- )} - - {tab === 'streaming' && isAdmin && ( - - )} + {tab === 'overview' && } + {tab === 'discovery' && canManageSpace && } + {tab === 'members' && } + {tab === 'roles' && canManageRoles && } + {tab === 'streaming' && isAdmin && }
diff --git a/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx new file mode 100644 index 00000000..d8079be5 --- /dev/null +++ b/packages/web/src/components/modals/spaceSettingsPanels/MembersPanel.tsx @@ -0,0 +1,185 @@ +import React, { useState } from 'react'; +import { Avatar } from '../../ui/Avatar'; +import { useSpaceStore } from '../../../stores/spaceStore'; +import { useAuthStore } from '../../../stores/authStore'; +import { api } from '../../../api/client'; +import { hasPermissionBit, PermissionBits } from '../../../utils/permissions'; +import type { MemberWithUser } from '@backspace/shared'; + +interface MembersPanelProps { + spaceId: string; +} + +export function MembersPanel({ spaceId }: MembersPanelProps) { + const spaces = useSpaceStore((s) => s.spaces); + const members = useSpaceStore((s) => s.members); + const roles = useSpaceStore((s) => s.roles); + const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail); + const currentUser = useAuthStore((s) => s.user); + const spacePermissions = useSpaceStore((s) => s.spacePermissions); + + const space = spaces.find((s) => s.id === spaceId); + const myPerms = spacePermissions.get(spaceId); + const canManageRoles = hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES); + const canKick = hasPermissionBit(myPerms, PermissionBits.KICK_MEMBERS); + + const [pendingRoleChanges, setPendingRoleChanges] = useState>>(new Map()); + const [error, setError] = useState(''); + + // Assignable roles: exclude @everyone (where role.id === spaceId) + const assignableRoles = roles.filter((r) => r.id !== spaceId); + + if (!space) return null; + + const getMemberRoleIds = (member: MemberWithUser): Set => { + const pending = pendingRoleChanges.get(member.userId); + if (pending) return pending; + return new Set(member.roles?.map((r) => r.id) ?? []); + }; + + const handleRoleToggle = (userId: string, roleId: string, currentRoleIds: Set) => { + const updated = new Set(currentRoleIds); + if (updated.has(roleId)) { + updated.delete(roleId); + } else { + updated.add(roleId); + } + setPendingRoleChanges((prev) => new Map(prev).set(userId, updated)); + }; + + const handleSaveRoles = async (userId: string) => { + const roleIds = pendingRoleChanges.get(userId); + if (!roleIds) return; + try { + await api.spaces.updateMember(spaceId, userId, { roleIds: Array.from(roleIds) }); + setPendingRoleChanges((prev) => { + const next = new Map(prev); + next.delete(userId); + return next; + }); + await loadSpaceDetail(spaceId); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update roles'); + } + }; + + const handleCancelRoleChange = (userId: string) => { + setPendingRoleChanges((prev) => { + const next = new Map(prev); + next.delete(userId); + return next; + }); + }; + + const handleKick = async (userId: string) => { + try { + await api.spaces.removeMember(spaceId, userId); + await loadSpaceDetail(spaceId); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to kick member'); + } + }; + + return ( +
+ {error && ( +
{error}
+ )} +
+ {members.map((member) => { + const displayName = member.user.displayName ?? member.user.username; + const isOwner = member.userId === space.ownerId; + const memberRoleIds = getMemberRoleIds(member); + const hasPendingChanges = pendingRoleChanges.has(member.userId); + + return ( +
+
+
+ +
+
{displayName}
+
+ {isOwner && ( + + Owner + + )} + {member.roles?.filter((r) => r.id !== spaceId).map((r) => ( + + {r.name} + + ))} + {!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && ( + No roles + )} +
+
+
+ + {canKick && member.userId !== currentUser?.id && !isOwner && ( +
+ +
+ )} +
+ + {/* Role checkboxes — shown for non-self, non-owner members when user can manage roles */} + {canManageRoles && member.userId !== currentUser?.id && !isOwner && assignableRoles.length > 0 && ( +
+ {assignableRoles.map((role) => ( + + ))} + {hasPendingChanges && ( +
+ + +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx new file mode 100644 index 00000000..fd4416f9 --- /dev/null +++ b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx @@ -0,0 +1,274 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { ImageCropModal } from '../../ui/ImageCropModal'; +import { Avatar } from '../../ui/Avatar'; +import { useSpaceStore } from '../../../stores/spaceStore'; +import { useAuthStore } from '../../../stores/authStore'; +import { useUIStore } from '../../../stores/uiStore'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../../../api/client'; +import { hasPermissionBit, PermissionBits } from '../../../utils/permissions'; + +interface OverviewPanelProps { + spaceId: string; +} + +export function OverviewPanel({ spaceId }: OverviewPanelProps) { + const spaces = useSpaceStore((s) => s.spaces); + const updateSpace = useSpaceStore((s) => s.updateSpace); + const deleteSpace = useSpaceStore((s) => s.deleteSpace); + const currentUser = useAuthStore((s) => s.user); + const closeModal = useUIStore((s) => s.closeModal); + const spacePermissions = useSpaceStore((s) => s.spacePermissions); + const navigate = useNavigate(); + + const space = spaces.find((s) => s.id === spaceId); + const isOwner = space?.ownerId === currentUser?.id; + const myPerms = spacePermissions.get(spaceId); + const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE); + + const [spaceName, setSpaceName] = useState(space?.name ?? ''); + // null = no change, '' = remove icon, 'filename.png' = new icon uploaded + const [iconFilename, setIconFilename] = useState(null); + const [iconPreview, setIconPreview] = useState(null); + const [uploadingIcon, setUploadingIcon] = useState(false); + const [cropSrc, setCropSrc] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + const [saveSuccess, setSaveSuccess] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const fileInputRef = useRef(null); + + useEffect(() => { + if (space) { + setSpaceName(space.name); + // Reset icon state when space data changes externally + setIconFilename(null); + if (iconPreview) { + URL.revokeObjectURL(iconPreview); + setIconPreview(null); + } + } + }, [space?.name, space?.icon]); + + if (!space) return null; + + const hasNameChange = spaceName.trim() !== space.name; + const hasIconChange = iconFilename !== null; + const hasChanges = hasNameChange || hasIconChange; + + const currentIconUrl = space.icon ? api.uploads.url(space.icon) : null; + + const handleIconSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => setCropSrc(reader.result as string); + reader.readAsDataURL(file); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const handleCropComplete = async (blob: Blob) => { + if (iconPreview) URL.revokeObjectURL(iconPreview); + const previewUrl = URL.createObjectURL(blob); + setIconPreview(previewUrl); + setCropSrc(null); + + const file = new File([blob], 'icon.png', { type: 'image/png' }); + setUploadingIcon(true); + try { + const attachment = await api.uploads.upload(file); + setIconFilename(attachment.filename); + } catch { + setSaveError('Failed to upload icon'); + setIconPreview(null); + URL.revokeObjectURL(previewUrl); + } finally { + setUploadingIcon(false); + } + }; + + const handleRemoveIcon = () => { + if (iconPreview) URL.revokeObjectURL(iconPreview); + setIconPreview(null); + setIconFilename(''); // '' signals removal + }; + + const handleSave = async () => { + setSaving(true); + setSaveError(''); + setSaveSuccess(false); + try { + const updates: { name?: string; icon?: string } = {}; + if (hasNameChange) updates.name = spaceName.trim(); + if (hasIconChange) { + // Empty string signals icon removal to the backend (sets to null) + updates.icon = iconFilename === '' ? '' : iconFilename!; + } + await updateSpace(spaceId, updates); + setIconFilename(null); + if (iconPreview) { + URL.revokeObjectURL(iconPreview); + setIconPreview(null); + } + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2000); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSaving(false); + } + }; + + const handleDiscard = () => { + setSpaceName(space.name); + setIconFilename(null); + if (iconPreview) { + URL.revokeObjectURL(iconPreview); + setIconPreview(null); + } + setSaveError(''); + }; + + const handleDelete = async () => { + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + try { + await deleteSpace(spaceId); + closeModal(); + navigate('/channels/@me'); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to delete space'); + } + }; + + // Determine what icon to show: preview of pending upload, or current space icon + const displayIconSrc = iconPreview ?? (iconFilename === '' ? null : currentIconUrl); + const displayIconName = space.name; + + return ( + <> +
+ {/* Space Icon */} +
+ +
+ + + {canManageSpace && (displayIconSrc || space.icon) && iconFilename !== '' && ( + + )} +
+
+ + {/* Space Name */} +
+ + setSpaceName(e.target.value)} + className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary" + disabled={!canManageSpace} + /> +
+ + {/* Save / Discard */} + {saveError && ( +
{saveError}
+ )} + {saveSuccess && ( +
Settings saved
+ )} + {canManageSpace && hasChanges && ( +
+ + +
+ )} + + {/* Danger Zone */} + {isOwner && ( +
+

Danger Zone

+ +
+ )} +
+ + setCropSrc(null)} + imageSrc={cropSrc ?? ''} + onCropComplete={handleCropComplete} + title="Crop Space Icon" + cropShape="round" + aspectRatio={1} + /> + + ); +} diff --git a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx new file mode 100644 index 00000000..fb89116f --- /dev/null +++ b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx @@ -0,0 +1,396 @@ +import React, { useState } from 'react'; +import { useSpaceStore } from '../../../stores/spaceStore'; +import { api } from '../../../api/client'; +import { PermissionBits, stringToPermissions, permissionsToString } from '../../../utils/permissions'; +import type { Role } from '@backspace/shared'; + +// ─── Permission display groups ───────────────────────────────────────────── + +interface PermDef { + bit: bigint; + label: string; +} + +const PERMISSION_GROUPS: { name: string; perms: PermDef[] }[] = [ + { + name: 'General', + perms: [ + { bit: PermissionBits.ADMINISTRATOR, label: 'Administrator' }, + { bit: PermissionBits.VIEW_CHANNEL, label: 'View Channels' }, + { bit: PermissionBits.MANAGE_CHANNELS, label: 'Manage Channels' }, + { bit: PermissionBits.MANAGE_ROLES, label: 'Manage Roles' }, + { bit: PermissionBits.MANAGE_SPACE, label: 'Manage Space' }, + { bit: PermissionBits.CREATE_INVITE, label: 'Create Invite' }, + { bit: PermissionBits.KICK_MEMBERS, label: 'Kick Members' }, + { bit: PermissionBits.BAN_MEMBERS, label: 'Ban Members' }, + ], + }, + { + name: 'Text', + perms: [ + { bit: PermissionBits.SEND_MESSAGES, label: 'Send Messages' }, + { bit: PermissionBits.MANAGE_MESSAGES, label: 'Manage Messages' }, + { bit: PermissionBits.ATTACH_FILES, label: 'Attach Files' }, + { bit: PermissionBits.READ_MESSAGE_HISTORY, label: 'Read Message History' }, + { bit: PermissionBits.ADD_REACTIONS, label: 'Add Reactions' }, + ], + }, + { + name: 'Voice', + perms: [ + { bit: PermissionBits.CONNECT, label: 'Connect' }, + { bit: PermissionBits.SPEAK, label: 'Speak' }, + { bit: PermissionBits.MUTE_MEMBERS, label: 'Mute Members' }, + { bit: PermissionBits.DEAFEN_MEMBERS, label: 'Deafen Members' }, + { bit: PermissionBits.MOVE_MEMBERS, label: 'Move Members' }, + { bit: PermissionBits.USE_VOICE_ACTIVITY, label: 'Voice Activity' }, + { bit: PermissionBits.STREAM, label: 'Stream' }, + ], + }, +]; + +const PRESET_COLORS = [ + '#b9bbbe', '#a5f3c4', '#ffc9a9', '#c4b5fd', '#93c5fd', + '#fbbf24', '#fda4af', '#f87171', '#60a5fa', '#34d399', +]; + +// ─── Component ────────────────────────────────────────────────────────────── + +interface RolesPanelProps { + spaceId: string; +} + +export function RolesPanel({ spaceId }: RolesPanelProps) { + const roles = useSpaceStore((s) => s.roles); + const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail); + + const [editingRoleId, setEditingRoleId] = useState(null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(''); + + // Sort: non-everyone roles by position desc, @everyone always last + const sortedRoles = [...roles].sort((a, b) => { + const aIsEveryone = a.id === spaceId; + const bIsEveryone = b.id === spaceId; + if (aIsEveryone) return 1; + if (bIsEveryone) return -1; + return b.position - a.position; + }); + + const handleCreateRole = async () => { + setCreating(true); + setError(''); + try { + const newRole = await api.roles.create(spaceId, { name: 'new role' }); + await loadSpaceDetail(spaceId); + setEditingRoleId(newRole.id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create role'); + } finally { + setCreating(false); + } + }; + + if (editingRoleId) { + const role = roles.find((r) => r.id === editingRoleId); + if (!role) { + setEditingRoleId(null); + return null; + } + return ( + setEditingRoleId(null)} + onDeleted={() => setEditingRoleId(null)} + /> + ); + } + + return ( +
+ {error && ( +
{error}
+ )} + + + +
+ {sortedRoles.map((role) => { + const isEveryone = role.id === spaceId; + return ( + + ); + })} +
+
+ ); +} + +// ─── Role Edit View ───────────────────────────────────────────────────────── + +interface RoleEditViewProps { + role: Role; + spaceId: string; + onBack: () => void; + onDeleted: () => void; +} + +function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) { + const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail); + const isEveryone = role.id === spaceId; + + const [draftName, setDraftName] = useState(role.name); + const [draftColor, setDraftColor] = useState(role.color); + const [draftPermissions, setDraftPermissions] = useState( + stringToPermissions(role.permissions) + ); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [saveError, setSaveError] = useState(''); + const [saveSuccess, setSaveSuccess] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + const hasNameChange = !isEveryone && draftName.trim() !== role.name; + const hasColorChange = !isEveryone && draftColor !== role.color; + const hasPermChange = permissionsToString(draftPermissions) !== (role.permissions ?? '0'); + const hasChanges = hasNameChange || hasColorChange || hasPermChange; + + const togglePermission = (bit: bigint) => { + setDraftPermissions((prev) => (prev & bit) !== 0n ? prev & ~bit : prev | bit); + }; + + const handleSave = async () => { + setSaving(true); + setSaveError(''); + setSaveSuccess(false); + try { + const data: { name?: string; color?: string; permissions?: string } = {}; + if (hasNameChange) data.name = draftName.trim(); + if (hasColorChange) data.color = draftColor; + if (hasPermChange) data.permissions = permissionsToString(draftPermissions); + await api.roles.update(spaceId, role.id, data); + await loadSpaceDetail(spaceId); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2000); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save role'); + } finally { + setSaving(false); + } + }; + + const handleDiscard = () => { + setDraftName(role.name); + setDraftColor(role.color); + setDraftPermissions(stringToPermissions(role.permissions)); + setSaveError(''); + }; + + const handleDelete = async () => { + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + setDeleting(true); + setSaveError(''); + try { + await api.roles.delete(spaceId, role.id); + await loadSpaceDetail(spaceId); + onDeleted(); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to delete role'); + setConfirmDelete(false); + } finally { + setDeleting(false); + } + }; + + return ( +
+ {/* Back button */} + + + {/* Role Name (not editable for @everyone) */} + {!isEveryone && ( +
+ + setDraftName(e.target.value)} + className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary text-sm" + /> +
+ )} + + {/* Role Color (not editable for @everyone) */} + {!isEveryone && ( +
+ +
+ {PRESET_COLORS.map((c) => ( +
+ )} + + {/* Permissions */} +
+ +
+ {PERMISSION_GROUPS.map((group) => ( +
+
+ {group.name} +
+
+ {group.perms.map((perm) => { + const isOn = (draftPermissions & perm.bit) !== 0n; + const isAdmin = perm.bit === PermissionBits.ADMINISTRATOR; + return ( +
+ ))} +
+
+ + {/* Save / Discard */} + {saveError && ( +
{saveError}
+ )} + {saveSuccess && ( +
Role saved
+ )} + {hasChanges && ( +
+ + +
+ )} + + {/* Delete Role (not for @everyone) */} + {!isEveryone && ( +
+ +
+ )} +
+ ); +}