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,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} />