ui, server handling improvements

This commit is contained in:
Yuri Tatishchev 2024-12-23 18:47:22 -08:00
parent 32927dfd55
commit 03fb89dc8b
Signed by: CaZzzer
GPG Key ID: E0EBF441EA424369
12 changed files with 106 additions and 115 deletions

View File

@ -4,16 +4,16 @@
@layer base {
:root {
--background: 0 0% 100%;
--background: 0 0% 90%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover: 0 0% 90%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card: 0 0% 90%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
@ -22,10 +22,10 @@
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary: 210 26% 86%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent: 210 26% 86%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
@ -39,31 +39,31 @@
@media (prefers-color-scheme: dark) {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--foreground: 210 40% 90%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--popover-foreground: 210 40% 90%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--card-foreground: 210 40% 90%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary: 210 40% 90%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--secondary-foreground: 210 40% 90%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--accent-foreground: 210 40% 90%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--destructive-foreground: 210 40% 90%;
--ring: 212.7 26.8% 83.9%;
}

View File

@ -31,14 +31,17 @@ const handleAuth: Handle = async ({ event, resolve }) => {
};
const authRequired = new Set([
'/user',
'/connections',
'/api/connections',
]);
const authRequired = [
/^\/api/,
/^\/user/,
/^\/connections/,
/^\/clients/,
];
const handleProtectedPaths: Handle = ({ event, resolve }) => {
if (authRequired.has(event.url.pathname) && !event.locals.user) {
return redirect(302, '/');
const isProtected = authRequired.some((re) => re.test(event.url.pathname));
if (!event.locals.user && isProtected) {
return redirect(302, '/');
}
return resolve(event);
}

View File

@ -140,7 +140,7 @@ export async function createClient(params: {
username: params.user.username,
pubkey: keys.pubkey,
psk: keys.psk,
allowedIps: getIpsFromIndex(ipAllocationId - 1).join(','),
allowedIps: getIpsFromIndex(ipAllocationId).join(','),
});
const opnsenseResJson = await opnsenseRes.json();
if (opnsenseResJson['result'] !== 'saved') {
@ -178,6 +178,7 @@ async function getKeys() {
}
export function getIpsFromIndex(ipIndex: number) {
ipIndex -= 1; // 1-indexed in the db
const v4StartingAddr = new Address4(IPV4_STARTING_ADDR);
const v6StartingAddr = new Address6(IPV6_STARTING_ADDR);
const v4Allowed = Address4.fromBigInt(v4StartingAddr.bigInt() + BigInt(ipIndex));

View File

@ -26,7 +26,7 @@ export const ipAllocations = sqliteTable('ip_allocations', {
// unique for now, only allowing one allocation per client
clientId: integer('client_id')
.unique()
.references(() => wgClients.id),
.references(() => wgClients.id, { onDelete: 'set null' }),
});
export const wgClients = sqliteTable('wg_clients', {

View File

@ -6,20 +6,20 @@
const { data, children } = $props();
const { user } = data;
function getNavClass(path: string) {
function getNavClass(path: RegExp) {
return cn('hover:text-foreground/80 transition-colors',
$page.url.pathname.startsWith(path) ? 'text-foreground' : 'text-foreground/60');
path.test($page.url.pathname) ? 'text-foreground' : 'text-foreground/60');
}
</script>
<header class="p-4 sm:flex">
<span class=" mr-6 font-bold sm:inline-block">VPGen</span>
<nav class="flex items-center gap-6 text-sm">
<a href="/" class={getNavClass("/")}>Home</a>
<a href="/" class={getNavClass(/^\/$/)}>Home</a>
{#if user}
<a href="/user" class={getNavClass("/user")}>Profile</a>
<a href="/connections" class={getNavClass("/connections")}>Connections</a>
<a href="/clients" class={getNavClass("/clients")}>Clients</a>
<a href="/user" class={getNavClass(/^\/user$/)}>Profile</a>
<a href="/connections" class={getNavClass(/^\/connections$/)}>Connections</a>
<a href="/clients" class={getNavClass(/^\/clients(\/\d+)?$/)}>Clients</a>
{/if}
</nav>
</header>

View File

@ -1,9 +1,6 @@
import { error } from '@sveltejs/kit';
import { wgClients } from '$lib/server/db/schema';
import { db } from '$lib/server/db';
import { eq } from 'drizzle-orm';
import type { RequestHandler } from './$types';
import { createClient, getIpsFromIndex } from '$lib/server/clients';
import { createClient, findClients, mapClientToDetails } from '$lib/server/clients';
export const GET: RequestHandler = async (event) => {
if (!event.locals.user) {
@ -13,38 +10,11 @@ export const GET: RequestHandler = async (event) => {
const clients = await findClients(event.locals.user.id);
return new Response(
JSON.stringify({
clients,
clients: clients.map(mapClientToDetails),
}),
);
};
async function findClients(userId: string) {
const clientsData = await db.query.wgClients.findMany({
columns: {
id: true,
name: true,
publicKey: true,
privateKey: true,
preSharedKey: true,
},
with: {
ipAllocation: true,
},
where: eq(wgClients.userId, userId),
});
// replace ip index with actual addresses
return clientsData.map((client) => {
const ips = getIpsFromIndex(client.ipAllocation.id);
return {
id: client.id,
name: client.name,
publicKey: client.publicKey,
privateKey: client.privateKey,
preSharedKey: client.preSharedKey,
ips,
};
});
}
export type Clients = Awaited<ReturnType<typeof findClients>>;

View File

@ -5,9 +5,11 @@ import { error } from '@sveltejs/kit';
export const actions = {
create: async (event) => {
if (!event.locals.user) return error(401, 'Unauthorized');
const name = 'New Client Name';
const formData = await event.request.formData();
const name = formData.get('name');
if (typeof name !== 'string' || name.trim() === '') return error(400, 'Invalid name');
const res = await createClient({
name,
name: name.trim(),
user: event.locals.user,
});

View File

@ -2,6 +2,7 @@
import * as Table from '$lib/components/ui/table';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import type { PageData } from './$types';
import { LucidePlus } from 'lucide-svelte';
@ -12,26 +13,28 @@
<title>Clients</title>
</svelte:head>
<Table.Root class="bg-accent rounded-xl">
<Table.Root class="bg-accent rounded-lg overflow-hidden divide-y-2 divide-background">
<Table.Header>
<Table.Head>Name</Table.Head>
<Table.Head>Public Key</Table.Head>
<Table.Head>Private Key</Table.Head>
<Table.Head>Pre-Shared Key</Table.Head>
<Table.Head>IP Allocation</Table.Head>
<Table.Row>
<Table.Head scope="col">Name</Table.Head>
<Table.Head scope="col">Public Key</Table.Head>
<!-- <Table.Head scope="col">Private Key</Table.Head>-->
<!-- <Table.Head scope="col">Pre-Shared Key</Table.Head>-->
<Table.Head scope="col">IP Allocation</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Body class="divide-y-2 divide-background">
{#each data.clients as client}
<Table.Row class="border-y-2 border-background hover:bg-muted-foreground">
<a href={`/clients/${client.id}`} class="contents">
<Table.Cell>
<Table.Row class="hover:bg-background hover:bg-opacity-40 group">
<Table.Head scope="row">
<a href={`/clients/${client.id}`} class="flex items-center size-full group-hover:underline">
{client.name}
</Table.Cell>
</a>
<Table.Cell class="truncate max-w-[10ch]">{client.publicKey}</Table.Cell>
<Table.Cell class="truncate max-w-[10ch]">{client.privateKey}</Table.Cell>
<Table.Cell class="truncate max-w-[10ch]">{client.preSharedKey}</Table.Cell>
<Table.Cell class="flex gap-1">
</a>
</Table.Head>
<Table.Cell class="truncate">{client.publicKey}</Table.Cell>
<!-- <Table.Cell class="truncate max-w-[10ch]">{client.privateKey}</Table.Cell>-->
<!-- <Table.Cell class="truncate max-w-[10ch]">{client.preSharedKey}</Table.Cell>-->
<Table.Cell class="flex flex-wrap gap-1">
{#each client.ips as ip}
<Badge class="bg-background select-auto" variant="secondary">{ip}</Badge>
{/each}
@ -42,9 +45,11 @@
</Table.Root>
<!--Floating action button for adding a new client-->
<form class="self-end mt-auto pt-4" method="post" action="?/create">
<!--Not sure if this is the best place for the input field, will think about it later-->
<form class="flex self-end mt-auto pt-4" method="post" action="?/create">
<Input type="text" name="name" placeholder="New Client" class="mr-2" />
<Button type="submit">
<LucidePlus />
<LucidePlus class="mr-2 h-4 w-4" />
Add Client
</Button>
</form>

View File

@ -1,9 +1,9 @@
import type { PageLoad } from './$types';
import type { Clients } from '../api/clients/+server';
import type { ClientDetails } from '$lib/types/clients';
export const load: PageLoad = async ({ fetch }) => {
const res = await fetch('/api/clients');
const { clients } = await res.json() as { clients: Clients };
const { clients } = await res.json() as { clients: ClientDetails[] };
return { clients };
};

View File

@ -1,19 +1,19 @@
<script lang="ts">
import type { PageData } from './$types';
import { LucideClipboardCopy } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
const { data }: { data: PageData } = $props();
let tooltipText = $state('Copy to clipboard');
function copyToClipboard() {
navigator.clipboard.writeText(data.config).then(() => {
tooltipText = 'Copied';
});
async function copyToClipboard() {
await navigator.clipboard.writeText(data.config);
tooltipText = 'Copied!';
}
function onMouseLeave() {
tooltipText = 'Copy to Clipboard';
tooltipText = 'Copy to clipboard';
}
</script>
@ -21,21 +21,24 @@
<title></title>
</svelte:head>
<h1>Client: {data.client.name}</h1>
<h1 class="bg-accent text-lg w-fit rounded-lg p-2 mb-4">{data.client.name}</h1>
<div class="flex relative bg-accent p-2 rounded-xl overflow-x-scroll">
<pre><code>{data.config}</code></pre>
<div class="relative bg-accent rounded-lg max-w-fit">
<div class="flex items-start p-2 overflow-x-auto">
<pre><code>{data.config}</code></pre>
<!--Copy button for the configuration-->
<div class="absolute flex right-2 items-center group">
<span class="hidden group-hover:block bg-background text-xs rounded py-1 px-2">
{tooltipText}
</span>
<button class="flex items-center justify-center w-10 h-10 bg-background rounded-xl ml-2"
onclick={copyToClipboard}
onmouseleave="{onMouseLeave}"
>
<LucideClipboardCopy />
</button>
<!--Copy button for the configuration-->
<!--Flex reverse for peer hover to work properly-->
<div class="absolute group flex flex-row-reverse items-center gap-1 right-2">
<Button class="peer size-10 p-2"
onclick={copyToClipboard}
onmouseleave={onMouseLeave}
>
<LucideClipboardCopy />
</Button>
<span class="hidden peer-hover:block bg-background text-xs rounded-lg p-2">
{tooltipText}
</span>
</div>
</div>
</div>

View File

@ -1,10 +1,15 @@
import type { PageLoad } from './$types';
import type { ClientDetails } from '$lib/types/clients';
import { clientDetailsToConfig } from '$lib/clients';
import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ fetch, params }) => {
const res = await fetch(`/api/clients/${params.id}`);
const client = (await res.json()) as ClientDetails;
const resJson = await res.json();
if (!res.ok) {
return error(res.status, resJson['message']);
}
const client = resJson as ClientDetails;
const config = clientDetailsToConfig(client);
return { client, config };

View File

@ -33,22 +33,24 @@
<title>Connections</title>
</svelte:head>
<Table.Root class="bg-accent rounded-xl">
<Table.Root class="bg-accent rounded-lg overflow-hidden divide-y-2 divide-background">
<Table.Header>
<Table.Head>Name</Table.Head>
<Table.Head>Public Key</Table.Head>
<Table.Head>Endpoint</Table.Head>
<Table.Head>Allowed IPs</Table.Head>
<Table.Head>Latest Handshake</Table.Head>
<Table.Head>RX</Table.Head>
<Table.Head>TX</Table.Head>
<Table.Head class="hidden">Persistent Keepalive</Table.Head>
<Table.Head class="hidden">Interface Name</Table.Head>
<Table.Row>
<Table.Head scope="col">Name</Table.Head>
<Table.Head scope="col">Public Key</Table.Head>
<Table.Head scope="col">Endpoint</Table.Head>
<Table.Head scope="col">Allowed IPs</Table.Head>
<Table.Head scope="col">Latest Handshake</Table.Head>
<Table.Head scope="col">RX</Table.Head>
<Table.Head scope="col">TX</Table.Head>
<Table.Head scope="col" class="hidden">Persistent Keepalive</Table.Head>
<Table.Head scope="col" class="hidden">Interface Name</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Body class="divide-y-2 divide-background">
{#each data.peers.rows as peer}
<Table.Row class="border-y-2 border-background">
<Table.Cell>{peer.name}</Table.Cell>
<Table.Row class="hover:bg-background hover:bg-opacity-40">
<Table.Head scope="row">{peer.name}</Table.Head>
<Table.Cell class="truncate max-w-[10ch]">{peer['public-key']}</Table.Cell>
<Table.Cell>{peer.endpoint}</Table.Cell>
<Table.Cell>