mirror of
https://github.com/nikdoof/pocket-id.git
synced 2025-12-14 15:22:18 +00:00
initial commit
This commit is contained in:
6
frontend/src/routes/+error.svelte
Normal file
6
frontend/src/routes/+error.svelte
Normal 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} />
|
||||
29
frontend/src/routes/+layout.server.ts
Normal file
29
frontend/src/routes/+layout.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
43
frontend/src/routes/+layout.svelte
Normal file
43
frontend/src/routes/+layout.svelte
Normal 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 />
|
||||
6
frontend/src/routes/+page.server.ts
Normal file
6
frontend/src/routes/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return redirect(302, '/login');
|
||||
};
|
||||
16
frontend/src/routes/authorize/+page.server.ts
Normal file
16
frontend/src/routes/authorize/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
131
frontend/src/routes/authorize/+page.svelte
Normal file
131
frontend/src/routes/authorize/+page.svelte
Normal 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}
|
||||
@@ -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>
|
||||
13
frontend/src/routes/authorize/components/scope-item.svelte
Normal file
13
frontend/src/routes/authorize/components/scope-item.svelte
Normal 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>
|
||||
49
frontend/src/routes/login/+page.svelte
Normal file
49
frontend/src/routes/login/+page.svelte
Normal 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>
|
||||
7
frontend/src/routes/login/[token]/+page.server.ts
Normal file
7
frontend/src/routes/login/[token]/+page.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
return {
|
||||
token: params.token
|
||||
};
|
||||
};
|
||||
43
frontend/src/routes/login/[token]/+page.svelte
Normal file
43
frontend/src/routes/login/[token]/+page.svelte
Normal 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>
|
||||
46
frontend/src/routes/settings/+layout.svelte
Normal file
46
frontend/src/routes/settings/+layout.svelte
Normal 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>
|
||||
5
frontend/src/routes/settings/+page.server.ts
Normal file
5
frontend/src/routes/settings/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() {
|
||||
throw redirect(307, '/settings/account');
|
||||
}
|
||||
14
frontend/src/routes/settings/account/+page.server.ts
Normal file
14
frontend/src/routes/settings/account/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
84
frontend/src/routes/settings/account/+page.svelte
Normal file
84
frontend/src/routes/settings/account/+page.svelte
Normal 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())}
|
||||
/>
|
||||
58
frontend/src/routes/settings/account/account-form.svelte
Normal file
58
frontend/src/routes/settings/account/account-form.svelte
Normal 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>
|
||||
79
frontend/src/routes/settings/account/passkey-list.svelte
Normal file
79
frontend/src/routes/settings/account/passkey-list.svelte
Normal 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())}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
76
frontend/src/routes/settings/admin/oidc-clients/+page.svelte
Normal file
76
frontend/src/routes/settings/admin/oidc-clients/+page.svelte
Normal 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>
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
8
frontend/src/routes/settings/admin/users/+page.server.ts
Normal file
8
frontend/src/routes/settings/admin/users/+page.server.ts
Normal 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;
|
||||
};
|
||||
74
frontend/src/routes/settings/admin/users/+page.svelte
Normal file
74
frontend/src/routes/settings/admin/users/+page.svelte
Normal 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>
|
||||
@@ -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;
|
||||
};
|
||||
46
frontend/src/routes/settings/admin/users/[id]/+page.svelte
Normal file
46
frontend/src/routes/settings/admin/users/[id]/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
78
frontend/src/routes/settings/admin/users/user-form.svelte
Normal file
78
frontend/src/routes/settings/admin/users/user-form.svelte
Normal 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>
|
||||
170
frontend/src/routes/settings/admin/users/user-list.svelte
Normal file
170
frontend/src/routes/settings/admin/users/user-list.svelte
Normal 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} />
|
||||
Reference in New Issue
Block a user