mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-13 09:13:23 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b2d981b7 | ||
|
|
8f146188d5 | ||
|
|
a0f93bda49 | ||
|
|
0423d354f5 |
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -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: |
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user