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 { 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", }, checkboxLabel: { display: "inline-flex", alignItems: "center", gap: "0.5rem", fontSize: "0.875rem", lineHeight: "1.25rem", }, 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 ( {(status) => (
{status.authority === "managed_file" && } {(rows) => }
)}
); } /** * 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 (
{!fileMode && }
{selected === undefined ? (

No groups exist.

) : fileMode ? ( ) : ( )}
); } function CreateGroupForm() { const queryClient = useQueryClient(); const create = useMutation(groupCreateMutation(queryClient)); const [newName, setNewName] = useState(""); return ( <>
{ event.preventDefault(); const name = newName.trim(); if (name === "") return; create.mutate({ name }, { onSuccess: () => setNewName("") }); }} > setNewName(event.target.value)} {...stylex.props(shared.smallInput, shared.focusRing)} />
); } function ClientCountLink({ group }: { group: Group }) { const clients = useQuery(clientsQuery()); const count = clients.data?.filter((client) => client.group_id === group.id).length; return (

{count === undefined ? "Clients in this group" : `${count} client${count === 1 ? "" : "s"} in this group`}

); } function GroupDetailReadOnly({ group }: { group: Group }) { return (

{group.name}

); } function GroupSourcesReadOnly({ group }: { group: Group }) { const blocklists = useQuery(blocklistsQuery()); const assigned = useQuery(groupSourcesQuery(group.id)); return (

Assigned sources group_sources

{(sourceIds) => ( {(catalogue) => } )}
); } function AssignedSources({ sourceIds, catalogue }: { sourceIds: number[]; catalogue: Blocklist[] }) { const assigned = catalogue.filter((source) => sourceIds.includes(source.id)); if (assigned.length === 0) return

This group is assigned no sources.

; return (
{assigned.map((source) => ( ))}
Source URL
{source.name} {source.url}
); } 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 (
{renaming ? (
{ 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) }, ); }} > setName(event.target.value)} {...stylex.props(shared.smallInput, shared.focusRing)} autoFocus />
) : (

{group.name}

)}
{!renaming && ( )}{" "}
{isDefault &&

{DEFAULT_GROUP_NOTE}

}

Assigned sources

{(catalogue) => }
{ setConfirming(false); remove.mutate(group.id); }} onCancel={() => setConfirming(false)} />
); } /** * 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 (

Rules {!editable && rules}

{(all) => { const scoped = all.filter((rule) => rule.group_id === group.id); return ( <> {scoped.length === 0 ? (

No allow or block rules for this group.

) : ( )} {editable && } ); }}
); } function RulesTable({ rules, editable }: { rules: Rule[]; editable: boolean }) { const queryClient = useQueryClient(); const remove = useMutation(ruleDeleteMutation(queryClient)); const [pendingDelete, setPendingDelete] = useState(null); return ( <>
{editable && ( )} {rules.map((rule) => ( {editable && ( )} ))}
Pattern Kind Action Created Actions
{rule.pattern} {rule.kind} {rule.action} {formatTime(rule.created_at)}
{ 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("exact"); const [action, setAction] = useState("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 (

Create rule in {group.name}

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)} />
setAction(value as RuleAction)} options={ACTION_OPTIONS} />
); }