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
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
461 lines
17 KiB
TypeScript
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
|
|
"*" 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: ["*"], deny: []). Adding
|
|
"*" 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>
|
|
);
|
|
}
|