import React, { useCallback, useRef, useState } from 'react'; import { Box, Button, Checkbox, Icon, IconButton, Icons, Input, Scroll, Spinner, Text, color, config, } from 'folds'; 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 { 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'; // ── Types ───────────────────────────────────────────────────────────────────── type ServerAclContent = { allow: string[]; deny: string[]; allow_ip_literals: boolean; }; const DEFAULT_ACL: ServerAclContent = { allow: ['*'], deny: [], allow_ip_literals: false, }; // ── Validation ──────────────────────────────────────────────────────────────── /** * Validate a server name or wildcard pattern. * Allowed forms: * - plain hostname / IP: letters, digits, hyphens, dots * - wildcard prefix: *.example.com (asterisk only at the very start) * The Matrix spec allows `*` on its own (match-all wildcard). */ function isValidServerPattern(value: string): boolean { if (value === '*') return true; // Strip leading wildcard const rest = value.startsWith('*.') ? value.slice(2) : value; // Must not be empty after stripping wildcard if (!rest) return false; // Remaining part: only letters, digits, dots, hyphens, colons (for IPv6/ports) return /^[A-Za-z0-9.:_-]+$/.test(rest); } // ── 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 server pattern. Use a hostname or *.example.com'); 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(); // 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 mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, { 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; // 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} )} {/* 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. ); }