Updates
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
Reference in New Issue
Block a user