Files
cinny/src/app/features/room-settings/RoomServerACL.tsx
T
Lotus CIandClaude Opus 5.5 adf94d7bc1
CI / Build & Quality Checks (pull_request) Successful in 1m54s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m45s
fix(acl): a missing allow_ip_literals means allowed (#261)
The Server ACL editor treated an absent allow_ip_literals as false, so the
first save in a room with no ACL (or one omitting the field) silently banned
IP-literal servers. The spec default is true. normalizeServerAcl() fills the
spec defaults once and feeds the initial state, the dirty check and the save.
It also stops showing an existing ACL with no allow list as allow-all: per
spec that allows no servers, and the /acl command already keeps it as-is.

The checkbox is driven by onClick (folds has no onChange), now readOnly so
React stops warning about a checked input without onChange.

Fixes #261

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:07:03 -04:00

461 lines
17 KiB
TypeScript

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, normalizeServerAcl } from '../../utils/serverAcl';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { useModalStyle } from '../../hooks/useModalStyle';
// ── Types ─────────────────────────────────────────────────────────────────────
// ── 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<HTMLInputElement>(null);
const [error, setError] = useState<string | undefined>();
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<HTMLInputElement>) => {
if (e.key === 'Enter') handleAdd();
};
return (
<Box direction="Column" gap="100">
<Box alignItems="Center" justifyContent="SpaceBetween">
<Text size="L400">{label}</Text>
{canEdit && (
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={<Icon src={Icons.Plus} size="100" />}
onClick={handleAdd}
>
<Text size="B300">Add</Text>
</Button>
)}
</Box>
{canEdit && (
<Box direction="Column" gap="100">
<Input
ref={inputRef}
type="text"
placeholder="e.g. *.example.com or badserver.org"
onKeyDown={handleKeyDown}
variant={error ? 'Critical' : 'Secondary'}
size="400"
radii="300"
/>
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
</Box>
)}
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="0"
>
{entries.length === 0 ? (
<Text size="T300" priority="300" style={{ padding: `${config.space.S100} 0` }}>
(empty)
</Text>
) : (
entries.map((entry, i) => (
<Box
key={`${entry}-${i}`}
alignItems="Center"
justifyContent="SpaceBetween"
style={{
padding: `${config.space.S100} 0`,
borderBottom:
i < entries.length - 1 ? `1px solid ${color.Surface.ContainerLine}` : undefined,
}}
>
<Text size="T300" style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
{entry}
</Text>
{canEdit && (
<IconButton
size="300"
variant="Background"
radii="300"
className={MobileTouchTarget}
aria-label={`Remove ${entry}`}
onClick={() => onRemove(i)}
style={{ flexShrink: 0 }}
>
<Icon src={Icons.Cross} size="100" />
</IconButton>
)}
</Box>
))
)}
</SequenceCard>
</Box>
);
}
// ── 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 = normalizeServerAcl(aclEvent?.getContent());
// Local draft state — initialised from current ACL
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow);
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny);
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
() => currentAcl.allow_ip_literals,
);
// 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;
// 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 (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon src={Icons.Shield} size="200" />
<Text as="h2" size="H3" truncate>
Server ACL
</Text>
</Box>
<Box shrink="No" gap="200" alignItems="Center">
{canEdit && (
<Button
size="400"
variant="Primary"
fill="Solid"
radii="300"
disabled={saving || !isDirty || emptyAllow}
onClick={() => setPrompt(true)}
before={saving ? <Spinner size="200" /> : <Icon src={Icons.Check} size="100" />}
>
<Text size="B400">{saving ? 'Saving…' : 'Save Changes'}</Text>
</Button>
)}
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{/* Info banner */}
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="200"
>
<Box gap="200" alignItems="Start">
<Icon src={Icons.Warning} size="200" style={{ flexShrink: 0, marginTop: 2 }} />
<Box direction="Column" gap="100">
<Text size="T300">
Server ACL controls which servers can participate in this room. Changes take
effect immediately.
</Text>
{!canEdit && (
<Text size="T300" style={{ color: color.Critical.Main }}>
You need power level {requiredPL} to edit the ACL (your level: {myPL}).
</Text>
)}
</Box>
</Box>
</SequenceCard>
{/* Save error */}
{saveError && (
<Text size="T300" style={{ color: color.Critical.Main }}>
{saveError}
</Text>
)}
{/* #1 Empty allow list guard — blocks save */}
{canEdit && emptyAllow && (
<Text size="T300" style={{ color: color.Critical.Main }}>
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
&quot;*&quot; to allow all servers).
</Text>
)}
{/* #2 Self-ban warning — save allowed but confirmation required */}
{canEdit && !emptyAllow && selfBanned && (
<Text size="T300" style={{ color: color.Warning.Main }}>
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.
</Text>
)}
{/* Allow IP literals toggle */}
<Box direction="Column" gap="100">
<Text size="L400">IP Address Access</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="200"
>
<Box alignItems="Center" gap="300">
<Checkbox
id="allow-ip-literals"
checked={allowIpLiterals}
disabled={!canEdit}
// folds' Checkbox has no onChange; readOnly tells React the
// click handler controls it (no "checked without onChange").
readOnly
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
size="300"
variant="Primary"
/>
<Box direction="Column" gap="0">
{}
<label
htmlFor="allow-ip-literals"
style={{ cursor: canEdit ? 'pointer' : 'default' }}
>
<Text size="T300">Allow IP literal addresses</Text>
</label>
<Text size="T200" priority="300">
When disabled, clients connecting via raw IP addresses (e.g. 1.2.3.4) cannot
participate.
</Text>
</Box>
</Box>
</SequenceCard>
</Box>
{/* Allowed servers */}
<ServerList
label="Allowed Servers"
entries={allowList}
canEdit={canEdit}
onAdd={(value) => setAllowList((prev) => [...prev, value])}
onRemove={(index) => setAllowList((prev) => prev.filter((_, i) => i !== index))}
/>
{/* Denied servers */}
<ServerList
label="Denied Servers"
entries={denyList}
canEdit={canEdit}
onAdd={(value) => setDenyList((prev) => [...prev, value])}
onRemove={(index) => setDenyList((prev) => prev.filter((_, i) => i !== index))}
/>
{/* Note about defaults */}
<Text size="T200" priority="300">
Tip: The default ACL allows all servers (allow: [&quot;*&quot;], deny: []). Adding
&quot;*&quot; to the allow list permits all servers not explicitly denied.
</Text>
</Box>
</PageContent>
</Scroll>
</Box>
{/* #4 Confirmation dialog — surfaces the empty-allow (#1) and self-ban (#2)
warnings and keeps a safe save one extra click. */}
{prompt && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
fallbackFocus: '#roomserveracl-dialog',
onDeactivate: () => setPrompt(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
id="roomserveracl-dialog"
role="dialog"
aria-modal="true"
tabIndex={-1}
variant="Surface"
aria-labelledby="server-acl-confirm-title"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text as="h2" size="H4" id="server-acl-confirm-title">
Apply Server ACL
</Text>
</Box>
<IconButton
size="300"
onClick={() => setPrompt(false)}
radii="300"
aria-label="Cancel"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">
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.
</Text>
{emptyAllow && (
<Text size="T300" style={{ color: color.Critical.Main }}>
The allow list is empty — this would deny every server and partition the
room from all federation permanently.
</Text>
)}
{!emptyAllow && selfBanned && (
<Text size="T300" style={{ color: color.Warning.Main }}>
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.
</Text>
)}
</Box>
<Button
type="submit"
variant={selfBanned ? 'Critical' : 'Primary'}
onClick={handleConfirmSave}
disabled={emptyAllow}
>
<Text size="B400">Apply ACL</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</Page>
);
}