Updates
This commit is contained in:
@@ -9,7 +9,10 @@ import Row from "@/src/components/twui/layout/Row";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import ClientFormModal from "../(partials)/client-form-modal";
|
||||
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
||||
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENTS,
|
||||
BUN_SQLITE_WGUI_CLIENT_RULES,
|
||||
} from "@/db/types/db";
|
||||
|
||||
type Props = {
|
||||
addOpen: boolean;
|
||||
@@ -33,6 +36,32 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const { res: client_rules } = useAdminCrudGet<BUN_SQLITE_WGUI_CLIENT_RULES>({
|
||||
table: "client_rules",
|
||||
sql_query: {
|
||||
limit: 500,
|
||||
},
|
||||
});
|
||||
|
||||
const rules_by_client_id = useMemo(() => {
|
||||
const map = new Map<number, BUN_SQLITE_WGUI_CLIENT_RULES[]>();
|
||||
|
||||
for (let i = 0; i < (client_rules || []).length; i++) {
|
||||
const rule = client_rules?.[i];
|
||||
const client_id = Number(rule?.client_id);
|
||||
|
||||
if (!rule || !client_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = map.get(client_id) || [];
|
||||
existing.push(rule);
|
||||
map.set(client_id, existing);
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [client_rules]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const clients = res || [];
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -80,15 +109,26 @@ export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
|
||||
<th className={thClass}>Client</th>
|
||||
<th className={thClass}>Tunnel IP</th>
|
||||
<th className={thClass}>Allowed IPs</th>
|
||||
<th className={thClass}>Access</th>
|
||||
<th className={thClass}>Public key</th>
|
||||
<th className={thRightClass}>Created</th>
|
||||
<th className={thRightClass}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((client) => (
|
||||
<ClientRow key={client.id} client={client} />
|
||||
))}
|
||||
{filtered.map((client) => (
|
||||
<ClientRow
|
||||
key={client.id}
|
||||
client={client}
|
||||
rules={
|
||||
client.id
|
||||
? rules_by_client_id.get(
|
||||
Number(client.id),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import expandClientRuleDestinations from "@/src/functions/backend/setup/expand-client-rule-destinations";
|
||||
import sanitizeClientRule from "@/src/functions/backend/setup/sanitize-client-rule";
|
||||
import validateClientRule from "@/src/functions/backend/setup/validate-client-rule";
|
||||
import ClientRuleFields from "./client-rule-fields";
|
||||
import {
|
||||
clientRuleDraftToRecord,
|
||||
clientRuleToDraft,
|
||||
createClientRuleDraft,
|
||||
type ClientRuleDraft,
|
||||
} from "./client-rule-draft";
|
||||
import Modal from "@/src/components/twui/elements/Modal";
|
||||
import useStatus from "@/src/components/twui/hooks/useStatus";
|
||||
import H4 from "@/src/components/twui/layout/H4";
|
||||
import Span from "@/src/components/twui/layout/Span";
|
||||
import Form from "@/src/components/twui/form/Form";
|
||||
import Divider from "@/src/components/twui/layout/Divider";
|
||||
|
||||
type Props = {
|
||||
rules: ClientRuleDraft[];
|
||||
setRules: (rules: ClientRuleDraft[]) => void;
|
||||
setError: Dispatch<SetStateAction<string | undefined>>;
|
||||
};
|
||||
|
||||
export default function AddClientRuleModal({
|
||||
rules,
|
||||
setRules,
|
||||
setError,
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState<ClientRuleDraft>(
|
||||
createClientRuleDraft(),
|
||||
);
|
||||
const { open, setOpen } = useStatus();
|
||||
|
||||
function handleAdd() {
|
||||
const sanitized = sanitizeClientRule({
|
||||
rule: clientRuleDraftToRecord({ draft }),
|
||||
});
|
||||
const valid = validateClientRule({ rule: sanitized });
|
||||
|
||||
if (!valid.success || !sanitized) {
|
||||
setError(valid.msg || "Invalid rule");
|
||||
return;
|
||||
}
|
||||
|
||||
const expanded = expandClientRuleDestinations({ rule: sanitized }).map(
|
||||
(rule) => clientRuleToDraft({ rule }),
|
||||
);
|
||||
|
||||
setRules([...rules, ...expanded]);
|
||||
setDraft(createClientRuleDraft());
|
||||
setError(undefined);
|
||||
}
|
||||
|
||||
const is_ll_access = Boolean(rules.find((r) => r.rule_type == "all"));
|
||||
|
||||
if (is_ll_access) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
target={
|
||||
<Button
|
||||
title="Add Client Rule"
|
||||
size="smaller"
|
||||
variant="outlined"
|
||||
>
|
||||
Add Client Rule
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Stack className="w-full items-stretch gap-8">
|
||||
<Stack className="gap-1!">
|
||||
<H4>Add Rule</H4>
|
||||
<Span>Add a new rule for this client</Span>{" "}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ClientRuleFields draft={draft} setDraft={setDraft} />
|
||||
|
||||
<Button
|
||||
title="Add access rule"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
beforeIcon={<Plus size={14} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Add rule
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import Input from "@/src/components/twui/form/Input";
|
||||
import type { ClientRuleDraft } from "./client-rule-draft";
|
||||
|
||||
type Props = {
|
||||
draft: ClientRuleDraft;
|
||||
setDraft: (draft: ClientRuleDraft) => void;
|
||||
};
|
||||
|
||||
export default function ClientRuleDestinationField({ draft, setDraft }: Props) {
|
||||
if (draft.rule_type == "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
title="Destination IP"
|
||||
placeholder="10.0.0.5 or 192.168.1.0/24"
|
||||
showLabel
|
||||
defaultValue={draft.destination}
|
||||
changeHandler={(value) => {
|
||||
setDraft({
|
||||
...draft,
|
||||
destination: value,
|
||||
});
|
||||
}}
|
||||
info="One IPv4 or CIDR. Comma-separate to add several."
|
||||
/>
|
||||
);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { ShieldCheck, Trash2 } from "lucide-react";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Span from "@/src/components/twui/layout/Span";
|
||||
import formatClientRuleLabel from "@/src/functions/frontend/format-client-rule-label";
|
||||
import {
|
||||
clientRuleDraftToRecord,
|
||||
type ClientRuleDraft,
|
||||
} from "./client-rule-draft";
|
||||
|
||||
type Props = {
|
||||
draft: ClientRuleDraft;
|
||||
onRemove: ({ local_id }: { local_id: string }) => void;
|
||||
};
|
||||
|
||||
export default function ClientRuleDraftItem({ draft, onRemove }: Props) {
|
||||
return (
|
||||
<Row className="w-full justify-between gap-3 py-2.5 border-t border-slate-200/60 dark:border-white/5">
|
||||
<Row className="items-center gap-2!">
|
||||
<ShieldCheck size={15} className="-mt-[4px] text-success" />
|
||||
<Span className="font-mono text-[12.5px] text-foreground-light/70 dark:text-foreground-dark/70 break-all">
|
||||
{formatClientRuleLabel({
|
||||
rule: clientRuleDraftToRecord({ draft }),
|
||||
})}
|
||||
</Span>
|
||||
</Row>
|
||||
<Button
|
||||
title="Remove rule"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="smaller"
|
||||
beforeIcon={<Trash2 size={14} />}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onRemove({ local_id: draft.local_id });
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
|
||||
import type { ClientRuleProtocol, ClientRuleType } from "@/src/types";
|
||||
|
||||
export type ClientRuleDraft = {
|
||||
local_id: string;
|
||||
id?: number | "";
|
||||
rule_type: ClientRuleType;
|
||||
destination?: string;
|
||||
ports?: string;
|
||||
protocol: ClientRuleProtocol;
|
||||
};
|
||||
|
||||
type CreateParams = Partial<ClientRuleDraft>;
|
||||
|
||||
export function createClientRuleDraft(params?: CreateParams): ClientRuleDraft {
|
||||
return {
|
||||
local_id: crypto.randomUUID(),
|
||||
rule_type: "destination",
|
||||
destination: "",
|
||||
ports: "",
|
||||
protocol: "any",
|
||||
...params,
|
||||
};
|
||||
}
|
||||
|
||||
export function clientRuleToDraft({
|
||||
rule,
|
||||
}: {
|
||||
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
|
||||
}): ClientRuleDraft {
|
||||
return {
|
||||
local_id: String(rule.id || crypto.randomUUID()),
|
||||
id: rule.id,
|
||||
rule_type: rule.rule_type == "all" ? "all" : "destination",
|
||||
destination: rule.destination || "",
|
||||
ports: rule.ports || "",
|
||||
protocol:
|
||||
rule.protocol == "tcp" || rule.protocol == "udp"
|
||||
? rule.protocol
|
||||
: "any",
|
||||
};
|
||||
}
|
||||
|
||||
export function clientRuleDraftToRecord({
|
||||
draft,
|
||||
}: {
|
||||
draft: ClientRuleDraft;
|
||||
}): BUN_SQLITE_WGUI_CLIENT_RULES {
|
||||
return {
|
||||
...(draft.id ? { id: draft.id } : {}),
|
||||
rule_type: draft.rule_type,
|
||||
destination: draft.destination || "",
|
||||
ports: draft.ports || "",
|
||||
protocol: draft.protocol,
|
||||
};
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import type { ClientRuleDraft } from "./client-rule-draft";
|
||||
import ClientRuleDestinationField from "./client-rule-destination-field";
|
||||
import ClientRulePortsField from "./client-rule-ports-field";
|
||||
import ClientRuleProtocolField from "./client-rule-protocol-field";
|
||||
import ClientRuleTypeField from "./client-rule-type-field";
|
||||
import Form from "@/src/components/twui/form/Form";
|
||||
|
||||
type Props = {
|
||||
draft: ClientRuleDraft;
|
||||
setDraft: (draft: ClientRuleDraft) => void;
|
||||
};
|
||||
|
||||
export default function ClientRuleFields({ draft, setDraft }: Props) {
|
||||
return (
|
||||
<Form>
|
||||
<Stack className="w-full items-stretch gap-4">
|
||||
<ClientRuleTypeField draft={draft} setDraft={setDraft} />
|
||||
<ClientRuleDestinationField draft={draft} setDraft={setDraft} />
|
||||
<ClientRulePortsField draft={draft} setDraft={setDraft} />
|
||||
<ClientRuleProtocolField draft={draft} setDraft={setDraft} />
|
||||
</Stack>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import Input from "@/src/components/twui/form/Input";
|
||||
import type { ClientRuleDraft } from "./client-rule-draft";
|
||||
|
||||
type Props = {
|
||||
draft: ClientRuleDraft;
|
||||
setDraft: (draft: ClientRuleDraft) => void;
|
||||
};
|
||||
|
||||
export default function ClientRulePortsField({ draft, setDraft }: Props) {
|
||||
if (draft.rule_type == "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
title="Ports"
|
||||
placeholder="80,443 or 8000:8080"
|
||||
showLabel
|
||||
value={draft.ports}
|
||||
onChange={(e) => {
|
||||
setDraft({
|
||||
...draft,
|
||||
ports: e.target.value,
|
||||
});
|
||||
}}
|
||||
info="Leave empty for any port."
|
||||
/>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import Select from "@/src/components/twui/form/Select";
|
||||
import { ClientRuleProtocols } from "@/src/dict/client-rules-dict";
|
||||
import type { ClientRuleProtocol } from "@/src/types";
|
||||
import type { ClientRuleDraft } from "./client-rule-draft";
|
||||
|
||||
type Props = {
|
||||
draft: ClientRuleDraft;
|
||||
setDraft: (draft: ClientRuleDraft) => void;
|
||||
};
|
||||
|
||||
export default function ClientRuleProtocolField({ draft, setDraft }: Props) {
|
||||
if (draft.rule_type == "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select<ClientRuleProtocol>
|
||||
title="Protocol"
|
||||
showLabel
|
||||
options={ClientRuleProtocols.map((protocol) => ({
|
||||
title: protocol.title,
|
||||
value: protocol.value,
|
||||
default: protocol.value == draft.protocol,
|
||||
}))}
|
||||
changeHandler={(value) => {
|
||||
setDraft({
|
||||
...draft,
|
||||
protocol: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import Select from "@/src/components/twui/form/Select";
|
||||
import { ClientRuleTypes } from "@/src/dict/client-rules-dict";
|
||||
import type { ClientRuleType } from "@/src/types";
|
||||
import type { ClientRuleDraft } from "./client-rule-draft";
|
||||
import Checkbox from "@/src/components/twui/form/Checkbox";
|
||||
|
||||
type Props = {
|
||||
draft: ClientRuleDraft;
|
||||
setDraft: (draft: ClientRuleDraft) => void;
|
||||
};
|
||||
|
||||
export default function ClientRuleTypeField({ draft, setDraft }: Props) {
|
||||
return (
|
||||
<Checkbox
|
||||
defaultChecked={draft.rule_type == "all"}
|
||||
label={`Allow all access?`}
|
||||
info={`This allows unlimited access to all clients in this network`}
|
||||
changeHandler={(checked) => {
|
||||
setDraft({
|
||||
...draft,
|
||||
rule_type: checked ? "all" : "destination",
|
||||
});
|
||||
}}
|
||||
wrapperWrapperProps={{ className: `mb-4` }}
|
||||
/>
|
||||
);
|
||||
|
||||
// return (
|
||||
// <Select<ClientRuleType>
|
||||
// title="Access"
|
||||
// showLabel
|
||||
// options={ClientRuleTypes.map((type) => ({
|
||||
// title: type.title,
|
||||
// value: type.value,
|
||||
// default: type.value == draft.rule_type,
|
||||
// }))}
|
||||
// changeHandler={(value) => {
|
||||
// setDraft({
|
||||
// ...draft,
|
||||
// rule_type: value,
|
||||
// });
|
||||
// }}
|
||||
// />
|
||||
// );
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import H4 from "@/src/components/twui/layout/H4";
|
||||
import P from "@/src/components/twui/layout/P";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Tag from "@/src/components/twui/elements/Tag";
|
||||
import expandClientRuleDestinations from "@/src/functions/backend/setup/expand-client-rule-destinations";
|
||||
import sanitizeClientRule from "@/src/functions/backend/setup/sanitize-client-rule";
|
||||
import validateClientRule from "@/src/functions/backend/setup/validate-client-rule";
|
||||
import ClientRuleDraftItem from "./client-rule-draft-item";
|
||||
import ClientRulesEmpty from "./client-rules-empty";
|
||||
import {
|
||||
clientRuleDraftToRecord,
|
||||
clientRuleToDraft,
|
||||
createClientRuleDraft,
|
||||
type ClientRuleDraft,
|
||||
} from "./client-rule-draft";
|
||||
import AddClientRuleModal from "./add-client-rule-modal";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Paper from "@/src/components/twui/elements/Paper";
|
||||
|
||||
type Props = {
|
||||
rules: ClientRuleDraft[];
|
||||
setRules: (rules: ClientRuleDraft[]) => void;
|
||||
};
|
||||
|
||||
export default function ClientRulesEditor({ rules, setRules }: Props) {
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
return (
|
||||
<Paper className="w-full xl:p-6">
|
||||
<Stack className="w-full items-stretch gap-4">
|
||||
<Row className="w-full justify-between">
|
||||
<Stack className="gap-1">
|
||||
<H4 className="mb-0!">Access rules</H4>
|
||||
<P
|
||||
noMargin
|
||||
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
|
||||
>
|
||||
Zero trust: nothing is allowed until you add a rule.
|
||||
</P>
|
||||
</Stack>
|
||||
|
||||
<AddClientRuleModal
|
||||
rules={rules}
|
||||
setRules={setRules}
|
||||
setError={setError}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{error ? <Tag color="error">{error}</Tag> : null}
|
||||
|
||||
{rules[0] ? (
|
||||
<Stack className="w-full items-stretch">
|
||||
{rules.map((rule) => (
|
||||
<ClientRuleDraftItem
|
||||
key={rule.local_id}
|
||||
draft={rule}
|
||||
onRemove={({ local_id }) => {
|
||||
setRules(
|
||||
rules.filter(
|
||||
(item) => item.local_id != local_id,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<ClientRulesEmpty />
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import EmptyContent from "@/src/components/twui/elements/EmptyContent";
|
||||
|
||||
export default function ClientRulesEmpty() {
|
||||
return (
|
||||
<EmptyContent
|
||||
title={`No rules. This client cannot reach the host, LAN, internet, or other clients.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import Stack from "@/src/components/twui/layout/Stack";
|
||||
import ClientRow from "@/src/components/general/client-row";
|
||||
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { useContext } from "react";
|
||||
import { useContext, useMemo } from "react";
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
|
||||
|
||||
type Props = {
|
||||
host_id: string | number;
|
||||
@@ -32,6 +33,33 @@ export default function ClientsList({ host_id }: Props) {
|
||||
initial_res: pageProps?.clients || undefined,
|
||||
});
|
||||
|
||||
const { res: client_rules } = useAdminCrudGet<BUN_SQLITE_WGUI_CLIENT_RULES>({
|
||||
table: "client_rules",
|
||||
sql_query: {
|
||||
limit: 500,
|
||||
},
|
||||
initial_res: pageProps?.client_rules || undefined,
|
||||
});
|
||||
|
||||
const rules_by_client_id = useMemo(() => {
|
||||
const map = new Map<number, BUN_SQLITE_WGUI_CLIENT_RULES[]>();
|
||||
|
||||
for (let i = 0; i < (client_rules || []).length; i++) {
|
||||
const rule = client_rules?.[i];
|
||||
const client_id = Number(rule?.client_id);
|
||||
|
||||
if (!rule || !client_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = map.get(client_id) || [];
|
||||
existing.push(rule);
|
||||
map.set(client_id, existing);
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [client_rules]);
|
||||
|
||||
if (!clients) {
|
||||
return (
|
||||
<Paper>
|
||||
@@ -60,6 +88,7 @@ export default function ClientsList({ host_id }: Props) {
|
||||
<th className={thClass}>Client</th>
|
||||
<th className={thClass}>Tunnel IP</th>
|
||||
<th className={thClass}>Allowed IPs</th>
|
||||
<th className={thClass}>Access</th>
|
||||
<th className={thClass}>Public key</th>
|
||||
<th className={thRightClass}>Created</th>
|
||||
<th className={thRightClass}>Actions</th>
|
||||
@@ -67,7 +96,15 @@ export default function ClientsList({ host_id }: Props) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{clients.map((client) => (
|
||||
<ClientRow key={client.id} client={client} />
|
||||
<ClientRow
|
||||
key={client.id}
|
||||
client={client}
|
||||
rules={
|
||||
client.id
|
||||
? rules_by_client_id.get(Number(client.id))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Tag from "@/src/components/twui/elements/Tag";
|
||||
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
|
||||
import ClientRuleFields from "../../(partials)/client-rules/client-rule-fields";
|
||||
import {
|
||||
clientRuleDraftToRecord,
|
||||
createClientRuleDraft,
|
||||
type ClientRuleDraft,
|
||||
} from "../../(partials)/client-rules/client-rule-draft";
|
||||
|
||||
type Props = {
|
||||
client: BUN_SQLITE_WGUI_CLIENTS;
|
||||
};
|
||||
|
||||
export default function AddClientRuleForm({ client }: Props) {
|
||||
const [draft, setDraft] = useState<ClientRuleDraft>(
|
||||
createClientRuleDraft(),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
async function handleAdd() {
|
||||
if (!client.id) {
|
||||
setError("Client is missing an id");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await adminCrudHandler({
|
||||
action: "insert",
|
||||
table: "client_rules",
|
||||
insert_data: [
|
||||
{
|
||||
...clientRuleDraftToRecord({ draft }),
|
||||
client_id: client.id,
|
||||
user_id: client.user_id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
setBusy(false);
|
||||
setError(res.msg || "Could not add rule");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className="w-full items-stretch gap-4">
|
||||
{error ? <Tag color="error">{error}</Tag> : null}
|
||||
<ClientRuleFields draft={draft} setDraft={setDraft} />
|
||||
<Button
|
||||
title="Add access rule"
|
||||
type="button"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
beforeIcon={<Plus size={14} />}
|
||||
loading={busy}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Add rule
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Span from "@/src/components/twui/layout/Span";
|
||||
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
|
||||
import formatClientRuleLabel from "@/src/functions/frontend/format-client-rule-label";
|
||||
|
||||
type Props = {
|
||||
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
|
||||
};
|
||||
|
||||
export default function ClientRuleRow({ rule }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!window.confirm(`Delete this access rule?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
|
||||
const res = await adminCrudHandler({
|
||||
action: "delete",
|
||||
table: "client_rules",
|
||||
id: rule.id,
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
setBusy(false);
|
||||
window.alert(res.msg || "Could not delete rule");
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<Row className="justify-between gap-3 px-5 py-2.5 border-t border-slate-200/60 dark:border-white/5">
|
||||
<Span className="font-mono text-[12.5px] text-foreground-light/70 dark:text-foreground-dark/70 break-all">
|
||||
{formatClientRuleLabel({ rule })}
|
||||
</Span>
|
||||
<Button
|
||||
title="Delete rule"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="smaller"
|
||||
beforeIcon={<Trash2 size={14} />}
|
||||
disabled={busy}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{busy ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import H3 from "@/src/components/twui/layout/H3";
|
||||
import Row from "@/src/components/twui/layout/Row";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import deriveClientAccessLabel from "@/src/functions/frontend/derive-client-access-label";
|
||||
import ClientInfoRow from "../(partials)/client-info-row";
|
||||
|
||||
export default function ClientInfoSection() {
|
||||
@@ -11,6 +12,9 @@ export default function ClientInfoSection() {
|
||||
|
||||
const client = pageProps?.client;
|
||||
const host_id = Number(client?.host_id || 0);
|
||||
const access = deriveClientAccessLabel({
|
||||
rules: pageProps?.client_rules,
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
@@ -31,6 +35,10 @@ export default function ClientInfoSection() {
|
||||
value: client.allowed_ips || "—",
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
label: "Access",
|
||||
value: access.text,
|
||||
},
|
||||
{
|
||||
label: "Public key",
|
||||
value: client.public_key || "—",
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { ShieldOff } from "lucide-react";
|
||||
import { useContext } from "react";
|
||||
import AdminCard from "@/src/components/general/admin-card";
|
||||
import EmptyContent from "@/src/components/twui/elements/EmptyContent";
|
||||
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 { AppContext } from "@/src/pages/__root";
|
||||
import AddClientRuleForm from "../(partials)/add-client-rule-form";
|
||||
import ClientRuleRow from "../(partials)/client-rule-row";
|
||||
import Modal from "@/src/components/twui/elements/Modal";
|
||||
import useStatus from "@/src/components/twui/hooks/useStatus";
|
||||
import Button from "@/src/components/twui/layout/Button";
|
||||
import H4 from "@/src/components/twui/layout/H4";
|
||||
import Span from "@/src/components/twui/layout/Span";
|
||||
|
||||
export default function ClientRulesSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
const client = pageProps?.client;
|
||||
const rules = pageProps?.client_rules || [];
|
||||
|
||||
const { open, setOpen } = useStatus();
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminCard className="w-full overflow-hidden">
|
||||
<Stack className="w-full items-stretch py-4 gap-4">
|
||||
<Row className="w-full justify-between px-5">
|
||||
<Stack className="gap-0.5">
|
||||
<Row className="justify-between gap-3 flex-wrap">
|
||||
<H3 className="mb-0!">Access rules</H3>
|
||||
</Row>
|
||||
<P
|
||||
noMargin
|
||||
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
|
||||
>
|
||||
Zero trust: this client can only reach what you list
|
||||
here.
|
||||
</P>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
target={
|
||||
<Button
|
||||
title="Add Rule"
|
||||
size="smaller"
|
||||
variant="outlined"
|
||||
>
|
||||
Add Rule
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Stack className="gap-10 w-full items-stretch">
|
||||
<Stack className="gap-0.5">
|
||||
<H4>Add Rule</H4>
|
||||
<Span>
|
||||
Add a new access rule to this client
|
||||
</Span>
|
||||
</Stack>
|
||||
<AddClientRuleForm client={client} />
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Row>
|
||||
|
||||
{rules[0] ? (
|
||||
<Stack className="w-full items-stretch">
|
||||
{rules.map((rule) => (
|
||||
<ClientRuleRow key={rule.id} rule={rule} />
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<EmptyContent
|
||||
title="This client cannot reach anything until you add a rule"
|
||||
icon={<ShieldOff size={16} />}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</AdminCard>
|
||||
);
|
||||
}
|
||||
+1
@@ -13,6 +13,7 @@ export default function EditClientFormSection() {
|
||||
<AddClientForm
|
||||
existing_client={pageProps?.client || undefined}
|
||||
existing_client_full={pageProps?.client || undefined}
|
||||
existing_client_rules={pageProps?.client_rules || undefined}
|
||||
host_id={host_id}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
|
||||
import grabClientRules from "@/src/functions/backend/db/client-rules/grab-client-rules";
|
||||
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
@@ -18,9 +19,16 @@ const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
||||
targetId: page_query.client_id,
|
||||
});
|
||||
|
||||
const client = client_res.singleRes || null;
|
||||
|
||||
const client_rules = await grabClientRules({
|
||||
client_id: client?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
client: client_res.singleRes || null,
|
||||
client,
|
||||
client_rules,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
} from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import grabClientRules from "@/src/functions/backend/db/client-rules/grab-client-rules";
|
||||
import generateWireguardClientQR from "@/src/functions/backend/setup/generate-wireguard-client-qr";
|
||||
import readWireguardClientConfig from "@/src/functions/backend/setup/read-wireguard-client-config";
|
||||
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
||||
@@ -55,6 +56,10 @@ const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
||||
config: config_res.config,
|
||||
});
|
||||
|
||||
const client_rules = await grabClientRules({
|
||||
client_id: client?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
client,
|
||||
@@ -62,6 +67,7 @@ const server: BunextPageServerFn<PagePropsType> = async ({ query }) => {
|
||||
client_config: config_res.config,
|
||||
client_config_path: config_res.config_path,
|
||||
client_qr_data_uri: qr_res.qr_data_uri,
|
||||
client_rules,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import EmptyContent from "@/src/components/twui/elements/EmptyContent";
|
||||
import ClientInfoSection from "./(sections)/client-info-section";
|
||||
import ClientConfigSection from "./(sections)/client-config-section";
|
||||
import ClientQRSection from "./(sections)/client-qr-section";
|
||||
import ClientRulesSection from "./(sections)/client-rules-section";
|
||||
import ClientDetailActions from "./(partials)/client-detail-actions";
|
||||
|
||||
export default function AdminClientDetailPage() {
|
||||
@@ -52,6 +53,7 @@ export default function AdminClientDetailPage() {
|
||||
{pageProps?.client ? (
|
||||
<>
|
||||
<ClientInfoSection />
|
||||
<ClientRulesSection />
|
||||
<ClientQRSection />
|
||||
<ClientConfigSection />
|
||||
</>
|
||||
|
||||
+57
-2
@@ -1,10 +1,20 @@
|
||||
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENT_RULES,
|
||||
BUN_SQLITE_WGUI_CLIENTS,
|
||||
} from "@/db/types/db";
|
||||
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
|
||||
import type useFormInit from "@/src/hooks/use-form-init";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
clientRuleDraftToRecord,
|
||||
type ClientRuleDraft,
|
||||
} from "../../(partials)/client-rules/client-rule-draft";
|
||||
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
|
||||
import type { ApiReqParams } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
host_id?: string | number;
|
||||
rules?: ClientRuleDraft[];
|
||||
};
|
||||
|
||||
export default async function submitAddClientForm(
|
||||
@@ -15,7 +25,7 @@ export default async function submitAddClientForm(
|
||||
existing_full,
|
||||
app_context,
|
||||
}: ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_CLIENTS>>,
|
||||
{ host_id }: Params,
|
||||
{ host_id, rules }: Params,
|
||||
) {
|
||||
try {
|
||||
const confirm_msg = existing_full?.id
|
||||
@@ -44,6 +54,10 @@ export default async function submitAddClientForm(
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const client_rules = (rules || []).map((draft) =>
|
||||
clientRuleDraftToRecord({ draft }),
|
||||
);
|
||||
|
||||
const res = existing_full?.id
|
||||
? await adminCrudHandler({
|
||||
action: "update",
|
||||
@@ -57,6 +71,47 @@ export default async function submitAddClientForm(
|
||||
insert_data: [new_client_data],
|
||||
});
|
||||
|
||||
const client_id = existing_full?.id || res.postInsertReturn?.insertId;
|
||||
|
||||
const delete_client_rules =
|
||||
await adminCrudHandler<BUN_SQLITE_WGUI_CLIENT_RULES>({
|
||||
action: "delete",
|
||||
table: "client_rules",
|
||||
sql_query: {
|
||||
query: {
|
||||
client_id: {
|
||||
value: String(client_id),
|
||||
},
|
||||
host_id: {
|
||||
value: String(host_id),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (client_rules?.[0] && client_id) {
|
||||
const insert_client_rules =
|
||||
await adminCrudHandler<BUN_SQLITE_WGUI_CLIENT_RULES>({
|
||||
action: "insert",
|
||||
table: "client_rules",
|
||||
insert_data: client_rules.map((cr) => ({
|
||||
...cr,
|
||||
host_id: Number(host_id),
|
||||
client_id: Number(client_id),
|
||||
user_id: app_context.user?.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (client_id) {
|
||||
await fetchApi<ApiReqParams>(`/api/admin/run-client-setup`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
client_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
if (existing_full?.id) {
|
||||
window.location.pathname = `/admin/hosts/${final_host_id}/clients/${existing_full.id}`;
|
||||
|
||||
+9
-2
@@ -10,7 +10,7 @@ export default function AddClientFormAllowedIps({
|
||||
}: ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_CLIENTS>>) {
|
||||
return (
|
||||
<Stack className="gap-2 my-4 w-full">
|
||||
{form.allow_all_ips ? null : (
|
||||
{form.allow_all_ips == 1 ? null : (
|
||||
<Input
|
||||
title="Allowed IPs"
|
||||
placeholder="Eg. 10.0.0.2/24"
|
||||
@@ -24,8 +24,9 @@ export default function AddClientFormAllowedIps({
|
||||
showLabel
|
||||
/>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
label={`Allow all IPs? That is 0.0.0.0/0, ::/0`}
|
||||
label={`Allow all IPs?`}
|
||||
defaultChecked={form.allow_all_ips == 1}
|
||||
changeHandler={(value) => {
|
||||
setForm((prev) => ({
|
||||
@@ -33,6 +34,12 @@ export default function AddClientFormAllowedIps({
|
||||
allow_all_ips: value ? 1 : 0,
|
||||
}));
|
||||
}}
|
||||
info={
|
||||
<span>
|
||||
Sets the wireguard <code>AllowedIPs</code> parameter to{" "}
|
||||
<code>0.0.0.0/0, ::/0</code>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
+33
-11
@@ -1,25 +1,37 @@
|
||||
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
BUN_SQLITE_WGUI_CLIENTS,
|
||||
BUN_SQLITE_WGUI_CLIENT_RULES,
|
||||
} from "@/db/types/db";
|
||||
import Form from "@/src/components/twui/form/Form";
|
||||
import LoadingOverlay from "@/src/components/twui/elements/LoadingOverlay";
|
||||
import Stack from "@/src/components/twui/layout/Stack";
|
||||
import Tag from "@/src/components/twui/elements/Tag";
|
||||
import useFormInit from "@/src/hooks/use-form-init";
|
||||
import submitAddClientForm from "../../(functions)/submit-add-client-form";
|
||||
import ClientRulesEditor from "../../../(partials)/client-rules/client-rules-editor";
|
||||
import {
|
||||
clientRuleToDraft,
|
||||
type ClientRuleDraft,
|
||||
} from "../../../(partials)/client-rules/client-rule-draft";
|
||||
import AddClientFormAction from "./add-client-form-action";
|
||||
import AddClientFormAllowedIps from "./add-client-form-allowed-ips";
|
||||
import AddClientFormNotes from "./add-client-form-notes";
|
||||
import AddClientFormTitle from "./add-client-form-title";
|
||||
import AddClientFormWgIpAddress from "./add-client-form-wg-ip-address";
|
||||
import LoadingRectangleBlock from "@/src/components/twui/layout/LoadingRectangleBlock";
|
||||
|
||||
type Props = {
|
||||
existing_client?: BUN_SQLITE_WGUI_CLIENTS;
|
||||
existing_client_full?: BUN_SQLITE_WGUI_CLIENTS;
|
||||
existing_client_rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
|
||||
host_id?: string | number;
|
||||
};
|
||||
|
||||
export default function AddClientForm({
|
||||
existing_client,
|
||||
existing_client_full,
|
||||
existing_client_rules,
|
||||
host_id,
|
||||
}: Props) {
|
||||
const init = useFormInit<BUN_SQLITE_WGUI_CLIENTS>({
|
||||
@@ -28,25 +40,35 @@ export default function AddClientForm({
|
||||
title: "Add Client",
|
||||
});
|
||||
|
||||
const [rules, setRules] = useState<ClientRuleDraft[]>(
|
||||
(existing_client_rules || []).map((rule) =>
|
||||
clientRuleToDraft({ rule }),
|
||||
),
|
||||
);
|
||||
|
||||
const { loading, status } = init;
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="w-full gap-6"
|
||||
onSubmit={() => {
|
||||
submitAddClientForm(init, { host_id });
|
||||
submitAddClientForm(init, { host_id, rules });
|
||||
}}
|
||||
>
|
||||
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
||||
{loading && <LoadingOverlay />}
|
||||
|
||||
<Stack>
|
||||
<AddClientFormTitle {...init} />
|
||||
<AddClientFormWgIpAddress {...init} host_id={host_id} />
|
||||
<AddClientFormAllowedIps {...init} />
|
||||
<AddClientFormNotes {...init} />
|
||||
<AddClientFormAction {...init} />
|
||||
</Stack>
|
||||
{init.ready ? (
|
||||
<Stack>
|
||||
<AddClientFormTitle {...init} />
|
||||
<AddClientFormWgIpAddress {...init} host_id={host_id} />
|
||||
<AddClientFormAllowedIps {...init} />
|
||||
<AddClientFormNotes {...init} />
|
||||
<ClientRulesEditor rules={rules} setRules={setRules} />
|
||||
<AddClientFormAction {...init} />
|
||||
</Stack>
|
||||
) : (
|
||||
<LoadingRectangleBlock className="h-[400px]" />
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BUN_SQLITE_WGUI_CLIENTS,
|
||||
BUN_SQLITE_WGUI_HOSTS,
|
||||
} from "@/db/types/db";
|
||||
import grabClientRules from "@/src/functions/backend/db/client-rules/grab-client-rules";
|
||||
import type { PagePropsType, PageQueryObject, TableType } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
@@ -32,10 +33,19 @@ const server: BunextPageServerFn<PagePropsType> = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const clients = host_clients.payload || null;
|
||||
|
||||
const client_rules = await grabClientRules({
|
||||
client_ids: (clients || [])
|
||||
.map((client) => client.id)
|
||||
.filter((id): id is number => Boolean(id)),
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
host: host_record_res.singleRes || null,
|
||||
clients: host_clients.payload || null,
|
||||
clients,
|
||||
client_rules,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user