From 450df432b22e09fd08ab6098f2344681b585669a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 9 Mar 2026 03:52:01 +0100 Subject: [PATCH] feat: dedicated Instance Settings modal with admin controls Move instance-level administration out of Space Settings into its own modal. Adds admin UI for instance name, registration toggle, and discovery toggle. Streaming limits panel relocated from SpaceSettings. - Add InstanceAdminSettings type and GET/PATCH /api/settings/instance - Add registration_open DB column (nullable, env var fallback) - Auth registration and instance info now check DB override - New InstanceSettings modal with General and Streaming tabs - Admin shield button in UserAreaPanel (visible to admins only) - Remove Streaming tab from SpaceSettings --- packages/server/src/db/migrate.ts | 3 +- packages/server/src/db/schema.ts | 1 + packages/server/src/routes/auth.ts | 11 +- packages/server/src/routes/instance.ts | 7 +- packages/server/src/routes/settings.ts | 69 +++++- packages/shared/src/types.ts | 6 + packages/web/src/api/client.ts | 6 + .../web/src/components/layout/AppLayout.tsx | 2 + .../src/components/layout/ChannelSidebar.tsx | 19 ++ .../components/modals/InstanceSettings.tsx | 53 ++++ .../src/components/modals/SpaceSettings.tsx | 234 +----------------- .../instanceSettingsPanels/GeneralPanel.tsx | 148 +++++++++++ .../instanceSettingsPanels/StreamingPanel.tsx | 226 +++++++++++++++++ packages/web/src/stores/settingsStore.ts | 28 ++- packages/web/src/stores/uiStore.ts | 1 + 15 files changed, 575 insertions(+), 239 deletions(-) create mode 100644 packages/web/src/components/modals/InstanceSettings.tsx create mode 100644 packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx create mode 100644 packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index a929e984..e3452a39 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -69,7 +69,8 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'instance_name', type: "TEXT DEFAULT 'Backspace'" }, { name: 'worker_id', type: 'INTEGER' }, - { name: 'discovery_enabled', type: 'INTEGER NOT NULL DEFAULT 1' } + { name: 'discovery_enabled', type: 'INTEGER NOT NULL DEFAULT 1' }, + { name: 'registration_open', type: 'INTEGER' } ] }, { diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 2028032a..19bdc7f3 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -192,6 +192,7 @@ export const instanceSettings = sqliteTable('instance_settings', { allowedFramerates: text('allowed_framerates').notNull().default('30,45,60'), maxResolution: integer('max_resolution').notNull().default(1080), maxFramerate: integer('max_framerate').notNull().default(60), + registrationOpen: integer('registration_open'), // null = use env var default, 0/1 = explicit updatedAt: integer('updated_at').notNull(), }); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index bed718db..f2ef6b42 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -71,12 +71,17 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 }); } - if (!config.registrationOpen) { + const db = getDb(); + + // Check registration: DB setting overrides env var if explicitly set by admin + const instanceRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + const registrationOpen = instanceRow?.registrationOpen !== null && instanceRow?.registrationOpen !== undefined + ? instanceRow.registrationOpen === 1 + : config.registrationOpen; + if (!registrationOpen) { return reply.code(403).send({ error: 'Registration is currently closed', statusCode: 403 }); } - const db = getDb(); - const existing = db.select().from(schema.users).where(eq(schema.users.username, trimmedUsername)).get(); if (existing) { return reply.code(409).send({ error: 'Username already taken', statusCode: 409 }); diff --git a/packages/server/src/routes/instance.ts b/packages/server/src/routes/instance.ts index e5971cac..8a235b94 100644 --- a/packages/server/src/routes/instance.ts +++ b/packages/server/src/routes/instance.ts @@ -13,10 +13,15 @@ export async function instanceRoutes(app: FastifyInstance): Promise { const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); const instanceName = settings?.instanceName ?? 'Backspace'; + // DB setting overrides env var if explicitly set by admin + const registrationOpen = settings?.registrationOpen !== null && settings?.registrationOpen !== undefined + ? settings.registrationOpen === 1 + : config.registrationOpen; + const response: InstanceInfoResponse = { name: instanceName, version: BACKSPACE_VERSION, - registrationOpen: config.registrationOpen, + registrationOpen, }; return reply.code(200).send(response); diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index 1431d6b2..3a6d7ed5 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -2,7 +2,8 @@ import type { FastifyInstance } from 'fastify'; import { eq } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; -import type { InstanceStreamingLimits } from '@backspace/shared'; +import { config } from '../config.js'; +import type { InstanceStreamingLimits, InstanceAdminSettings } from '@backspace/shared'; const VALID_RESOLUTIONS = [540, 720, 1080]; const VALID_FRAMERATES = [30, 45, 60]; @@ -126,4 +127,70 @@ export async function settingsRoutes(app: FastifyInstance): Promise { return reply.code(200).send(rowToLimits(updatedRow)); }); + + // GET /api/settings/instance — admin only, returns instance admin settings + app.get('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => { + const db = getDb(); + + const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!caller || caller.isAdmin !== 1) { + return reply.code(403).send({ error: 'Only instance admins can view instance settings', statusCode: 403 }); + } + + const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + if (!row) { + return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 }); + } + + const response: InstanceAdminSettings = { + instanceName: row.instanceName ?? 'Backspace', + registrationOpen: row.registrationOpen !== null ? row.registrationOpen === 1 : config.registrationOpen, + discoveryEnabled: row.discoveryEnabled === 1, + }; + + return reply.code(200).send(response); + }); + + // PATCH /api/settings/instance — admin only, updates instance admin settings + app.patch<{ Body: Partial }>('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => { + const db = getDb(); + + const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + if (!caller || caller.isAdmin !== 1) { + return reply.code(403).send({ error: 'Only instance admins can modify instance settings', statusCode: 403 }); + } + + const body = request.body; + const updateData: Record = { updatedAt: Date.now() }; + + if (body.instanceName !== undefined) { + if (typeof body.instanceName !== 'string' || body.instanceName.trim().length === 0 || body.instanceName.trim().length > 32) { + return reply.code(400).send({ error: 'Instance name must be 1-32 characters', statusCode: 400 }); + } + updateData.instanceName = body.instanceName.trim(); + } + + if (body.registrationOpen !== undefined) { + updateData.registrationOpen = body.registrationOpen ? 1 : 0; + } + + if (body.discoveryEnabled !== undefined) { + updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0; + } + + db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run(); + + const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); + if (!updatedRow) { + return reply.code(500).send({ error: 'Failed to read updated settings', statusCode: 500 }); + } + + const response: InstanceAdminSettings = { + instanceName: updatedRow.instanceName ?? 'Backspace', + registrationOpen: updatedRow.registrationOpen !== null ? updatedRow.registrationOpen === 1 : config.registrationOpen, + discoveryEnabled: updatedRow.discoveryEnabled === 1, + }; + + return reply.code(200).send(response); + }); } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 7d643815..24ac0a3b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -419,6 +419,12 @@ export interface UpdateFriendRequest { // ─── Instance Settings Types ──────────────────────────────────────────────── +export interface InstanceAdminSettings { + instanceName: string; + registrationOpen: boolean; + discoveryEnabled: boolean; +} + export interface InstanceStreamingLimits { maxBitrateKbps: number; minBitrateKbps: number; diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index fd98ceb5..04e6add3 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -26,6 +26,7 @@ import type { Friend, FriendRequest, InstanceStreamingLimits, + InstanceAdminSettings, InstanceInfoResponse, VerifyPasswordResponse, ExploreSpace, @@ -112,6 +113,8 @@ export class BackspaceApiClient { readonly settings: { getStreaming: () => Promise; updateStreaming: (data: Partial) => Promise; + getInstance: () => Promise; + updateInstance: (data: Partial) => Promise; }; readonly instance: { @@ -300,6 +303,9 @@ export class BackspaceApiClient { getStreaming: () => request('GET', '/settings/streaming'), updateStreaming: (data: Partial) => request('PATCH', '/settings/streaming', data), + getInstance: () => request('GET', '/settings/instance'), + updateInstance: (data: Partial) => + request('PATCH', '/settings/instance', data), }; this.instance = { diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 2588af55..7a637718 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -12,6 +12,7 @@ import { CreateChannelModal } from '../modals/CreateChannel'; import { InviteModal } from '../modals/InviteModal'; import { UserSettingsModal } from '../modals/UserSettings'; import { SpaceSettingsModal } from '../modals/SpaceSettings'; +import { InstanceSettingsModal } from '../modals/InstanceSettings'; import { ChannelSettingsModal } from '../modals/ChannelSettingsModal'; import { NewDmModal } from '../modals/NewDmModal'; import { AddDmMemberModal } from '../modals/AddDmMemberModal'; @@ -273,6 +274,7 @@ export function AppLayout() { + diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 60277cc5..654ff4c7 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -122,9 +122,11 @@ export function ChannelSidebar() { user={user} isMuted={isMuted} isDeafened={isDeafened} + isAdmin={!!user.isAdmin} onMicToggle={handleMicToggle} onDeafenToggle={handleDeafenToggle} onSettingsClick={() => openModal('userSettings')} + onAdminClick={() => openModal('instanceSettings')} /> @@ -442,16 +444,20 @@ function UserAreaPanel({ user, isMuted, isDeafened, + isAdmin, onMicToggle, onDeafenToggle, onSettingsClick, + onAdminClick, }: { user: any; isMuted: boolean; isDeafened: boolean; + isAdmin: boolean; onMicToggle: () => void; onDeafenToggle: () => void; onSettingsClick: () => void; + onAdminClick: () => void; }) { const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); const [inputDevices, setInputDevices] = useState([]); @@ -812,6 +818,19 @@ function UserAreaPanel({ + {/* Admin — Instance Settings */} + {isAdmin && ( + + )} + {/* Settings */} + + + + + {/* Content */} +
+ {tab === 'general' && } + {tab === 'streaming' && } +
+ + + ); +} diff --git a/packages/web/src/components/modals/SpaceSettings.tsx b/packages/web/src/components/modals/SpaceSettings.tsx index 39216545..0b2819a3 100644 --- a/packages/web/src/components/modals/SpaceSettings.tsx +++ b/packages/web/src/components/modals/SpaceSettings.tsx @@ -9,230 +9,7 @@ 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; -const VALID_FRAMERATES = [30, 45, 60] as const; - -function formatKbps(kbps: number): string { - return kbps >= 1000 - ? `${(kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1)} Mbps` - : `${kbps} kbps`; -} - -function StreamingLimitsPanel() { - const limits = useSettingsStore((s) => s.streamingLimits); - const updateStreamingLimits = useSettingsStore((s) => s.updateStreamingLimits); - - const [draft, setDraft] = useState(null); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(''); - const [saveSuccess, setSaveSuccess] = useState(false); - - useEffect(() => { - if (limits) setDraft({ ...limits }); - }, [limits]); - - if (!draft) return
Loading settings...
; - - const hasChanges = JSON.stringify(draft) !== JSON.stringify(limits); - - const handleSave = async () => { - setSaving(true); - setSaveError(''); - setSaveSuccess(false); - try { - await updateStreamingLimits(draft); - setSaveSuccess(true); - setTimeout(() => setSaveSuccess(false), 2000); - } catch (err) { - setSaveError(err instanceof Error ? err.message : 'Failed to save'); - } finally { - setSaving(false); - } - }; - - const handleReset = () => { - if (limits) setDraft({ ...limits }); - setSaveError(''); - }; - - const toggleResolution = (res: number) => { - const current = new Set(draft.allowedResolutions); - if (current.has(res)) { - if (current.size <= 1) return; // Must have at least one - current.delete(res); - } else { - current.add(res); - } - setDraft({ ...draft, allowedResolutions: Array.from(current).sort((a, b) => a - b) }); - }; - - const toggleFramerate = (fps: number) => { - const current = new Set(draft.allowedFramerates); - if (current.has(fps)) { - if (current.size <= 1) return; - current.delete(fps); - } else { - current.add(fps); - } - setDraft({ ...draft, allowedFramerates: Array.from(current).sort((a, b) => a - b) }); - }; - - const pillBase = 'px-3 py-1.5 rounded text-[13px] font-medium transition-colors cursor-pointer select-none'; - const pillOn = 'bg-accent-primary text-white'; - const pillOff = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover'; - - return ( -
-
- These limits apply to all users on this instance. Users can pick values within these bounds. -
- - {/* Bandwidth */} -
-
Bandwidth
-

Minimum and maximum bitrate bounds, and the step size for the quality slider.

-
- {/* Bitrate Range */} -
-
- Bitrate Range -
-
-
- - setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })} - className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md - [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0" - /> -
{formatKbps(draft.minBitrateKbps)}
-
-
- - setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })} - className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md - [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0" - /> -
{formatKbps(draft.maxBitrateKbps)}
-
-
-
- - {/* Bitrate Step */} -
-
- Slider Step -
-
- { - const v = Number(e.target.value); - if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v }); - }} - className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary" - /> - kbps -
-
-
-
- - {/* Quality */} -
-
Quality
-

Available resolution and frame rate options for screen sharing.

-
- {/* Allowed Resolutions */} -
-
- Allowed Resolutions -
-
- {VALID_RESOLUTIONS.map((res) => ( - - ))} -
-
- - {/* Allowed Frame Rates */} -
-
- Allowed Frame Rates -
-
- {VALID_FRAMERATES.map((fps) => ( - - ))} -
-
-
-
- - {/* Save / Reset */} - {saveError && ( -
{saveError}
- )} - {saveSuccess && ( -
Settings saved
- )} - {hasChanges && ( -
-
-
- - -
-
-
- )} -
- ); -} +import type { SpaceVisibility, JoinRequest } from '@backspace/shared'; function DiscoveryPanel({ spaceId }: { spaceId: string }) { const spaces = useSpaceStore((s) => s.spaces); @@ -490,10 +267,9 @@ export function SpaceSettingsModal() { const closeModal = useUIStore((s) => s.closeModal); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const spaces = useSpaceStore((s) => s.spaces); - const isAdmin = useSettingsStore((s) => s.isAdmin); const spacePermissions = useSpaceStore((s) => s.spacePermissions); - const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'streaming'>('overview'); + const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles'>('overview'); const isOpen = activeModal === 'spaceSettings'; const space = spaces.find(s => s.id === currentSpaceId); @@ -530,11 +306,6 @@ export function SpaceSettingsModal() { Roles )} - {isAdmin && ( - - )} @@ -544,7 +315,6 @@ export function SpaceSettingsModal() { {tab === 'discovery' && canManageSpace && } {tab === 'members' && } {tab === 'roles' && canManageRoles && } - {tab === 'streaming' && isAdmin && } diff --git a/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx new file mode 100644 index 00000000..8a697ce5 --- /dev/null +++ b/packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx @@ -0,0 +1,148 @@ +import { useState, useEffect } from 'react'; +import { useSettingsStore } from '../../../stores/settingsStore'; +import type { InstanceAdminSettings } from '@backspace/shared'; + +export function GeneralPanel() { + const instanceSettings = useSettingsStore((s) => s.instanceSettings); + const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); + + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + const [saveSuccess, setSaveSuccess] = useState(false); + + useEffect(() => { + if (instanceSettings) setDraft({ ...instanceSettings }); + }, [instanceSettings]); + + if (!draft) return
Loading settings...
; + + const hasChanges = JSON.stringify(draft) !== JSON.stringify(instanceSettings); + + const handleSave = async () => { + setSaving(true); + setSaveError(''); + setSaveSuccess(false); + try { + await updateInstanceSettings(draft); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2000); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSaving(false); + } + }; + + const handleReset = () => { + if (instanceSettings) setDraft({ ...instanceSettings }); + setSaveError(''); + }; + + return ( +
+
+ Configure your Backspace instance. These settings affect all users. +
+ + {/* Instance Name */} +
+
Instance Name
+

The name shown on the login page and to federated instances.

+
+ setDraft({ ...draft, instanceName: e.target.value.slice(0, 32) })} + placeholder="Backspace" + className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary placeholder:text-txt-tertiary" + /> +
{draft.instanceName.length}/32
+
+
+ + {/* Registration */} +
+
Registration
+
+ +
+
+ + {/* Discovery */} +
+
Discovery
+
+ +
+
+ + {/* Status messages */} + {saveError && ( +
{saveError}
+ )} + {saveSuccess && ( +
Settings saved
+ )} + + {/* Save / Reset bar */} + {hasChanges && ( +
+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx new file mode 100644 index 00000000..32bdf322 --- /dev/null +++ b/packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx @@ -0,0 +1,226 @@ +import { useState, useEffect } from 'react'; +import { useSettingsStore } from '../../../stores/settingsStore'; +import type { InstanceStreamingLimits } from '@backspace/shared'; + +const VALID_RESOLUTIONS = [540, 720, 1080] as const; +const VALID_FRAMERATES = [30, 45, 60] as const; + +function formatKbps(kbps: number): string { + return kbps >= 1000 + ? `${(kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1)} Mbps` + : `${kbps} kbps`; +} + +export function StreamingPanel() { + const limits = useSettingsStore((s) => s.streamingLimits); + const updateStreamingLimits = useSettingsStore((s) => s.updateStreamingLimits); + + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + const [saveSuccess, setSaveSuccess] = useState(false); + + useEffect(() => { + if (limits) setDraft({ ...limits }); + }, [limits]); + + if (!draft) return
Loading settings...
; + + const hasChanges = JSON.stringify(draft) !== JSON.stringify(limits); + + const handleSave = async () => { + setSaving(true); + setSaveError(''); + setSaveSuccess(false); + try { + await updateStreamingLimits(draft); + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 2000); + } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save'); + } finally { + setSaving(false); + } + }; + + const handleReset = () => { + if (limits) setDraft({ ...limits }); + setSaveError(''); + }; + + const toggleResolution = (res: number) => { + const current = new Set(draft.allowedResolutions); + if (current.has(res)) { + if (current.size <= 1) return; + current.delete(res); + } else { + current.add(res); + } + setDraft({ ...draft, allowedResolutions: Array.from(current).sort((a, b) => a - b) }); + }; + + const toggleFramerate = (fps: number) => { + const current = new Set(draft.allowedFramerates); + if (current.has(fps)) { + if (current.size <= 1) return; + current.delete(fps); + } else { + current.add(fps); + } + setDraft({ ...draft, allowedFramerates: Array.from(current).sort((a, b) => a - b) }); + }; + + const pillBase = 'px-3 py-1.5 rounded text-[13px] font-medium transition-colors cursor-pointer select-none'; + const pillOn = 'bg-accent-primary text-white'; + const pillOff = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover'; + + return ( +
+
+ These limits apply to all users on this instance. Users can pick values within these bounds. +
+ + {/* Bandwidth */} +
+
Bandwidth
+

Minimum and maximum bitrate bounds, and the step size for the quality slider.

+
+ {/* Bitrate Range */} +
+
+ Bitrate Range +
+
+
+ + setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })} + className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full + [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md + [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0" + /> +
{formatKbps(draft.minBitrateKbps)}
+
+
+ + setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })} + className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full + [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md + [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0" + /> +
{formatKbps(draft.maxBitrateKbps)}
+
+
+
+ + {/* Bitrate Step */} +
+
+ Slider Step +
+
+ { + const v = Number(e.target.value); + if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v }); + }} + className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary" + /> + kbps +
+
+
+
+ + {/* Quality */} +
+
Quality
+

Available resolution and frame rate options for screen sharing.

+
+ {/* Allowed Resolutions */} +
+
+ Allowed Resolutions +
+
+ {VALID_RESOLUTIONS.map((res) => ( + + ))} +
+
+ + {/* Allowed Frame Rates */} +
+
+ Allowed Frame Rates +
+
+ {VALID_FRAMERATES.map((fps) => ( + + ))} +
+
+
+
+ + {/* Save / Reset */} + {saveError && ( +
{saveError}
+ )} + {saveSuccess && ( +
Settings saved
+ )} + {hasChanges && ( +
+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/packages/web/src/stores/settingsStore.ts b/packages/web/src/stores/settingsStore.ts index c2f57cb2..61e4fa20 100644 --- a/packages/web/src/stores/settingsStore.ts +++ b/packages/web/src/stores/settingsStore.ts @@ -1,12 +1,15 @@ import { create } from 'zustand'; -import type { InstanceStreamingLimits } from '@backspace/shared'; +import type { InstanceStreamingLimits, InstanceAdminSettings } from '@backspace/shared'; import { api } from '../api/client'; interface SettingsState { streamingLimits: InstanceStreamingLimits | null; + instanceSettings: InstanceAdminSettings | null; isAdmin: boolean; fetchStreamingLimits: () => Promise; updateStreamingLimits: (limits: Partial) => Promise; + fetchInstanceSettings: () => Promise; + updateInstanceSettings: (data: Partial) => Promise; setIsAdmin: (isAdmin: boolean) => void; } @@ -27,6 +30,7 @@ export function getStreamingLimits(): InstanceStreamingLimits { export const useSettingsStore = create((set) => ({ streamingLimits: null, + instanceSettings: null, isAdmin: false, fetchStreamingLimits: async () => { @@ -44,5 +48,27 @@ export const useSettingsStore = create((set) => ({ set({ streamingLimits: updated }); }, + fetchInstanceSettings: async () => { + try { + const settings = await api.settings.getInstance(); + set({ instanceSettings: settings }); + } catch (err) { + console.warn('[Settings] Failed to fetch instance settings:', err); + } + }, + + updateInstanceSettings: async (data: Partial) => { + const updated = await api.settings.updateInstance(data); + set({ instanceSettings: updated }); + // If discoveryEnabled changed, also update it in streamingLimits for the DiscoveryPanel warning banner + if (data.discoveryEnabled !== undefined) { + set((state) => ({ + streamingLimits: state.streamingLimits + ? { ...state.streamingLimits, discoveryEnabled: updated.discoveryEnabled } + : state.streamingLimits, + })); + } + }, + setIsAdmin: (isAdmin: boolean) => set({ isAdmin }), })); diff --git a/packages/web/src/stores/uiStore.ts b/packages/web/src/stores/uiStore.ts index 51177d76..8e7397e2 100644 --- a/packages/web/src/stores/uiStore.ts +++ b/packages/web/src/stores/uiStore.ts @@ -10,6 +10,7 @@ type ModalType = | 'userSettings' | 'spaceSettings' | 'channelSettings' + | 'instanceSettings' | 'imagePreview' | 'newDm' | 'addDmMember'