diff --git a/src/components/general/client-rules/build-client-rule-drafts.test.ts b/src/components/general/client-rules/build-client-rule-drafts.test.ts new file mode 100644 index 0000000..24a85ac --- /dev/null +++ b/src/components/general/client-rules/build-client-rule-drafts.test.ts @@ -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", + }); + }); +}); diff --git a/src/components/general/client-rules/build-client-rule-drafts.ts b/src/components/general/client-rules/build-client-rule-drafts.ts index a540158..1e62534 100644 --- a/src/components/general/client-rules/build-client-rule-drafts.ts +++ b/src/components/general/client-rules/build-client-rule-drafts.ts @@ -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 }; } diff --git a/src/components/general/client-rules/client-rule-draft-item.tsx b/src/components/general/client-rules/client-rule-draft-item.tsx index 3fafc6b..3767bee 100644 --- a/src/components/general/client-rules/client-rule-draft-item.tsx +++ b/src/components/general/client-rules/client-rule-draft-item.tsx @@ -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 ( { + 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 })} /> ); diff --git a/src/components/general/client-rules/client-rule-item.tsx b/src/components/general/client-rules/client-rule-item.tsx index 45036e4..b506210 100644 --- a/src/components/general/client-rules/client-rule-item.tsx +++ b/src/components/general/client-rules/client-rule-item.tsx @@ -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,29 +8,33 @@ 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 ( {formatClientRuleLabel({ rule })} - + + {edit_action} + + ); } diff --git a/src/components/general/client-rules/client-rule-modal-body.tsx b/src/components/general/client-rules/client-rule-modal-body.tsx new file mode 100644 index 0000000..b22c58a --- /dev/null +++ b/src/components/general/client-rules/client-rule-modal-body.tsx @@ -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(createClientRuleDraft(rule)); + const [error, setError] = useState(); + 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 ( + + +

{is_edit ? "Edit access rule" : "Add access rule"}

+

+ {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."} +

+
+ + {error ? : null} + + { + setDraft(next); + setError(undefined); + }} + onEnter={handleSave} + /> + + + + + +
+ ); +} diff --git a/src/components/general/client-rules/client-rule-modal-target.tsx b/src/components/general/client-rules/client-rule-modal-target.tsx new file mode 100644 index 0000000..b663da1 --- /dev/null +++ b/src/components/general/client-rules/client-rule-modal-target.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/src/components/general/client-rules/client-rule-modal.tsx b/src/components/general/client-rules/client-rule-modal.tsx index fa10ce5..23823df 100644 --- a/src/components/general/client-rules/client-rule-modal.tsx +++ b/src/components/general/client-rules/client-rule-modal.tsx @@ -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; type Props = { - onAdd: ({ rules }: { rules: ClientRuleDraft[] }) => Promise; + /** 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(createClientRuleDraft()); - const [error, setError] = useState(); - 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 ( { - setOpen(value); - if (value === false) reset(); - }} + setOpen={setOpen} className="max-w-lg p-5" - target={ - - } + target={} > - - -

Add access rule

-

- Allow this client to reach a destination. Leave ports empty for any port. -

-
- - {error ? : null} - - { - setDraft(next); - setError(undefined); - }} - onEnter={handleAdd} - /> - - - - - -
+ setOpen(false)} />
); } diff --git a/src/components/general/client-rules/client-rules-editor.tsx b/src/components/general/client-rules/client-rules-editor.tsx index 136e30e..5c14aad 100644 --- a/src/components/general/client-rules/client-rules-editor.tsx +++ b/src/components/general/client-rules/client-rules-editor.tsx @@ -22,6 +22,13 @@ export default function ClientRulesEditor({ rules, setRules }: Props) { + 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) { { + onSave={async ({ rules: added }) => { setRules([...rules, ...added]); return { success: true }; }} diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(hooks)/use-client-rules.ts b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(hooks)/use-client-rules.ts index 493f9ea..75974a6 100644 --- a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(hooks)/use-client-rules.ts +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(hooks)/use-client-rules.ts @@ -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({ + function formatDraftLabels({ drafts }: { drafts: ClientRuleDraft[] }) { + return drafts + .map((draft) => formatClientRuleLabel({ rule: clientRuleDraftToRecord({ draft }) })) + .join(", "); + } + + function insertRules({ drafts }: { drafts: ClientRuleDraft[] }) { + return adminCrudHandler({ 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({ + 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 }; } diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-saved-rule-item.tsx b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-saved-rule-item.tsx index 94a6dd7..fcf7e0e 100644 --- a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-saved-rule-item.tsx +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-saved-rule-item.tsx @@ -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; onRemove: ({ rule }: { rule: BUN_SQLITE_WGUI_CLIENT_RULES }) => Promise; }; -export default function ClientSavedRuleItem({ rule, onRemove }: Props) { +export default function ClientSavedRuleItem({ rule, onUpdate, onRemove }: Props) { const [removing, setRemoving] = useState(false); return ( onUpdate({ rule, rules })} + /> + } onRemove={async () => { setRemoving(true); await onRemove({ rule }); diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-detail-tabs.tsx b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-detail-tabs.tsx index e232fa8..9e9e263 100644 --- a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-detail-tabs.tsx +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-detail-tabs.tsx @@ -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: ( - + ), }, { diff --git a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-rules-tab.tsx b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-rules-tab.tsx index b03169d..eee9a7e 100644 --- a/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-rules-tab.tsx +++ b/src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-rules-tab.tsx @@ -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; + addRules: ({ rules }: { rules: ClientRuleDraft[] }) => Promise; + updateRule: ({ + rule, + rules, + }: { + rule: BUN_SQLITE_WGUI_CLIENT_RULES; + rules: ClientRuleDraft[]; + }) => Promise; removeRule: ({ rule }: { rule: BUN_SQLITE_WGUI_CLIENT_RULES }) => Promise; }; -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={} + actions={} > {rules?.length ? ( rules.map((rule) => ( - + )) ) : ( diff --git a/src/pages/api/admin/crud/(tables)/client_rules/put.ts b/src/pages/api/admin/crud/(tables)/client_rules/put.ts index 51003ee..db71c0f 100644 --- a/src/pages/api/admin/crud/(tables)/client_rules/put.ts +++ b/src/pages/api/admin/crud/(tables)/client_rules/put.ts @@ -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,