)}
);
}
/**
* 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 (
);
}
/**
* 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 (
{
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 (
);
}