Compare commits

..

4 Commits

Author SHA1 Message Date
Elias Schneider
e9b2d981b7 release: 0.41.0 2025-03-18 21:04:53 +01:00
Kyle Mendell
8f146188d5 feat(profile-picture): allow reset of profile picture (#355)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-18 19:59:31 +00:00
Viktor Szépe
a0f93bda49 chor: correct misspellings (#352) 2025-03-18 12:54:39 +01:00
Savely Krasovsky
0423d354f5 fix: own avatar not loading (#351) 2025-03-18 12:02:59 +01:00
14 changed files with 176 additions and 39 deletions

View File

@@ -49,7 +49,7 @@ body:
required: false required: false
attributes: attributes:
label: "Log Output" label: "Log Output"
description: "Output of log files when the issue occured to help us diagnose the issue." description: "Output of log files when the issue occurred to help us diagnose the issue."
- type: markdown - type: markdown
attributes: attributes:
value: | value: |

View File

@@ -1 +1 @@
0.40.1 0.41.0

View File

@@ -1,3 +1,15 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.1...v) (2025-03-18)
### Features
* **profile-picture:** allow reset of profile picture ([#355](https://github.com/pocket-id/pocket-id/issues/355)) ([8f14618](https://github.com/pocket-id/pocket-id/commit/8f146188d57b5c08a4c6204674c15379232280d8))
### Bug Fixes
* own avatar not loading ([#351](https://github.com/pocket-id/pocket-id/issues/351)) ([0423d35](https://github.com/pocket-id/pocket-id/commit/0423d354f533d2ff4fd431859af3eea7d4d7044f))
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.0...v) (2025-03-16) ## [](https://github.com/pocket-id/pocket-id/compare/v0.40.0...v) (2025-03-16)

View File

@@ -47,6 +47,9 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler) group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler) group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
} }
type UserController struct { type UserController struct {
@@ -480,3 +483,40 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
} }
// resetUserProfilePictureHandler godoc
// @Summary Reset user profile picture
// @Description Reset a specific user's profile picture to the default
// @Tags Users
// @Produce json
// @Param id path string true "User ID"
// @Success 204 "No Content"
// @Router /users/{id}/profile-picture [delete]
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// resetCurrentUserProfilePictureHandler godoc
// @Summary Reset current user's profile picture
// @Description Reset the currently authenticated user's profile picture to the default
// @Tags Users
// @Produce json
// @Success 204 "No Content"
// @Router /users/me/profile-picture [delete]
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -365,3 +365,27 @@ func (s *UserService) checkDuplicatedFields(user model.User) error {
return nil return nil
} }
// ResetProfilePicture deletes a user's custom profile picture
func (s *UserService) ResetProfilePicture(userID string) error {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return &common.InvalidUUIDError{}
}
// Build path to profile picture
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
// Check if file exists and delete it
if _, err := os.Stat(profilePicturePath); err == nil {
if err := os.Remove(profilePicturePath); err != nil {
return fmt.Errorf("failed to delete profile picture: %w", err)
}
} else if !os.IsNotExist(err) {
// If any error other than "file not exists"
return fmt.Errorf("failed to check if profile picture exists: %w", err)
}
// It's okay if the file doesn't exist - just means there's no custom picture to delete
return nil
}

View File

@@ -27,7 +27,7 @@ func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V])
return templateMap[template.Path] return templateMap[template.Path]
} }
type clonable[V pareseable[V]] interface { type cloneable[V pareseable[V]] interface {
Clone() (V, error) Clone() (V, error)
} }
@@ -35,7 +35,7 @@ type pareseable[V any] interface {
ParseFS(fs.FS, ...string) (V, error) ParseFS(fs.FS, ...string) (V, error)
} }
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate clonable[V], suffix string) (V, error) { func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate cloneable[V], suffix string) (V, error) {
tmpl, err := rootTemplate.Clone() tmpl, err := rootTemplate.Clone()
if err != nil { if err != nil {
return *new(V), fmt.Errorf("clone root template: %w", err) return *new(V), fmt.Errorf("clone root template: %w", err)

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.40.1", "version": "0.41.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,20 +1,23 @@
<script lang="ts"> <script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte'; import FileInput from '$lib/components/form/file-input.svelte';
import * as Avatar from '$lib/components/ui/avatar'; import * as Avatar from '$lib/components/ui/avatar';
import { LucideLoader, LucideUpload } from 'lucide-svelte'; import Button from '$lib/components/ui/button/button.svelte';
import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte';
import { openConfirmDialog } from '../confirm-dialog';
let { let {
userId, userId,
isLdapUser = false, isLdapUser = false,
callback resetCallback,
updateCallback
}: { }: {
userId: string; userId: string;
isLdapUser?: boolean; isLdapUser?: boolean;
callback: (image: File) => Promise<void>; resetCallback: () => Promise<void>;
updateCallback: (image: File) => Promise<void>;
} = $props(); } = $props();
let isLoading = $state(false); let isLoading = $state(false);
let imageDataURL = $state(`/api/users/${userId}/profile-picture.png`); let imageDataURL = $state(`/api/users/${userId}/profile-picture.png`);
async function onImageChange(e: Event) { async function onImageChange(e: Event) {
@@ -29,11 +32,27 @@
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
await callback(file).catch(() => { await updateCallback(file).catch(() => {
imageDataURL = `/api/users/${userId}/profile-picture.png`; imageDataURL = `/api/users/${userId}/profile-picture.png}`;
}); });
isLoading = false; isLoading = false;
} }
function onReset() {
openConfirmDialog({
title: 'Reset profile picture?',
message:
'This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?',
confirm: {
label: 'Reset',
action: async () => {
isLoading = true;
await resetCallback().catch();
isLoading = false;
}
}
});
}
</script> </script>
<div class="flex gap-5"> <div class="flex gap-5">
@@ -50,34 +69,48 @@
</p> </p>
<p class="text-muted-foreground mt-1 text-sm">The image should be in PNG or JPEG format.</p> <p class="text-muted-foreground mt-1 text-sm">The image should be in PNG or JPEG format.</p>
{/if} {/if}
<Button
variant="outline"
size="sm"
class="mt-5"
on:click={onReset}
disabled={isLoading || isLdapUser}
>
<LucideRefreshCw class="mr-2 h-4 w-4" />
Reset to default
</Button>
</div> </div>
{#if isLdapUser} {#if isLdapUser}
<Avatar.Root class="h-24 w-24"> <Avatar.Root class="h-24 w-24">
<Avatar.Image class="object-cover" src={imageDataURL} /> <Avatar.Image class="object-cover" src={imageDataURL} />
</Avatar.Root> </Avatar.Root>
{:else} {:else}
<FileInput <div class="flex flex-col items-center gap-2">
id="profile-picture-input" <FileInput
variant="secondary" id="profile-picture-input"
accept="image/png, image/jpeg" variant="secondary"
onchange={onImageChange} 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"> <div class="group relative h-28 w-28 rounded-full">
<Avatar.Image <Avatar.Root class="h-full w-full transition-opacity duration-200">
class="object-cover group-hover:opacity-10 {isLoading ? 'opacity-10' : ''}" <Avatar.Image
src={imageDataURL} class="object-cover group-hover:opacity-10 {isLoading ? 'opacity-10' : ''}"
/> src={imageDataURL}
</Avatar.Root> />
<div class="absolute inset-0 flex items-center justify-center"> </Avatar.Root>
{#if isLoading} <div class="absolute inset-0 flex items-center justify-center">
<LucideLoader class="h-5 w-5 animate-spin" /> {#if isLoading}
{:else} <LucideLoader class="h-5 w-5 animate-spin" />
<LucideUpload class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100" /> {:else}
{/if} <LucideUpload
class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/if}
</div>
</div> </div>
</div> </FileInput>
</FileInput> </div>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -16,7 +16,7 @@
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
><Avatar.Root class="h-9 w-9"> ><Avatar.Root class="h-9 w-9">
<Avatar.Image src="/api/users/me/profile-picture.png" /> <Avatar.Image src="/api/users/{$userStore?.id}/profile-picture.png" />
</Avatar.Root></DropdownMenu.Trigger </Avatar.Root></DropdownMenu.Trigger
> >
<DropdownMenu.Content class="min-w-40" align="start"> <DropdownMenu.Content class="min-w-40" align="start">

View File

@@ -59,6 +59,14 @@ export default class UserService extends APIService {
await this.api.put('/users/me/profile-picture', formData); await this.api.put('/users/me/profile-picture', formData);
} }
async resetCurrentUserProfilePicture() {
await this.api.delete(`/users/me/profile-picture`);
}
async resetProfilePicture(userId: string) {
await this.api.delete(`/users/${userId}/profile-picture`);
}
async createOneTimeAccessToken(expiresAt: Date, userId: string) { async createOneTimeAccessToken(expiresAt: Date, userId: string) {
const res = await this.api.post(`/users/${userId}/one-time-access-token`, { const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
userId, userId,

View File

@@ -31,7 +31,7 @@
{#if !appConfig} {#if !appConfig}
<Error <Error
message="A critical error occured. Please contact your administrator." message="A critical error occurred. Please contact your administrator."
showButton={false} showButton={false}
/> />
{:else} {:else}

View File

@@ -21,7 +21,7 @@
await userService await userService
.requestOneTimeAccessEmail(email, data.redirect) .requestOneTimeAccessEmail(email, data.redirect)
.then(() => (success = true)) .then(() => (success = true))
.catch((e) => (error = e.response?.data.error || 'An unknown error occured')); .catch((e) => (error = e.response?.data.error || 'An unknown error occurred'));
isLoading = false; isLoading = false;
} }

View File

@@ -26,6 +26,15 @@
const userService = new UserService(); const userService = new UserService();
const webauthnService = new WebAuthnService(); 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) { async function updateAccount(user: UserCreate) {
let success = true; let success = true;
await userService await userService
@@ -42,7 +51,9 @@
async function updateProfilePicture(image: File) { async function updateProfilePicture(image: File) {
await userService await userService
.updateCurrentUsersProfilePicture(image) .updateCurrentUsersProfilePicture(image)
.then(() => toast.success('Profile picture updated successfully')) .then(() =>
toast.success('Profile picture updated successfully. It may take a few minutes to update.')
)
.catch(axiosErrorToast); .catch(axiosErrorToast);
} }
@@ -101,7 +112,8 @@
<ProfilePictureSettings <ProfilePictureSettings
userId={account.id} userId={account.id}
isLdapUser={!!account.ldapId} isLdapUser={!!account.ldapId}
callback={updateProfilePicture} updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/> />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -58,7 +58,14 @@
async function updateProfilePicture(image: File) { async function updateProfilePicture(image: File) {
await userService await userService
.updateProfilePicture(user.id, image) .updateProfilePicture(user.id, image)
.then(() => toast.success('Profile picture updated successfully')) .then(() => toast.success('Profile picture updated successfully. It may take a few minutes to update.'))
.catch(axiosErrorToast);
}
async function resetProfilePicture() {
await userService
.resetProfilePicture(user.id)
.then(() => toast.success('Profile picture has been reset. It may take a few minutes to update.'))
.catch(axiosErrorToast); .catch(axiosErrorToast);
} }
</script> </script>
@@ -89,7 +96,8 @@
<ProfilePictureSettings <ProfilePictureSettings
userId={user.id} userId={user.id}
isLdapUser={!!user.ldapId} isLdapUser={!!user.ldapId}
callback={updateProfilePicture} updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/> />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>