Allow toggling room privacy after creation (#48)

is_private was previously only settable at room creation. RoomUpdate now
accepts it, update_room() applies it, and PATCH /api/rooms/{id} allows a
site admin to make the change even for a room they haven't joined (in
addition to the existing room owner/admin gate) -- require_room_role
normally 403s a non-member before the role check ever runs, so this is a
deliberate bypass for site admins specifically.

Flipping the flag has no effect on existing members either direction
(confirmed is_private is only ever checked at self-serve join time) --
it purely controls Browse Rooms visibility and future self-joins.

Frontend: RoomInfoPanel's "Room settings" section is now visible to room
owner, room admin, or site admin (was owner-only), with a privacy toggle
reusing NewRoomModal's existing toggle-switch UI. "Delete room" stays
owner-only, now nested inside that wider section rather than gating the
whole thing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:25:33 -06:00
co-authored by Claude Sonnet 5
parent 54932c9c03
commit 84dc99d1a1
6 changed files with 100 additions and 9 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ export function createRoom(
export function updateRoom(
roomId: string,
data: { name?: string; description?: string },
data: { name?: string; description?: string; is_private?: boolean },
): Promise<Room> {
return apiFetch<Room>(`/api/rooms/${roomId}`, {
method: 'PATCH',
+33 -6
View File
@@ -42,6 +42,7 @@ import { FileAttachmentIcon } from './MessageList'
import { RoomAvatar } from './RoomAvatar'
import { UserAvatar } from './UserAvatar'
import { UserPicker } from './UserPicker'
import './Modal.css'
import './RoomInfoPanel.css'
const EVENT_TYPES: EventType[] = ['message.created', 'message.updated']
@@ -87,6 +88,7 @@ export function RoomInfoPanel({
const [settingsOpen, setSettingsOpen] = useState(false)
const [nameDraft, setNameDraft] = useState(room.name)
const [descDraft, setDescDraft] = useState(room.description ?? '')
const [isPrivateDraft, setIsPrivateDraft] = useState(room.is_private)
const [roomError, setRoomError] = useState<string | null>(null)
const [integrationsOpen, setIntegrationsOpen] = useState(false)
@@ -99,10 +101,15 @@ export function RoomInfoPanel({
const [integrationsError, setIntegrationsError] = useState<string | null>(null)
const canManage = myRole === 'admin' || myRole === 'owner'
// #48: room owner, room admin, or site admin (regardless of their role in
// *this* room) can edit room settings, including privacy -- matches the
// backend PATCH /api/rooms/{id} gate exactly (see rooms.py).
const canEditSettings = canManage || !!user?.is_site_admin
useEffect(() => {
setNameDraft(room.name)
setDescDraft(room.description ?? '')
setIsPrivateDraft(room.is_private)
if (canManage) {
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
@@ -112,7 +119,7 @@ export function RoomInfoPanel({
setEventSubscriptions([])
setDirectoryUsers([])
}
}, [room.id, room.name, room.description, canManage])
}, [room.id, room.name, room.description, room.is_private, canManage])
useEffect(() => {
// Fetched lazily (only once expanded), not alongside the section above
@@ -246,7 +253,11 @@ export function RoomInfoPanel({
e.preventDefault()
setRoomError(null)
try {
await updateRoom(room.id, { name: nameDraft.trim(), description: descDraft.trim() })
await updateRoom(room.id, {
name: nameDraft.trim(),
description: descDraft.trim(),
is_private: isPrivateDraft,
})
onRoomUpdated()
} catch (err) {
setRoomError(err instanceof ApiError ? err.message : String(err))
@@ -500,7 +511,7 @@ export function RoomInfoPanel({
</div>
)}
{myRole === 'owner' && (
{canEditSettings && (
<div className="room-info-section">
<button
type="button"
@@ -519,13 +530,29 @@ export function RoomInfoPanel({
Description
<textarea value={descDraft} onChange={(e) => setDescDraft(e.target.value)} rows={2} />
</label>
<div className="toggle-row">
<div className="toggle-label">
<span className="t">Private room</span>
<span className="d">Joinable by invite only</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={isPrivateDraft}
onChange={(e) => setIsPrivateDraft(e.target.checked)}
/>
<span className="track" />
</label>
</div>
{roomError && <p className="room-info-error">{roomError}</p>}
<button type="submit" className="btn-secondary">
Save
</button>
<button type="button" className="room-info-danger-link" onClick={handleDelete}>
Delete room
</button>
{myRole === 'owner' && (
<button type="button" className="room-info-danger-link" onClick={handleDelete}>
Delete room
</button>
)}
</form>
)}
</div>