import React, { useCallback, useRef, useState } from 'react'; import { Box, Button, Checkbox, Dialog, Header, Icon, IconButton, Icons, Input, Overlay, OverlayBackdrop, OverlayCenter, Scroll, Spinner, Text, color, config, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { Page, PageContent, PageHeader } from '../../components/page'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoom } from '../../hooks/useRoom'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; import { sendStateEvent } from '../../utils/room'; import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { SequenceCard } from '../../components/sequence-card'; import { SequenceCardStyle } from '../common-settings/styles.css'; import { stopPropagation } from '../../utils/keyboard'; import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl'; import { MobileTouchTarget } from '../../styles/mobile.css'; import { useModalStyle } from '../../hooks/useModalStyle'; // ── Types ───────────────────────────────────────────────────────────────────── type ServerAclContent = { allow: string[]; deny: string[]; allow_ip_literals: boolean; }; const DEFAULT_ACL: ServerAclContent = { allow: ['*'], deny: [], allow_ip_literals: false, }; // ── Server list sub-component ───────────────────────────────────────────────── type ServerListProps = { label: string; entries: string[]; canEdit: boolean; onAdd: (value: string) => void; onRemove: (index: number) => void; }; function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProps) { const inputRef = useRef(null); const [error, setError] = useState(); const handleAdd = () => { const raw = inputRef.current?.value ?? ''; const value = raw.trim(); if (!value) return; if (!isValidServerPattern(value)) { setError('Invalid pattern. Use a hostname, IP, or glob (e.g. *.evil.com, 1.2.3.*, 10.0.0.?)'); return; } setError(undefined); onAdd(value); if (inputRef.current) inputRef.current.value = ''; }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') handleAdd(); }; return ( {label} {canEdit && ( )} {canEdit && ( {error && ( {error} )} )} {entries.length === 0 ? ( (empty) ) : ( entries.map((entry, i) => ( {entry} {canEdit && ( onRemove(i)} style={{ flexShrink: 0 }} > )} )) )} ); } // ── Main component ──────────────────────────────────────────────────────────── type RoomServerACLProps = { requestClose: () => void; }; export function RoomServerACL({ requestClose }: RoomServerACLProps) { const mx = useMatrixClient(); const room = useRoom(); const modalStyle = useModalStyle(480); // Power level checks const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); const permissions = useRoomPermissions(creators, powerLevels); const myUserId = mx.getSafeUserId(); const canEdit = permissions.stateEvent(StateEvent.RoomServerAcl, myUserId); // Read current ACL from room state const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl); const currentAcl = aclEvent?.getContent() ?? DEFAULT_ACL; // Local draft state — initialised from current ACL const [allowList, setAllowList] = useState(() => currentAcl.allow ?? ['*']); const [denyList, setDenyList] = useState(() => currentAcl.deny ?? []); const [allowIpLiterals, setAllowIpLiterals] = useState( () => currentAcl.allow_ip_literals ?? false, ); // Track whether there are unsaved changes const isDirty = JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) || JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) || allowIpLiterals !== (currentAcl.allow_ip_literals ?? false); // Save handler const [saveState, save] = useAsyncCallback( useCallback(async () => { await sendStateEvent(mx, room.roomId, StateEvent.RoomServerAcl, { allow: allowList, deny: denyList, allow_ip_literals: allowIpLiterals, }); }, [mx, room.roomId, allowList, denyList, allowIpLiterals]), ); const saving = saveState.status === AsyncStatus.Loading; const saveError = saveState.status === AsyncStatus.Error ? 'Failed to save ACL. Please try again.' : undefined; // ── Save guards ─────────────────────────────────────────────────────────── // #1 Empty allow list denies EVERY server (allow: [] is not "allow all") and // partitions the room from all federation irreversibly — block the save. const emptyAllow = allowList.length === 0; // #2 Self-ban: the local homeserver must match at least one allow glob and no // deny glob, otherwise applying this ACL removes our own server from the room. const localDomain = mx.getDomain() ?? ''; const selfBanned = localDomain.length > 0 && (!matchesAnyGlob(localDomain, allowList) || matchesAnyGlob(localDomain, denyList)); // #4 Gate the destructive write behind a confirmation dialog. const [prompt, setPrompt] = useState(false); const handleConfirmSave = () => { setPrompt(false); save(); }; // Required power level for this state event const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl); const myPL = readPowerLevel.user(powerLevels, myUserId); return ( Server ACL {canEdit && ( )} {/* Info banner */} Server ACL controls which servers can participate in this room. Changes take effect immediately. {!canEdit && ( You need power level {requiredPL} to edit the ACL (your level: {myPL}). )} {/* Save error */} {saveError && ( {saveError} )} {/* #1 Empty allow list guard — blocks save */} {canEdit && emptyAllow && ( The allow list is empty. An empty allow list denies every server and partitions this room from all federation permanently. Add at least one entry (use "*" to allow all servers). )} {/* #2 Self-ban warning — save allowed but confirmation required */} {canEdit && !emptyAllow && selfBanned && ( Warning: your own homeserver ({localDomain}) is not permitted by this ACL. Applying it will remove your server from the room and you may lose the ability to moderate it. )} {/* Allow IP literals toggle */} IP Address Access setAllowIpLiterals(!allowIpLiterals)} size="300" variant="Primary" /> {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} When disabled, clients connecting via raw IP addresses (e.g. 1.2.3.4) cannot participate. {/* Allowed servers */} setAllowList((prev) => [...prev, value])} onRemove={(index) => setAllowList((prev) => prev.filter((_, i) => i !== index))} /> {/* Denied servers */} setDenyList((prev) => [...prev, value])} onRemove={(index) => setDenyList((prev) => prev.filter((_, i) => i !== index))} /> {/* Note about defaults */} Tip: The default ACL allows all servers (allow: ["*"], deny: []). Adding "*" to the allow list permits all servers not explicitly denied. {/* #4 Confirmation dialog — surfaces the empty-allow (#1) and self-ban (#2) warnings and keeps a safe save one extra click. */} {prompt && ( }> setPrompt(false), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} >
Apply Server ACL setPrompt(false)} radii="300" aria-label="Cancel" >
Server ACL changes take effect immediately and control which servers can participate in this room. This cannot be undone by other servers once they are removed. {emptyAllow && ( The allow list is empty — this would deny every server and partition the room from all federation permanently. )} {!emptyAllow && selfBanned && ( Warning: your own homeserver ({localDomain}) is not permitted by this ACL. Applying it will remove your server from the room and you may lose the ability to moderate it. )}
)}
); }