Files
nxdns/admin/src/features/configuration/ProtectionGroups.tsx
T
mokhtar e656670dd4
Gates / frontend (push) Failing after 1m6s
Gates / package (push) Skipped
Gates / container (push) Skipped
Gates / test (push) Failing after 3m29s
Gates / test-aarch64 (push) Successful in 9m53s
CI / gates (push) Failing after 13m23s
admin: ui polish pass, thirty findings from the emil audit
selected states stop changing font weight, buttons gain a pressed scale and scoped 120ms transitions with a reduced-motion override, every loading and empty state reserves its height, charts measure before first paint, hit targets rise to the 44px enhanced target where layout permits, long domains clamp to two lines on an unpadded inner span, chips and name cells truncate, tabular figures on counts and time columns, a z-index layer scale replaces magic numbers and fixes the dialog-over-confirm tie, history's empty state gains a clear-filters action, page headings balance, font smoothing and color-scheme land on the html reset.
2026-09-01 23:23:07 +02:00

590 lines
17 KiB
TypeScript

import { useEffect, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import * as stylex from "@stylexjs/stylex";
import { DEFAULT_GROUP_ID } from "@/lib/defaultGroup";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
blocklistsQuery,
clientsQuery,
groupCreateMutation,
groupDeleteMutation,
groupSourcesQuery,
groupUpdateMutation,
groupsQuery,
ruleCreateMutation,
ruleDeleteMutation,
rulesQuery,
} from "@/lib/queries";
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
import ConfirmDialog from "@/ui/ConfirmDialog";
import DefinitionList from "@/ui/DefinitionList";
import Select from "@/ui/Select";
import Switch from "@/ui/Switch";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import AuthorityGate from "./AuthorityGate";
import FileModeNote from "./FileModeNote";
import GroupSourcesEditor from "./GroupSourcesEditor";
import QueryPanel from "./QueryPanel";
import { styles as config } from "./styles";
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const KIND_OPTIONS = [
{ value: "exact", label: "exact" },
{ value: "wildcard", label: "wildcard" },
{ value: "regex", label: "regex" },
];
const ACTION_OPTIONS = [
{ value: "allow", label: "allow" },
{ value: "block", label: "block" },
];
const styles = stylex.create({
createForm: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.75rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
detailHeading: {
fontSize: "1.25rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
controlRow: {
marginTop: "0.75rem",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.75rem",
},
spacer: {
marginLeft: "auto",
},
destructive: {
color: colors.danger,
},
ruleForm: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
},
},
allow: {
color: colors.primaryOnSurface,
},
block: {
color: colors.danger,
},
pattern: {
fontWeight: 500,
},
});
export default function ProtectionGroups() {
const groups = useQuery(groupsQuery());
return (
<AuthorityGate>
{(status) => (
<div>
{status.authority === "managed_file" && <FileModeNote path={status.path} />}
<QueryPanel query={groups}>
{(rows) => <GroupsMasterDetail groups={rows} status={status} />}
</QueryPanel>
</div>
)}
</AuthorityGate>
);
}
/**
* Master/detail on one group at a time. The selection is `?group=`, so a view
* of a group is a link and the browser's back button walks the groups the
* reader looked at.
*/
function GroupsMasterDetail({ groups, status }: { groups: Group[]; status: ConfigStatus }) {
const search = useSearch({ from: "/shell/configuration/protection" });
const navigate = useNavigate({ from: "/configuration/protection" });
const known = groups.some((group) => group.id === search.group);
const selectedId = known ? search.group : groups[0]?.id;
const selected = groups.find((group) => group.id === selectedId);
// An id the URL named that no group has — deleted, or hand-typed — falls
// back to the first group, and the URL is rewritten to say so. `replace`,
// because a corrected address is not a place the reader chose to be and a
// back button that returns to it would trap them.
useEffect(() => {
if (selectedId === undefined || selectedId === search.group) return;
void navigate({ search: { tab: search.tab, group: selectedId }, replace: true });
}, [navigate, search.group, search.tab, selectedId]);
const fileMode = status.authority === "managed_file";
return (
<div {...stylex.props(config.split)}>
<div>
{!fileMode && <CreateGroupForm />}
<nav aria-label="Groups" {...stylex.props(styles.controlRow)}>
<ul {...stylex.props(config.masterList)}>
{groups.map((group) => (
<li key={group.id}>
<Link
to="/configuration/protection"
search={{ tab: search.tab, group: group.id }}
activeOptions={{ includeSearch: true }}
{...stylex.props(
config.masterLink,
group.id === selectedId && config.masterLinkActive,
shared.focusRing,
)}
>
{group.name}
</Link>
</li>
))}
</ul>
</nav>
</div>
{selected === undefined ? (
<p {...stylex.props(config.empty)}>
{fileMode ? "No groups exist." : "No groups exist. Create one with the New group field."}
</p>
) : fileMode ? (
<GroupDetailReadOnly group={selected} />
) : (
<GroupDetailEditable group={selected} />
)}
</div>
);
}
function CreateGroupForm() {
const queryClient = useQueryClient();
const create = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState("");
return (
<>
<form
{...stylex.props(styles.createForm)}
onSubmit={(event: FormEvent) => {
event.preventDefault();
const name = newName.trim();
if (name === "") return;
create.mutate({ name }, { onSuccess: () => setNewName("") });
}}
>
<label {...stylex.props(styles.fieldLabel)} htmlFor="new-group-name">
New group
</label>
<input
id="new-group-name"
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
{...stylex.props(shared.smallInput, shared.focusRing)}
/>
<button
type="submit"
disabled={create.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Create
</button>
</form>
<InlineError error={create.error} />
</>
);
}
function ClientCountLink({ group }: { group: Group }) {
const clients = useQuery(clientsQuery());
const count = clients.data?.filter((client) => client.group_id === group.id).length;
return (
<p {...stylex.props(config.note)}>
<Link to="/clients" search={{ group: group.id }} {...stylex.props(shared.focusRing)}>
{count === undefined
? "Clients in this group"
: `${count} client${count === 1 ? "" : "s"} in this group`}
</Link>
</p>
);
}
function GroupDetailReadOnly({ group }: { group: Group }) {
return (
<div>
<h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2>
<section {...stylex.props(config.panel)}>
<DefinitionList
items={[
{ label: "Name", zonKey: "groups[].name", value: group.name },
{ label: "Safe search", zonKey: "groups[].safe_search", value: String(group.safe_search) },
]}
/>
</section>
<GroupSourcesReadOnly group={group} />
<GroupRules group={group} editable={false} />
<ClientCountLink group={group} />
</div>
);
}
function GroupSourcesReadOnly({ group }: { group: Group }) {
const blocklists = useQuery(blocklistsQuery());
const assigned = useQuery(groupSourcesQuery(group.id));
return (
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>
Assigned sources
<code {...stylex.props(shared.mono, config.panelKey)}>group_sources</code>
</h3>
<QueryPanel query={assigned}>
{(sourceIds) => (
<QueryPanel query={blocklists}>
{(catalogue) => <AssignedSources sourceIds={sourceIds} catalogue={catalogue} />}
</QueryPanel>
)}
</QueryPanel>
</section>
);
}
function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalogue: Blocklist[] }) {
const assigned = catalogue.filter((source) => sourceIds.includes(source.id));
if (assigned.length === 0) return <p {...stylex.props(config.empty)}>This group is assigned no sources.</p>;
return (
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Source</th>
<th {...stylex.props(shared.th)}>URL</th>
</tr>
</thead>
<tbody>
{assigned.map((source) => (
<tr key={source.id}>
<td {...stylex.props(shared.td)}>{source.name}</td>
<td {...stylex.props(shared.td, shared.mono)}>{source.url}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function GroupDetailEditable({ group }: { group: Group }) {
const queryClient = useQueryClient();
const blocklists = useQuery(blocklistsQuery());
const update = useMutation(groupUpdateMutation(queryClient));
const remove = useMutation(groupDeleteMutation(queryClient));
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(group.name);
const [confirming, setConfirming] = useState(false);
const isDefault = group.id === DEFAULT_GROUP_ID;
return (
<div>
{renaming ? (
<form
{...stylex.props(styles.controlRow)}
onSubmit={(event: FormEvent) => {
event.preventDefault();
const trimmed = name.trim();
if (trimmed === "") return;
update.mutate(
{ id: group.id, input: { name: trimmed, safe_search: group.safe_search } },
{ onSuccess: () => setRenaming(false) },
);
}}
>
<input
type="text"
aria-label={`New name for ${group.name}`}
value={name}
onChange={(event) => setName(event.target.value)}
{...stylex.props(shared.smallInput, shared.focusRing)}
autoFocus
/>
<button
type="submit"
disabled={update.isPending}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Save
</button>
<button
type="button"
onClick={() => {
setName(group.name);
setRenaming(false);
}}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Cancel
</button>
</form>
) : (
<h2 {...stylex.props(styles.detailHeading)}>{group.name}</h2>
)}
<div {...stylex.props(styles.controlRow)}>
<Switch
isSelected={group.safe_search}
isDisabled={update.isPending}
onChange={(safeSearch) =>
update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
}
>
Safe search
</Switch>
<span {...stylex.props(styles.spacer)}>
{!renaming && (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
onClick={() => {
setName(group.name);
setRenaming(true);
}}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Rename group
</button>
)}{" "}
<button
type="button"
disabled={isDefault || remove.isPending}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
onClick={() => setConfirming(true)}
{...stylex.props(shared.smallButton, styles.destructive, shared.focusRing)}
>
Delete group
</button>
</span>
</div>
{isDefault && <p {...stylex.props(config.note)}>{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={update.error ?? remove.error} />
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>Assigned sources</h3>
<QueryPanel query={blocklists}>
{(catalogue) => <GroupSourcesEditor groupId={group.id} blocklists={catalogue} />}
</QueryPanel>
</section>
<GroupRules group={group} editable />
<ClientCountLink group={group} />
<ConfirmDialog
isOpen={confirming}
title="Delete group"
message={`Delete group "${group.name}"? Its clients fall back to the default group.`}
confirmLabel="Delete"
onConfirm={() => {
setConfirming(false);
remove.mutate(group.id);
}}
onCancel={() => setConfirming(false)}
/>
</div>
);
}
/**
* The rules that apply to the selected group, and nothing else. Rules are
* always scoped to a group, so the group-centred page is the only place they
* need to be read: a flat list of every rule in the household was a list of
* facts about no particular policy.
*/
function GroupRules({ group, editable }: { group: Group; editable: boolean }) {
const rules = useQuery(rulesQuery());
return (
<section {...stylex.props(config.panel)}>
<h3 {...stylex.props(config.panelHeading)}>
Rules
{!editable && <code {...stylex.props(shared.mono, config.panelKey)}>rules</code>}
</h3>
<QueryPanel query={rules}>
{(all) => {
const scoped = all.filter((rule) => rule.group_id === group.id);
return (
<>
{scoped.length === 0 ? (
<p {...stylex.props(config.empty)}>
No allow or block rules for this group.
{editable && " Add one below."}
</p>
) : (
<RulesTable rules={scoped} editable={editable} />
)}
{editable && <CreateRuleForm group={group} />}
</>
);
}}
</QueryPanel>
</section>
);
}
function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) {
const queryClient = useQueryClient();
const remove = useMutation(ruleDeleteMutation(queryClient));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
return (
<>
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(config.table)}>
<thead>
<tr>
<th {...stylex.props(shared.th)}>Pattern</th>
<th {...stylex.props(shared.th)}>Kind</th>
<th {...stylex.props(shared.th)}>Action</th>
<th {...stylex.props(shared.th)}>Created</th>
{editable && (
<th {...stylex.props(shared.th)}>
<span {...stylex.props(shared.srOnly)}>Actions</span>
</th>
)}
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td {...stylex.props(shared.td)}>{rule.kind}</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
{rule.action}
</span>
</td>
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
{editable && (
<td {...stylex.props(shared.td)}>
<button
type="button"
onClick={() => setPendingDelete(rule)}
disabled={remove.isPending}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
<InlineError error={remove.error} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete rule"
message={
pendingDelete === null
? ""
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
}
confirmLabel="Delete"
onConfirm={() => {
if (pendingDelete !== null) remove.mutate(pendingDelete.id);
setPendingDelete(null);
}}
onCancel={() => setPendingDelete(null)}
/>
</>
);
}
function CreateRuleForm({ group }: { group: Group }) {
const queryClient = useQueryClient();
const create = useMutation(ruleCreateMutation(queryClient));
const [pattern, setPattern] = useState("");
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
function onSubmit(event: FormEvent) {
event.preventDefault();
// A regex pattern is stored and matched byte for byte, so the UI must not
// edit it: trimming here would make a UI-created rule differ from the same
// bytes posted to /api/rules. Name-shaped kinds are normalized server-side,
// so trimming them only spares a pasted space a 400.
const sent = kind === "regex" ? pattern : pattern.trim();
create.mutate({ group_id: group.id, pattern: sent, kind, action }, { onSuccess: () => setPattern("") });
}
return (
<form onSubmit={onSubmit} {...stylex.props(styles.ruleForm)}>
<h4 {...stylex.props(styles.fieldLabel)}>Create rule in {group.name}</h4>
<div>
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
Pattern
</label>
<input
id="rule-pattern"
type="text"
required
value={pattern}
onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com, *.example.com or ^ad[0-9]+-"
// A phone keyboard capitalizing the first letter is silent for
// exact and wildcard (normalized server-side) but fatal for a
// regex, which matches the lowercase query name byte for byte.
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div {...stylex.props(styles.fieldGrid)}>
<Select
label="Kind"
value={kind}
onChange={(value) => setKind(value as RuleKind)}
options={KIND_OPTIONS}
/>
<Select
label="Action"
value={action}
onChange={(value) => setAction(value as RuleAction)}
options={ACTION_OPTIONS}
/>
</div>
<div>
<button
type="submit"
disabled={create.isPending}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
</div>
<InlineError error={create.error} />
</form>
);
}