initial implementation of adding clients
This commit is contained in:
parent
5e3772d39b
commit
2b56cba770
@ -14,3 +14,4 @@ IPV6_STARTING_ADDR=fd00:10:18:11::100:0
|
|||||||
IPV6_CLIENT_PREFIX_SIZE=112
|
IPV6_CLIENT_PREFIX_SIZE=112
|
||||||
IP_MAX_INDEX=100
|
IP_MAX_INDEX=100
|
||||||
VPN_ENDPOINT=vpn.lab.cazzzer.com:51820
|
VPN_ENDPOINT=vpn.lab.cazzzer.com:51820
|
||||||
|
MAX_CLIENTS_PER_USER=20
|
||||||
|
173
src/lib/server/clients.ts
Normal file
173
src/lib/server/clients.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import type { User } from '$lib/server/db/schema';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { wgClients, ipAllocations } from '$lib/server/db/schema';
|
||||||
|
import { opnsenseAuth, opnsenseUrl, serverUuid } from '$lib/server/opnsense';
|
||||||
|
import { Address4, Address6 } from 'ip-address';
|
||||||
|
import {
|
||||||
|
IP_MAX_INDEX,
|
||||||
|
IPV4_STARTING_ADDR,
|
||||||
|
IPV6_CLIENT_PREFIX_SIZE,
|
||||||
|
IPV6_STARTING_ADDR, MAX_CLIENTS_PER_USER,
|
||||||
|
VPN_ENDPOINT,
|
||||||
|
} from '$env/static/private';
|
||||||
|
import { count, eq, isNull } from 'drizzle-orm';
|
||||||
|
import { err, ok, type Result } from '$lib/types';
|
||||||
|
|
||||||
|
export async function createClient(params: {
|
||||||
|
name: string;
|
||||||
|
user: User;
|
||||||
|
}): Promise<Result<null, [400 | 500, string]>> {
|
||||||
|
// check if user exceeds the limit of clients
|
||||||
|
const [{ clientCount }] = await db
|
||||||
|
.select({ clientCount: count() })
|
||||||
|
.from(wgClients)
|
||||||
|
.where(eq(wgClients.userId, params.user.id));
|
||||||
|
if (clientCount >= parseInt(MAX_CLIENTS_PER_USER)) return err([400, 'Maximum number of clients reached'] as [400, string]);
|
||||||
|
|
||||||
|
// this is going to be quite long
|
||||||
|
// 1. fetch params for new client from opnsense api
|
||||||
|
// 2.1 get an allocation for the client
|
||||||
|
// 2.2. insert new client into db
|
||||||
|
// 2.3. update the allocation with the client id
|
||||||
|
// 3. create the client in opnsense
|
||||||
|
// 4. reconfigure opnsense to enable the new client
|
||||||
|
const error= await db.transaction(async (tx) => {
|
||||||
|
const [keys, availableAllocation, lastAllocation] = await Promise.all([
|
||||||
|
// fetch params for new client from opnsense api
|
||||||
|
getKeys(),
|
||||||
|
// find first unallocated IP
|
||||||
|
await tx.query.ipAllocations.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
where: isNull(ipAllocations.clientId),
|
||||||
|
}),
|
||||||
|
// find last allocation to check if we have any IPs left
|
||||||
|
await tx.query.ipAllocations.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
orderBy: (ipAllocations, { desc }) => desc(ipAllocations.id),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// check for existing allocation or if we have any IPs left
|
||||||
|
if (!availableAllocation && lastAllocation && lastAllocation.id >= parseInt(IP_MAX_INDEX)) {
|
||||||
|
return err([500, 'No more IP addresses available'] as [500, string]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// use existing allocation or create a new one
|
||||||
|
const ipAllocationId =
|
||||||
|
availableAllocation?.id ??
|
||||||
|
(await tx.insert(ipAllocations).values({}).returning({ id: ipAllocations.id }))[0].id;
|
||||||
|
|
||||||
|
// transaction savepoint after creating a new IP allocation
|
||||||
|
// TODO: not sure if this is needed
|
||||||
|
return await tx.transaction(async (tx2) => {
|
||||||
|
// create new client in db
|
||||||
|
const [newClient] = await tx2
|
||||||
|
.insert(wgClients)
|
||||||
|
.values({
|
||||||
|
userId: params.user.id,
|
||||||
|
name: params.name,
|
||||||
|
publicKey: keys.pubkey,
|
||||||
|
privateKey: keys.privkey,
|
||||||
|
preSharedKey: keys.psk,
|
||||||
|
})
|
||||||
|
.returning({ id: wgClients.id });
|
||||||
|
|
||||||
|
// update IP allocation with client ID
|
||||||
|
await tx2
|
||||||
|
.update(ipAllocations)
|
||||||
|
.set({ clientId: newClient.id })
|
||||||
|
.where(eq(ipAllocations.id, ipAllocationId));
|
||||||
|
|
||||||
|
// create client in opnsense
|
||||||
|
const opnsenseRes = await opnsenseCreateClient({
|
||||||
|
username: params.user.username,
|
||||||
|
pubkey: keys.pubkey,
|
||||||
|
psk: keys.psk,
|
||||||
|
allowedIps: getAllowedIps(ipAllocationId - 1),
|
||||||
|
});
|
||||||
|
const opnsenseResJson = await opnsenseRes.json();
|
||||||
|
if (opnsenseResJson['result'] !== 'saved') {
|
||||||
|
tx2.rollback();
|
||||||
|
console.error(`Error creating client in OPNsense: \n${opnsenseResJson}`);
|
||||||
|
return err([500, 'Error creating client in OPNsense'] as [500, string]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconfigure opnsense
|
||||||
|
await opnsenseReconfigure();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (error) return error;
|
||||||
|
return ok(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getKeys() {
|
||||||
|
// fetch key pair from opnsense
|
||||||
|
const options: RequestInit = {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: opnsenseAuth,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resKeyPair = await fetch(`${opnsenseUrl}/api/wireguard/server/key_pair`, options);
|
||||||
|
const resPsk = await fetch(`${opnsenseUrl}/api/wireguard/client/psk`, options);
|
||||||
|
const keyPair = await resKeyPair.json();
|
||||||
|
const psk = await resPsk.json();
|
||||||
|
return {
|
||||||
|
pubkey: keyPair['pubkey'] as string,
|
||||||
|
privkey: keyPair['privkey'] as string,
|
||||||
|
psk: psk['psk'] as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedIps(ipIndex: number) {
|
||||||
|
const v4StartingAddr = new Address4(IPV4_STARTING_ADDR);
|
||||||
|
const v6StartingAddr = new Address6(IPV6_STARTING_ADDR);
|
||||||
|
const v4Allowed = Address4.fromBigInt(v4StartingAddr.bigInt() + BigInt(ipIndex));
|
||||||
|
const v6Offset = BigInt(ipIndex) << (128n - BigInt(IPV6_CLIENT_PREFIX_SIZE));
|
||||||
|
const v6Allowed = Address6.fromBigInt(v6StartingAddr.bigInt() + v6Offset);
|
||||||
|
const v6AllowedShort = v6Allowed.parsedAddress.join(':');
|
||||||
|
|
||||||
|
return `${v4Allowed.address}/32,${v6AllowedShort}/${IPV6_CLIENT_PREFIX_SIZE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function opnsenseCreateClient(params: {
|
||||||
|
username: string;
|
||||||
|
pubkey: string;
|
||||||
|
psk: string;
|
||||||
|
allowedIps: string;
|
||||||
|
}) {
|
||||||
|
return fetch(`${opnsenseUrl}/api/wireguard/client/addClientBuilder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: opnsenseAuth,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
configbuilder: {
|
||||||
|
enabled: '1',
|
||||||
|
name: `vpgen-${params.username}`,
|
||||||
|
pubkey: params.pubkey,
|
||||||
|
psk: params.psk,
|
||||||
|
tunneladdress: params.allowedIps,
|
||||||
|
server: serverUuid,
|
||||||
|
endpoint: VPN_ENDPOINT,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function opnsenseReconfigure() {
|
||||||
|
return fetch(`${opnsenseUrl}/api/wireguard/service/reconfigure`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: opnsenseAuth,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
27
src/lib/types.ts
Normal file
27
src/lib/types.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
class Ok<T> {
|
||||||
|
readonly _tag = "ok";
|
||||||
|
value: T;
|
||||||
|
|
||||||
|
constructor(value: T) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Err<E> {
|
||||||
|
readonly _tag = "err";
|
||||||
|
error: E;
|
||||||
|
|
||||||
|
constructor(error: E) {
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Result<T, E> = Ok<T> | Err<E>;
|
||||||
|
|
||||||
|
export function err<E>(e: E): Err<E> {
|
||||||
|
return new Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ok<T>(t: T): Ok<T> {
|
||||||
|
return new Ok(t);
|
||||||
|
}
|
@ -23,7 +23,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-grow p-4">
|
<main class="flex flex-col flex-grow p-4">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
@ -1,17 +1,9 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { ipAllocations, wgClients } from '$lib/server/db/schema';
|
import { wgClients } from '$lib/server/db/schema';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { eq, isNull } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import {
|
|
||||||
IP_MAX_INDEX,
|
|
||||||
IPV4_STARTING_ADDR,
|
|
||||||
IPV6_CLIENT_PREFIX_SIZE,
|
|
||||||
IPV6_STARTING_ADDR,
|
|
||||||
VPN_ENDPOINT,
|
|
||||||
} from '$env/static/private';
|
|
||||||
import { opnsenseAuth, opnsenseUrl, serverUuid } from '$lib/server/opnsense';
|
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { Address4, Address6 } from 'ip-address';
|
import { createClient } from '$lib/server/clients';
|
||||||
|
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
if (!event.locals.user) {
|
if (!event.locals.user) {
|
||||||
@ -41,150 +33,21 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
if (!event.locals.user) {
|
if (!event.locals.user) {
|
||||||
return error(401, 'Unauthorized');
|
return error(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
// this is going to be quite long
|
const { name } = await event.request.json();
|
||||||
// 1. fetch params for new client from opnsense api
|
const res = await createClient({
|
||||||
// 2.1 get an allocation for the client
|
name,
|
||||||
// 2.2. insert new client into db
|
user: event.locals.user,
|
||||||
// 2.3. update the allocation with the client id
|
});
|
||||||
// 3. create the client in opnsense
|
|
||||||
// 4. reconfigure opnsense to enable the new client
|
|
||||||
const err: ReturnType<typeof error> | undefined = await db.transaction(async (tx) => {
|
|
||||||
// fetch params for new client from opnsense api
|
|
||||||
const keys = await getKeys();
|
|
||||||
|
|
||||||
// find first unallocated IP
|
switch (res._tag) {
|
||||||
const availableAllocation = await tx.query.ipAllocations.findFirst({
|
case 'ok': {
|
||||||
columns: {
|
return new Response(null, {
|
||||||
id: true,
|
status: 201,
|
||||||
},
|
|
||||||
where: isNull(ipAllocations.clientId),
|
|
||||||
});
|
|
||||||
// find last allocation to check if we have any IPs left
|
|
||||||
const lastAllocation = await tx.query.ipAllocations.findFirst({
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
orderBy: (ipAllocations, { desc }) => desc(ipAllocations.id),
|
|
||||||
});
|
|
||||||
// check for existing allocation or if we have any IPs left
|
|
||||||
if (!availableAllocation && lastAllocation && lastAllocation.id >= parseInt(IP_MAX_INDEX)) {
|
|
||||||
return error(500, 'No more IP addresses available');
|
|
||||||
}
|
|
||||||
|
|
||||||
// use existing allocation or create a new one
|
|
||||||
const ipAllocationId =
|
|
||||||
availableAllocation?.id ??
|
|
||||||
(await tx.insert(ipAllocations).values({}).returning({ id: ipAllocations.id }))[0].id;
|
|
||||||
|
|
||||||
// transaction savepoint after creating a new IP allocation
|
|
||||||
// TODO: not sure if this is needed
|
|
||||||
return await tx.transaction(async (tx2) => {
|
|
||||||
// create client in opnsense
|
|
||||||
const opnsenseRes = await opnsenseCreateClient({
|
|
||||||
// @ts-expect-error event.locals.user is checked at the beginning of the function
|
|
||||||
username: event.locals.user.username,
|
|
||||||
pubkey: keys.pubkey,
|
|
||||||
psk: keys.psk,
|
|
||||||
allowedIps: getAllowedIps(ipAllocationId - 1),
|
|
||||||
});
|
});
|
||||||
const opnsenseResJson = await opnsenseRes.json();
|
}
|
||||||
if (opnsenseResJson['result'] !== 'saved') {
|
case 'err': {
|
||||||
tx2.rollback();
|
const [status, message] = res.error;
|
||||||
console.error(`Error creating client in OPNsense: \n${opnsenseResJson}`);
|
return error(status, message);
|
||||||
return error(500, 'Error creating client in OPNsense');
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// create new client in db
|
|
||||||
const [newClient] = await tx2
|
|
||||||
.insert(wgClients)
|
|
||||||
.values({
|
|
||||||
// @ts-expect-error event.locals.user is checked at the beginning of the function
|
|
||||||
userId: event.locals.user.id,
|
|
||||||
name: 'New Client',
|
|
||||||
publicKey: keys.pubkey,
|
|
||||||
privateKey: keys.privkey,
|
|
||||||
preSharedKey: keys.psk,
|
|
||||||
})
|
|
||||||
.returning({ id: wgClients.id });
|
|
||||||
|
|
||||||
// update IP allocation with client ID
|
|
||||||
await tx2
|
|
||||||
.update(ipAllocations)
|
|
||||||
.set({ clientId: newClient.id })
|
|
||||||
.where(eq(ipAllocations.id, ipAllocationId));
|
|
||||||
|
|
||||||
// reconfigure opnsense
|
|
||||||
await opnsenseReconfigure();
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
return err?? new Response(null, { status: 201 });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getKeys() {
|
|
||||||
// fetch key pair from opnsense
|
|
||||||
const options: RequestInit = {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Authorization: opnsenseAuth,
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const resKeyPair = await fetch(`${opnsenseUrl}/api/wireguard/server/key_pair`, options);
|
|
||||||
const resPsk = await fetch(`${opnsenseUrl}/api/wireguard/client/psk`, options);
|
|
||||||
const keyPair = await resKeyPair.json();
|
|
||||||
const psk = await resPsk.json();
|
|
||||||
return {
|
|
||||||
pubkey: keyPair['pubkey'] as string,
|
|
||||||
privkey: keyPair['privkey'] as string,
|
|
||||||
psk: psk['psk'] as string,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllowedIps(ipIndex: number) {
|
|
||||||
const v4StartingAddr = new Address4(IPV4_STARTING_ADDR);
|
|
||||||
const v6StartingAddr = new Address6(IPV6_STARTING_ADDR);
|
|
||||||
const v4Allowed = Address4.fromBigInt(v4StartingAddr.bigInt() + BigInt(ipIndex));
|
|
||||||
const v6Offset = BigInt(ipIndex) << (128n - BigInt(IPV6_CLIENT_PREFIX_SIZE));
|
|
||||||
const v6Allowed = Address6.fromBigInt(v6StartingAddr.bigInt() + v6Offset);
|
|
||||||
const v6AllowedShort = v6Allowed.parsedAddress.join(':');
|
|
||||||
|
|
||||||
return `${v4Allowed.address}/32,${v6AllowedShort}/${IPV6_CLIENT_PREFIX_SIZE}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function opnsenseCreateClient(params: {
|
|
||||||
username: string;
|
|
||||||
pubkey: string;
|
|
||||||
psk: string;
|
|
||||||
allowedIps: string;
|
|
||||||
}) {
|
|
||||||
return fetch(`${opnsenseUrl}/api/wireguard/client/addClientBuilder`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: opnsenseAuth,
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
configbuilder: {
|
|
||||||
enabled: '1',
|
|
||||||
name: `vpgen-${params.username}`,
|
|
||||||
pubkey: params.pubkey,
|
|
||||||
psk: params.psk,
|
|
||||||
tunneladdress: params.allowedIps,
|
|
||||||
server: serverUuid,
|
|
||||||
endpoint: VPN_ENDPOINT,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function opnsenseReconfigure() {
|
|
||||||
return fetch(`${opnsenseUrl}/api/wireguard/service/reconfigure`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: opnsenseAuth,
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
26
src/routes/clients/+page.server.ts
Normal file
26
src/routes/clients/+page.server.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { Actions } from './$types';
|
||||||
|
import { createClient } from '$lib/server/clients';
|
||||||
|
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 res = await createClient({
|
||||||
|
name,
|
||||||
|
user: event.locals.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (res._tag) {
|
||||||
|
case 'ok': {
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'err': {
|
||||||
|
const [status, message] = res.error;
|
||||||
|
return error(status, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} satisfies Actions;
|
@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import { LucidePlus } from 'lucide-svelte';
|
||||||
|
|
||||||
const { data }: { data: PageData } = $props();
|
const { data }: { data: PageData } = $props();
|
||||||
</script>
|
</script>
|
||||||
@ -32,3 +34,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
|
|
||||||
|
<!--Floating action button for adding a new client-->
|
||||||
|
<form class="self-end mt-auto pt-4" method="post" action="?/create">
|
||||||
|
<Button type="submit">
|
||||||
|
<LucidePlus />
|
||||||
|
Add Client
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
@ -7,9 +7,9 @@
|
|||||||
let isLoadingSignOut = $state(false);
|
let isLoadingSignOut = $state(false);
|
||||||
|
|
||||||
function refetch() {
|
function refetch() {
|
||||||
console.log("refetching");
|
console.log('refetching');
|
||||||
invalidate((url) => {
|
invalidate((url) => {
|
||||||
console.log("invalidation url", url);
|
console.log('invalidation url', url);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
@ -20,21 +20,21 @@
|
|||||||
<title>User Profile</title>
|
<title>User Profile</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<p>
|
<pre>{JSON.stringify(data.user, null, 2)}</pre>
|
||||||
{JSON.stringify(data.user)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button onclick={refetch}>
|
<div class="flex gap-2">
|
||||||
|
<Button onclick={refetch}>
|
||||||
<LucideRefreshCw class="mr-2 h-4 w-4" />
|
<LucideRefreshCw class="mr-2 h-4 w-4" />
|
||||||
Invalidate Data
|
Invalidate Data
|
||||||
</Button>
|
|
||||||
<form class="inline-flex" method="post" action="/auth?/logout">
|
|
||||||
<Button type="submit" onclick={() => {isLoadingSignOut = true}}>
|
|
||||||
{#if isLoadingSignOut}
|
|
||||||
<LucideLoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{:else}
|
|
||||||
<LucideLogOut class="mr-2 h-4 w-4" />
|
|
||||||
{/if}
|
|
||||||
Sign Out
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
<form class="inline-flex" method="post" action="/auth?/logout">
|
||||||
|
<Button type="submit" onclick={() => {isLoadingSignOut = true}}>
|
||||||
|
{#if isLoadingSignOut}
|
||||||
|
<LucideLoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<LucideLogOut class="mr-2 h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user