initial commit

This commit is contained in:
Elias Schneider
2024-08-12 11:00:25 +02:00
commit eaff977b22
241 changed files with 14378 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import Error from '$lib/components/error.svelte';
</script>
<Error message={$page.error!.message} />

View File

@@ -0,0 +1,29 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service';
import UserService from '$lib/services/user-service';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
const userService = new UserService(cookies.get('access_token'));
const applicationConfigurationService = new ApplicationConfigurationService(
cookies.get('access_token')
);
const user = await userService
.getCurrent()
.then((user) => user)
.catch(() => null);
const applicationConfiguration = await applicationConfigurationService
.list()
.then((config) => config)
.catch((e) => {
console.error(
`Failed to get application configuration: ${e.response?.data.error || e.message}`
);
return null;
});
return {
user,
applicationConfiguration
};
};

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { browser } from '$app/environment';
import ConfirmDialog from '$lib/components/confirm-dialog/confirm-dialog.svelte';
import Error from '$lib/components/error.svelte';
import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { ModeWatcher } from 'mode-watcher';
import type { Snippet } from 'svelte';
import '../app.css';
import type { LayoutData } from './$types';
let {
data,
children
}: {
data: LayoutData;
children: Snippet;
} = $props();
const { user, applicationConfiguration } = data;
if (browser && user) {
userStore.setUser(user);
}
if (applicationConfiguration) {
applicationConfigurationStore.set(applicationConfiguration);
}
</script>
{#if !applicationConfiguration}
<Error
message="A critical error occured. Please contact your administrator."
showButton={false}
/>
{:else}
<Header />
{@render children()}
{/if}
<Toaster />
<ConfirmDialog />
<ModeWatcher />

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return redirect(302, '/login');
};

View File

@@ -0,0 +1,16 @@
import OidcService from '$lib/services/oidc-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url, cookies }) => {
const clientId = url.searchParams.get('client_id');
const oidcService = new OidcService(cookies.get('access_token'));
const client = await oidcService.getClient(clientId!);
return {
scope: url.searchParams.get('scope')!,
nonce: url.searchParams.get('nonce') || undefined,
state: url.searchParams.get('state')!,
client
};
};

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import OidcService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { AxiosError } from 'axios';
import { LucideMail, LucideUser } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import type { PageData } from './$types';
import ClientProviderImages from './components/client-provider-images.svelte';
import ScopeItem from './components/scope-item.svelte';
const webauthnService = new WebAuthnService();
const oidService = new OidcService();
let isLoading = false;
let success = false;
let errorMessage: string | null = null;
let authorizationRequired = false;
export let data: PageData;
let { scope, nonce, client, state } = data;
async function authorize() {
isLoading = true;
try {
// Get access token if not signed in
if (!$userStore?.id) {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication(loginOptions);
await webauthnService.finishLogin(authResponse);
}
await oidService.authorize(client!.id, scope, nonce).then(async (code) => {
onSuccess(code);
});
} catch (e) {
if (e instanceof AxiosError && e.response?.status === 403) {
authorizationRequired = true;
} else {
errorMessage = getWebauthnErrorMessage(e);
}
isLoading = false;
}
}
async function authorizeNewClient() {
isLoading = true;
try {
await oidService.authorizeNewClient(client!.id, scope, nonce).then(async (code) => {
onSuccess(code);
});
} catch (e) {
errorMessage = getWebauthnErrorMessage(e);
isLoading = false;
}
}
function onSuccess(code: string) {
success = true;
setTimeout(() => {
window.location.href = `${client!.callbackURL}?code=${code}&state=${state}`;
}, 1000);
}
</script>
<svelte:head>
<title>Sign in to {client.name}</title>
</svelte:head>
{#if client == null}
<p>Client not found</p>
{:else}
<SignInWrapper>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if !authorizationRequired}
<p class="text-muted-foreground mb-10 mt-2">
{#if errorMessage}
{errorMessage}. Please try again.
{:else}
Do you want to sign in to <b>{client.name}</b> with your
<b>{$applicationConfigurationStore.appName}</b> account?
{/if}
</p>
{:else}
<div transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10 mt-6">
<Card.Header class="pb-5">
<p class="text-muted-foreground text-start">
<b>{client.name}</b> wants to access the following information:
</p>
</Card.Header>
<Card.Content data-testid="scopes">
<div class="flex flex-col gap-3">
{#if scope!.includes('email')}
<ScopeItem icon={LucideMail} name="Email" description="View your email address" />
{/if}
{#if scope!.includes('profile')}
<ScopeItem
icon={LucideUser}
name="Profile"
description="View your profile information"
/>
{/if}
</div>
</Card.Content>
</Card.Root>
</div>
{/if}
<div class="flex justify-center gap-2">
<Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button>
{#if !errorMessage}
<Button
class="w-full"
{isLoading}
on:click={authorizationRequired ? authorizeNewClient : authorize}
>
Sign in
</Button>
{:else}
<Button class="w-full" on:click={() => (errorMessage = null)}>Try again</Button>
{/if}
</div>
</SignInWrapper>
{/if}

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import Logo from '$lib/components/logo.svelte';
import CheckmarkAnimated from '$lib/icons/checkmark-animated.svelte';
import ConnectArrow from '$lib/icons/connect-arrow.svelte';
import CrossAnimated from '$lib/icons/cross-animated.svelte';
import type { OidcClient } from '$lib/types/oidc.type';
const {
success,
error,
client
}: {
success: boolean;
error: boolean;
client: OidcClient;
} = $props();
let animationDone = $state(false);
$effect(() => {
if (success || error) {
setTimeout(() => {
animationDone = true;
}, 500);
} else {
animationDone = false;
}
});
</script>
<div class="flex justify-center gap-3">
<div
class=" bg-muted rounded-2xl p-3 transition-transform duration-500 ease-in {success || error
? 'translate-x-[108px]'
: ''}"
>
<Logo class="h-10 w-10" />
</div>
<ConnectArrow
class="arrow-fade-out h-w-32 w-32 {success || error ? 'opacity-0' : 'opacity-100'}"
/>
<div
class="rounded-2xl p-3 [transition:transform_500ms_ease-in,background-color_200ms] {success ||
error
? '-translate-x-[108px]'
: ''} {animationDone ? (success ? 'bg-green-200' : 'bg-red-200') : 'bg-muted'}"
>
{#if animationDone && success}
<div class="flex h-10 w-10 items-center justify-center">
<CheckmarkAnimated class="h-7 w-7" />
</div>
{:else if animationDone && error}
<div class="flex h-10 w-10 items-center justify-center">
<CrossAnimated class="h-5 w-5" />
</div>
{:else if client.hasLogo}
<img
class="h-10 w-10"
src="/api/oidc/clients/{client.id}/logo"
draggable={false}
alt="Client Logo"
/>
{:else}
<div class="flex h-10 w-10 items-center justify-center text-3xl font-bold">
{client.name.charAt(0).toUpperCase()}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
export let icon: ConstructorOfATypedSvelteComponent;
export let name: string;
export let description: string;
</script>
<div class="flex items-center">
<div class="mr-5 rounded-lg bg-muted p-2"><svelte:component this={icon} /></div>
<div class="text-start">
<h3 class="font-semibold">{name}</h3>
<p class="text-sm text-muted-foreground">{description}</p>
</div>
</div>

View File

@@ -0,0 +1,49 @@
<script>
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import WebAuthnService from '$lib/services/webauthn-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { toast } from 'svelte-sonner';
const webauthnService = new WebAuthnService();
let isLoading = $state(false);
async function authenticate() {
isLoading = true;
try {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication(loginOptions);
const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
goto('/settings');
} catch (e) {
toast.error(getWebauthnErrorMessage(e));
}
isLoading = false;
}
</script>
<svelte:head>
<title>Sign In</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$applicationConfigurationStore.appName}
</h1>
<p class="text-muted-foreground mt-2">
Authenticate yourself with your passkey to access the admin panel
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Authenticate</Button>
</SignInWrapper>

View File

@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
return {
token: params.token
};
};

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import UserService from '$lib/services/user-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js';
import type { User } from '$lib/types/user.type.js';
import { axiosErrorToast } from '$lib/utils/error-util';
let { data } = $props();
let isLoading = $state(false);
const userService = new UserService();
async function authenticate() {
isLoading = true;
userService
.exchangeOneTimeAccessToken(data.token)
.then((user :User) => {
userStore.setUser(user);
goto('/settings')
})
.catch(axiosErrorToast);
isLoading = false;
}
</script>
<SignInWrapper>
<div class="flex justify-center">
<div class="rounded-2xl bg-muted p-3">
<Logo class="h-10 w-10" />
</div>
</div>
<h1 class="mt-5 font-playfair text-4xl font-bold">One Time Access</h1>
<p class="mt-2 text-muted-foreground">
You've been granted one-time access to your {$applicationConfigurationStore.appName} account. Please note that if you continue,
this link will become invalid. To avoid this, make sure to add a passkey. Otherwise, you'll need
to request a new link.
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
</SignInWrapper>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { page } from '$app/stores';
import userStore from '$lib/stores/user-store';
import type { Snippet } from 'svelte';
let {
children
}: {
children: Snippet;
} = $props();
let links = $state([{ href: '/settings/account', label: 'My Account' }]);
if ($userStore?.isAdmin) {
links = [
...links,
{ href: '/settings/admin/users', label: 'Users' },
{ href: '/settings/admin/oidc-clients', label: 'OIDC Clients' },
{ href: '/settings/admin/application-configuration', label: 'Application Configuration' }
];
}
</script>
<section>
<div class="h-screen w-full">
<main class="flex min-h-screen flex-1 flex-col gap-4 bg-muted/40 p-4 md:gap-8 md:p-10">
<div class="mx-auto grid w-full max-w-[1440px] gap-2">
<h1 class="text-3xl font-semibold">Settings</h1>
</div>
<div
class="mx-auto grid w-full max-w-[1440px] items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]"
>
<nav class="grid gap-4 text-sm text-muted-foreground">
{#each links as { href, label }}
<a {href} class={$page.url.pathname.startsWith(href) ? 'font-bold text-primary' : ''}>
{label}
</a>
{/each}
</nav>
<div class="flex flex-col gap-5">
{@render children()}
</div>
</div>
</main>
</div>
</section>

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(307, '/settings/account');
}

View File

@@ -0,0 +1,14 @@
import UserService from '$lib/services/user-service';
import WebAuthnService from '$lib/services/webauthn-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const webauthnService = new WebAuthnService(cookies.get('access_token'));
const userService = new UserService(cookies.get('access_token'));
const account = await userService.getCurrent();
const passkeys = await webauthnService.listCredentials();
return {
account,
passkeys
};
};

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import UserService from '$lib/services/user-service';
import WebAuthnService from '$lib/services/webauthn-service';
import type { Passkey } from '$lib/types/passkey.type';
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startRegistration } from '@simplewebauthn/browser';
import { toast } from 'svelte-sonner';
import AccountForm from './account-form.svelte';
import PasskeyList from './passkey-list.svelte';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
let { data } = $props();
let account = $state(data.account);
let passkeys = $state(data.passkeys);
let passkeyToRename: Passkey | null = $state(null);
const userService = new UserService();
const webauthnService = new WebAuthnService();
async function updateAccount(user: UserCreate) {
let success = true;
await userService
.updateCurrent(user)
.then(() => toast.success('Account details updated successfully'))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
}
async function createPasskey() {
try {
const opts = await webauthnService.getRegistrationOptions();
const attResp = await startRegistration(opts);
const passkey = await webauthnService.finishRegistration(attResp);
passkeys = await webauthnService.listCredentials();
passkeyToRename = passkey;
} catch (e) {
toast.error(getWebauthnErrorMessage(e));
}
}
</script>
<svelte:head>
<title>Account Settings</title>
</svelte:head>
<Card.Root>
<Card.Header>
<Card.Title>Account Details</Card.Title>
</Card.Header>
<Card.Content>
<AccountForm {account} callback={updateAccount} />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Passkeys</Card.Title>
<Card.Description class="mt-1">
Manage your passkeys that you can use to authenticate yourself.
</Card.Description>
</div>
<Button size="sm" on:click={createPasskey}>Add Passkey</Button>
</div>
</Card.Header>
{#if passkeys.length != 0}
<Card.Content>
<PasskeyList {passkeys} />
</Card.Content>
{/if}
</Card.Root>
<RenamePasskeyModal
bind:passkey={passkeyToRename}
callback={async () => (passkeys = await webauthnService.listCredentials())}
/>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import type { UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
let {
callback,
account
}: {
account: UserCreate;
callback: (user: UserCreate) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
const formSchema = z.object({
firstName: z.string().min(2).max(50),
lastName: z.string().min(2).max(50),
username: z.string().min(2).max(50),
email: z.string().email(),
isAdmin: z.boolean()
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, account);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
// Reset form if user was successfully created
isLoading = false;
}
</script>
<form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label="Firstname" bind:input={$inputs.firstName} />
</div>
<div class="w-full">
<FormInput label="Lastname" bind:input={$inputs.lastName} />
</div>
</div>
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label="Email" bind:input={$inputs.email} />
</div>
<div class="w-full">
<FormInput label="Username" bind:input={$inputs.username} />
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
</div>
</form>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator';
import WebauthnService from '$lib/services/webauthn-service';
import type { Passkey } from '$lib/types/passkey.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideKeyRound, LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
let { passkeys: initialsPasskeys }: { passkeys: Passkey[] } = $props();
let passkeys = $state<Passkey[]>(initialsPasskeys);
const webauthnService = new WebauthnService();
$effect(() => {
passkeys = initialsPasskeys;
});
let passkeyToRename: Passkey | null = $state(null);
async function deletePasskey(passkey: Passkey) {
openConfirmDialog({
title: `Delete ${passkey.name}`,
message: 'Are you sure you want to delete this passkey?',
confirm: {
label: 'Delete',
destructive: true,
action: async () => {
try {
await webauthnService.removeCredential(passkey.id);
passkeys = await webauthnService.listCredentials();
toast.success('Passkey deleted successfully');
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
</script>
<div class="flex flex-col">
{#each passkeys as passkey, i}
<div class="flex justify-between">
<div class="flex items-center">
<LucideKeyRound class="mr-4 inline h-6 w-6" />
<div>
<p>{passkey.name}</p>
<p class="text-xs text-muted-foreground">
Added on {new Date(passkey.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div>
<Button
on:click={() => (passkeyToRename = passkey)}
size="sm"
variant="outline"
aria-label="Rename"><LucidePencil class="h-3 w-3" /></Button
>
<Button
on:click={() => deletePasskey(passkey)}
size="sm"
variant="outline"
aria-label="Delete"><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</div>
</div>
{#if i !== passkeys.length - 1}
<Separator class="my-2" />
{/if}
{/each}
</div>
<RenamePasskeyModal
bind:passkey={passkeyToRename}
callback={async () => (passkeys = await webauthnService.listCredentials())}
/>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import WebAuthnService from '$lib/services/webauthn-service';
import type { Passkey } from '$lib/types/passkey.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { toast } from 'svelte-sonner';
let {
passkey = $bindable(),
callback
}: {
passkey: Passkey | null;
callback?: () => void;
} = $props();
let name = $state('');
$effect(() => {
if (passkey) name = passkey.name;
});
const webauthnService = new WebAuthnService();
function onOpenChange(open: boolean) {
if (!open) {
passkey = null;
}
}
async function onSubmit() {
await webauthnService
.updateCredentialName(passkey!.id, name)
.then(() => {
passkey = null;
toast.success('Passkey name updated successfully');
callback?.();
})
.catch(axiosErrorToast);
}
</script>
<Dialog.Root open={!!passkey} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Name Passkey</Dialog.Title>
<Dialog.Description>Name your passkey to easily identify it later.</Dialog.Description>
</Dialog.Header>
<form onsubmit={onSubmit}>
<div class="grid items-center gap-4 sm:grid-cols-4">
<Label for="name" class="sm:text-right">Name</Label>
<Input id="name" bind:value={name} class="col-span-3" />
</div>
<Dialog.Footer class="mt-4">
<Button type="submit">Save</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,10 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const applicationConfigurationService = new ApplicationConfigurationService(
cookies.get('access_token')
);
const applicationConfiguration = await applicationConfigurationService.list();
return { applicationConfiguration };
};

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import ApplicationConfigurationService from '$lib/services/application-configuration-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import type { AllApplicationConfiguration } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util';
import { toast } from 'svelte-sonner';
import ApplicationConfigurationForm from './application-configuration-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte';
let { data } = $props();
let applicationConfiguration = $state(data.applicationConfiguration);
const applicationConfigurationService = new ApplicationConfigurationService();
async function updateConfiguration(configuration: AllApplicationConfiguration) {
await applicationConfigurationService
.update(configuration)
.then(() => toast.success('Application configuration updated successfully'))
.catch(axiosErrorToast);
await applicationConfigurationStore.reload();
}
async function updateImages(
logo: File | null,
backgroundImage: File | null,
favicon: File | null
) {
const faviconPromise = favicon
? applicationConfigurationService.updateFavicon(favicon)
: Promise.resolve();
const logoPromise = logo ? applicationConfigurationService.updateLogo(logo) : Promise.resolve();
const backgroundImagePromise = backgroundImage
? applicationConfigurationService.updateBackgroundImage(backgroundImage)
: Promise.resolve();
await Promise.all([logoPromise, backgroundImagePromise, faviconPromise])
.then(() => toast.success('Images updated successfully'))
.catch(axiosErrorToast);
}
</script>
<svelte:head>
<title>Application Configuration</title>
</svelte:head>
<Card.Root>
<Card.Header>
<Card.Title>General</Card.Title>
</Card.Header>
<Card.Content>
<ApplicationConfigurationForm {applicationConfiguration} callback={updateConfiguration} />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Images</Card.Title>
</Card.Header>
<Card.Content>
<UpdateApplicationImages callback={updateImages} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import type { AllApplicationConfiguration } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
let {
callback,
applicationConfiguration
}: {
applicationConfiguration: AllApplicationConfiguration;
callback: (user: AllApplicationConfiguration) => Promise<void>;
} = $props();
let isLoading = $state(false);
const updatedApplicationConfiguration: AllApplicationConfiguration = {
appName: applicationConfiguration.appName
};
const formSchema = z.object({
appName: z.string().min(2).max(30)
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, updatedApplicationConfiguration);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data);
isLoading = false;
}
</script>
<form onsubmit={onSubmit}>
<div class="flex gap-3">
<div class="w-full">
<FormInput label="Application Name" bind:input={$inputs.appName} />
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
</div>
</form>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import FileInput from '$lib/components/file-input.svelte';
import { Label } from '$lib/components/ui/label';
import { cn } from '$lib/utils/style';
import type { HTMLAttributes } from 'svelte/elements';
let {
id,
imageClass,
label,
image = $bindable<File | null>(null),
imageURL,
accept = 'image/png, image/jpeg, image/svg+xml',
...restProps
}: HTMLAttributes<HTMLDivElement> & {
id: string;
imageClass: string;
label: string;
image: File | null;
imageURL: string;
accept?: string;
} = $props();
let imageDataURL = $state(imageURL);
function onImageChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] || null;
if (!file) return;
image = file;
const reader = new FileReader();
reader.onload = (event) => {
imageDataURL = event.target?.result as string;
};
reader.readAsDataURL(file);
}
</script>
<div {...restProps}>
<Label for={id}>{label}</Label>
<FileInput {id} variant="secondary" {accept} onchange={onImageChange}>
<div class="bg-muted group relative flex items-center rounded">
<img
class={cn(
'h-full w-full rounded object-cover p-3 transition-opacity duration-200 group-hover:opacity-10',
imageClass
)}
src={imageDataURL}
alt={label}
/>
<span
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform font-medium opacity-0 transition-opacity group-hover:opacity-100"
>
Update
</span>
</div>
</FileInput>
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import ApplicationImage from './application-image.svelte';
let {
callback
}: {
callback: (logo: File | null, backgroundImage: File | null, favicon: File | null) => void;
} = $props();
let logo = $state<File | null>(null);
let backgroundImage = $state<File | null>(null);
let favicon = $state<File | null>(null);
</script>
<div class="application-images-grid">
<ApplicationImage
id="favicon"
imageClass="h-14 w-14 p-2"
label="Favicon"
bind:image={favicon}
imageURL="/api/application-configuration/favicon"
accept="image/x-icon"
/>
<ApplicationImage
id="logo"
imageClass="h-32 w-32"
label="Logo"
bind:image={logo}
imageURL="/api/application-configuration/logo"
/>
<ApplicationImage
id="background-image"
class="basis-full lg:basis-auto"
imageClass="h-[350px] max-w-[500px]"
label="Background Image"
bind:image={backgroundImage}
imageURL="/api/application-configuration/background-image"
/>
</div>
<div class="flex justify-end">
<Button class="mt-5" onclick={() => callback(logo, backgroundImage, favicon)}>Save</Button>
</div>

View File

@@ -0,0 +1,8 @@
import OIDCService from '$lib/services/oidc-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const oidcService = new OIDCService(cookies.get('access_token'));
const clients = await oidcService.listClients();
return clients;
};

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import OIDCService from '$lib/services/oidc-service';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OIDCClientForm from './oidc-client-form.svelte';
import OIDCClientList from './oidc-client-list.svelte';
import { axiosErrorToast } from '$lib/utils/error-util';
import clientSecretStore from '$lib/stores/client-secret-store';
import { goto } from '$app/navigation';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
let { data } = $props();
let clients = $state(data);
let expandAddClient = $state(false);
const oidcService = new OIDCService();
async function createOIDCClient(client: OidcClientCreateWithLogo) {
try {
const createdClient = await oidcService.createClient(client);
if(client.logo){
await oidcService.updateClientLogo(createdClient, client.logo);
}
const clientSecret = await oidcService.createClientSecret(createdClient.id);
clientSecretStore.set(clientSecret);
goto(`/settings/admin/oidc-clients/${createdClient.id}`);
toast.success('OIDC client created successfully');
return true;
} catch (e) {
axiosErrorToast(e)
return false;
}
}
</script>
<svelte:head>
<title>OIDC Clients</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create OIDC Client</Card.Title>
<Card.Description>Add a new OIDC client to {$applicationConfigurationStore.appName}.</Card.Description>
</div>
{#if !expandAddClient}
<Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddClient = false)}>
<LucideMinus class="h-5 w-5" />
</Button>
{/if}
</div>
</Card.Header>
{#if expandAddClient}
<div transition:slide>
<Card.Content>
<OIDCClientForm callback={createOIDCClient} />
</Card.Content>
</div>
{/if}
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Manage OIDC Clients</Card.Title>
</Card.Header>
<Card.Content>
<OIDCClientList {clients} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,7 @@
import OidcService from '$lib/services/oidc-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const oidcService = new OidcService(cookies.get('access_token'));
return await oidcService.getClient(params.id);
};

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import Label from '$lib/components/ui/label/label.svelte';
import OidcService from '$lib/services/oidc-service';
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft, LucideRefreshCcw } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import OidcForm from '../oidc-client-form.svelte';
let { data } = $props();
let client = $state(data);
const oidcService = new OidcService();
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
let success = true;
const dataPromise = oidcService.updateClient(client.id, updatedClient);
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
await Promise.all([dataPromise, imagePromise])
.then(() => {
toast.success('OIDC client updated successfully');
})
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
}
async function createClientSecret() {
openConfirmDialog({
title: 'Create new client secret',
message:
'Are you sure you want to create a new client secret? The old one will be invalidated.',
confirm: {
label: 'Generate',
destructive: true,
action: async () => {
try {
const clientSecret = await oidcService.createClientSecret(client.id);
clientSecretStore.set(clientSecret);
toast.success('New client secret created successfully');
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
beforeNavigate(() => {
clientSecretStore.clear();
});
</script>
<svelte:head>
<title>OIDC Client {client.name}</title>
</svelte:head>
<div>
<a class="text-muted-foreground flex text-sm" href="/settings/admin/oidc-clients"
><LucideChevronLeft class="h-5 w-5" /> Back</a
>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{client.name}</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex">
<Label class="mb-0 w-44">Client ID</Label>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</div>
<div class="mt-3 flex items-center">
<Label class="mb-0 w-44">Client secret</Label>
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span
>
{#if !$clientSecretStore}
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
>
{/if}
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Content class="p-5">
<OidcForm existingClient={client} callback={updateClient} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
let {
oneTimeLink = $bindable()
}: {
oneTimeLink: string | null;
} = $props();
function onOpenChange(open: boolean) {
if (!open) {
oneTimeLink = null;
}
}
</script>
<Dialog.Root open={!!oneTimeLink} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>One Time Link</Dialog.Title>
<Dialog.Description
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or
have lost it.</Dialog.Description
>
</Dialog.Header>
<Label for="one-time-link">One Time Link</Label>
<Input id="one-time-link" value={oneTimeLink} readonly />
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import FileInput from '$lib/components/file-input.svelte';
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
import type {
OidcClient,
OidcClientCreate,
OidcClientCreateWithLogo
} from '$lib/types/oidc.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
let {
callback,
existingClient
}: {
existingClient?: OidcClient;
callback: (user: OidcClientCreateWithLogo) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
let logo = $state<File | null>(null);
let logoDataURL: string | null = $state(
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
);
const client: OidcClientCreate = {
name: existingClient?.name || '',
callbackURL: existingClient?.callbackURL || ''
};
const formSchema = z.object({
name: z.string().min(2).max(50),
callbackURL: z.string().url()
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, client);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback({
...data,
logo
});
// Reset form if client was successfully created
if (success && !existingClient) form.reset();
isLoading = false;
}
function onLogoChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] || null;
if (file) {
logo = file;
const reader = new FileReader();
reader.onload = (event) => {
logoDataURL = event.target?.result as string;
};
reader.readAsDataURL(file);
}
}
function resetLogo() {
logo = null;
logoDataURL = null;
}
</script>
<form onsubmit={onSubmit}>
<div class="mt-3 grid grid-cols-2 gap-3">
<FormInput label="Name" bind:input={$inputs.name} />
<FormInput label="Callback URL" bind:input={$inputs.callbackURL} />
<div class="mt-3">
<Label for="logo">Logo</Label>
<div class="mt-2 flex items-end gap-3">
{#if logoDataURL}
<div class="h-32 w-32 rounded-2xl bg-muted p-3">
<img class="m-auto max-h-full max-w-full object-contain" src={logoDataURL} alt={`${$inputs.name.value} logo`} />
</div>
{/if}
<div class="flex flex-col gap-2">
<FileInput
id="logo"
variant="secondary"
accept="image/png, image/jpeg, image/svg+xml"
onchange={onLogoChange}
>
<Button variant="secondary">
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
</Button>
</FileInput>
{#if logoDataURL}
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
{/if}
</div>
</div>
</div>
</div>
<div class="w-full"></div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
</div>
</form>

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Pagination from '$lib/components/ui/pagination';
import * as Table from '$lib/components/ui/table';
import OIDCService from '$lib/services/oidc-service';
import type { OidcClient } from '$lib/types/oidc.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './client-secret.svelte';
let { clients: initialClients }: { clients: Paginated<OidcClient> } = $props();
let clients = $state<Paginated<OidcClient>>(initialClients);
let oneTimeLink = $state<string | null>(null);
$effect(() => {
clients = initialClients;
});
const oidcService = new OIDCService();
let pagination = $state<PaginationRequest>({
page: 1,
limit: 10
});
let search = $state('');
async function deleteClient(client: OidcClient) {
openConfirmDialog({
title: `Delete ${client.name}`,
message: 'Are you sure you want to delete this OIDC client?',
confirm: {
label: 'Delete',
destructive: true,
action: async () => {
try {
await oidcService.removeClient(client.id);
clients = await oidcService.listClients(search, pagination);
toast.success('OIDC client deleted successfully');
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
</script>
<Input
type="search"
placeholder="Search clients"
bind:value={search}
on:input={async (e) =>
(clients = await oidcService.listClients((e.target as HTMLInputElement).value, pagination))}
/>
<Table.Root>
<Table.Header class="sr-only">
<Table.Row>
<Table.Head>Logo</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if clients.data.length === 0}
<Table.Row>
<Table.Cell colspan={6} class="text-center">No OIDC clients found</Table.Cell>
</Table.Row>
{:else}
{#each clients.data as client}
<Table.Row>
<Table.Cell class="w-8 font-medium">
{#if client.hasLogo}
<div class="h-8 w-8">
<img
class="m-auto max-h-full max-w-full object-contain"
src="/api/oidc/clients/{client.id}/logo"
alt="{client.name} logo"
/>
</div>
{/if}
</Table.Cell>
<Table.Cell class="font-medium">{client.name}</Table.Cell>
<Table.Cell class="flex justify-end gap-1">
<Button
href="/settings/admin/oidc-clients/{client.id}"
size="sm"
variant="outline"
aria-label="Edit"><LucidePencil class="h-3 w-3 " /></Button
>
<Button
on:click={() => deleteClient(client)}
size="sm"
variant="outline"
aria-label="Delete"><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{#if clients?.data?.length ?? 0 > 0}
<Pagination.Root
class="mt-5"
count={clients.pagination.totalItems}
perPage={pagination.limit}
onPageChange={async (p) =>
(clients = await oidcService.listClients(search, {
page: p,
limit: pagination.limit
}))}
bind:page={clients.pagination.currentPage}
let:pages
let:currentPage
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={clients.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
{/if}
<OneTimeLinkModal {oneTimeLink} />

View File

@@ -0,0 +1,8 @@
import UserService from '$lib/services/user-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const userService = new UserService(cookies.get('access_token'));
const users = await userService.list();
return users;
};

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import UserService from '$lib/services/user-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import type { Paginated } from '$lib/types/pagination.type';
import type { User, UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import CreateUser from './user-form.svelte';
import UserList from './user-list.svelte';
let { data } = $props();
let users: Paginated<User> = $state(data);
let expandAddUser = $state(false);
const userService = new UserService();
async function createUser(user: UserCreate) {
let success = true;
await userService
.create(user)
.then(() => toast.success('User created successfully'))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
users = await userService.list();
return success;
}
</script>
<svelte:head>
<title>Users</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create User</Card.Title>
<Card.Description
>Add a new user to {$applicationConfigurationStore.appName}.</Card.Description
>
</div>
{#if !expandAddUser}
<Button on:click={() => (expandAddUser = true)}>Add User</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUser = false)}>
<LucideMinus class="h-5 w-5" />
</Button>
{/if}
</div>
</Card.Header>
{#if expandAddUser}
<div transition:slide>
<Card.Content>
<CreateUser callback={createUser} />
</Card.Content>
</div>
{/if}
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Manage Users</Card.Title>
</Card.Header>
<Card.Content>
<UserList {users} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,8 @@
import UserService from '$lib/services/user-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const userService = new UserService(cookies.get('access_token'));
const user = await userService.get(params.id);
return user;
};

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import UserService from '$lib/services/user-service';
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import UserForm from '../user-form.svelte';
let { data } = $props();
let user = $state(data);
const userService = new UserService();
async function updateUser(updatedUser: UserCreate) {
let success = true;
await userService
.update(user.id, updatedUser)
.then(() => toast.success('User updated successfully'))
.catch((e) => {
axiosErrorToast(e);
success = false;
});
return success;
}
</script>
<svelte:head>
<title>User Details {user.firstName} {user.lastName}</title>
</svelte:head>
<div>
<a class="text-muted-foreground flex text-sm" href="/settings/admin/users"
><LucideChevronLeft class="h-5 w-5" /> Back</a
>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{user.firstName} {user.lastName}</Card.Title>
</Card.Header>
<Card.Content>
<UserForm existingUser={user} callback={updateUser} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
let {
oneTimeLink = $bindable()
}: {
oneTimeLink: string | null;
} = $props();
function onOpenChange(open: boolean) {
if (!open) {
oneTimeLink = null;
}
}
</script>
<Dialog.Root open={!!oneTimeLink} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>One Time Link</Dialog.Title>
<Dialog.Description
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or
have lost it.</Dialog.Description
>
</Dialog.Header>
<div>
<Label for="one-time-link">One Time Link</Label>
<Input id="one-time-link" value={oneTimeLink} readonly />
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import type { UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
let {
callback,
existingUser
}: {
existingUser?: UserCreate;
callback: (user: UserCreate) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
const user = {
firstName: existingUser?.firstName || '',
lastName: existingUser?.lastName || '',
email: existingUser?.email || '',
username: existingUser?.username || '',
isAdmin: existingUser?.isAdmin || false
};
const formSchema = z.object({
firstName: z.string().min(2).max(50),
lastName: z.string().min(2).max(50),
username: z.string().min(2).max(50),
email: z.string().email(),
isAdmin: z.boolean()
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, user);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
// Reset form if user was successfully created
if (success && !existingUser) form.reset();
isLoading = false;
}
</script>
<form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label="Firstname" bind:input={$inputs.firstName} />
</div>
<div class="w-full">
<FormInput label="Lastname" bind:input={$inputs.lastName} />
</div>
</div>
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label="Email" bind:input={$inputs.email} />
</div>
<div class="w-full">
<FormInput label="Username" bind:input={$inputs.username} />
</div>
</div>
<div class="items-top mt-5 flex space-x-2">
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} />
<div class="grid gap-1.5 leading-none">
<Label for="admin-privileges" class="text-sm font-medium leading-none mb-0">
Admin Privileges
</Label>
<p class="text-[0.8rem] text-muted-foreground">Admins have full access to the admin panel.</p>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
</div>
</form>

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { page } from '$app/stores';
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Badge } from '$lib/components/ui/badge/index';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import * as Pagination from '$lib/components/ui/pagination';
import * as Table from '$lib/components/ui/table';
import UserService from '$lib/services/user-service';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type';
import { debounced } from '$lib/utils/debounce-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './one-time-link-modal.svelte';
let { users: initialUsers }: { users: Paginated<User> } = $props();
let users = $state<Paginated<User>>(initialUsers);
let oneTimeLink = $state<string | null>(null);
$effect(() => {
users = initialUsers;
});
const userService = new UserService();
let pagination = $state<PaginationRequest>({
page: 1,
limit: 10
});
let search = $state('');
const debouncedFetchUsers = debounced(userService.list, 500);
async function deleteUser(user: User) {
openConfirmDialog({
title: `Delete ${user.firstName} ${user.lastName}`,
message: 'Are you sure you want to delete this user?',
confirm: {
label: 'Delete',
destructive: true,
action: async () => {
try {
await userService.remove(user.id);
users = await userService.list(search, pagination);
} catch (e) {
axiosErrorToast(e);
}
toast.success('User deleted successfully');
}
}
});
}
async function createOneTimeAccessToken(userId: string) {
try {
const token = await userService.createOneTimeAccessToken(userId);
oneTimeLink = `${$page.url.origin}/login/${token}`;
} catch (e) {
axiosErrorToast(e);
}
}
</script>
<Input
type="search"
placeholder="Search users"
bind:value={search}
on:input={async (e) =>
(users = await userService.list((e.target as HTMLInputElement).value, pagination))}
/>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="hidden md:table-cell">First name</Table.Head>
<Table.Head class="hidden md:table-cell">Last name</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Username</Table.Head>
<Table.Head class="hidden lg:table-cell">Role</Table.Head>
<Table.Head>
<span class="sr-only">Actions</span>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if users.data.length === 0}
<Table.Row>
<Table.Cell colspan={6} class="text-center">No users found</Table.Cell>
</Table.Row>
{:else}
{#each users.data as user}
<Table.Row>
<Table.Cell class="hidden md:table-cell">{user.firstName}</Table.Cell>
<Table.Cell class="hidden md:table-cell">{user.lastName}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell>{user.username}</Table.Cell>
<Table.Cell class="hidden lg:table-cell">
<Badge variant="outline">{user.isAdmin ? 'Admin' : 'User'}</Badge>
</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item on:click={() => createOneTimeAccessToken(user.id)}
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
>
<DropdownMenu.Item href="/settings/admin/users/{user.id}"
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
>
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
on:click={() => deleteUser(user)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{#if users?.data?.length ?? 0 > 0}
<Pagination.Root
class="mt-5"
count={users.pagination.totalItems}
perPage={pagination.limit}
onPageChange={async (p) =>
(users = await userService.list(search, {
page: p,
limit: pagination.limit
}))}
bind:page={users.pagination.currentPage}
let:pages
let:currentPage
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={users.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
{/if}
<OneTimeLinkModal {oneTimeLink} />