feat: extended profile fields, push rule editor, server ACL editor
P2-8: Pronouns (m.pronouns) and Timezone (m.tz) fields in Settings →
Account → Profile; saved via MSC4133 PUT /profile/{userId}/{field};
useExtendedProfile hook fetches both in parallel; UserHero displays
pronouns below display name and timezone string below username
P2-11: Full push rule editor in Settings → Notifications below keyword
rules; covers override/room/sender/underride rule kinds; enable/disable
toggle per rule, human-readable labels for built-in rules, delete button
for custom rules, add-rule form for room and sender rules
P2-12: Server ACL viewer/editor in room settings (Server ACL tab);
reads m.room.server_acl state event; allow/deny server lists with
wildcard validation; allow IP literals toggle; power-level gated
(edit requires sufficient PL, otherwise read-only view)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Box, Button, Icon, IconButton, Icons, 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}
|
||||
style={{
|
||||
background: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
border: `1px solid ${error ? color.Critical.Main : color.Surface.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{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">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allow-ip-literals"
|
||||
checked={allowIpLiterals}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setAllowIpLiterals(e.target.checked)}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
cursor: canEdit ? 'pointer' : 'default',
|
||||
}}
|
||||
/>
|
||||
<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: ["*"], deny: []). Adding
|
||||
"*" to the allow list permits all servers not explicitly denied.
|
||||
</Text>
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user