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
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import buildClientRuleDrafts from "./build-client-rule-drafts";
import { createClientRuleDraft } from "./client-rule-draft";
describe("buildClientRuleDrafts", () => {
test("keeps the id of a rule edited to a single destination", () => {
const built = buildClientRuleDrafts({
draft: createClientRuleDraft({ id: 7, destination: "10.0.0.6" }),
});
expect(built.success && built.drafts.map((draft) => draft.id)).toEqual([7]);
});
test("splits comma-separated destinations", () => {
const built = buildClientRuleDrafts({
draft: createClientRuleDraft({
destination: "10.0.0.6, 192.168.1.0/24",
ports: "443",
protocol: "tcp",
}),
});
expect(built.success).toBe(true);
expect(
built.success &&
built.drafts.map(({ destination, ports, protocol }) => ({
destination,
ports,
protocol,
})),
).toEqual([
{ destination: "10.0.0.6", ports: "443", protocol: "tcp" },
{ destination: "192.168.1.0/24", ports: "443", protocol: "tcp" },
]);
});
test("rejects the whole rule if any destination is invalid", () => {
const built = buildClientRuleDrafts({
draft: createClientRuleDraft({ destination: "10.0.0.6, nope" }),
});
expect(built.success).toBe(false);
expect(!built.success && built.msg).toContain("nope");
});
test("clears destination fields for all access", () => {
const built = buildClientRuleDrafts({
draft: createClientRuleDraft({
rule_type: "all",
destination: "10.0.0.6",
ports: "80",
protocol: "tcp",
}),
});
expect(built.success && built.drafts[0]).toMatchObject({
rule_type: "all",
destination: "",
ports: "",
protocol: "any",
});
});
});
@@ -11,20 +11,27 @@ type Params = {
draft: ClientRuleDraft;
};
/** Sanitizes and validates a draft, splitting comma-separated destinations. */
/** Sanitizes a draft, splits comma-separated destinations, and validates each. */
export default function buildClientRuleDrafts({ draft }: Params) {
const sanitized = sanitizeClientRule({
rule: clientRuleDraftToRecord({ draft }),
});
const valid = validateClientRule({ rule: sanitized });
if (!valid.success || !sanitized) {
return { success: false as const, msg: valid.msg || "Invalid rule" };
if (!sanitized) {
return { success: false as const, msg: "Invalid rule" };
}
const drafts = expandClientRuleDestinations({ rule: sanitized }).map(
(rule) => clientRuleToDraft({ rule }),
);
const rules = expandClientRuleDestinations({ rule: sanitized });
for (const rule of rules) {
const valid = validateClientRule({ rule });
if (!valid.success) {
return { success: false as const, msg: valid.msg || "Invalid rule" };
}
}
const drafts = rules.map((rule) => clientRuleToDraft({ rule }));
return { success: true as const, drafts };
}
@@ -1,15 +1,34 @@
import ClientRuleItem from "./client-rule-item";
import ClientRuleModal from "./client-rule-modal";
import { clientRuleDraftToRecord, type ClientRuleDraft } from "./client-rule-draft";
type Props = {
draft: ClientRuleDraft;
onUpdate: ({ local_id, rules }: { local_id: string; rules: ClientRuleDraft[] }) => void;
onRemove: ({ local_id }: { local_id: string }) => void;
};
export default function ClientRuleDraftItem({ draft, onRemove }: Props) {
export default function ClientRuleDraftItem({ draft, onUpdate, onRemove }: Props) {
return (
<ClientRuleItem
rule={clientRuleDraftToRecord({ draft })}
edit_action={
<ClientRuleModal
rule={draft}
onSave={async ({ rules: [first, ...added] }) => {
if (!first) return { success: false };
onUpdate({
local_id: draft.local_id,
rules: [
{ ...first, id: draft.id, local_id: draft.local_id },
...added,
],
});
return { success: true };
}}
/>
}
onRemove={() => onRemove({ local_id: draft.local_id })}
/>
);
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { ShieldCheck, Trash2 } from "lucide-react";
import Button from "@/src/components/twui/layout/Button";
import Row from "@/src/components/twui/layout/Row";
@@ -7,17 +8,20 @@ import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
type Props = {
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
edit_action: ReactNode;
onRemove: () => void;
removing?: boolean;
};
export default function ClientRuleItem({ rule, onRemove, removing }: Props) {
export default function ClientRuleItem({ rule, edit_action, onRemove, removing }: Props) {
return (
<Row className="w-full justify-between gap-3 px-4 py-2.5 border-t border-line first:border-t-0 flex-nowrap">
<Row className="gap-2.5 flex-nowrap min-w-0">
<ShieldCheck size={15} className="shrink-0 text-success dark:text-success-dark" />
<MonoText className="break-all">{formatClientRuleLabel({ rule })}</MonoText>
</Row>
<Row className="gap-1 flex-nowrap shrink-0">
{edit_action}
<Button
title="Remove rule"
type="button"
@@ -31,5 +35,6 @@ export default function ClientRuleItem({ rule, onRemove, removing }: Props) {
<Trash2 size={14} />
</Button>
</Row>
</Row>
);
}
@@ -0,0 +1,98 @@
import { useState } from "react";
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 type { ClientRuleModalSave } from "./client-rule-modal";
type Props = {
rule?: ClientRuleDraft;
onSave: ClientRuleModalSave;
onClose: () => void;
};
/**
* Mounted only while the modal is open, so each open starts from the current
* rule with no state to reset.
*/
export default function ClientRuleModalBody({ rule, onSave, onClose }: Props) {
const is_edit = Boolean(rule);
const [draft, setDraft] = useState<ClientRuleDraft>(createClientRuleDraft(rule));
const [error, setError] = useState<string>();
const [saving, setSaving] = useState(false);
async function handleSave() {
if (saving) return;
const built = buildClientRuleDrafts({ draft });
if (!built.success) {
setError(built.msg);
return;
}
setSaving(true);
const res = await onSave({ rules: built.drafts });
setSaving(false);
if (res.cancelled) return;
if (!res.success) {
setError(res.msg || "Couldn't save the rule");
return;
}
onClose();
}
return (
<Stack className="w-full items-stretch gap-5">
<Stack className="gap-1 pr-8">
<H3 className="text-lg!">{is_edit ? "Edit access rule" : "Add access rule"}</H3>
<P className="text-[15px]">
{is_edit
? "Change what this client can reach. Leave ports empty for any port."
: "Allow this client to reach a destination. Leave ports empty for any port."}
</P>
</Stack>
{error ? <InlineAlert tone="error" msg={error} /> : null}
<ClientRuleFields
draft={draft}
setDraft={(next) => {
setDraft(next);
setError(undefined);
}}
onEnter={handleSave}
/>
<Row className="w-full justify-end gap-2 pt-1">
<Button
title="Cancel"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={onClose}
>
Cancel
</Button>
<Button
title={is_edit ? "Save rule" : "Add rule"}
type="button"
size="small"
loading={saving}
onClick={handleSave}
>
{is_edit ? "Save rule" : "Add rule"}
</Button>
</Row>
</Stack>
);
}
@@ -0,0 +1,39 @@
import { Pencil, Plus } from "lucide-react";
import Button from "@/src/components/twui/layout/Button";
type Props = {
is_edit: boolean;
disabled?: boolean;
};
export default function ClientRuleModalTarget({ is_edit, disabled }: Props) {
if (is_edit) {
return (
<Button
title="Edit rule"
type="button"
variant="ghost"
color="gray"
size="small"
className="p-1.5!"
disabled={disabled}
>
<Pencil size={14} />
</Button>
);
}
return (
<Button
title="Add rule"
type="button"
variant="outlined"
color="gray"
size="small"
disabled={disabled}
>
<Plus size={14} />
Add rule
</Button>
);
}
@@ -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}
target={<ClientRuleModalTarget is_edit={Boolean(rule)} disabled={disabled} />}
>
<Plus size={14} />
Add rule
</Button>
}
>
<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>
);
}
@@ -22,6 +22,13 @@ export default function ClientRulesEditor({ rules, setRules }: Props) {
<ClientRuleDraftItem
key={rule.local_id}
draft={rule}
onUpdate={({ local_id, rules: edited }) =>
setRules(
rules.flatMap((item) =>
item.local_id == local_id ? edited : [item],
),
)
}
onRemove={({ local_id }) =>
setRules(rules.filter((item) => item.local_id != local_id))
}
@@ -34,7 +41,7 @@ export default function ClientRulesEditor({ rules, setRules }: Props) {
<Stack className="items-start">
<ClientRuleModal
disabled={has_all_access}
onAdd={async ({ rules: added }) => {
onSave={async ({ rules: added }) => {
setRules([...rules, ...added]);
return { success: true };
}}
@@ -46,8 +46,14 @@ export default function useClientRules() {
toast({ msg: success_msg, color: "success" });
}
async function addRules({ rules: drafts }: { rules: ClientRuleDraft[] }) {
const res = await adminCrudHandler<BUN_SQLITE_WGUI_CLIENT_RULES>({
function formatDraftLabels({ drafts }: { drafts: ClientRuleDraft[] }) {
return drafts
.map((draft) => formatClientRuleLabel({ rule: clientRuleDraftToRecord({ draft }) }))
.join(", ");
}
function insertRules({ drafts }: { drafts: ClientRuleDraft[] }) {
return adminCrudHandler<BUN_SQLITE_WGUI_CLIENT_RULES>({
action: "insert",
table: "client_rules",
insert_data: drafts.map((draft) => ({
@@ -57,6 +63,20 @@ export default function useClientRules() {
user_id: client?.user_id,
})),
});
}
async function addRules({ rules: drafts }: { rules: ClientRuleDraft[] }) {
const confirmed = await confirm({
title: drafts.length > 1 ? "Add these rules?" : "Add this rule?",
message: formatDraftLabels({ drafts }),
confirm_label: drafts.length > 1 ? "Add rules" : "Add rule",
});
if (!confirmed) {
return { success: false, cancelled: true };
}
const res = await insertRules({ drafts });
if (!res.success) {
return { success: false, msg: res.msg || "Couldn't add the rule" };
@@ -67,6 +87,68 @@ export default function useClientRules() {
return { success: true };
}
/**
* The first draft replaces `rule`. Extra drafts come from comma-separated
* destinations and are added as new rules.
*/
async function updateRule({
rule,
rules: drafts,
}: {
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
rules: ClientRuleDraft[];
}) {
const [first, ...added] = drafts;
if (!first) {
return { success: false, msg: "Couldn't save the rule" };
}
const confirmed = await confirm({
title: "Save this rule?",
message: formatDraftLabels({ drafts }),
confirm_label: "Save rule",
});
if (!confirmed) {
return { success: false, cancelled: true };
}
const update_data = clientRuleDraftToRecord({ draft: first });
const res = await adminCrudHandler<BUN_SQLITE_WGUI_CLIENT_RULES>({
action: "update",
table: "client_rules",
id: rule.id,
update_data,
});
if (!res.success) {
return { success: false, msg: res.msg || "Couldn't save the rule" };
}
setRes((prev) =>
prev?.map((r) => (r.id == rule.id ? { ...r, ...update_data, id: r.id } : r)),
);
const added_res = added.length ? await insertRules({ drafts: added }) : undefined;
await applySetup({ success_msg: "Rule updated" });
if (added_res && !added_res.success) {
return {
success: false,
msg: added_res.msg || "Rule updated, but the extra destinations weren't added",
};
}
// Refetching blanks the list and unmounts this rule's open modal, so it
// must come after every failure path that reports into that modal.
if (added_res) refetch();
return { success: true };
}
async function removeRule({ rule }: { rule: BUN_SQLITE_WGUI_CLIENT_RULES }) {
const confirmed = await confirm({
title: "Remove this rule?",
@@ -92,5 +174,5 @@ export default function useClientRules() {
await applySetup({ success_msg: "Rule removed" });
}
return { rules, addRules, removeRule };
return { rules, addRules, updateRule, removeRule };
}
@@ -1,19 +1,38 @@
import { useState } from "react";
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
import ClientRuleItem from "@/src/components/general/client-rules/client-rule-item";
import ClientRuleModal from "@/src/components/general/client-rules/client-rule-modal";
import {
clientRuleToDraft,
type ClientRuleDraft,
} from "@/src/components/general/client-rules/client-rule-draft";
import type { ClientRuleModalSaveResult } from "@/src/components/general/client-rules/client-rule-modal";
type Props = {
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
onUpdate: ({
rule,
rules,
}: {
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
rules: ClientRuleDraft[];
}) => Promise<ClientRuleModalSaveResult>;
onRemove: ({ rule }: { rule: BUN_SQLITE_WGUI_CLIENT_RULES }) => Promise<void>;
};
export default function ClientSavedRuleItem({ rule, onRemove }: Props) {
export default function ClientSavedRuleItem({ rule, onUpdate, onRemove }: Props) {
const [removing, setRemoving] = useState(false);
return (
<ClientRuleItem
rule={rule}
removing={removing}
edit_action={
<ClientRuleModal
rule={clientRuleToDraft({ rule })}
onSave={({ rules }) => onUpdate({ rule, rules })}
/>
}
onRemove={async () => {
setRemoving(true);
await onRemove({ rule });
@@ -10,7 +10,7 @@ const TabValues = ["overview", "rules", "config"];
export default function ClientDetailTabs() {
const { pageProps } = useContext(AppContext);
const { rules, addRules, removeRule } = useClientRules();
const { rules, addRules, updateRule, removeRule } = useClientRules();
const requested = pageProps?.url?.searchParams?.tab;
const default_tab = requested && TabValues.includes(requested) ? requested : "overview";
@@ -31,7 +31,12 @@ export default function ClientDetailTabs() {
title: `Rules${rules ? ` (${rules.length})` : ""}`,
value: "rules",
content: (
<ClientRulesTab rules={rules} addRules={addRules} removeRule={removeRule} />
<ClientRulesTab
rules={rules}
addRules={addRules}
updateRule={updateRule}
removeRule={removeRule}
/>
),
},
{
@@ -3,16 +3,23 @@ import Panel from "@/src/components/general/panel";
import ClientRuleModal from "@/src/components/general/client-rules/client-rule-modal";
import ClientRulesEmpty from "@/src/components/general/client-rules/client-rules-empty";
import type { ClientRuleDraft } from "@/src/components/general/client-rules/client-rule-draft";
import type { ClientRuleModalAddResult } from "@/src/components/general/client-rules/client-rule-modal";
import type { ClientRuleModalSaveResult } from "@/src/components/general/client-rules/client-rule-modal";
import ClientSavedRuleItem from "../(partials)/client-saved-rule-item";
type Props = {
rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
addRules: ({ rules }: { rules: ClientRuleDraft[] }) => Promise<ClientRuleModalAddResult>;
addRules: ({ rules }: { rules: ClientRuleDraft[] }) => Promise<ClientRuleModalSaveResult>;
updateRule: ({
rule,
rules,
}: {
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
rules: ClientRuleDraft[];
}) => Promise<ClientRuleModalSaveResult>;
removeRule: ({ rule }: { rule: BUN_SQLITE_WGUI_CLIENT_RULES }) => Promise<void>;
};
export default function ClientRulesTab({ rules, addRules, removeRule }: Props) {
export default function ClientRulesTab({ rules, addRules, updateRule, removeRule }: Props) {
const has_all_access = Boolean(rules?.some((rule) => rule.rule_type == "all"));
return (
@@ -20,11 +27,16 @@ export default function ClientRulesTab({ rules, addRules, removeRule }: Props) {
flush
title="Access rules"
description="Zero trust: this client can only reach what's listed here."
actions={<ClientRuleModal onAdd={addRules} disabled={has_all_access} />}
actions={<ClientRuleModal onSave={addRules} disabled={has_all_access} />}
>
{rules?.length ? (
rules.map((rule) => (
<ClientSavedRuleItem key={rule.id} rule={rule} onRemove={removeRule} />
<ClientSavedRuleItem
key={rule.id}
rule={rule}
onUpdate={updateRule}
onRemove={removeRule}
/>
))
) : (
<ClientRulesEmpty />
@@ -57,6 +57,7 @@ export default async function (
rule: {
...existing,
...body.update_data,
id: existing.id,
},
client_id: existing.client_id,
user_id: existing.user_id,