Add editing of client access rules

Saved rules and client form drafts can now be edited through the rule
modal. On the Rules tab, saving re-runs client setup so the firewall
matches, and both add and edit ask for confirmation.

Comma-separated destinations were rejected because drafts were validated
before being split; validate each one after splitting instead. When
editing, the first destination updates the rule and the rest are added
as new rules.

Pin the id in the client_rules PUT handler so a request body can't
change it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-01 14:27:10 +01:00
co-authored by Claude Opus 5.5
parent 577f3828c8
commit 77ced6cc52
13 changed files with 416 additions and 138 deletions
@@ -1,123 +1,44 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import Modal from "@/src/components/twui/elements/Modal";
import Button from "@/src/components/twui/layout/Button";
import H3 from "@/src/components/twui/layout/H3";
import P from "@/src/components/twui/layout/P";
import Row from "@/src/components/twui/layout/Row";
import Stack from "@/src/components/twui/layout/Stack";
import InlineAlert from "../inline-alert";
import ClientRuleFields from "./client-rule-fields";
import buildClientRuleDrafts from "./build-client-rule-drafts";
import { createClientRuleDraft, type ClientRuleDraft } from "./client-rule-draft";
import ClientRuleModalBody from "./client-rule-modal-body";
import ClientRuleModalTarget from "./client-rule-modal-target";
import type { ClientRuleDraft } from "./client-rule-draft";
export type ClientRuleModalAddResult = { success: boolean; msg?: string };
/** `cancelled` keeps the modal open without showing an error. */
export type ClientRuleModalSaveResult = {
success: boolean;
msg?: string;
cancelled?: boolean;
};
export type ClientRuleModalSave = ({
rules,
}: {
rules: ClientRuleDraft[];
}) => Promise<ClientRuleModalSaveResult>;
type Props = {
onAdd: ({ rules }: { rules: ClientRuleDraft[] }) => Promise<ClientRuleModalAddResult>;
/** The rule to edit. Omit to add a new one. */
rule?: ClientRuleDraft;
onSave: ClientRuleModalSave;
disabled?: boolean;
};
/** Validates a rule and hands it to `onAdd`. Closes only on success. */
export default function ClientRuleModal({ onAdd, disabled }: Props) {
/**
* Validates a rule and hands it to `onSave`. Closes only on success. Commas in
* the destination yield several drafts, in both add and edit.
*/
export default function ClientRuleModal({ rule, onSave, disabled }: Props) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<ClientRuleDraft>(createClientRuleDraft());
const [error, setError] = useState<string>();
const [saving, setSaving] = useState(false);
function reset() {
setDraft(createClientRuleDraft());
setError(undefined);
}
async function handleAdd() {
const built = buildClientRuleDrafts({ draft });
if (!built.success) {
setError(built.msg);
return;
}
setSaving(true);
const res = await onAdd({ rules: built.drafts });
setSaving(false);
if (!res.success) {
setError(res.msg || "Couldn't add the rule");
return;
}
reset();
setOpen(false);
}
return (
<Modal
open={open}
setOpen={(value) => {
setOpen(value);
if (value === false) reset();
}}
setOpen={setOpen}
className="max-w-lg p-5"
target={
<Button
title="Add rule"
type="button"
variant="outlined"
color="gray"
size="small"
disabled={disabled}
>
<Plus size={14} />
Add rule
</Button>
}
target={<ClientRuleModalTarget is_edit={Boolean(rule)} disabled={disabled} />}
>
<Stack className="w-full items-stretch gap-5">
<Stack className="gap-1 pr-8">
<H3 className="text-lg!">Add access rule</H3>
<P className="text-[15px]">
Allow this client to reach a destination. Leave ports empty for any port.
</P>
</Stack>
{error ? <InlineAlert tone="error" msg={error} /> : null}
<ClientRuleFields
key={draft.local_id}
draft={draft}
setDraft={(next) => {
setDraft(next);
setError(undefined);
}}
onEnter={handleAdd}
/>
<Row className="w-full justify-end gap-2 pt-1">
<Button
title="Cancel"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => {
reset();
setOpen(false);
}}
>
Cancel
</Button>
<Button
title="Add rule"
type="button"
size="small"
loading={saving}
onClick={handleAdd}
>
Add rule
</Button>
</Row>
</Stack>
<ClientRuleModalBody rule={rule} onSave={onSave} onClose={() => setOpen(false)} />
</Modal>
);
}