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.
590 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|