mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-15 12:15:11 +00:00
feat: add ability to set default profile picture (#1061)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
@@ -62,7 +62,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
|||||||
}
|
}
|
||||||
|
|
||||||
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
|
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
|
||||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService)
|
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService)
|
||||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
|
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
|
||||||
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||||
|
|
||||||
|
|||||||
@@ -388,3 +388,13 @@ func (e *UserEmailNotSetError) Error() string {
|
|||||||
func (e *UserEmailNotSetError) HttpStatusCode() int {
|
func (e *UserEmailNotSetError) HttpStatusCode() int {
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ImageNotFoundError struct{}
|
||||||
|
|
||||||
|
func (e *ImageNotFoundError) Error() string {
|
||||||
|
return "Image not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ImageNotFoundError) HttpStatusCode() int {
|
||||||
|
return http.StatusNotFound
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ func NewAppImagesController(
|
|||||||
group.GET("/application-images/logo", controller.getLogoHandler)
|
group.GET("/application-images/logo", controller.getLogoHandler)
|
||||||
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
||||||
group.GET("/application-images/favicon", controller.getFaviconHandler)
|
group.GET("/application-images/favicon", controller.getFaviconHandler)
|
||||||
|
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
|
||||||
|
|
||||||
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
|
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
|
||||||
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
||||||
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||||
|
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
||||||
|
|
||||||
|
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppImagesController struct {
|
type AppImagesController struct {
|
||||||
@@ -78,6 +82,18 @@ func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
|
|||||||
c.getImage(ctx, "favicon")
|
c.getImage(ctx, "favicon")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDefaultProfilePicture godoc
|
||||||
|
// @Summary Get default profile picture image
|
||||||
|
// @Description Get the default profile picture image for the application
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Produce image/png
|
||||||
|
// @Produce image/jpeg
|
||||||
|
// @Success 200 {file} binary "Default profile picture image"
|
||||||
|
// @Router /api/application-images/default-profile-picture [get]
|
||||||
|
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
|
||||||
|
c.getImage(ctx, "default-profile-picture")
|
||||||
|
}
|
||||||
|
|
||||||
// updateLogoHandler godoc
|
// updateLogoHandler godoc
|
||||||
// @Summary Update logo
|
// @Summary Update logo
|
||||||
// @Description Update the application logo
|
// @Description Update the application logo
|
||||||
@@ -171,3 +187,41 @@ func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
|
|||||||
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
|
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
|
||||||
ctx.File(imagePath)
|
ctx.File(imagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateDefaultProfilePicture godoc
|
||||||
|
// @Summary Update default profile picture image
|
||||||
|
// @Description Update the default profile picture image
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param file formData file true "Profile picture image file"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/default-profile-picture [put]
|
||||||
|
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.appImagesService.UpdateImage(file, "default-profile-picture"); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteDefaultProfilePicture godoc
|
||||||
|
// @Summary Delete default profile picture image
|
||||||
|
// @Description Delete the default profile picture image
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/default-profile-picture [delete]
|
||||||
|
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) {
|
||||||
|
if err := c.appImagesService.DeleteImage("default-profile-picture"); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName str
|
|||||||
|
|
||||||
currentExt, ok := s.extensions[imageName]
|
currentExt, ok := s.extensions[imageName]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("unknown application image '%s'", imageName)
|
s.extensions[imageName] = fileType
|
||||||
}
|
}
|
||||||
|
|
||||||
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, fileType))
|
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, fileType))
|
||||||
@@ -69,13 +69,39 @@ func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AppImagesService) DeleteImage(imageName string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
ext, ok := s.extensions[imageName]
|
||||||
|
if !ok || ext == "" {
|
||||||
|
return &common.ImageNotFoundError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", imageName+"."+ext)
|
||||||
|
if err := os.Remove(imagePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(s.extensions, imageName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppImagesService) IsDefaultProfilePictureSet() bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
_, ok := s.extensions["default-profile-picture"]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AppImagesService) getExtension(name string) (string, error) {
|
func (s *AppImagesService) getExtension(name string) (string, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
ext, ok := s.extensions[name]
|
ext, ok := s.extensions[name]
|
||||||
if !ok || ext == "" {
|
if !ok || ext == "" {
|
||||||
return "", fmt.Errorf("unknown application image '%s'", name)
|
return "", &common.ImageNotFoundError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.ToLower(ext), nil
|
return strings.ToLower(ext), nil
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,9 +34,10 @@ type UserService struct {
|
|||||||
emailService *EmailService
|
emailService *EmailService
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
customClaimService *CustomClaimService
|
customClaimService *CustomClaimService
|
||||||
|
appImagesService *AppImagesService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService) *UserService {
|
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService) *UserService {
|
||||||
return &UserService{
|
return &UserService{
|
||||||
db: db,
|
db: db,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
@@ -43,6 +45,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
|
|||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
customClaimService: customClaimService,
|
customClaimService: customClaimService,
|
||||||
|
appImagesService: appImagesService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,39 +90,42 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
|
|||||||
return nil, 0, &common.InvalidUUIDError{}
|
return nil, 0, &common.InvalidUUIDError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// First check for a custom uploaded profile picture (userID.png)
|
|
||||||
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
|
|
||||||
file, err := os.Open(profilePicturePath)
|
|
||||||
if err == nil {
|
|
||||||
// Get the file size
|
|
||||||
fileInfo, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
return file, fileInfo.Size(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no custom picture exists, get the user's data for creating initials
|
|
||||||
user, err := s.GetUser(ctx, userID)
|
user, err := s.GetUser(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have a cached default picture for these initials
|
profilePicturePath := filepath.Join(common.EnvConfig.UploadPath, "profile-pictures", userID+".png")
|
||||||
defaultProfilePicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults/"
|
|
||||||
defaultPicturePath := defaultProfilePicturesDir + user.Initials() + ".png"
|
// Try custom profile picture
|
||||||
file, err = os.Open(defaultPicturePath)
|
if file, size, err := utils.OpenFileWithSize(profilePicturePath); err == nil {
|
||||||
if err == nil {
|
return file, size, nil
|
||||||
fileInfo, err := file.Stat()
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
if err != nil {
|
return nil, 0, err
|
||||||
file.Close()
|
}
|
||||||
|
|
||||||
|
// Try default global profile picture
|
||||||
|
if s.appImagesService.IsDefaultProfilePictureSet() {
|
||||||
|
path, _, err := s.appImagesService.GetImage("default-profile-picture")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if file, size, err := utils.OpenFileWithSize(path); err == nil {
|
||||||
|
return file, size, nil
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
return file, fileInfo.Size(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no cached default picture exists, create one and save it for future use
|
// Try cached default for initials
|
||||||
|
defaultProfilePicturesDir := filepath.Join(common.EnvConfig.UploadPath, "profile-pictures", "defaults")
|
||||||
|
defaultPicturePath := filepath.Join(defaultProfilePicturesDir, user.Initials()+".png")
|
||||||
|
|
||||||
|
if file, size, err := utils.OpenFileWithSize(defaultPicturePath); err == nil {
|
||||||
|
return file, size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and return generated default with initials
|
||||||
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials())
|
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -128,19 +134,16 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
|
|||||||
// Save the default picture for future use (in a goroutine to avoid blocking)
|
// Save the default picture for future use (in a goroutine to avoid blocking)
|
||||||
defaultPictureBytes := defaultPicture.Bytes()
|
defaultPictureBytes := defaultPicture.Bytes()
|
||||||
go func() {
|
go func() {
|
||||||
// Ensure the directory exists
|
if err := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm); err != nil {
|
||||||
errInternal := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm)
|
slog.Error("Failed to create directory for default profile picture", slog.Any("error", err))
|
||||||
if errInternal != nil {
|
|
||||||
slog.Error("Failed to create directory for default profile picture", slog.Any("error", errInternal))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
errInternal = utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath)
|
if err := utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath); err != nil {
|
||||||
if errInternal != nil {
|
slog.Error("Failed to cache default profile picture", slog.String("initials", user.Initials()), slog.Any("error", err))
|
||||||
slog.Error("Failed to cache default profile picture for initials", slog.String("initials", user.Initials()), slog.Any("error", errInternal))
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(defaultPicture.Len()), nil
|
return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(len(defaultPictureBytes)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model.UserGroup, error) {
|
func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model.UserGroup, error) {
|
||||||
|
|||||||
@@ -239,3 +239,17 @@ func IsWritableDir(dir string) (bool, error) {
|
|||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenFileWithSize opens a file and returns its size
|
||||||
|
func OpenFileWithSize(path string) (io.ReadCloser, int64, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return f, info.Size(), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
|
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
|
||||||
"last_used": "Last Used",
|
"last_used": "Last Used",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"images_updated_successfully": "Images updated successfully",
|
"images_updated_successfully": "Images updated successfully. It may take a few minutes to update.",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
@@ -461,5 +461,6 @@
|
|||||||
"locale": "Locale",
|
"locale": "Locale",
|
||||||
"ldap_id": "LDAP ID",
|
"ldap_id": "LDAP ID",
|
||||||
"reauthentication": "Re-authentication",
|
"reauthentication": "Re-authentication",
|
||||||
"clear_filters" : "Clear Filters"
|
"clear_filters": "Clear Filters",
|
||||||
|
"default_profile_picture": "Default Profile Picture"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,9 +55,13 @@
|
|||||||
disabled,
|
disabled,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
autofocus = false,
|
autofocus = false,
|
||||||
|
onclick,
|
||||||
|
usePromiseLoading = false,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: ButtonProps = $props();
|
}: ButtonProps & {
|
||||||
|
usePromiseLoading?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Using autofocus can be bad for a11y, but in the case of Pocket ID is only used responsibly in places where there are not many choices, and on buttons only where there's descriptive text
|
// Using autofocus can be bad for a11y, but in the case of Pocket ID is only used responsibly in places where there are not many choices, and on buttons only where there's descriptive text
|
||||||
@@ -66,6 +70,19 @@
|
|||||||
setTimeout(() => ref?.focus(), 100);
|
setTimeout(() => ref?.focus(), 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function handleOnClick(event: any) {
|
||||||
|
if (usePromiseLoading && onclick) {
|
||||||
|
isLoading = true;
|
||||||
|
try {
|
||||||
|
await onclick(event);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onclick?.(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
{#if href}
|
||||||
@@ -91,6 +108,7 @@
|
|||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
{type}
|
{type}
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
|
onclick={handleOnClick}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
|
import userStore from '$lib/stores/user-store';
|
||||||
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
|
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
|
||||||
import { cachedApplicationLogo, cachedBackgroundImage } from '$lib/utils/cached-image-util';
|
import {
|
||||||
|
cachedApplicationLogo,
|
||||||
|
cachedBackgroundImage,
|
||||||
|
cachedDefaultProfilePicture,
|
||||||
|
cachedProfilePicture
|
||||||
|
} from '$lib/utils/cached-image-util';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
export default class AppConfigService extends APIService {
|
export default class AppConfigService extends APIService {
|
||||||
@@ -24,14 +31,14 @@ export default class AppConfigService extends APIService {
|
|||||||
|
|
||||||
updateFavicon = async (favicon: File) => {
|
updateFavicon = async (favicon: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', favicon!);
|
formData.append('file', favicon);
|
||||||
|
|
||||||
await this.api.put(`/application-images/favicon`, formData);
|
await this.api.put(`/application-images/favicon`, formData);
|
||||||
}
|
};
|
||||||
|
|
||||||
updateLogo = async (logo: File, light = true) => {
|
updateLogo = async (logo: File, light = true) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', logo!);
|
formData.append('file', logo);
|
||||||
|
|
||||||
await this.api.put(`/application-images/logo`, formData, {
|
await this.api.put(`/application-images/logo`, formData, {
|
||||||
params: { light }
|
params: { light }
|
||||||
@@ -39,6 +46,14 @@ export default class AppConfigService extends APIService {
|
|||||||
cachedApplicationLogo.bustCache(light);
|
cachedApplicationLogo.bustCache(light);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
updateDefaultProfilePicture = async (defaultProfilePicture: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', defaultProfilePicture);
|
||||||
|
|
||||||
|
await this.api.put(`/application-images/default-profile-picture`, formData);
|
||||||
|
cachedDefaultProfilePicture.bustCache();
|
||||||
|
};
|
||||||
|
|
||||||
updateBackgroundImage = async (backgroundImage: File) => {
|
updateBackgroundImage = async (backgroundImage: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', backgroundImage!);
|
formData.append('file', backgroundImage!);
|
||||||
@@ -47,6 +62,12 @@ export default class AppConfigService extends APIService {
|
|||||||
cachedBackgroundImage.bustCache();
|
cachedBackgroundImage.bustCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
deleteDefaultProfilePicture = async () => {
|
||||||
|
await this.api.delete('/application-images/default-profile-picture');
|
||||||
|
cachedDefaultProfilePicture.bustCache();
|
||||||
|
cachedProfilePicture.bustCache(get(userStore)!.id);
|
||||||
|
};
|
||||||
|
|
||||||
sendTestEmail = async () => {
|
sendTestEmail = async () => {
|
||||||
await this.api.post('/application-configuration/test-email');
|
await this.api.post('/application-configuration/test-email');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export const cachedApplicationLogo: CachableImage = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const cachedDefaultProfilePicture: CachableImage = {
|
||||||
|
getUrl: () =>
|
||||||
|
getCachedImageUrl(new URL('/api/application-images/default-profile-picture', window.location.origin)),
|
||||||
|
bustCache: () =>
|
||||||
|
bustImageCache(new URL('/api/application-images/default-profile-picture', window.location.origin))
|
||||||
|
};
|
||||||
|
|
||||||
export const cachedBackgroundImage: CachableImage = {
|
export const cachedBackgroundImage: CachableImage = {
|
||||||
getUrl: () =>
|
getUrl: () =>
|
||||||
getCachedImageUrl(new URL('/api/application-images/background', window.location.origin)),
|
getCachedImageUrl(new URL('/api/application-images/background', window.location.origin)),
|
||||||
|
|||||||
@@ -40,23 +40,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateImages(
|
async function updateImages(
|
||||||
logoLight: File | null,
|
logoLight: File | undefined,
|
||||||
logoDark: File | null,
|
logoDark: File | undefined,
|
||||||
backgroundImage: File | null,
|
defaultProfilePicture: File | null | undefined,
|
||||||
favicon: File | null
|
backgroundImage: File | undefined,
|
||||||
|
favicon: File | undefined
|
||||||
) {
|
) {
|
||||||
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
|
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
|
||||||
|
|
||||||
const lightLogoPromise = logoLight
|
const lightLogoPromise = logoLight
|
||||||
? appConfigService.updateLogo(logoLight, true)
|
? appConfigService.updateLogo(logoLight, true)
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
const darkLogoPromise = logoDark
|
const darkLogoPromise = logoDark
|
||||||
? appConfigService.updateLogo(logoDark, false)
|
? appConfigService.updateLogo(logoDark, false)
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
|
const defaultProfilePicturePromise =
|
||||||
|
defaultProfilePicture === null
|
||||||
|
? appConfigService.deleteDefaultProfilePicture()
|
||||||
|
: defaultProfilePicture
|
||||||
|
? appConfigService.updateDefaultProfilePicture(defaultProfilePicture)
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
const backgroundImagePromise = backgroundImage
|
const backgroundImagePromise = backgroundImage
|
||||||
? appConfigService.updateBackgroundImage(backgroundImage)
|
? appConfigService.updateBackgroundImage(backgroundImage)
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
await Promise.all([lightLogoPromise, darkLogoPromise, backgroundImagePromise, faviconPromise])
|
await Promise.all([
|
||||||
|
lightLogoPromise,
|
||||||
|
darkLogoPromise,
|
||||||
|
defaultProfilePicturePromise,
|
||||||
|
backgroundImagePromise,
|
||||||
|
faviconPromise
|
||||||
|
])
|
||||||
.then(() => toast.success(m.images_updated_successfully()))
|
.then(() => toast.success(m.images_updated_successfully()))
|
||||||
.catch(axiosErrorToast);
|
.catch(axiosErrorToast);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<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 { Button } from '$lib/components/ui/button';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { cn } from '$lib/utils/style';
|
import { cn } from '$lib/utils/style';
|
||||||
import { LucideUpload } from '@lucide/svelte';
|
import { LucideImageOff, LucideUpload, LucideX } from '@lucide/svelte';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -13,54 +14,98 @@
|
|||||||
imageURL,
|
imageURL,
|
||||||
accept = 'image/png, image/jpeg, image/svg+xml, image/gif, image/webp, image/avif, image/heic',
|
accept = 'image/png, image/jpeg, image/svg+xml, image/gif, image/webp, image/avif, image/heic',
|
||||||
forceColorScheme,
|
forceColorScheme,
|
||||||
|
isResetable = false,
|
||||||
|
isImageSet = $bindable(true),
|
||||||
...restProps
|
...restProps
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
id: string;
|
id: string;
|
||||||
imageClass: string;
|
imageClass: string;
|
||||||
label: string;
|
label: string;
|
||||||
image: File | null;
|
image: File | null | undefined;
|
||||||
imageURL: string;
|
imageURL: string;
|
||||||
forceColorScheme?: 'light' | 'dark';
|
forceColorScheme?: 'light' | 'dark';
|
||||||
accept?: string;
|
accept?: string;
|
||||||
|
isResetable?: boolean;
|
||||||
|
isImageSet?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let imageDataURL = $state(imageURL);
|
let imageDataURL = $state(imageURL);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (image) {
|
||||||
|
isImageSet = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function onImageChange(e: Event) {
|
function onImageChange(e: Event) {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0] || null;
|
const file = (e.target as HTMLInputElement).files?.[0] || undefined;
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
image = file;
|
image = file;
|
||||||
imageDataURL = URL.createObjectURL(file);
|
imageDataURL = URL.createObjectURL(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onReset() {
|
||||||
|
image = null;
|
||||||
|
imageDataURL = imageURL;
|
||||||
|
isImageSet = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col items-start md:flex-row md:items-center" {...restProps}>
|
<div class="flex flex-col items-start md:flex-row md:items-center" {...restProps}>
|
||||||
<Label class="w-52" for={id}>{label}</Label>
|
<Label class="w-52" for={id}>{label}</Label>
|
||||||
<FileInput {id} variant="secondary" {accept} onchange={onImageChange}>
|
<FileInput {id} variant="secondary" {accept} onchange={onImageChange}>
|
||||||
<div
|
<div
|
||||||
class={{
|
class={cn('group/image relative flex items-center rounded transition-colors', {
|
||||||
'group relative flex items-center rounded': true,
|
|
||||||
'bg-[#F5F5F5]': forceColorScheme === 'light',
|
'bg-[#F5F5F5]': forceColorScheme === 'light',
|
||||||
'bg-[#262626]': forceColorScheme === 'dark',
|
'bg-[#262626]': forceColorScheme === 'dark',
|
||||||
'bg-muted': !forceColorScheme
|
'bg-muted': !forceColorScheme
|
||||||
}}
|
})}
|
||||||
>
|
>
|
||||||
|
{#if !isImageSet}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex h-full w-full items-center justify-center p-3 transition-opacity duration-200',
|
||||||
|
'group-hover/image:opacity-10 group-has-[button:hover]/image:opacity-100',
|
||||||
|
imageClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LucideImageOff class="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<img
|
<img
|
||||||
class={cn(
|
class={cn(
|
||||||
'h-full w-full rounded object-cover p-3 transition-opacity duration-200 group-hover:opacity-10',
|
'h-full w-full rounded object-cover p-3 transition-opacity duration-200',
|
||||||
|
'group-hover/image:opacity-10 group-has-[button:hover]/image:opacity-100',
|
||||||
imageClass
|
imageClass
|
||||||
)}
|
)}
|
||||||
src={imageDataURL}
|
src={imageDataURL}
|
||||||
alt={label}
|
alt={label}
|
||||||
|
onerror={() => (isImageSet = false)}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
<LucideUpload
|
<LucideUpload
|
||||||
class={{
|
class={cn(
|
||||||
'absolute top-1/2 left-1/2 size-5 -translate-x-1/2 -translate-y-1/2 transform font-medium opacity-0 transition-opacity group-hover:opacity-100': true,
|
'absolute top-1/2 left-1/2 size-5 -translate-x-1/2 -translate-y-1/2 transform font-medium opacity-0 transition-opacity duration-200',
|
||||||
|
'group-hover/image:opacity-100 group-has-[button:hover]/image:opacity-0',
|
||||||
|
{
|
||||||
'text-black': forceColorScheme === 'light',
|
'text-black': forceColorScheme === 'light',
|
||||||
'text-white': forceColorScheme === 'dark'
|
'text-white': forceColorScheme === 'dark'
|
||||||
}}
|
}
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
{#if isResetable && isImageSet}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onReset();
|
||||||
|
}}
|
||||||
|
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md"
|
||||||
|
>
|
||||||
|
<LucideX class="size-3" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</FileInput>
|
</FileInput>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from '$lib/components/ui/button/button.svelte';
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import { cachedApplicationLogo, cachedBackgroundImage } from '$lib/utils/cached-image-util';
|
import {
|
||||||
|
cachedApplicationLogo,
|
||||||
|
cachedBackgroundImage,
|
||||||
|
cachedDefaultProfilePicture
|
||||||
|
} from '$lib/utils/cached-image-util';
|
||||||
import ApplicationImage from './application-image.svelte';
|
import ApplicationImage from './application-image.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
callback
|
callback
|
||||||
}: {
|
}: {
|
||||||
callback: (
|
callback: (
|
||||||
logoLight: File | null,
|
logoLight: File | undefined,
|
||||||
logoDark: File | null,
|
logoDark: File | undefined,
|
||||||
backgroundImage: File | null,
|
defaultProfilePicture: File | null | undefined,
|
||||||
favicon: File | null
|
backgroundImage: File | undefined,
|
||||||
|
favicon: File | undefined
|
||||||
) => void;
|
) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let logoLight = $state<File | null>(null);
|
let logoLight = $state<File | undefined>();
|
||||||
let logoDark = $state<File | null>(null);
|
let logoDark = $state<File | undefined>();
|
||||||
let backgroundImage = $state<File | null>(null);
|
let defaultProfilePicture = $state<File | null | undefined>();
|
||||||
let favicon = $state<File | null>(null);
|
let backgroundImage = $state<File | undefined>();
|
||||||
|
let favicon = $state<File | undefined>();
|
||||||
|
|
||||||
|
let defaultProfilePictureSet = $state(true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8">
|
<div class="flex flex-col gap-8">
|
||||||
@@ -46,6 +54,15 @@
|
|||||||
imageURL={cachedApplicationLogo.getUrl(false)}
|
imageURL={cachedApplicationLogo.getUrl(false)}
|
||||||
forceColorScheme="dark"
|
forceColorScheme="dark"
|
||||||
/>
|
/>
|
||||||
|
<ApplicationImage
|
||||||
|
id="default-profile-picture"
|
||||||
|
imageClass="size-24"
|
||||||
|
label={m.default_profile_picture()}
|
||||||
|
isResetable
|
||||||
|
bind:image={defaultProfilePicture}
|
||||||
|
imageURL={cachedDefaultProfilePicture.getUrl()}
|
||||||
|
isImageSet={defaultProfilePictureSet}
|
||||||
|
/>
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
id="background-image"
|
id="background-image"
|
||||||
imageClass="h-[350px] max-w-[500px]"
|
imageClass="h-[350px] max-w-[500px]"
|
||||||
@@ -55,7 +72,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<Button class="mt-5" onclick={() => callback(logoLight, logoDark, backgroundImage, favicon)}
|
<Button
|
||||||
|
class="mt-5"
|
||||||
|
usePromiseLoading
|
||||||
|
onclick={() => callback(logoLight, logoDark, defaultProfilePicture, backgroundImage, favicon)}
|
||||||
>{m.save()}</Button
|
>{m.save()}</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,10 +122,13 @@ test('Update application images', async ({ page }) => {
|
|||||||
await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico');
|
await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico');
|
||||||
await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png');
|
await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png');
|
||||||
await page.getByLabel('Dark Mode Logo').setInputFiles('assets/nextcloud-logo.png');
|
await page.getByLabel('Dark Mode Logo').setInputFiles('assets/nextcloud-logo.png');
|
||||||
|
await page.getByLabel('Default Profile Picture').setInputFiles('assets/pingvin-share-logo.png');
|
||||||
await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg');
|
await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg');
|
||||||
await page.getByRole('button', { name: 'Save' }).last().click();
|
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText('Images updated successfully');
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||||
|
'Images updated successfully. It may take a few minutes to update.'
|
||||||
|
);
|
||||||
|
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-images/favicon')
|
.get('/api/application-images/favicon')
|
||||||
|
|||||||
Reference in New Issue
Block a user