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

@@ -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())}