This commit is contained in:
2026-09-13 06:53:33 +01:00
parent 75670e4d5c
commit e7e63bc491
45 changed files with 2117 additions and 22 deletions
@@ -0,0 +1,122 @@
import type { WgStatus } from "@/src/components/general/status-dot";
export type ClientRecord = {
id: string;
name: string;
tunnelIp: string;
allowedIps: string;
publicKey: string;
endpoint: string;
sent: string;
received: string;
lastHandshake: string;
status: WgStatus;
createdAt: string;
};
export const CLIENTS: ClientRecord[] = [
{
id: "c-01",
name: "MacBook Pro",
tunnelIp: "10.0.0.2",
allowedIps: "10.0.0.2/32",
publicKey: "gQ1k4Lv9MxWn7Vp2RzHsTb8FcJdKqY3eWa",
endpoint: "81.2.69.142:51820",
sent: "12.4 GB",
received: "48.2 GB",
lastHandshake: "just now",
status: "connected",
createdAt: "Jan 12, 2026",
},
{
id: "c-02",
name: "iPhone 15",
tunnelIp: "10.0.0.3",
allowedIps: "10.0.0.3/32",
publicKey: "aZ9xN4cM6vBq2wEs8rT7yU1iO5pLkDfGhJ",
endpoint: "92.28.211.234:51820",
sent: "3.1 GB",
received: "11.7 GB",
lastHandshake: "1m ago",
status: "connected",
createdAt: "Feb 3, 2026",
},
{
id: "c-03",
name: "Home Server",
tunnelIp: "10.0.0.4",
allowedIps: "10.0.0.4/32, 10.0.0.0/24",
publicKey: "qW5eRt7yU8iO9pL0kM2nB3vC4xD5fG6hJ7k",
endpoint: "10.0.0.4:51820",
sent: "220.8 GB",
received: "84.3 GB",
lastHandshake: "4m ago",
status: "connected",
createdAt: "Nov 21, 2025",
},
{
id: "c-04",
name: "Office Desktop",
tunnelIp: "10.0.0.5",
allowedIps: "10.0.0.5/32",
publicKey: "zC1vB2nM3kL4jH5gF6dS7aA8sD9fG1hJ2kL",
endpoint: "77.111.247.28:51820",
sent: "18.9 GB",
received: "32.5 GB",
lastHandshake: "22m ago",
status: "connected",
createdAt: "Dec 9, 2025",
},
{
id: "c-05",
name: "Galaxy S24",
tunnelIp: "10.0.0.6",
allowedIps: "10.0.0.6/32",
publicKey: "pL0kM2nB3vC4xD5fG6hJ7kQ8wE9rT1yU2iO",
endpoint: "151.101.1.69:51820",
sent: "1.2 GB",
received: "6.8 GB",
lastHandshake: "1h ago",
status: "idle",
createdAt: "Feb 20, 2026",
},
{
id: "c-06",
name: "iPad",
tunnelIp: "10.0.0.7",
allowedIps: "10.0.0.7/32",
publicKey: "vB2nM3kL4jH5gF6dS7aA8sD9fG1hJ2kLqW",
endpoint: "89.187.168.36:51820",
sent: "0.9 GB",
received: "4.1 GB",
lastHandshake: "3h ago",
status: "idle",
createdAt: "Mar 2, 2026",
},
{
id: "c-07",
name: "Old Laptop",
tunnelIp: "10.0.0.8",
allowedIps: "10.0.0.8/32",
publicKey: "nM3kL4jH5gF6dS7aA8sD9fG1hJ2kLqW5eR",
endpoint: "192.168.1.23:51820",
sent: "0.0 GB",
received: "0.0 GB",
lastHandshake: "3d ago",
status: "error",
createdAt: "Aug 17, 2025",
},
{
id: "c-08",
name: "Travel Router",
tunnelIp: "10.0.0.9",
allowedIps: "10.0.0.9/32",
publicKey: "bV3cX4dZ5eA6sD7fG8hJ9kQ0wE1rT2yU3iO",
endpoint: "203.0.113.19:51820",
sent: "6.7 GB",
received: "21.9 GB",
lastHandshake: "2h ago",
status: "connected",
createdAt: "Jan 30, 2026",
},
];
@@ -0,0 +1,108 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { X } from "lucide-react";
import Modal from "@/src/components/twui/elements/Modal";
import Input from "@/src/components/twui/form/Input";
import AdminButton from "@/src/components/general/admin-button";
type Props = {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
};
type FormFieldProps = {
label: string;
htmlFor: string;
children: ReactNode;
};
function FormField({ label, htmlFor, children }: FormFieldProps) {
return (
<div className="flex flex-col gap-1.5">
<label
htmlFor={htmlFor}
className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/50 dark:text-foreground-dark/50"
>
{label}
</label>
{children}
</div>
);
}
export default function ClientFormModal({ open, setOpen }: Props) {
return (
<Modal
open={open}
setOpen={setOpen}
no_cancel_button
className="p-6"
>
<div className="flex items-start justify-between gap-4 mb-6">
<div>
<h3 className="text-lg font-bold text-foreground-light dark:text-foreground-dark">
Add client
</h3>
<p className="text-xs text-foreground-light/50 dark:text-foreground-dark/50 mt-1">
A WireGuard config will be generated on save
</p>
</div>
<button
type="button"
aria-label="Close"
onClick={() => setOpen(false)}
className="p-1 cursor-pointer text-foreground-light/60 dark:text-foreground-dark/60 hover:text-foreground-light dark:hover:text-foreground-dark transition-colors"
>
<X size={18} />
</button>
</div>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault();
setOpen(false);
}}
>
<FormField label="Client name" htmlFor="client_name">
<Input
name="client_name"
id="client_name"
placeholder="e.g. MacBook Pro"
required
/>
</FormField>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Tunnel IP" htmlFor="tunnel_ip">
<Input
name="tunnel_ip"
id="tunnel_ip"
placeholder="10.0.0.10 · auto-assigned"
/>
</FormField>
<FormField label="Allowed IPs" htmlFor="allowed_ips">
<Input
name="allowed_ips"
id="allowed_ips"
placeholder="10.0.0.10/32"
/>
</FormField>
</div>
<FormField label="Notes" htmlFor="client_notes">
<Input
name="client_notes"
id="client_notes"
istextarea
placeholder="Optional description for this client"
/>
</FormField>
<div className="flex justify-end gap-2 mt-2">
<AdminButton onClick={() => setOpen(false)}>
Cancel
</AdminButton>
<AdminButton variant="primary" type="submit">
Add client
</AdminButton>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,47 @@
import StatusDot from "@/src/components/general/status-dot";
import type { ClientRecord } from "../(data)/clients-mock-data";
type Props = {
client: ClientRecord;
};
export default function ClientRow({ client }: Props) {
return (
<tr className="border-b border-slate-200/60 dark:border-white/5 last:border-b-0 hover:bg-foreground-light/[0.02] dark:hover:bg-foreground-dark/[0.02] transition-colors">
<td className="px-4 py-[9px]">
<div className="flex items-center gap-2.5">
<StatusDot status={client.status} />
<span className="text-[13.5px] font-medium text-foreground-light dark:text-foreground-dark">
{client.name}
</span>
</div>
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/60 dark:text-foreground-dark/60 whitespace-nowrap">
{client.tunnelIp}
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.allowedIps}
</td>
<td className="px-4 py-[9px]">
<span className="font-mono text-[12px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.publicKey.slice(0, 16)}…
</span>
</td>
<td className="px-4 py-[9px] text-right whitespace-nowrap">
<span className="tabular text-[13px] font-semibold text-foreground-light dark:text-foreground-dark">
↓ {client.received}
</span>
<span className="tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40">
{" "}
↑ {client.sent}
</span>
</td>
<td className="px-4 py-[9px] tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40 text-right whitespace-nowrap">
{client.lastHandshake}
</td>
<td className="px-4 py-[9px] tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40 text-right whitespace-nowrap">
{client.createdAt}
</td>
</tr>
);
}
@@ -0,0 +1,88 @@
import { useMemo, useState, type Dispatch, type SetStateAction } from "react";
import Search from "@/src/components/twui/elements/Search";
import AdminCard from "@/src/components/general/admin-card";
import ClientRow from "../(partials)/client-row";
import ClientFormModal from "../(partials)/client-form-modal";
import { CLIENTS } from "../(data)/clients-mock-data";
type Props = {
addOpen: boolean;
setAddOpen: Dispatch<SetStateAction<boolean>>;
};
const thClass =
"px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-[0.08em] " +
"text-foreground-light/40 dark:text-foreground-dark/40 whitespace-nowrap";
const thRightClass = `${thClass} text-right`;
export default function ClientsTableSection({ addOpen, setAddOpen }: Props) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return CLIENTS;
return CLIENTS.filter((client) =>
[client.name, client.tunnelIp, client.allowedIps].some((value) =>
value.toLowerCase().includes(q),
),
);
}, [query]);
return (
<AdminCard className="w-full overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 h-12 flex-wrap">
<h2 className="text-[13.5px] font-medium text-foreground-light/60 dark:text-foreground-dark/60">
Clients
<span className="tabular text-foreground-light/40 dark:text-foreground-dark/40 ml-2">
{filtered.length}
</span>
</h2>
<Search
no_search_button
placeholder="Search clients…"
changeHandler={(value) => setQuery(value || "")}
inputProps={{
className: "!text-[13.5px]",
wrapperProps: {
className:
"!py-[5px] !min-h-[30px] w-[220px] bg-foreground-light/[0.02] dark:bg-foreground-dark/[0.03]",
},
}}
/>
</div>
{filtered.length ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[860px]">
<thead>
<tr className="border-y border-slate-200 dark:border-white/10 bg-foreground-light/[0.02] dark:bg-foreground-dark/[0.03]">
<th className={thClass}>Client</th>
<th className={thClass}>Tunnel IP</th>
<th className={thClass}>Allowed IPs</th>
<th className={thClass}>Public key</th>
<th className={thRightClass}>Traffic</th>
<th className={thRightClass}>Last handshake</th>
<th className={thRightClass}>Created</th>
</tr>
</thead>
<tbody>
{filtered.map((client) => (
<ClientRow key={client.id} client={client} />
))}
</tbody>
</table>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-1 py-14 px-6 text-center">
<span className="text-[13.5px] font-medium text-foreground-light/50 dark:text-foreground-dark/50">
No clients found
</span>
<span className="text-[12.5px] text-foreground-light/35 dark:text-foreground-dark/35">
Try adjusting your search terms
</span>
</div>
)}
<ClientFormModal open={addOpen} setOpen={setAddOpen} />
</AdminCard>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import AdminHero from "@/src/components/general/admin-hero";
import AdminButton from "@/src/components/general/admin-button";
import Stack from "@/src/components/twui/layout/Stack";
import { SiteData } from "@/src/data/site-data";
import ClientsTableSection from "./(sections)/clients-table-section";
export default function AdminClientsPage() {
const [addOpen, setAddOpen] = useState(false);
return (
<>
<AdminHero
title="Clients"
description="Manage WireGuard peers and their configurations"
buttons={
<AdminButton
variant="primary"
Icon={Plus}
onClick={() => setAddOpen(true)}
>
Add client
</AdminButton>
}
/>
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<ClientsTableSection
addOpen={addOpen}
setAddOpen={setAddOpen}
/>
</Stack>
</>
);
}
export const meta: BunextPageModuleMeta = {
title: `Clients | ${SiteData["SiteName"]}`,
description: `Manage WireGuard clients`,
};