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:
@@ -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;
|
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) {
|
export default function buildClientRuleDrafts({ draft }: Params) {
|
||||||
const sanitized = sanitizeClientRule({
|
const sanitized = sanitizeClientRule({
|
||||||
rule: clientRuleDraftToRecord({ draft }),
|
rule: clientRuleDraftToRecord({ draft }),
|
||||||
});
|
});
|
||||||
const valid = validateClientRule({ rule: sanitized });
|
|
||||||
|
|
||||||
if (!valid.success || !sanitized) {
|
if (!sanitized) {
|
||||||
return { success: false as const, msg: valid.msg || "Invalid rule" };
|
return { success: false as const, msg: "Invalid rule" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const drafts = expandClientRuleDestinations({ rule: sanitized }).map(
|
const rules = expandClientRuleDestinations({ rule: sanitized });
|
||||||
(rule) => clientRuleToDraft({ rule }),
|
|
||||||
);
|
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 };
|
return { success: true as const, drafts };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
import ClientRuleItem from "./client-rule-item";
|
import ClientRuleItem from "./client-rule-item";
|
||||||
|
import ClientRuleModal from "./client-rule-modal";
|
||||||
import { clientRuleDraftToRecord, type ClientRuleDraft } from "./client-rule-draft";
|
import { clientRuleDraftToRecord, type ClientRuleDraft } from "./client-rule-draft";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
draft: ClientRuleDraft;
|
draft: ClientRuleDraft;
|
||||||
|
onUpdate: ({ local_id, rules }: { local_id: string; rules: ClientRuleDraft[] }) => void;
|
||||||
onRemove: ({ local_id }: { local_id: string }) => void;
|
onRemove: ({ local_id }: { local_id: string }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ClientRuleDraftItem({ draft, onRemove }: Props) {
|
export default function ClientRuleDraftItem({ draft, onUpdate, onRemove }: Props) {
|
||||||
return (
|
return (
|
||||||
<ClientRuleItem
|
<ClientRuleItem
|
||||||
rule={clientRuleDraftToRecord({ draft })}
|
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 })}
|
onRemove={() => onRemove({ local_id: draft.local_id })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { ShieldCheck, Trash2 } from "lucide-react";
|
import { ShieldCheck, Trash2 } from "lucide-react";
|
||||||
import Button from "@/src/components/twui/layout/Button";
|
import Button from "@/src/components/twui/layout/Button";
|
||||||
import Row from "@/src/components/twui/layout/Row";
|
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 = {
|
type Props = {
|
||||||
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
|
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
|
||||||
|
edit_action: ReactNode;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
removing?: boolean;
|
removing?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ClientRuleItem({ rule, onRemove, removing }: Props) {
|
export default function ClientRuleItem({ rule, edit_action, onRemove, removing }: Props) {
|
||||||
return (
|
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="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">
|
<Row className="gap-2.5 flex-nowrap min-w-0">
|
||||||
<ShieldCheck size={15} className="shrink-0 text-success dark:text-success-dark" />
|
<ShieldCheck size={15} className="shrink-0 text-success dark:text-success-dark" />
|
||||||
<MonoText className="break-all">{formatClientRuleLabel({ rule })}</MonoText>
|
<MonoText className="break-all">{formatClientRuleLabel({ rule })}</MonoText>
|
||||||
</Row>
|
</Row>
|
||||||
|
<Row className="gap-1 flex-nowrap shrink-0">
|
||||||
|
{edit_action}
|
||||||
<Button
|
<Button
|
||||||
title="Remove rule"
|
title="Remove rule"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -31,5 +35,6 @@ export default function ClientRuleItem({ rule, onRemove, removing }: Props) {
|
|||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</Row>
|
</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 { useState } from "react";
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
import Modal from "@/src/components/twui/elements/Modal";
|
import Modal from "@/src/components/twui/elements/Modal";
|
||||||
import Button from "@/src/components/twui/layout/Button";
|
import ClientRuleModalBody from "./client-rule-modal-body";
|
||||||
import H3 from "@/src/components/twui/layout/H3";
|
import ClientRuleModalTarget from "./client-rule-modal-target";
|
||||||
import P from "@/src/components/twui/layout/P";
|
import type { ClientRuleDraft } from "./client-rule-draft";
|
||||||
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";
|
|
||||||
|
|
||||||
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 = {
|
type Props = {
|
||||||
onAdd: ({ rules }: { rules: ClientRuleDraft[] }) => Promise<ClientRuleModalAddResult>;
|
/** The rule to edit. Omit to add a new one. */
|
||||||
|
rule?: ClientRuleDraft;
|
||||||
|
onSave: ClientRuleModalSave;
|
||||||
disabled?: boolean;
|
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 [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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={(value) => {
|
setOpen={setOpen}
|
||||||
setOpen(value);
|
|
||||||
if (value === false) reset();
|
|
||||||
}}
|
|
||||||
className="max-w-lg p-5"
|
className="max-w-lg p-5"
|
||||||
target={
|
target={<ClientRuleModalTarget is_edit={Boolean(rule)} disabled={disabled} />}
|
||||||
<Button
|
|
||||||
title="Add rule"
|
|
||||||
type="button"
|
|
||||||
variant="outlined"
|
|
||||||
color="gray"
|
|
||||||
size="small"
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<ClientRuleModalBody rule={rule} onSave={onSave} onClose={() => setOpen(false)} />
|
||||||
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>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export default function ClientRulesEditor({ rules, setRules }: Props) {
|
|||||||
<ClientRuleDraftItem
|
<ClientRuleDraftItem
|
||||||
key={rule.local_id}
|
key={rule.local_id}
|
||||||
draft={rule}
|
draft={rule}
|
||||||
|
onUpdate={({ local_id, rules: edited }) =>
|
||||||
|
setRules(
|
||||||
|
rules.flatMap((item) =>
|
||||||
|
item.local_id == local_id ? edited : [item],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
onRemove={({ local_id }) =>
|
onRemove={({ local_id }) =>
|
||||||
setRules(rules.filter((item) => item.local_id != 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">
|
<Stack className="items-start">
|
||||||
<ClientRuleModal
|
<ClientRuleModal
|
||||||
disabled={has_all_access}
|
disabled={has_all_access}
|
||||||
onAdd={async ({ rules: added }) => {
|
onSave={async ({ rules: added }) => {
|
||||||
setRules([...rules, ...added]);
|
setRules([...rules, ...added]);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -46,8 +46,14 @@ export default function useClientRules() {
|
|||||||
toast({ msg: success_msg, color: "success" });
|
toast({ msg: success_msg, color: "success" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRules({ rules: drafts }: { rules: ClientRuleDraft[] }) {
|
function formatDraftLabels({ drafts }: { drafts: ClientRuleDraft[] }) {
|
||||||
const res = await adminCrudHandler<BUN_SQLITE_WGUI_CLIENT_RULES>({
|
return drafts
|
||||||
|
.map((draft) => formatClientRuleLabel({ rule: clientRuleDraftToRecord({ draft }) }))
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertRules({ drafts }: { drafts: ClientRuleDraft[] }) {
|
||||||
|
return adminCrudHandler<BUN_SQLITE_WGUI_CLIENT_RULES>({
|
||||||
action: "insert",
|
action: "insert",
|
||||||
table: "client_rules",
|
table: "client_rules",
|
||||||
insert_data: drafts.map((draft) => ({
|
insert_data: drafts.map((draft) => ({
|
||||||
@@ -57,6 +63,20 @@ export default function useClientRules() {
|
|||||||
user_id: client?.user_id,
|
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) {
|
if (!res.success) {
|
||||||
return { success: false, msg: res.msg || "Couldn't add the rule" };
|
return { success: false, msg: res.msg || "Couldn't add the rule" };
|
||||||
@@ -67,6 +87,68 @@ export default function useClientRules() {
|
|||||||
return { success: true };
|
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 }) {
|
async function removeRule({ rule }: { rule: BUN_SQLITE_WGUI_CLIENT_RULES }) {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: "Remove this rule?",
|
title: "Remove this rule?",
|
||||||
@@ -92,5 +174,5 @@ export default function useClientRules() {
|
|||||||
await applySetup({ success_msg: "Rule removed" });
|
await applySetup({ success_msg: "Rule removed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rules, addRules, removeRule };
|
return { rules, addRules, updateRule, removeRule };
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -1,19 +1,38 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
|
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
|
||||||
import ClientRuleItem from "@/src/components/general/client-rules/client-rule-item";
|
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 = {
|
type Props = {
|
||||||
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
|
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>;
|
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);
|
const [removing, setRemoving] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientRuleItem
|
<ClientRuleItem
|
||||||
rule={rule}
|
rule={rule}
|
||||||
removing={removing}
|
removing={removing}
|
||||||
|
edit_action={
|
||||||
|
<ClientRuleModal
|
||||||
|
rule={clientRuleToDraft({ rule })}
|
||||||
|
onSave={({ rules }) => onUpdate({ rule, rules })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
onRemove={async () => {
|
onRemove={async () => {
|
||||||
setRemoving(true);
|
setRemoving(true);
|
||||||
await onRemove({ rule });
|
await onRemove({ rule });
|
||||||
|
|||||||
+7
-2
@@ -10,7 +10,7 @@ const TabValues = ["overview", "rules", "config"];
|
|||||||
|
|
||||||
export default function ClientDetailTabs() {
|
export default function ClientDetailTabs() {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const { rules, addRules, removeRule } = useClientRules();
|
const { rules, addRules, updateRule, removeRule } = useClientRules();
|
||||||
|
|
||||||
const requested = pageProps?.url?.searchParams?.tab;
|
const requested = pageProps?.url?.searchParams?.tab;
|
||||||
const default_tab = requested && TabValues.includes(requested) ? requested : "overview";
|
const default_tab = requested && TabValues.includes(requested) ? requested : "overview";
|
||||||
@@ -31,7 +31,12 @@ export default function ClientDetailTabs() {
|
|||||||
title: `Rules${rules ? ` (${rules.length})` : ""}`,
|
title: `Rules${rules ? ` (${rules.length})` : ""}`,
|
||||||
value: "rules",
|
value: "rules",
|
||||||
content: (
|
content: (
|
||||||
<ClientRulesTab rules={rules} addRules={addRules} removeRule={removeRule} />
|
<ClientRulesTab
|
||||||
|
rules={rules}
|
||||||
|
addRules={addRules}
|
||||||
|
updateRule={updateRule}
|
||||||
|
removeRule={removeRule}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+17
-5
@@ -3,16 +3,23 @@ import Panel from "@/src/components/general/panel";
|
|||||||
import ClientRuleModal from "@/src/components/general/client-rules/client-rule-modal";
|
import ClientRuleModal from "@/src/components/general/client-rules/client-rule-modal";
|
||||||
import ClientRulesEmpty from "@/src/components/general/client-rules/client-rules-empty";
|
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 { 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";
|
import ClientSavedRuleItem from "../(partials)/client-saved-rule-item";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
|
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>;
|
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"));
|
const has_all_access = Boolean(rules?.some((rule) => rule.rule_type == "all"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,11 +27,16 @@ export default function ClientRulesTab({ rules, addRules, removeRule }: Props) {
|
|||||||
flush
|
flush
|
||||||
title="Access rules"
|
title="Access rules"
|
||||||
description="Zero trust: this client can only reach what's listed here."
|
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?.length ? (
|
||||||
rules.map((rule) => (
|
rules.map((rule) => (
|
||||||
<ClientSavedRuleItem key={rule.id} rule={rule} onRemove={removeRule} />
|
<ClientSavedRuleItem
|
||||||
|
key={rule.id}
|
||||||
|
rule={rule}
|
||||||
|
onUpdate={updateRule}
|
||||||
|
onRemove={removeRule}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<ClientRulesEmpty />
|
<ClientRulesEmpty />
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default async function (
|
|||||||
rule: {
|
rule: {
|
||||||
...existing,
|
...existing,
|
||||||
...body.update_data,
|
...body.update_data,
|
||||||
|
id: existing.id,
|
||||||
},
|
},
|
||||||
client_id: existing.client_id,
|
client_id: existing.client_id,
|
||||||
user_id: existing.user_id,
|
user_id: existing.user_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user