feat: modernize ui (#381)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-03-30 13:19:14 -05:00
committed by GitHub
parent 5dcf69e974
commit 9881a1df9e
28 changed files with 847 additions and 512 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "pocket-id-frontend",
"version": "0.44.0",
"version": "0.45.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pocket-id-frontend",
"version": "0.44.0",
"version": "0.45.0",
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0",
@@ -16,7 +16,7 @@
"crypto": "^1.0.1",
"formsnap": "^1.0.1",
"jose": "^5.9.6",
"lucide-svelte": "^0.479.0",
"lucide-svelte": "^0.483.0",
"mode-watcher": "^0.5.1",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.23.1",
@@ -3605,9 +3605,9 @@
"dev": true
},
"node_modules/lucide-svelte": {
"version": "0.479.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.479.0.tgz",
"integrity": "sha512-epCj6WL86ykxg7oCQTmPEth5e11pwJUzIfG9ROUsWsTP+WPtb3qat+VmAjfx/r4TRW7memTFcbTPvMrZvKthqw==",
"version": "0.483.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.483.0.tgz",
"integrity": "sha512-MyMgEVLlFfPbyodGpkB+KCpyPkpjI7EKiFw1crA92B1ZXRK5hq5vTsGWAm9Nt3GAKHunoNc5MVsq3EOCz0DZSQ==",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}

View File

@@ -1,59 +1,59 @@
{
"name": "pocket-id-frontend",
"version": "0.45.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview --port 3000",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0",
"axios": "^1.8.2",
"bits-ui": "^0.22.0",
"clsx": "^2.1.1",
"crypto": "^1.0.1",
"formsnap": "^1.0.1",
"jose": "^5.9.6",
"lucide-svelte": "^0.479.0",
"mode-watcher": "^0.5.1",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^2.6.0",
"tailwind-variants": "^0.3.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@inlang/plugin-m-function-matcher": "^2.0.7",
"@inlang/plugin-message-format": "^4.0.0",
"@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/eslint": "^9.6.1",
"@types/node": "^22.10.10",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.19.3",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.0.0",
"tslib": "^2.8.1",
"typescript": "^5.7.3",
"typescript-eslint": "^8.21.0",
"vite": "^6.2.3"
}
"name": "pocket-id-frontend",
"version": "0.45.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview --port 3000",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0",
"axios": "^1.8.2",
"bits-ui": "^0.22.0",
"clsx": "^2.1.1",
"crypto": "^1.0.1",
"formsnap": "^1.0.1",
"jose": "^5.9.6",
"lucide-svelte": "^0.483.0",
"mode-watcher": "^0.5.1",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^2.6.0",
"tailwind-variants": "^0.3.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@inlang/plugin-m-function-matcher": "^2.0.7",
"@inlang/plugin-message-format": "^4.0.0",
"@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/eslint": "^9.6.1",
"@types/node": "^22.10.10",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.19.3",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.0.0",
"tslib": "^2.8.1",
"typescript": "^5.7.3",
"typescript-eslint": "^8.21.0",
"vite": "^6.2.3"
}
}

View File

@@ -1,4 +1,4 @@
@import "tailwindcss";
@import 'tailwindcss';
@config '../tailwind.config.ts';
@@ -68,6 +68,55 @@
}
}
.animate-fade-in {
animation: fadeIn 0.8s ease-out forwards;
opacity: 0;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-bg-container {
0% {
left: 0;
}
100% {
left: 650px;
}
}
.animate-slide-bg-container {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
animation: slide-bg-container 1.2s cubic-bezier(0.33, 1, 0.68, 1) forwards;
}
/* Fade in for content after the slide is mostly complete */
@keyframes delayed-fade {
0%,
40% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.animate-delayed-fade {
animation: delayed-fade 1.5s ease-out forwards;
}
@layer base {
* {
@apply border-border;
@@ -77,7 +126,7 @@
@apply bg-background text-foreground;
}
button{
button {
@apply cursor-pointer;
}

View File

@@ -1,23 +1,25 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from 'lucide-svelte';
import { LucideChevronDown, type Icon as IconType } from 'lucide-svelte';
import { onMount, type Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import { Button } from './ui/button';
import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let {
id,
title,
description,
defaultExpanded = false,
icon,
children
}: {
id: string;
title: string;
description?: string;
defaultExpanded?: boolean;
icon?: typeof IconType;
children: Snippet;
} = $props();
@@ -51,7 +53,12 @@
<Card.Header class="cursor-pointer" onclick={toggleExpanded}>
<div class="flex items-center justify-between">
<div>
<Card.Title>{title}</Card.Title>
<Card.Title class="flex items-center gap-2 text-xl font-semibold">
{#if icon}{@const Icon = icon}
<Icon class="text-primary/80 h-5 w-5" />
{/if}
{title}
</Card.Title>
{#if description}
<Card.Description>{description}</Card.Description>
{/if}
@@ -68,7 +75,7 @@
</Card.Header>
{#if expanded}
<div transition:slide={{ duration: 200 }}>
<Card.Content>
<Card.Content class="bg-muted/20 pt-5">
{@render children()}
</Card.Content>
</div>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { page } from '$app/state';
import type { Snippet } from 'svelte';
let {
delay = 50,
stagger = 150,
children
}: {
delay?: number;
stagger?: number;
children: Snippet;
} = $props();
let containerNode: HTMLElement;
$effect(() => {
page.route;
applyAnimationDelays();
});
function applyAnimationDelays() {
if (containerNode) {
const childNodes = Array.from(containerNode.children);
childNodes.forEach((child, index) => {
// Skip comment nodes and text nodes
if (child.nodeType === 1) {
const itemDelay = delay + index * stagger;
(child as HTMLElement).style.setProperty('animation-delay', `${itemDelay}ms`);
console.log(itemDelay);
}
});
}
}
</script>
<svelte:head>
<style>
/* Base styles */
.fade-wrapper {
display: contents;
overflow: hidden;
}
/* Apply these styles to all children */
.fade-wrapper > * {
animation-fill-mode: both;
opacity: 0;
transform: translateY(10px);
animation-delay: calc(var(--animation-delay, 0ms) + 0.1s);
animation: fadeIn 0.8s ease-out forwards;
will-change: opacity, transform;
}
</style>
</svelte:head>
<div class="fade-wrapper" bind:this={containerNode}>
{@render children()}
</div>

View File

@@ -5,6 +5,7 @@
import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte';
import { openConfirmDialog } from '../confirm-dialog';
import { m } from '$lib/paraglide/messages';
import type UserService from '$lib/services/user-service';
let {
userId,
@@ -34,7 +35,7 @@
reader.readAsDataURL(file);
await updateCallback(file).catch(() => {
imageDataURL = `/api/users/${userId}/profile-picture.png}`;
imageDataURL = `/api/users/${userId}/profile-picture.png`;
});
isLoading = false;
}
@@ -55,62 +56,53 @@
}
</script>
<div class="flex gap-5">
<div class="flex w-full flex-col justify-between gap-5 sm:flex-row">
<div>
<h3 class="text-xl font-semibold">{m.profile_picture()}</h3>
{#if isLdapUser}
<p class="text-muted-foreground mt-1 text-sm">
{m.profile_picture_is_managed_by_ldap_server()}
</p>
{:else}
<p class="text-muted-foreground mt-1 text-sm">
{m.click_profile_picture_to_upload_custom()}
</p>
<p class="text-muted-foreground mt-1 text-sm">{m.image_should_be_in_format()}</p>
{/if}
<Button
variant="outline"
size="sm"
class="mt-5"
on:click={onReset}
disabled={isLoading || isLdapUser}
>
<LucideRefreshCw class="mr-2 h-4 w-4" />
{m.reset_to_default()}
</Button>
</div>
<div class="flex flex-col items-center gap-6 sm:flex-row">
<div class="shrink-0">
{#if isLdapUser}
<Avatar.Root class="h-24 w-24">
<Avatar.Image class="object-cover" src={imageDataURL} />
</Avatar.Root>
{:else}
<div class="flex flex-col items-center gap-2">
<FileInput
id="profile-picture-input"
variant="secondary"
accept="image/png, image/jpeg"
onchange={onImageChange}
>
<div class="group relative h-28 w-28 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-10 {isLoading ? 'opacity-10' : ''}"
src={imageDataURL}
/>
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<LucideLoader class="h-5 w-5 animate-spin" />
{:else}
<LucideUpload
class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/if}
</div>
<FileInput
id="profile-picture-input"
variant="secondary"
accept="image/png, image/jpeg"
onchange={onImageChange}
>
<div class="group relative h-24 w-24 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-30 {isLoading ? 'opacity-30' : ''}"
src={imageDataURL}
/>
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<LucideLoader class="h-5 w-5 animate-spin" />
{:else}
<LucideUpload class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100" />
{/if}
</div>
</FileInput>
</div>
</div>
</FileInput>
{/if}
</div>
<div class="grow">
<h3 class="font-medium">{m.profile_picture()}</h3>
{#if isLdapUser}
<p class="text-muted-foreground text-sm">
{m.profile_picture_is_managed_by_ldap_server()}
</p>
{:else}
<p class="text-muted-foreground text-sm">
{m.click_profile_picture_to_upload_custom()}
</p>
<p class="text-muted-foreground mb-2 text-sm">{m.image_should_be_in_format()}</p>
<Button variant="outline" size="sm" on:click={onReset} disabled={isLoading || isLdapUser}>
<LucideRefreshCw class="mr-2 h-4 w-4" />
{m.reset_to_default()}
</Button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import { LucideCalendar, LucidePencil, LucideTrash, type Icon as IconType } from 'lucide-svelte';
let {
icon,
onRename,
onDelete,
label,
description
}: {
icon: typeof IconType;
onRename: () => void;
onDelete: () => void;
description?: string;
label?: string;
} = $props();
</script>
<div class="bg-card hover:bg-muted/50 group rounded-lg p-3 transition-colors">
<div class="flex items-center justify-between">
<div class="flex items-start gap-3">
<div class="bg-primary/10 text-primary mt-1 rounded-lg p-2">
{#if icon}{@const Icon = icon}
<Icon class="h-5 w-5" />
{/if}
</div>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{label}</p>
</div>
{#if description}
<div class="text-muted-foreground mt-1 flex items-center text-xs">
<LucideCalendar class="mr-1 h-3 w-3" />
{description}
</div>
{/if}
</div>
</div>
<div class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<Button
on:click={onRename}
size="icon"
variant="ghost"
class="h-8 w-8"
aria-label={m.rename()}
>
<LucidePencil class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{m.rename()}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
on:click={onDelete}
size="icon"
variant="ghost"
class="hover:bg-destructive/10 hover:text-destructive h-8 w-8"
aria-label={m.delete()}
>
<LucideTrash class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{m.delete()}</TooltipContent>
</Tooltip>
</div>
</div>
</div>

View File

@@ -20,10 +20,15 @@
>
<div class="flex h-16 items-center">
{#if !isAuthPage}
<Logo class="mr-3 h-8 w-8" />
<h1 class="text-lg font-medium" data-testid="application-name">
{$appConfigStore.appName}
</h1>
<a
href="/settings/account"
class="flex items-center gap-3 transition-opacity hover:opacity-80"
>
<Logo class="h-8 w-8" />
<h1 class="text-lg font-semibold tracking-tight" data-testid="application-name">
{$appConfigStore.appName}
</h1>
</a>
{/if}
</div>
<div class="flex items-center justify-between gap-4">

View File

@@ -1,34 +1,46 @@
<script lang="ts">
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import type { Snippet } from 'svelte';
import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let {
children,
showAlternativeSignInMethodButton = false
showAlternativeSignInMethodButton = false,
animate = false
}: {
children: Snippet;
showAlternativeSignInMethodButton?: boolean;
animate?: boolean;
} = $props();
</script>
<!-- Desktop -->
<div class="hidden h-screen items-center text-center lg:flex">
<div class="h-full min-w-[650px] p-16 {showAlternativeSignInMethodButton ? 'pb-0' : ''}">
<div class="flex h-full flex-col">
<div class="flex flex-grow flex-col items-center justify-center">
<!-- Desktop with sliding reveal animation -->
<div class="hidden h-screen items-center overflow-hidden text-center lg:flex">
<!-- Content area that fades in after background slides -->
<div
class="relative z-10 flex h-full w-[650px] p-16 {cn(
showAlternativeSignInMethodButton && 'pb-0',
animate && 'animate-delayed-fade'
)}"
>
<div class="flex h-full w-full flex-col overflow-hidden">
<div class="relative flex flex-grow flex-col items-center justify-center overflow-auto">
{@render children()}
</div>
{#if showAlternativeSignInMethodButton}
<div class="mb-4 flex justify-center">
<div
class="mb-4 flex items-center justify-center"
style={animate ? 'animation-delay: 1000ms;' : ''}
>
<a
href={page.url.pathname == '/login'
? '/login/alternative'
: `/login/alternative?redirect=${encodeURIComponent(
page.url.pathname + page.url.search
)}`}
class="text-muted-foreground text-xs"
class="text-muted-foreground text-xs transition-colors hover:underline"
>
{m.dont_have_access_to_your_passkey()}
</a>
@@ -36,18 +48,22 @@
{/if}
</div>
</div>
<img
src="/api/application-configuration/background-image"
class="h-screen w-[calc(100vw-650px)] rounded-l-[60px] object-cover"
alt={m.login_background()}
/>
<!-- Background image with slide animation -->
<div class="{cn(animate && 'animate-slide-bg-container')} absolute bottom-0 right-0 top-0 z-0">
<img
src="/api/application-configuration/background-image"
class="h-screen rounded-l-[60px] object-cover {animate ? 'w-full' : 'w-[calc(100vw-650px)]'}"
alt={m.login_background()}
/>
</div>
</div>
<!-- Mobile -->
<div
class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center lg:hidden"
>
<Card.Root class="mx-3">
<Card.Root class="mx-3 w-full max-w-md" style={animate ? 'animation-delay: 200ms;' : ''}>
<Card.CardContent
class="px-4 py-10 sm:p-10 {showAlternativeSignInMethodButton ? 'pb-3 sm:pb-3' : ''}"
>
@@ -59,7 +75,7 @@
: `/login/alternative?redirect=${encodeURIComponent(
page.url.pathname + page.url.search
)}`}
class="text-muted-foreground mt-7 flex justify-center text-xs"
class="text-muted-foreground mt-7 flex justify-center text-xs transition-colors hover:underline"
>
{m.dont_have_access_to_your_passkey()}
</a>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
type $$Props = HTMLAttributes<HTMLDivElement>;
@@ -8,6 +8,6 @@
export { className as class };
</script>
<div class={cn('p-6 pt-0', className)} {...$$restProps}>
<div class={cn('bg-muted/20 p-6 pt-5 peer-[.card-header]:border-t', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -8,6 +8,6 @@
export { className as class };
</script>
<p class={cn('text-sm text-muted-foreground mt-1', className)} {...$$restProps}>
<p class={cn('text-muted-foreground mt-1 text-sm', className)} {...$$restProps}>
<slot />
</p>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
type $$Props = HTMLAttributes<HTMLDivElement>;
@@ -8,6 +8,6 @@
export { className as class };
</script>
<div class={cn('flex flex-col space-y-1.5 p-6', className)} {...$$restProps}>
<div class={cn('card-header peer flex flex-col space-y-1.5 p-6', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -14,7 +14,7 @@
<svelte:element
this={tag}
class={cn('text-xl font-semibold leading-none tracking-tight', className)}
class={cn('flex items-center gap-2 text-xl font-semibold leading-none tracking-tight', className)}
{...$$restProps}
>
<slot />

View File

@@ -9,7 +9,7 @@
</script>
<div
class={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
class={cn('bg-card text-card-foreground overflow-hidden rounded-lg border shadow-sm', className)}
{...$$restProps}
>
<slot />

View File

@@ -78,15 +78,17 @@
</script>
<svelte:head>
<title>{m.sign_in_to({name: client.name})}</title>
<title>{m.sign_in_to({ name: client.name })}</title>
</svelte:head>
{#if client == null}
<p>{m.client_not_found()}</p>
{:else}
<SignInWrapper showAlternativeSignInMethodButton>
<SignInWrapper animate showAlternativeSignInMethodButton>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.sign_in_to({name: client.name})}</h1>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to({ name: client.name })}
</h1>
{#if errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
{errorMessage}.
@@ -110,7 +112,11 @@
<Card.Content data-testid="scopes">
<div class="flex flex-col gap-3">
{#if scope!.includes('email')}
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
<ScopeItem
icon={LucideMail}
name={m.email()}
description={m.view_your_email_address()}
/>
{/if}
{#if scope!.includes('profile')}
<ScopeItem
@@ -132,7 +138,8 @@
</div>
{/if}
<div class="flex w-full justify-stretch gap-2">
<Button onclick={() => history.back()} class="w-full" variant="secondary">{m.cancel()}</Button>
<Button onclick={() => history.back()} class="w-full" variant="secondary">{m.cancel()}</Button
>
{#if !errorMessage}
<Button class="w-full" {isLoading} on:click={authorize}>{m.sign_in()}</Button>
{:else}

View File

@@ -36,12 +36,12 @@
<title>{m.sign_in()}</title>
</svelte:head>
<SignInWrapper showAlternativeSignInMethodButton>
<SignInWrapper animate showAlternativeSignInMethodButton>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to_appname({ appName: $appConfigStore.appName})}
{m.sign_in_to_appname({ appName: $appConfigStore.appName })}
</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>

View File

@@ -26,7 +26,7 @@
<title>{m.logout()}</title>
</svelte:head>
<SignInWrapper>
<SignInWrapper animate>
<div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" />
@@ -35,7 +35,9 @@
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.sign_out()}</h1>
<p class="text-muted-foreground mt-2">
{@html m.do_you_want_to_sign_out_of_pocketid_with_the_account({ username: $userStore?.username ?? '' })}
{@html m.do_you_want_to_sign_out_of_pocketid_with_the_account({
username: $userStore?.username ?? ''
})}
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button class="w-full" variant="secondary" onclick={() => history.back()}>{m.cancel()}</Button>

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import { page } from '$app/stores';
import userStore from '$lib/stores/user-store';
import { LucideExternalLink } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { page } from '$app/state';
import FadeWrapper from '$lib/components/fade-wrapper.svelte';
import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store';
import { LucideExternalLink, LucideSettings } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import { fade, fly } from 'svelte/transition';
import type { LayoutData } from './$types';
let {
children,
@@ -14,60 +16,79 @@
data: LayoutData;
} = $props();
const { versionInformation } = data;
const { versionInformation, user } = data;
let links = $state([
const links = [
{ href: '/settings/account', label: m.my_account() },
{ href: '/settings/audit-log', label: m.audit_log() }
]);
{ href: '/settings/audit-log', label: m.audit_log() },
];
if ($userStore?.isAdmin) {
links = [
// svelte-ignore state_referenced_locally
...links,
{ href: '/settings/admin/users', label: m.users() },
{ href: '/settings/admin/user-groups', label: m.user_groups() },
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients() },
{ href: '/settings/admin/api-keys', label: m.api_keys() },
{ href: '/settings/admin/application-configuration', label: m.application_configuration() }
];
const adminLinks = [
{ href: '/settings/admin/users', label: m.users() },
{ href: '/settings/admin/user-groups', label: m.user_groups() },
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients() },
{ href: '/settings/admin/api-keys', label: m.api_keys() },
{ href: '/settings/admin/application-configuration', label: m.application_configuration() }
];
if (user?.isAdmin || $userStore?.isAdmin) {
links.push(...adminLinks);
}
</script>
<section>
<div class="bg-muted/40 flex min-h-[calc(100vh-64px)] w-full flex-col justify-between">
<main
class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row"
in:fade={{ duration: 300 }}
class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-8 gap-y-8 overflow-hidden p-4 md:p-8 lg:flex-row"
>
<div class="min-w-[200px] xl:min-w-[250px]">
<div class="mx-auto grid w-full gap-2">
<h1 class="mb-5 text-3xl font-semibold">{m.settings()}</h1>
<div in:fly={{ x: -15, duration: 300 }} class="sticky top-6">
<div class="mx-auto grid w-full gap-2">
<h1 class="mb-4 flex items-center gap-2 text-2xl font-semibold">
<LucideSettings class="h-5 w-5" />
{m.settings()}
</h1>
</div>
<nav class="text-muted-foreground grid gap-2 text-sm">
{#each links as { href, label }, i}
<a
{href}
class={`animate-fade-in ${
page.url.pathname.startsWith(href)
? 'text-primary bg-card rounded-md px-3 py-1.5 font-medium shadow-sm transition-all'
: 'hover:text-foreground hover:bg-muted/70 rounded-md px-3 py-1.5 transition-all hover:-translate-y-[2px] hover:shadow-sm'
}`}
style={`animation-delay: ${150 + i * 75}ms;`}
>
{label}
</a>
{/each}
{#if $userStore?.isAdmin && versionInformation.isUpToDate === false}
<a
href="https://github.com/pocket-id/pocket-id/releases/latest"
target="_blank"
class="animate-fade-in hover:text-foreground hover:bg-muted/70 mt-1 flex items-center gap-2 rounded-md px-3 py-1.5 text-orange-500 transition-all hover:-translate-y-[2px] hover:shadow-sm"
style={`animation-delay: ${150 + links.length * 75}ms;`}
>
{m.update_pocket_id()}
<LucideExternalLink class="my-auto inline-block h-3 w-3" />
</a>
{/if}
</nav>
</div>
<nav class="text-muted-foreground grid gap-4 text-sm">
{#each links as { href, label }}
<a {href} class={$page.url.pathname.startsWith(href) ? 'text-primary font-bold' : ''}>
{label}
</a>
{/each}
{#if $userStore?.isAdmin && versionInformation.isUpToDate === false}
<a
href="https://github.com/pocket-id/pocket-id/releases/latest"
target="_blank"
class="flex items-center gap-2"
>
{m.update_pocket_id()} <LucideExternalLink class="my-auto inline-block h-3 w-3" />
</a>
{/if}
</nav>
</div>
<div class="flex w-full flex-col gap-5 overflow-x-hidden">
{@render children()}
<div class="flex w-full flex-col gap-4 overflow-hidden">
<FadeWrapper>
{@render children()}
</FadeWrapper>
</div>
</main>
<div class="flex flex-col items-center">
<div class="animate-fade-in flex flex-col items-center" style="animation-delay: 400ms;">
<p class="text-muted-foreground py-3 text-xs">
{m.powered_by()} <a
class="text-foreground"
{m.powered_by()}
<a
class="text-foreground transition-all hover:underline"
href="https://github.com/pocket-id/pocket-id"
target="_blank">Pocket ID</a
>

View File

@@ -10,9 +10,14 @@
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startRegistration } from '@simplewebauthn/browser';
import { LucideAlertTriangle } from 'lucide-svelte';
import {
KeyRound,
Languages,
LucideAlertTriangle,
RectangleEllipsis,
UserCog
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte';
import AccountForm from './account-form.svelte';
import LocalePicker from './locale-picker.svelte';
import LoginCodeModal from './login-code-modal.svelte';
@@ -28,15 +33,6 @@
const userService = new UserService();
const webauthnService = new WebAuthnService();
async function resetProfilePicture() {
await userService
.resetCurrentUserProfilePicture()
.then(() =>
toast.success('Profile picture has been reset. It may take a few minutes to update.')
)
.catch(axiosErrorToast);
}
async function updateAccount(user: UserCreate) {
let success = true;
await userService
@@ -50,13 +46,6 @@
return success;
}
async function updateProfilePicture(image: File) {
await userService
.updateCurrentUsersProfilePicture(image)
.then(() => toast.success(m.profile_picture_updated_successfully()))
.catch(axiosErrorToast);
}
async function createPasskey() {
try {
const opts = await webauthnService.getRegistrationOptions();
@@ -76,94 +65,119 @@
</svelte:head>
{#if passkeys.length == 0}
<Alert.Root variant="warning">
<Alert.Root variant="warning" class="flex gap-3">
<LucideAlertTriangle class="size-4" />
<Alert.Title>{m.passkey_missing()}</Alert.Title>
<Alert.Description
>{m.please_provide_a_passkey_to_prevent_losing_access_to_your_account()}</Alert.Description
>
<div>
<Alert.Title class="font-semibold">{m.passkey_missing()}</Alert.Title>
<Alert.Description class="text-sm">
{m.please_provide_a_passkey_to_prevent_losing_access_to_your_account()}
</Alert.Description>
</div>
</Alert.Root>
{:else if passkeys.length == 1}
<Alert.Root variant="warning" dismissibleId="single-passkey">
<Alert.Root variant="warning" dismissibleId="single-passkey" class="flex gap-3">
<LucideAlertTriangle class="size-4" />
<Alert.Title>{m.single_passkey_configured()}</Alert.Title>
<Alert.Description>{m.it_is_recommended_to_add_more_than_one_passkey()}</Alert.Description>
<div>
<Alert.Title class="font-semibold">{m.single_passkey_configured()}</Alert.Title>
<Alert.Description class="text-sm">
{m.it_is_recommended_to_add_more_than_one_passkey()}
</Alert.Description>
</div>
</Alert.Root>
{/if}
<!-- Account details card -->
<fieldset
disabled={!$appConfigStore.allowOwnAccountEdit ||
(!!account.ldapId && $appConfigStore.ldapEnabled)}
>
<Card.Root>
<Card.Header>
<Card.Title>{m.account_details()}</Card.Title>
<Card.Title>
<UserCog class="text-primary/80 h-5 w-5" />
{m.account_details()}
</Card.Title>
</Card.Header>
<Card.Content>
<AccountForm {account} callback={updateAccount} />
<AccountForm
{account}
userId={account.id}
callback={updateAccount}
isLdapUser={!!account.ldapId}
/>
</Card.Content>
</Card.Root>
</fieldset>
<Card.Root>
<Card.Content class="pt-6">
<ProfilePictureSettings
userId={account.id}
isLdapUser={!!account.ldapId}
updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>{m.passkeys()}</Card.Title>
<Card.Description class="mt-1">
{m.manage_your_passkeys_that_you_can_use_to_authenticate_yourself()}
</Card.Description>
<!-- Passkey management card -->
<div>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>
<KeyRound class="text-primary/80 h-5 w-5" />
{m.passkeys()}
</Card.Title>
<Card.Description>
{m.manage_your_passkeys_that_you_can_use_to_authenticate_yourself()}
</Card.Description>
</div>
<Button variant="outline" class="ml-3" on:click={createPasskey}>
{m.add_passkey()}
</Button>
</div>
<Button size="sm" class="ml-3" on:click={createPasskey}>{m.add_passkey()}</Button>
</div>
</Card.Header>
{#if passkeys.length != 0}
<Card.Content>
<PasskeyList bind:passkeys />
</Card.Content>
{/if}
</Card.Root>
</Card.Header>
{#if passkeys.length != 0}
<Card.Content>
<PasskeyList bind:passkeys />
</Card.Content>
{/if}
</Card.Root>
</div>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>{m.login_code()}</Card.Title>
<Card.Description class="mt-1">
{m.create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey()}
</Card.Description>
<!-- Login code card -->
<div>
<Card.Root>
<Card.Header>
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
<div>
<Card.Title>
<RectangleEllipsis class="text-primary/80 h-5 w-5" />
{m.login_code()}
</Card.Title>
<Card.Description>
{m.create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey()}
</Card.Description>
</div>
<Button variant="outline" on:click={() => (showLoginCodeModal = true)}>
{m.create()}
</Button>
</div>
<Button size="sm" class="ml-auto" on:click={() => (showLoginCodeModal = true)}
>{m.create()}</Button
>
</div>
</Card.Header>
</Card.Root>
</Card.Header>
</Card.Root>
</div>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>{m.language()}</Card.Title>
<Card.Description class="mt-1">
{m.select_the_language_you_want_to_use()}
</Card.Description>
<!-- Language selection card -->
<div>
<Card.Root>
<Card.Header>
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
<div>
<Card.Title>
<Languages class="text-primary/80 h-5 w-5" />
{m.language()}
</Card.Title>
<Card.Description>
{m.select_the_language_you_want_to_use()}
</Card.Description>
</div>
<LocalePicker />
</div>
<LocalePicker />
</div>
</Card.Header>
</Card.Root>
</Card.Header>
</Card.Root>
</div>
<RenamePasskeyModal
bind:passkey={passkeyToRename}

View File

@@ -1,21 +1,32 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { createForm } from '$lib/utils/form-util';
import { BookUser } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { z } from 'zod';
let {
callback,
account
account,
userId,
isLdapUser = false
}: {
account: UserCreate;
userId: string;
callback: (user: UserCreate) => Promise<boolean>;
isLdapUser?: boolean;
} = $props();
let isLoading = $state(false);
const userService = new UserService();
const formSchema = z.object({
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
@@ -23,44 +34,70 @@
.string()
.min(2)
.max(30)
.regex(
/^[a-z0-9_@.-]+$/,
m.username_can_only_contain()
),
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
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;
await callback(data);
// Reset form if user was successfully created
isLoading = false;
}
async function updateProfilePicture(image: File) {
await userService
.updateProfilePicture(userId, image)
.then(() => toast.success(m.profile_picture_updated_successfully()))
.catch(axiosErrorToast);
}
async function resetProfilePicture() {
await userService
.resetProfilePicture(userId)
.then(() => toast.success(m.profile_picture_has_been_reset()))
.catch(axiosErrorToast);
}
</script>
<form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
<form onsubmit={onSubmit} class="space-y-6">
<!-- Profile Picture Section -->
<ProfilePictureSettings
{userId}
{isLdapUser}
updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/>
<!-- Divider -->
<hr class="border-border" />
<!-- User Information -->
<div>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
</div>
<div class="w-full">
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
</div>
</div>
<div class="w-full">
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label={m.email()} bind:input={$inputs.email} />
</div>
<div class="w-full">
<FormInput label={m.username()} bind:input={$inputs.username} />
</div>
</div>
</div>
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label={m.email()} bind:input={$inputs.email} />
</div>
<div class="w-full">
<FormInput label={m.username()} bind:input={$inputs.username} />
</div>
</div>
<div class="mt-5 flex justify-end">
<div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>

View File

@@ -1,14 +1,13 @@
<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 GlassRowItem from '$lib/components/glass-row-item.svelte';
import { m } from '$lib/paraglide/messages';
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 { LucideKeyRound } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
import { m } from '$lib/paraglide/messages';
let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props();
@@ -37,38 +36,18 @@
}
</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">
{m.added_on()} {new Date(passkey.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div>
<Button
on:click={() => (passkeyToRename = passkey)}
size="sm"
variant="outline"
aria-label={m.rename()}><LucidePencil class="h-3 w-3" /></Button
>
<Button
on:click={() => deletePasskey(passkey)}
size="sm"
variant="outline"
aria-label={m.delete()}><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</div>
</div>
{#if i !== passkeys.length - 1}
<Separator class="my-2" />
{/if}
<div class="space-y-3">
{#each passkeys as passkey}
<GlassRowItem
label={passkey.name}
description={m.added_on() + ' ' + new Date(passkey.createdAt).toLocaleDateString()}
icon={LucideKeyRound}
onRename={() => (passkeyToRename = passkey)}
onDelete={() => deletePasskey(passkey)}
/>
{/each}
</div>
<RenamePasskeyModal
bind:passkey={passkeyToRename}
callback={async () => (passkeys = await webauthnService.listCredentials())}

View File

@@ -1,15 +1,15 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import ApiKeyService from '$lib/services/api-key-service';
import type { ApiKeyCreate, ApiKeyResponse } from '$lib/types/api-key.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte';
import { LucideMinus, ShieldEllipsis, ShieldPlus } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import ApiKeyDialog from './api-key-dialog.svelte';
import ApiKeyForm from './api-key-form.svelte';
import ApiKeyList from './api-key-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let apiKeys = $state(data.apiKeys);
@@ -39,38 +39,48 @@
<title>{m.api_keys()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>{m.create_api_key()}</Card.Title>
<Card.Description>{m.add_a_new_api_key_for_programmatic_access()}</Card.Description>
<div>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>
<ShieldPlus class="text-primary/80 h-5 w-5" />
{m.create_api_key()}
</Card.Title>
<Card.Description>{m.add_a_new_api_key_for_programmatic_access()}</Card.Description>
</div>
{#if !expandAddApiKey}
<Button on:click={() => (expandAddApiKey = true)}>{m.add_api_key()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddApiKey = false)}>
<LucideMinus class="h-5 w-5" />
</Button>
{/if}
</div>
{#if !expandAddApiKey}
<Button on:click={() => (expandAddApiKey = true)}>{m.add_api_key()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddApiKey = false)}>
<LucideMinus class="h-5 w-5" />
</Button>
{/if}
</div>
</Card.Header>
{#if expandAddApiKey}
<div transition:slide>
<Card.Content>
<ApiKeyForm callback={createApiKey} />
</Card.Content>
</div>
{/if}
</Card.Root>
</Card.Header>
{#if expandAddApiKey}
<div transition:slide>
<Card.Content>
<ApiKeyForm callback={createApiKey} />
</Card.Content>
</div>
{/if}
</Card.Root>
</div>
<Card.Root class="mt-6">
<Card.Header>
<Card.Title>{m.manage_api_keys()}</Card.Title>
</Card.Header>
<Card.Content>
<ApiKeyList {apiKeys} requestOptions={apiKeysRequestOptions} />
</Card.Content>
</Card.Root>
<div>
<Card.Root>
<Card.Header>
<Card.Title>
<ShieldEllipsis class="text-primary/80 h-5 w-5" />
{m.manage_api_keys()}
</Card.Title>
</Card.Header>
<Card.Content>
<ApiKeyList {apiKeys} requestOptions={apiKeysRequestOptions} />
</Card.Content>
</Card.Root>
</div>
<ApiKeyDialog bind:apiKeyResponse />

View File

@@ -1,15 +1,16 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideImage, Mail, SlidersHorizontal, UserSearch } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let appConfig = $state(data.appConfig);
@@ -56,26 +57,41 @@
<title>{m.application_configuration()}</title>
</svelte:head>
<CollapsibleCard id="application-configuration-general" title={m.general()} defaultExpanded>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<div>
<CollapsibleCard
id="application-configuration-general"
icon={SlidersHorizontal}
title={m.general()}
defaultExpanded
>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<CollapsibleCard
id="application-configuration-email"
title={m.email()}
description={m.enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location()}
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<div>
<CollapsibleCard
id="application-configuration-email"
icon={Mail}
title={m.email()}
description={m.enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location()}
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<CollapsibleCard
id="application-configuration-ldap"
title={m.ldap()}
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<div>
<CollapsibleCard
id="application-configuration-ldap"
icon={UserSearch}
title={m.ldap()}
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<CollapsibleCard id="application-configuration-images" title={m.images()}>
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>
<div>
<CollapsibleCard id="application-configuration-images" icon={LucideImage} title={m.images()}>
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>
</div>

View File

@@ -2,17 +2,17 @@
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte';
import { LucideMinus, ShieldCheck, ShieldPlus } 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 { m } from '$lib/paraglide/messages';
let { data } = $props();
let clients = $state(data.clients);
@@ -43,36 +43,50 @@
<title>{m.oidc_clients()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>{m.create_oidc_client()}</Card.Title>
<Card.Description>{m.add_a_new_oidc_client_to_appname({ appName: $appConfigStore.appName})}</Card.Description>
<div>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>
<ShieldPlus class="text-primary/80 h-5 w-5" />
{m.create_oidc_client()}
</Card.Title>
<Card.Description
>{m.add_a_new_oidc_client_to_appname({
appName: $appConfigStore.appName
})}</Card.Description
>
</div>
{#if !expandAddClient}
<Button on:click={() => (expandAddClient = true)}>{m.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>
{#if !expandAddClient}
<Button on:click={() => (expandAddClient = true)}>{m.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.Header>
{#if expandAddClient}
<div transition:slide>
<Card.Content>
<OIDCClientForm callback={createOIDCClient} />
</Card.Content>
</div>
{/if}
</Card.Root>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.manage_oidc_clients()}</Card.Title>
</Card.Header>
<Card.Content>
<OIDCClientList {clients} requestOptions={clientsRequestOptions} />
</Card.Content>
</Card.Root>
<div>
<Card.Root>
<Card.Header>
<Card.Title>
<ShieldCheck class="text-primary/80 h-5 w-5" />
{m.manage_oidc_clients()}
</Card.Title>
</Card.Header>
<Card.Content>
<OIDCClientList {clients} requestOptions={clientsRequestOptions} />
</Card.Content>
</Card.Root>
</div>

View File

@@ -26,7 +26,7 @@
async function deleteClient(client: OidcClient) {
openConfirmDialog({
title: m.delete_name({name: client.name}),
title: m.delete_name({ name: client.name }),
message: m.are_you_sure_you_want_to_delete_this_oidc_client(),
confirm: {
label: m.delete(),
@@ -58,12 +58,14 @@
{#snippet rows({ item })}
<Table.Cell class="w-8 font-medium">
{#if item.hasLogo}
<div class="h-8 w-8">
<img
class="m-auto max-h-full max-w-full object-contain"
src="/api/oidc/clients/{item.id}/logo"
alt={m.name_logo({name: item.name})}
/>
<div class="bg-secondary rounded-2xl p-3">
<div class="h-8 w-8">
<img
class="m-auto max-h-full max-w-full object-contain"
src="/api/oidc/clients/{item.id}/logo"
alt={m.name_logo({ name: item.name })}
/>
</div>
</div>
{/if}
</Table.Cell>
@@ -75,8 +77,11 @@
variant="outline"
aria-label={m.edit()}><LucidePencil class="h-3 w-3 " /></Button
>
<Button on:click={() => deleteClient(item)} size="sm" variant="outline" aria-label={m.delete()}
><LucideTrash class="h-3 w-3 text-red-500" /></Button
<Button
on:click={() => deleteClient(item)}
size="sm"
variant="outline"
aria-label={m.delete()}><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</Table.Cell>
{/snippet}

View File

@@ -2,16 +2,15 @@
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
import type { Paginated } from '$lib/types/pagination.type';
import type { UserGroupCreate, UserGroupWithUserCount } from '$lib/types/user-group.type';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte';
import { LucideMinus, UserCog, UserPlus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import UserGroupForm from './user-group-form.svelte';
import UserGroupList from './user-group-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let userGroups = $state(data.userGroups);
@@ -40,36 +39,47 @@
<title>{m.user_groups()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>{m.create_user_group()}</Card.Title>
<Card.Description>{m.create_a_new_group_that_can_be_assigned_to_users()}</Card.Description>
<div>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>
<UserPlus class="text-primary/80 h-5 w-5" />
{m.create_user_group()}
</Card.Title>
<Card.Description>{m.create_a_new_group_that_can_be_assigned_to_users()}</Card.Description
>
</div>
{#if !expandAddUserGroup}
<Button on:click={() => (expandAddUserGroup = true)}>{m.add_group()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUserGroup = false)}>
<LucideMinus class="h-5 w-5" />
</Button>
{/if}
</div>
{#if !expandAddUserGroup}
<Button on:click={() => (expandAddUserGroup = true)}>{m.add_group()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUserGroup = false)}>
<LucideMinus class="h-5 w-5" />
</Button>
{/if}
</div>
</Card.Header>
{#if expandAddUserGroup}
<div transition:slide>
<Card.Content>
<UserGroupForm callback={createUserGroup} />
</Card.Content>
</div>
{/if}
</Card.Root>
</Card.Header>
{#if expandAddUserGroup}
<div transition:slide>
<Card.Content>
<UserGroupForm callback={createUserGroup} />
</Card.Content>
</div>
{/if}
</Card.Root>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.manage_user_groups()}</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupList {userGroups} requestOptions={userGroupsRequestOptions} />
</Card.Content>
</Card.Root>
<div>
<Card.Root>
<Card.Header>
<Card.Title>
<UserCog class="text-primary/80 h-5 w-5" />
{m.manage_user_groups()}
</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupList {userGroups} requestOptions={userGroupsRequestOptions} />
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte';
import { LucideMinus, UserPen, UserPlus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import UserForm from './user-form.svelte';
import UserList from './user-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let users = $state(data.users);
@@ -39,36 +39,50 @@
<title>{m.users()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>{m.create_user()}</Card.Title>
<Card.Description>{m.add_a_new_user_to_appname({ appName: $appConfigStore.appName })}.</Card.Description>
<div>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>
<UserPlus class="text-primary/80 h-5 w-5" />
{m.create_user()}
</Card.Title>
<Card.Description
>{m.add_a_new_user_to_appname({
appName: $appConfigStore.appName
})}.</Card.Description
>
</div>
{#if !expandAddUser}
<Button on:click={() => (expandAddUser = true)}>{m.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>
{#if !expandAddUser}
<Button on:click={() => (expandAddUser = true)}>{m.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>
<UserForm callback={createUser} />
</Card.Content>
</div>
{/if}
</Card.Root>
</Card.Header>
{#if expandAddUser}
<div transition:slide>
<Card.Content>
<UserForm callback={createUser} />
</Card.Content>
</div>
{/if}
</Card.Root>
</div>
<Card.Root>
<Card.Header>
<Card.Title>{m.manage_users()}</Card.Title>
</Card.Header>
<Card.Content>
<UserList {users} requestOptions={usersRequestOptions} />
</Card.Content>
</Card.Root>
<div>
<Card.Root>
<Card.Header>
<Card.Title>
<UserPen class="text-primary/80 h-5 w-5" />
{m.manage_users()}
</Card.Title>
</Card.Header>
<Card.Content>
<UserList {users} requestOptions={usersRequestOptions} />
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { LogsIcon } from 'lucide-svelte';
import AuditLogList from './audit-log-list.svelte';
let { data } = $props();
let { auditLogs } = data;
let auditLogsRequestOptions = $state(data.auditLogsRequestOptions);
</script>
@@ -12,14 +12,17 @@
<title>{m.audit_log()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<Card.Title>{m.audit_log()}</Card.Title>
<Card.Description class="mt-1"
>{m.see_your_account_activities_from_the_last_3_months()}</Card.Description
>
</Card.Header>
<Card.Content>
<AuditLogList auditLogs={data.auditLogs} requestOptions={auditLogsRequestOptions} />
</Card.Content>
</Card.Root>
<div>
<Card.Root>
<Card.Header>
<Card.Title>
<LogsIcon class="text-primary/80 h-5 w-5" />
{m.audit_log()}
</Card.Title>
<Card.Description>{m.see_your_account_activities_from_the_last_3_months()}</Card.Description>
</Card.Header>
<Card.Content>
<AuditLogList auditLogs={data.auditLogs} requestOptions={auditLogsRequestOptions} />
</Card.Content>
</Card.Root>
</div>