CI / Build & Quality Checks (pull_request) Successful in 3m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 15s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m30s
dedupePolicyEntries keyed on the raw recommendation, so m.ban and org.matrix.mjolnir.ban for the same entity never matched and the rule showed twice (both labelled "Ban"). Key on the normalised value; move the pure helpers to policyEntries.ts with unit tests. Fixes #321 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
import React, { useCallback, useRef, useState } from 'react';
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Input,
|
|
Scroll,
|
|
Text,
|
|
color,
|
|
config,
|
|
} from 'folds';
|
|
import { EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk';
|
|
import { Page, PageContent, PageHeader } from '../../components/page';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { SequenceCard } from '../../components/sequence-card';
|
|
import { SequenceCardStyle } from '../common-settings/styles.css';
|
|
import { dedupePolicyEntries, PolicyEntry, recommendationLabel } from './policyEntries';
|
|
|
|
// ── Policy event types ────────────────────────────────────────────────────────
|
|
|
|
const POLICY_USER_EVENT = 'm.policy.rule.user';
|
|
const POLICY_ROOM_EVENT = 'm.policy.rule.room';
|
|
const POLICY_SERVER_EVENT = 'm.policy.rule.server';
|
|
|
|
// Legacy, unstable-prefixed event types still emitted by Draupnir/Mjolnir
|
|
// policy lists that predate MSC stabilization (or haven't migrated). Queried
|
|
// alongside the stable types and merged/de-duped so those lists don't show
|
|
// as falsely empty.
|
|
const LEGACY_POLICY_USER_EVENT = 'org.matrix.mjolnir.rule.user';
|
|
const LEGACY_POLICY_ROOM_EVENT = 'org.matrix.mjolnir.rule.room';
|
|
const LEGACY_POLICY_SERVER_EVENT = 'org.matrix.mjolnir.rule.server';
|
|
|
|
type PolicyRuleContent = {
|
|
entity?: string;
|
|
reason?: string;
|
|
recommendation?: string;
|
|
};
|
|
|
|
type PolicyTab = 'users' | 'rooms' | 'servers';
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function isGlob(entity: string): boolean {
|
|
return entity.includes('*') || entity.includes('?');
|
|
}
|
|
|
|
/**
|
|
* Fetch all state events of a given type from a room's live forward state.
|
|
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
|
|
*/
|
|
function getRoomPolicyEvents(room: Room, eventType: string): MatrixEvent[] {
|
|
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
|
if (!state) return [];
|
|
return state.getStateEvents(eventType);
|
|
}
|
|
|
|
function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
|
|
return events
|
|
.map((e) => {
|
|
const content = e.getContent<PolicyRuleContent>();
|
|
return {
|
|
entity: content.entity ?? '',
|
|
reason: content.reason ?? '',
|
|
recommendation: content.recommendation ?? '',
|
|
stateKey: e.getStateKey() ?? '',
|
|
};
|
|
})
|
|
.filter((entry) => entry.entity !== '');
|
|
}
|
|
|
|
// ── Entry row ─────────────────────────────────────────────────────────────────
|
|
|
|
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
|
const glob = isGlob(entry.entity);
|
|
return (
|
|
<Box
|
|
direction="Column"
|
|
gap="100"
|
|
style={{
|
|
padding: `${config.space.S200} ${config.space.S300}`,
|
|
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
|
}}
|
|
>
|
|
<Box gap="200" alignItems="Center" wrap="Wrap">
|
|
<Text
|
|
size="T300"
|
|
style={{
|
|
fontFamily: 'monospace',
|
|
color: glob ? color.Warning.Main : undefined,
|
|
wordBreak: 'break-all',
|
|
}}
|
|
>
|
|
{entry.entity}
|
|
</Text>
|
|
{glob && (
|
|
<Badge variant="Warning" fill="Soft" radii="Pill">
|
|
<Text size="T200">glob</Text>
|
|
</Badge>
|
|
)}
|
|
{entry.recommendation && (
|
|
<Badge variant="Critical" fill="Soft" radii="Pill">
|
|
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
|
|
</Badge>
|
|
)}
|
|
</Box>
|
|
{entry.reason && (
|
|
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
|
{entry.reason}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// ── Tab button ────────────────────────────────────────────────────────────────
|
|
|
|
function TabButton({
|
|
label,
|
|
count,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
count: number;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<Button
|
|
onClick={onClick}
|
|
variant={active ? 'Primary' : 'Secondary'}
|
|
fill={active ? 'Solid' : 'Soft'}
|
|
size="300"
|
|
radii="300"
|
|
>
|
|
<Text size="B300">
|
|
{label} ({count})
|
|
</Text>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
// ── Main component ────────────────────────────────────────────────────────────
|
|
|
|
type PolicyListViewerProps = {
|
|
requestClose: () => void;
|
|
};
|
|
|
|
export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
|
|
const mx = useMatrixClient();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [roomIdInput, setRoomIdInput] = useState('');
|
|
const [activeTab, setActiveTab] = useState<PolicyTab>('users');
|
|
const [error, setError] = useState<string | undefined>();
|
|
|
|
const [userEntries, setUserEntries] = useState<PolicyEntry[]>([]);
|
|
const [roomEntries, setRoomEntries] = useState<PolicyEntry[]>([]);
|
|
const [serverEntries, setServerEntries] = useState<PolicyEntry[]>([]);
|
|
const [loadedRoomId, setLoadedRoomId] = useState<string | undefined>();
|
|
|
|
const handleLoad = useCallback(() => {
|
|
const rawInput = (inputRef.current?.value ?? roomIdInput).trim();
|
|
if (!rawInput) {
|
|
setError('Please enter a room ID or alias.');
|
|
return;
|
|
}
|
|
|
|
// Resolve alias to room ID using local cache
|
|
let roomId = rawInput;
|
|
if (rawInput.startsWith('#')) {
|
|
const cachedId = mx.getRooms().find((r) => {
|
|
const aliases = r.getAltAliases();
|
|
const canonical = r.getCanonicalAlias();
|
|
return aliases.includes(rawInput) || canonical === rawInput;
|
|
})?.roomId;
|
|
|
|
if (cachedId) {
|
|
roomId = cachedId;
|
|
} else {
|
|
setError(`Cannot resolve alias "${rawInput}". Make sure you have joined that room.`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const room = mx.getRoom(roomId);
|
|
if (!room) {
|
|
setError(`Not joined to room "${roomId}". Join the policy list room first.`);
|
|
setUserEntries([]);
|
|
setRoomEntries([]);
|
|
setServerEntries([]);
|
|
setLoadedRoomId(undefined);
|
|
return;
|
|
}
|
|
|
|
setUserEntries(
|
|
dedupePolicyEntries([
|
|
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)),
|
|
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_USER_EVENT)),
|
|
]),
|
|
);
|
|
setRoomEntries(
|
|
dedupePolicyEntries([
|
|
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)),
|
|
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_ROOM_EVENT)),
|
|
]),
|
|
);
|
|
setServerEntries(
|
|
dedupePolicyEntries([
|
|
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)),
|
|
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_SERVER_EVENT)),
|
|
]),
|
|
);
|
|
setLoadedRoomId(roomId);
|
|
setError(undefined);
|
|
}, [mx, roomIdInput]);
|
|
|
|
const activeEntries =
|
|
activeTab === 'users' ? userEntries : activeTab === 'rooms' ? roomEntries : serverEntries;
|
|
|
|
return (
|
|
<Page>
|
|
<PageHeader outlined={false}>
|
|
<Box grow="Yes" gap="200">
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|
<Text as="h2" size="H3" truncate>
|
|
Policy Lists
|
|
</Text>
|
|
</Box>
|
|
<Box shrink="No">
|
|
<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">
|
|
{/* Description */}
|
|
<Box direction="Column" gap="100">
|
|
<Text size="L400">About</Text>
|
|
<SequenceCard
|
|
className={SequenceCardStyle}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="200"
|
|
>
|
|
<Text size="T300">
|
|
Policy lists are Matrix rooms containing ban rules managed by moderation bots
|
|
(e.g. Draupnir). Enter a policy list room ID below to inspect its current rules.
|
|
This is a read-only viewer — rule enforcement is handled by your moderation bot.
|
|
</Text>
|
|
</SequenceCard>
|
|
</Box>
|
|
|
|
{/* Room ID input */}
|
|
<Box direction="Column" gap="100">
|
|
<Text size="L400">Policy List Room</Text>
|
|
<SequenceCard
|
|
className={SequenceCardStyle}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="300"
|
|
>
|
|
<Box gap="200" alignItems="Center">
|
|
<Input
|
|
ref={inputRef}
|
|
value={roomIdInput}
|
|
onChange={(e) => setRoomIdInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleLoad();
|
|
}}
|
|
placeholder="!roomId:server or #alias:server"
|
|
variant={error ? 'Critical' : 'Secondary'}
|
|
size="400"
|
|
radii="300"
|
|
style={{ flexGrow: 1 }}
|
|
/>
|
|
<Button
|
|
onClick={handleLoad}
|
|
variant="Primary"
|
|
fill="Solid"
|
|
size="300"
|
|
radii="300"
|
|
before={<Icon src={Icons.Search} size="100" />}
|
|
>
|
|
<Text size="B300">Load</Text>
|
|
</Button>
|
|
</Box>
|
|
{error && (
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
{error}
|
|
</Text>
|
|
)}
|
|
{loadedRoomId && (
|
|
<Text size="T200" priority="300">
|
|
Showing rules from:{' '}
|
|
<span style={{ fontFamily: 'monospace' }}>{loadedRoomId}</span>
|
|
</Text>
|
|
)}
|
|
</SequenceCard>
|
|
</Box>
|
|
|
|
{/* Rules viewer */}
|
|
{loadedRoomId && (
|
|
<Box direction="Column" gap="100">
|
|
<Text size="L400">Rules</Text>
|
|
<SequenceCard
|
|
className={SequenceCardStyle}
|
|
variant="SurfaceVariant"
|
|
direction="Column"
|
|
gap="300"
|
|
>
|
|
{/* Tabs */}
|
|
<Box gap="200" wrap="Wrap">
|
|
<TabButton
|
|
label="Users"
|
|
count={userEntries.length}
|
|
active={activeTab === 'users'}
|
|
onClick={() => setActiveTab('users')}
|
|
/>
|
|
<TabButton
|
|
label="Rooms"
|
|
count={roomEntries.length}
|
|
active={activeTab === 'rooms'}
|
|
onClick={() => setActiveTab('rooms')}
|
|
/>
|
|
<TabButton
|
|
label="Servers"
|
|
count={serverEntries.length}
|
|
active={activeTab === 'servers'}
|
|
onClick={() => setActiveTab('servers')}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Entry list */}
|
|
<Box
|
|
direction="Column"
|
|
style={{
|
|
border: `1px solid ${color.Surface.ContainerLine}`,
|
|
borderRadius: config.radii.R300,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{activeEntries.length === 0 ? (
|
|
<Box
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
style={{ padding: config.space.S500 }}
|
|
>
|
|
<Text size="T300" priority="300">
|
|
No{' '}
|
|
{activeTab === 'users'
|
|
? 'user'
|
|
: activeTab === 'rooms'
|
|
? 'room'
|
|
: 'server'}{' '}
|
|
ban rules found.
|
|
</Text>
|
|
</Box>
|
|
) : (
|
|
activeEntries.map((entry) => (
|
|
<PolicyEntryRow key={entry.stateKey || entry.entity} entry={entry} />
|
|
))
|
|
)}
|
|
</Box>
|
|
</SequenceCard>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</PageContent>
|
|
</Scroll>
|
|
</Box>
|
|
</Page>
|
|
);
|
|
}
|