feat: add ability to upload a profile picture (#244)

This commit is contained in:
Elias Schneider
2025-02-19 14:28:45 +01:00
committed by GitHub
parent dca9e7a11a
commit 652ee6ad5d
38 changed files with 500 additions and 73 deletions

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Checkbox } from './ui/checkbox';
import { Label } from './ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
let {
id,
@@ -31,7 +31,7 @@
{label}
</Label>
{#if description}
<p class="text-[0.8rem] text-muted-foreground">
<p class="text-muted-foreground text-[0.8rem]">
{description}
</p>
{/if}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import CustomClaimService from '$lib/services/custom-claim-service';

View File

@@ -2,7 +2,7 @@
import { cn } from '$lib/utils/style';
import type { HTMLInputAttributes } from 'svelte/elements';
import type { VariantProps } from 'tailwind-variants';
import type { buttonVariants } from './ui/button';
import type { buttonVariants } from '$lib/components/ui/button';
let {
id,

View File

@@ -3,7 +3,7 @@
import type { FormInput } from '$lib/utils/form-util';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { Input, type FormInputEvent } from './ui/input';
import { Input, type FormInputEvent } from '$lib/components/ui/input';
let {
input = $bindable(),

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { LucideLoader, LucideUpload } from 'lucide-svelte';
let {
userId,
isLdapUser = false,
callback
}: {
userId: string;
isLdapUser?: boolean;
callback: (image: File) => Promise<void>;
} = $props();
let isLoading = $state(false);
let imageDataURL = $state(`/api/users/${userId}/profile-picture.png`);
async function onImageChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] || null;
if (!file) return;
isLoading = true;
const reader = new FileReader();
reader.onload = (event) => {
imageDataURL = event.target?.result as string;
};
reader.readAsDataURL(file);
await callback(file).catch(() => {
imageDataURL = `/api/users/${userId}/profile-picture.png`;
});
isLoading = false;
}
</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">Profile Picture</h3>
{#if isLdapUser}
<p class="text-muted-foreground mt-1 text-sm">
The profile picture is managed by the LDAP server and cannot be changed here.
</p>
{:else}
<p class="text-muted-foreground mt-1 text-sm">
Click on the profile picture to upload a custom one from your files.
</p>
<p class="text-muted-foreground mt-1 text-sm">The image should be in PNG or JPEG format.</p>
{/if}
</div>
{#if isLdapUser}
<Avatar.Root class="h-24 w-24">
<Avatar.Image class="object-cover" src={imageDataURL} />
</Avatar.Root>
{:else}
<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>
</div>
</FileInput>
{/if}
</div>
</div>

View File

@@ -3,22 +3,10 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store';
import { createSHA256hash } from '$lib/utils/crypto-util';
import { LucideLogOut, LucideUser } from 'lucide-svelte';
const webauthnService = new WebAuthnService();
let initials = $derived(
($userStore!.firstName.charAt(0) + $userStore!.lastName?.charAt(0)).toUpperCase()
);
let gravatarURL: string | undefined = $state();
if ($userStore) {
createSHA256hash($userStore.email).then((email) => {
gravatarURL = `https://www.gravatar.com/avatar/${email}?d=404`;
});
}
async function logout() {
await webauthnService.logout();
window.location.reload();
@@ -28,8 +16,7 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger
><Avatar.Root class="h-9 w-9">
<Avatar.Image src={gravatarURL} />
<Avatar.Fallback>{initials}</Avatar.Fallback>
<Avatar.Image src="/api/users/me/profile-picture.png" />
</Avatar.Root></DropdownMenu.Trigger
>
<DropdownMenu.Content class="min-w-40" align="start">
@@ -39,7 +26,7 @@
{$userStore?.firstName}
{$userStore?.lastName}
</p>
<p class="text-xs leading-none text-muted-foreground">{$userStore?.email}</p>
<p class="text-muted-foreground text-xs leading-none">{$userStore?.email}</p>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />

View File

@@ -5,9 +5,11 @@
import Logo from '../logo.svelte';
import HeaderAvatar from './header-avatar.svelte';
const authUrls = ['/authorize', '/login', '/logout'];
let isAuthPage = $derived(!$page.error && authUrls.includes($page.url.pathname));
const authUrls = [/^\/authorize$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
let isAuthPage = $derived(
!$page.error && authUrls.some((pattern) => pattern.test($page.url.pathname))
);
</script>
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">

View File

@@ -11,7 +11,7 @@
<AvatarPrimitive.Root
{delayMs}
class={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
class={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full border', className)}
{...$$restProps}
>
<slot />

View File

@@ -39,6 +39,20 @@ export default class UserService extends APIService {
await this.api.delete(`/users/${id}`);
}
async updateProfilePicture(userId: string, image: File) {
const formData = new FormData();
formData.append('file', image!);
await this.api.put(`/users/${userId}/profile-picture`, formData);
}
async updateCurrentUsersProfilePicture(image: File) {
const formData = new FormData();
formData.append('file', image!);
await this.api.put('/users/me/profile-picture', formData);
}
async createOneTimeAccessToken(userId: string, expiresAt: Date) {
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
userId,

View File

@@ -31,6 +31,7 @@ export type AllAppConfig = AppConfig & {
ldapAttributeUserEmail: string;
ldapAttributeUserFirstName: string;
ldapAttributeUserLastName: string;
ldapAttributeUserProfilePicture: string;
ldapAttributeGroupMember: string;
ldapAttributeGroupUniqueIdentifier: string;
ldapAttributeGroupName: string;
@@ -46,5 +47,5 @@ export type AppConfigRawResponse = {
export type AppVersionInformation = {
isUpToDate: boolean | null;
newestVersion: string | null;
currentVersion: string
currentVersion: string;
};

View File

@@ -33,41 +33,37 @@
</script>
<section>
<div class="flex min-h-[calc(100vh-64px)] w-full flex-col justify-between bg-muted/40">
<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"
>
<div>
<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">Settings</h1>
</div>
<div
class="mx-auto grid 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}
{#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"
>
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"
>
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>
</main>
<div class="flex flex-col items-center">
<p class="py-3 text-xs text-muted-foreground">
<p class="text-muted-foreground py-3 text-xs">
Powered by <a
class="text-foreground"
href="https://github.com/pocket-id/pocket-id"

View File

@@ -13,6 +13,7 @@
import { toast } from 'svelte-sonner';
import AccountForm from './account-form.svelte';
import PasskeyList from './passkey-list.svelte';
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
let { data } = $props();
@@ -36,6 +37,13 @@
return success;
}
async function updateProfilePicture(image: File) {
await userService
.updateCurrentUsersProfilePicture(image)
.then(() => toast.success('Profile picture updated successfully'))
.catch(axiosErrorToast);
}
async function createPasskey() {
try {
const opts = await webauthnService.getRegistrationOptions();
@@ -86,6 +94,12 @@
</Card.Root>
</fieldset>
<Card.Root>
<Card.Content class="pt-6">
<ProfilePictureSettings userId="me" isLdapUser={!!account.ldapId} callback={updateProfilePicture} />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import FormInput from '$lib/components/form/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';

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import FileInput from '$lib/components/file-input.svelte';
import FileInput from '$lib/components/form/file-input.svelte';
import { Label } from '$lib/components/ui/label';
import { cn } from '$lib/utils/style';
import type { HTMLAttributes } from 'svelte/elements';

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import FormInput from '$lib/components/form-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte';
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte';
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration';
@@ -38,6 +38,7 @@
ldapAttributeUserEmail: appConfig.ldapAttributeUserEmail,
ldapAttributeUserFirstName: appConfig.ldapAttributeUserFirstName,
ldapAttributeUserLastName: appConfig.ldapAttributeUserLastName,
ldapAttributeUserProfilePicture: appConfig.ldapAttributeUserProfilePicture,
ldapAttributeGroupMember: appConfig.ldapAttributeGroupMember,
ldapAttributeGroupUniqueIdentifier: appConfig.ldapAttributeGroupUniqueIdentifier,
ldapAttributeGroupName: appConfig.ldapAttributeGroupName,
@@ -57,6 +58,7 @@
ldapAttributeUserEmail: z.string().min(1),
ldapAttributeUserFirstName: z.string().min(1),
ldapAttributeUserLastName: z.string().min(1),
ldapAttributeUserProfilePicture: z.string(),
ldapAttributeGroupMember: z.string(),
ldapAttributeGroupUniqueIdentifier: z.string().min(1),
ldapAttributeGroupName: z.string().min(1),
@@ -166,6 +168,12 @@
placeholder="sn"
bind:input={$inputs.ldapAttributeUserLastName}
/>
<FormInput
label="User Profile Picture Attribute"
description="The value of this attribute can either be a URL, a binary or a base64 encoded image."
placeholder="jpegPhoto"
bind:input={$inputs.ldapAttributeUserProfilePicture}
/>
<FormInput
label="Group Members Attribute"
description="The attribute to use for querying members of a group."

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { LucideMinus, LucidePlus } from 'lucide-svelte';

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FileInput from '$lib/components/file-input.svelte';
import FormInput from '$lib/components/form-input.svelte';
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FileInput from '$lib/components/form/file-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
import type {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/custom-claims-input.svelte';
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type';

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
@@ -9,7 +11,6 @@
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import CustomClaimsInput from '../../../../../lib/components/custom-claims-input.svelte';
import UserForm from '../user-form.svelte';
let { data } = $props();
@@ -39,6 +40,13 @@
axiosErrorToast(e);
});
}
async function updateProfilePicture(image: File) {
await userService
.updateProfilePicture(user.id, image)
.then(() => toast.success('Profile picture updated successfully'))
.catch(axiosErrorToast);
}
</script>
<svelte:head>
@@ -62,6 +70,16 @@
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Content class="pt-6">
<ProfilePictureSettings
userId={user.id}
isLdapUser={!!user.ldapId}
callback={updateProfilePicture}
/>
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-custom-claims"
title="Custom Claims"

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte';
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { User, UserCreate } from '$lib/types/user.type';