diff --git a/backend/go.mod b/backend/go.mod index fb759344..aab4b929 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.23.1 require ( github.com/caarlos0/env/v11 v11.3.1 + github.com/disintegration/imaging v1.6.2 github.com/fxamacker/cbor/v2 v2.7.0 github.com/gin-gonic/gin v1.10.0 github.com/go-co-op/gocron/v2 v2.15.0 @@ -17,6 +18,7 @@ require ( github.com/mileusna/useragent v1.3.5 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 golang.org/x/crypto v0.32.0 + golang.org/x/image v0.24.0 golang.org/x/time v0.9.0 gorm.io/driver/postgres v1.5.11 gorm.io/driver/sqlite v1.5.7 @@ -64,9 +66,9 @@ require ( golang.org/x/arch v0.13.0 // indirect golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/net v0.34.0 // indirect - golang.org/x/sync v0.10.0 // indirect + golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/text v0.22.0 // indirect google.golang.org/protobuf v1.36.4 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 064eaba7..90df780b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -22,6 +22,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= @@ -211,6 +213,9 @@ golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -235,8 +240,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -268,8 +274,9 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 4a0153f7..dda1acf2 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -211,3 +211,11 @@ func (e *UiConfigDisabledError) Error() string { return "The configuration can't be changed since the UI configuration is disabled" } func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden } + +type InvalidUUIDError struct{} + +func (e *InvalidUUIDError) Error() string { + return "Invalid UUID" +} + +type InvalidEmailError struct{} diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 0200acd0..90883af0 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -30,6 +30,11 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler) group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler) + group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler) + group.GET("/users/me/profile-picture.png", jwtAuthMiddleware.Add(false), uc.getCurrentUserProfilePictureHandler) + group.PUT("/users/:id/profile-picture", jwtAuthMiddleware.Add(true), uc.updateUserProfilePictureHandler) + group.PUT("/users/me/profile-picture", jwtAuthMiddleware.Add(false), uc.updateUserProfilePictureHandler) + group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler) 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) @@ -142,6 +147,74 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { uc.updateUser(c, true) } +func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) { + userID := c.Param("id") + + picture, size, err := uc.userService.GetProfilePicture(userID) + if err != nil { + c.Error(err) + return + } + + c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) +} + +func (uc *UserController) getCurrentUserProfilePictureHandler(c *gin.Context) { + userID := c.GetString("userID") + + picture, size, err := uc.userService.GetProfilePicture(userID) + if err != nil { + c.Error(err) + return + } + + c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) +} + +func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) { + userID := c.GetString("userID") + fileHeader, err := c.FormFile("file") + if err != nil { + c.Error(err) + return + } + file, err := fileHeader.Open() + if err != nil { + c.Error(err) + return + } + defer file.Close() + + if err := uc.userService.UpdateProfilePicture(userID, file); err != nil { + c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} + +func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) { + userID := c.GetString("userID") + fileHeader, err := c.FormFile("file") + if err != nil { + c.Error(err) + return + } + file, err := fileHeader.Open() + if err != nil { + c.Error(err) + return + } + defer file.Close() + + if err := uc.userService.UpdateProfilePicture(userID, file); err != nil { + c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} + func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) { var input dto.OneTimeAccessTokenCreateDto if err := c.ShouldBindJSON(&input); err != nil { diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index 48aeba72..3672d85a 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -38,7 +38,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) { "end_session_endpoint": appUrl + "/api/oidc/end-session", "jwks_uri": appUrl + "/.well-known/jwks.json", "scopes_supported": []string{"openid", "profile", "email"}, - "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"}, + "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture"}, "response_types_supported": []string{"code", "id_token"}, "subject_types_supported": []string{"public"}, "id_token_signing_alg_values_supported": []string{"RS256"}, diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index c0cbc9a8..d3c0a7eb 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -36,6 +36,7 @@ type AppConfigUpdateDto struct { LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` + LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupName string `json:"ldapAttributeGroupName"` diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index 7a834f82..ac479705 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -43,6 +43,7 @@ type AppConfig struct { LdapAttributeUserEmail AppConfigVariable LdapAttributeUserFirstName AppConfigVariable LdapAttributeUserLastName AppConfigVariable + LdapAttributeUserProfilePicture AppConfigVariable LdapAttributeGroupMember AppConfigVariable LdapAttributeGroupUniqueIdentifier AppConfigVariable LdapAttributeGroupName AppConfigVariable diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index b5667491..50562015 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -173,6 +173,10 @@ var defaultDbConfig = model.AppConfig{ Key: "ldapAttributeUserLastName", Type: "string", }, + LdapAttributeUserProfilePicture: model.AppConfigVariable{ + Key: "ldapAttributeUserProfilePicture", + Type: "string", + }, LdapAttributeGroupMember: model.AppConfigVariable{ Key: "ldapAttributeGroupMember", Type: "string", diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index a8b652d6..bc97d111 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -1,9 +1,14 @@ package service import ( + "bytes" "crypto/tls" + "encoding/base64" "fmt" + "io" "log" + "net/http" + "net/url" "strings" "github.com/go-ldap/ldap/v3" @@ -177,6 +182,7 @@ func (s *LdapService) SyncUsers() error { emailAttribute := s.appConfigService.DbConfig.LdapAttributeUserEmail.Value firstNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserFirstName.Value lastNameAttribute := s.appConfigService.DbConfig.LdapAttributeUserLastName.Value + profilePictureAttribute := s.appConfigService.DbConfig.LdapAttributeUserProfilePicture.Value adminGroupAttribute := s.appConfigService.DbConfig.LdapAttributeAdminGroup.Value filter := s.appConfigService.DbConfig.LdapUserSearchFilter.Value @@ -189,6 +195,7 @@ func (s *LdapService) SyncUsers() error { emailAttribute, firstNameAttribute, lastNameAttribute, + profilePictureAttribute, } // Filters must start and finish with ()! @@ -237,9 +244,14 @@ func (s *LdapService) SyncUsers() error { if err != nil { log.Printf("Error syncing user %s: %s", newUser.Username, err) } - } + // Save profile picture + if pictureString := value.GetAttributeValue(profilePictureAttribute); pictureString != "" { + if err := s.SaveProfilePicture(databaseUser.ID, pictureString); err != nil { + log.Printf("Error saving profile picture for user %s: %s", newUser.Username, err) + } + } } // Get all LDAP users from the database @@ -260,3 +272,33 @@ func (s *LdapService) SyncUsers() error { } return nil } + +func (s *LdapService) SaveProfilePicture(userId string, pictureString string) error { + var reader io.Reader + + if _, err := url.ParseRequestURI(pictureString); err == nil { + // If the photo is a URL, download it + response, err := http.Get(pictureString) + if err != nil { + return fmt.Errorf("failed to download profile picture: %w", err) + } + defer response.Body.Close() + + reader = response.Body + + } else if decodedPhoto, err := base64.StdEncoding.DecodeString(pictureString); err == nil { + // If the photo is a base64 encoded string, decode it + reader = bytes.NewReader(decodedPhoto) + + } else { + // If the photo is a string, we assume that it's a binary string + reader = bytes.NewReader([]byte("pictureString")) + } + + // Update the profile picture + if err := s.userService.UpdateProfilePicture(userId, reader); err != nil { + return fmt.Errorf("failed to update profile picture: %w", err) + } + + return nil +} diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 91b3c27f..96967cc7 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -401,6 +401,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma "family_name": user.LastName, "name": user.FullName(), "preferred_username": user.Username, + "picture": fmt.Sprintf("%s/api/users/%s/profile-picture.png", common.EnvConfig.AppURL, user.ID), } if strings.Contains(scope, "profile") { diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 060b3703..2166f029 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -3,8 +3,12 @@ package service import ( "errors" "fmt" + "github.com/google/uuid" + "github.com/pocket-id/pocket-id/backend/internal/utils/image" + "io" "log" "net/url" + "os" "strings" "time" @@ -48,6 +52,71 @@ func (s *UserService) GetUser(userID string) (model.User, error) { return user, err } +func (s *UserService) GetProfilePicture(userID string) (io.Reader, int64, error) { + // Validate the user ID to prevent directory traversal + if err := uuid.Validate(userID); err != nil { + return nil, 0, &common.InvalidUUIDError{} + } + + profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID) + file, err := os.Open(profilePicturePath) + if err == nil { + // Get the file size + fileInfo, err := file.Stat() + if err != nil { + return nil, 0, err + } + return file, fileInfo.Size(), nil + } + + // If the file does not exist, return the default profile picture + user, err := s.GetUser(userID) + if err != nil { + return nil, 0, err + } + + defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.FirstName, user.LastName) + if err != nil { + return nil, 0, err + } + + return defaultPicture, int64(defaultPicture.Len()), nil +} + +func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error { + // Validate the user ID to prevent directory traversal + if err := uuid.Validate(userID); err != nil { + return &common.InvalidUUIDError{} + } + + // Convert the image to a smaller square image + profilePicture, err := profilepicture.CreateProfilePicture(file) + if err != nil { + return err + } + + // Ensure the directory exists + profilePictureDir := fmt.Sprintf("%s/profile-pictures", common.EnvConfig.UploadPath) + if err := os.MkdirAll(profilePictureDir, os.ModePerm); err != nil { + return err + } + + // Create the profile picture file + createdProfilePicture, err := os.Create(fmt.Sprintf("%s/%s.png", profilePictureDir, userID)) + if err != nil { + return err + } + defer createdProfilePicture.Close() + + // Copy the image to the file + _, err = io.Copy(createdProfilePicture, profilePicture) + if err != nil { + return err + } + + return nil +} + func (s *UserService) DeleteUser(userID string) error { var user model.User if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil { diff --git a/backend/internal/utils/image/profile_picture.go b/backend/internal/utils/image/profile_picture.go new file mode 100644 index 00000000..3a7b6c1a --- /dev/null +++ b/backend/internal/utils/image/profile_picture.go @@ -0,0 +1,96 @@ +package profilepicture + +import ( + "bytes" + "fmt" + "github.com/disintegration/imaging" + "github.com/pocket-id/pocket-id/backend/resources" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" + "image" + "image/color" + "io" + "strings" +) + +const profilePictureSize = 300 + +// CreateProfilePicture resizes the profile picture to a square +func CreateProfilePicture(file io.Reader) (*bytes.Buffer, error) { + img, err := imaging.Decode(file) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + + img = imaging.Fill(img, profilePictureSize, profilePictureSize, imaging.Center, imaging.Lanczos) + + var buf bytes.Buffer + err = imaging.Encode(&buf, img, imaging.PNG) + if err != nil { + return nil, fmt.Errorf("failed to encode image: %v", err) + } + + return &buf, nil +} + +// CreateDefaultProfilePicture creates a profile picture with the initials +func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, error) { + // Get the initials + initials := "" + if len(firstName) > 0 { + initials += string(firstName[0]) + } + if len(lastName) > 0 { + initials += string(lastName[0]) + } + initials = strings.ToUpper(initials) + + // Create a blank image with a white background + img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255}) + + // Load the font + fontBytes, err := resources.FS.ReadFile("fonts/PlayfairDisplay-Bold.ttf") + if err != nil { + return nil, fmt.Errorf("failed to read font file: %w", err) + } + + // Parse the font + fontFace, err := opentype.Parse(fontBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse font: %w", err) + } + + // Create a font.Face with a specific size + fontSize := 160.0 + face, err := opentype.NewFace(fontFace, &opentype.FaceOptions{ + Size: fontSize, + DPI: 72, + }) + if err != nil { + return nil, fmt.Errorf("failed to create font face: %w", err) + } + + // Create a drawer for the image + drawer := &font.Drawer{ + Dst: img, + Src: image.NewUniform(color.RGBA{R: 0, G: 0, B: 0, A: 255}), // Black text color + Face: face, + } + + // Center the initials + x := (profilePictureSize - font.MeasureString(face, initials).Ceil()) / 2 + y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil() - 10 + drawer.Dot = fixed.P(x, y) + + // Draw the initials + drawer.DrawString(initials) + + var buf bytes.Buffer + err = imaging.Encode(&buf, img, imaging.PNG) + if err != nil { + return nil, fmt.Errorf("failed to encode image: %v", err) + } + + return &buf, nil +} diff --git a/backend/resources/files.go b/backend/resources/files.go index 9ef53285..6eb3d091 100644 --- a/backend/resources/files.go +++ b/backend/resources/files.go @@ -4,5 +4,5 @@ import "embed" // Embedded file systems for the project -//go:embed email-templates images migrations +//go:embed email-templates images migrations fonts var FS embed.FS diff --git a/backend/resources/fonts/PlayfairDisplay-Bold.ttf b/backend/resources/fonts/PlayfairDisplay-Bold.ttf new file mode 100644 index 00000000..029a1a60 Binary files /dev/null and b/backend/resources/fonts/PlayfairDisplay-Bold.ttf differ diff --git a/frontend/src/lib/components/auto-complete-input.svelte b/frontend/src/lib/components/form/auto-complete-input.svelte similarity index 100% rename from frontend/src/lib/components/auto-complete-input.svelte rename to frontend/src/lib/components/form/auto-complete-input.svelte diff --git a/frontend/src/lib/components/checkbox-with-label.svelte b/frontend/src/lib/components/form/checkbox-with-label.svelte similarity index 80% rename from frontend/src/lib/components/checkbox-with-label.svelte rename to frontend/src/lib/components/form/checkbox-with-label.svelte index 143ee3b4..9cfbd2ef 100644 --- a/frontend/src/lib/components/checkbox-with-label.svelte +++ b/frontend/src/lib/components/form/checkbox-with-label.svelte @@ -1,6 +1,6 @@ + +
+
+
+

Profile Picture

+ {#if isLdapUser} +

+ The profile picture is managed by the LDAP server and cannot be changed here. +

+ {:else} +

+ Click on the profile picture to upload a custom one from your files. +

+

The image should be in PNG or JPEG format.

+ {/if} +
+ {#if isLdapUser} + + + + {:else} + +
+ + + +
+ {#if isLoading} + + {:else} + + {/if} +
+
+
+ {/if} +
+
diff --git a/frontend/src/lib/components/header/header-avatar.svelte b/frontend/src/lib/components/header/header-avatar.svelte index d3e47c5b..b428b0b2 100644 --- a/frontend/src/lib/components/header/header-avatar.svelte +++ b/frontend/src/lib/components/header/header-avatar.svelte @@ -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 @@ - - {initials} + @@ -39,7 +26,7 @@ {$userStore?.firstName} {$userStore?.lastName}

-

{$userStore?.email}

+

{$userStore?.email}

diff --git a/frontend/src/lib/components/header/header.svelte b/frontend/src/lib/components/header/header.svelte index d5f65de1..7a1fcf06 100644 --- a/frontend/src/lib/components/header/header.svelte +++ b/frontend/src/lib/components/header/header.svelte @@ -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)) + );
diff --git a/frontend/src/lib/components/ui/avatar/avatar.svelte b/frontend/src/lib/components/ui/avatar/avatar.svelte index 9a12d651..b3da2515 100644 --- a/frontend/src/lib/components/ui/avatar/avatar.svelte +++ b/frontend/src/lib/components/ui/avatar/avatar.svelte @@ -11,7 +11,7 @@ diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index ac987528..d946340a 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -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, diff --git a/frontend/src/lib/types/application-configuration.ts b/frontend/src/lib/types/application-configuration.ts index 4cac7767..a5ea06d0 100644 --- a/frontend/src/lib/types/application-configuration.ts +++ b/frontend/src/lib/types/application-configuration.ts @@ -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; }; diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index e426c2ab..36a10a13 100644 --- a/frontend/src/routes/settings/+layout.svelte +++ b/frontend/src/routes/settings/+layout.svelte @@ -33,41 +33,37 @@
-
+
-
+

Settings

-
- -
+
{@render children()}
-

+

Powered by toast.success('Profile picture updated successfully')) + .catch(axiosErrorToast); + } + async function createPasskey() { try { const opts = await webauthnService.getRegistrationOptions(); @@ -86,6 +94,12 @@ + + + + + +

diff --git a/frontend/src/routes/settings/account/account-form.svelte b/frontend/src/routes/settings/account/account-form.svelte index 4b21f01d..fcfe9d15 100644 --- a/frontend/src/routes/settings/account/account-form.svelte +++ b/frontend/src/routes/settings/account/account-form.svelte @@ -1,5 +1,5 @@ @@ -62,6 +70,16 @@ + + + + + + - 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';