Files
cinny/src/app/features/room-settings/RoomServerACL.tsx
T
jared b361d43088 fix(ui): native inputs/checkboxes, QR fallback, focus + report modal cleanup
- N23 RoomServerACL: raw text input -> folds Input; raw checkbox -> folds Checkbox
- N24 PolicyListViewer: raw room-id input -> folds Input (Critical variant on error)
- N25 ExportRoomHistory: raw <input type="date"> x2 -> folds Input
- N26 RoomShareInvite: QR <img> gets loading="lazy" + onError fallback card
  ("QR code unavailable") instead of a broken-image icon
- N27 GifPicker: FocusTrap returnFocusOnDeactivate:false (matches EmojiBoard)
- N76 Report modals: drop redundant Cancel button (dismiss via header x /
  click-outside, like MessageReportItem)
- N5 ReadReceiptAvatars: hover/focus moved to co-located css :hover/:focus-visible
  (removed JS onMouseEnter/Leave .style mutation)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 18:12:25 -04:00

358 lines
12 KiB
TypeScript

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<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 server pattern. Use a hostname or *.example.com');
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"
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();
// 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<ServerAclContent>() ?? DEFAULT_ACL;
// 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 ?? 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 (
<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}
onClick={() => save()}
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>
)}
{/* 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}
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
size="300"
variant="Primary"
/>
<Box direction="Column" gap="0">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<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>
</Page>
);
}