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;
|
||||
};
|
||||
|
||||
/** 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,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 (
|
||||
<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>
|
||||
<Button
|
||||
title="Remove rule"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="small"
|
||||
loading={removing}
|
||||
className="p-1.5!"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Row className="gap-1 flex-nowrap shrink-0">
|
||||
{edit_action}
|
||||
<Button
|
||||
title="Remove rule"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="small"
|
||||
loading={removing}
|
||||
className="p-1.5!"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
+20
-1
@@ -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 });
|
||||
|
||||
+7
-2
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
+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 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,
|
||||
|
||||
Reference in New Issue
Block a user