Compare commits

..

12 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
Elias Schneider
9245851126 release: 0.40.1 2025-03-16 18:02:49 +01:00
Alexander Lehmann
39b7f6678c fix: emails are considered as medium spam by rspamd (#337) 2025-03-16 17:46:45 +01:00
Elias Schneider
e45d9e970d fix: caching for own profile picture 2025-03-16 17:45:30 +01:00
Elias Schneider
8ead0be8cd fix: API keys not working if sqlite is used 2025-03-16 14:28:44 +01:00
Elias Schneider
9f28503d6c fix: remove custom claim key restrictions 2025-03-16 14:11:33 +01:00
Elias Schneider
26e05947fe ci/cd: add separate worfklow for unit tests 2025-03-16 13:08:56 +01:00
Alessandro (Ale) Segala
348192b9d7 fix: Fixes and performance improvements in utils package (#331) 2025-03-14 19:21:24 -05:00
Kyle Mendell
b483e2e92f fix: email logo icon displaying too big (#336) 2025-03-14 13:38:27 -05:00
28 changed files with 496 additions and 115 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: |

34
.github/workflows/unit-tests.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Unit Tests
on:
push:
branches: [main]
paths:
- "backend/**"
pull_request:
branches: [main]
paths:
- "backend/**"
jobs:
test-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: 'backend/go.mod'
cache-dependency-path: 'backend/go.sum'
- name: Install dependencies
working-directory: backend
run: |
go get ./...
- name: Run backend unit tests
working-directory: backend
run: |
go test -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v4
if: always()
with:
name: backend-unit-tests
path: /tmp/TestResults.log
retention-days: 15

View File

@@ -1 +1 @@
0.40.0 0.41.0

View File

@@ -1,3 +1,27 @@
## [](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)
### Bug Fixes
* API keys not working if sqlite is used ([8ead0be](https://github.com/pocket-id/pocket-id/commit/8ead0be8cd0cfb542fe488b7251cfd5274975ae1))
* caching for own profile picture ([e45d9e9](https://github.com/pocket-id/pocket-id/commit/e45d9e970d327a5120ff9fb0c8d42df8af69bb38))
* email logo icon displaying too big ([#336](https://github.com/pocket-id/pocket-id/issues/336)) ([b483e2e](https://github.com/pocket-id/pocket-id/commit/b483e2e92fdb528e7de026350a727d6970227426))
* emails are considered as medium spam by rspamd ([#337](https://github.com/pocket-id/pocket-id/issues/337)) ([39b7f66](https://github.com/pocket-id/pocket-id/commit/39b7f6678c98cadcdc3abfbcb447d8eb0daa9eb0))
* Fixes and performance improvements in utils package ([#331](https://github.com/pocket-id/pocket-id/issues/331)) ([348192b](https://github.com/pocket-id/pocket-id/commit/348192b9d7e2698add97810f8fba53d13d0df018))
* remove custom claim key restrictions ([9f28503](https://github.com/pocket-id/pocket-id/commit/9f28503d6c73d3521d1309bee055704a0507e9b5))
## [](https://github.com/pocket-id/pocket-id/compare/v0.39.0...v) (2025-03-13) ## [](https://github.com/pocket-id/pocket-id/compare/v0.39.0...v) (2025-03-13)

View File

@@ -38,7 +38,7 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups) group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler) group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
group.GET("/users/me/profile-picture.png", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserProfilePictureHandler)
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler) group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler)
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler) group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler)
@@ -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 {
@@ -249,24 +252,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
return return
} }
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) c.Header("Cache-Control", "public, max-age=300")
}
// getCurrentUserProfilePictureHandler godoc
// @Summary Get current user's profile picture
// @Description Retrieve the currently authenticated user's profile picture
// @Tags Users
// @Produce image/png
// @Success 200 {file} binary "PNG image"
// @Router /users/me/profile-picture.png [get]
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) c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
} }
@@ -497,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

@@ -6,6 +6,6 @@ type CustomClaimDto struct {
} }
type CustomClaimCreateDto struct { type CustomClaimCreateDto struct {
Key string `json:"key" binding:"required,claimKey"` Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"` Value string `json:"value" binding:"required"`
} }

View File

@@ -16,22 +16,10 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
return matched return matched
} }
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
func init() { func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok { if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("username", validateUsername); err != nil { if err := v.RegisterValidation("username", validateUsername); err != nil {
log.Fatalf("Failed to register custom validation: %v", err) log.Fatalf("Failed to register custom validation: %v", err)
} }
} }
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
} }

View File

@@ -82,7 +82,7 @@ func (s *ApiKeyService) ValidateApiKey(apiKey string) (model.User, error) {
hashedKey := utils.CreateSha256Hash(apiKey) hashedKey := utils.CreateSha256Hash(apiKey)
if err := s.db.Preload("User").Where("key = ? AND expires_at > ?", if err := s.db.Preload("User").Where("key = ? AND expires_at > ?",
hashedKey, time.Now()).Preload("User").First(&key).Error; err != nil { hashedKey, datatype.DateTime(time.Now())).Preload("User").First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, &common.InvalidAPIKeyError{} return model.User{}, &common.InvalidAPIKeyError{}

View File

@@ -18,6 +18,8 @@ import (
"os" "os"
ttemplate "text/template" ttemplate "text/template"
"time" "time"
"github.com/google/uuid"
"strings"
) )
type EmailService struct { type EmailService struct {
@@ -84,6 +86,29 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
c.AddHeaderRaw("Content-Type", c.AddHeaderRaw("Content-Type",
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary), fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
) )
c.AddHeader("MIME-Version", "1.0")
c.AddHeader("Date", time.Now().Format(time.RFC1123Z))
// to create a message-id, we need the FQDN of the sending server, but that may be a docker hostname or localhost
// so we use the domain of the from address instead (the same as Thunderbird does)
// if the address does not have an @ (which would be unusual), we use hostname
from_address := srv.appConfigService.DbConfig.SmtpFrom.Value
domain := ""
if strings.Contains(from_address, "@") {
domain = strings.Split(from_address, "@")[1]
} else {
hostname, err := os.Hostname()
if err != nil {
// can that happen? we just give up
return fmt.Errorf("failed to get own hostname: %w", err)
} else {
domain = hostname
}
}
c.AddHeader("Message-ID", "<" + uuid.New().String() + "@" + domain + ">")
c.Body(body) c.Body(body)
// Connect to the SMTP server // Connect to the SMTP server

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

@@ -45,7 +45,11 @@ func genAddressHeader(name string, addresses []Address, maxLength int) string {
} else { } else {
email = fmt.Sprintf("<%s>", addr.Email) email = fmt.Sprintf("<%s>", addr.Email)
} }
writeHeaderQ(hl, addr.Name) if isPrintableASCII(addr.Name) {
writeHeaderAtom(hl, addr.Name)
} else {
writeHeaderQ(hl, addr.Name)
}
writeHeaderAtom(hl, " ") writeHeaderAtom(hl, " ")
writeHeaderAtom(hl, email) writeHeaderAtom(hl, email)
} }

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

@@ -5,14 +5,16 @@ import (
"mime/multipart" "mime/multipart"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
) )
func GetFileExtension(filename string) string { func GetFileExtension(filename string) string {
splitted := strings.Split(filename, ".") ext := filepath.Ext(filename)
return splitted[len(splitted)-1] if len(ext) > 0 && ext[0] == '.' {
return ext[1:]
}
return filename
} }
func GetImageMimeType(ext string) string { func GetImageMimeType(ext string) string {

View File

@@ -0,0 +1,73 @@
package utils
import (
"testing"
)
func TestGetFileExtension(t *testing.T) {
tests := []struct {
name string
filename string
want string
}{
{
name: "Simple file with extension",
filename: "document.pdf",
want: "pdf",
},
{
name: "File with path",
filename: "/path/to/document.txt",
want: "txt",
},
{
name: "File with path (Windows style)",
filename: "C:\\path\\to\\document.jpg",
want: "jpg",
},
{
name: "Multiple extensions",
filename: "archive.tar.gz",
want: "gz",
},
{
name: "Hidden file with extension",
filename: ".config.json",
want: "json",
},
{
name: "Filename with dots",
filename: "version.1.2.3.txt",
want: "txt",
},
{
name: "File with uppercase extension",
filename: "image.JPG",
want: "JPG",
},
{
name: "File without extension",
filename: "README",
want: "README",
},
{
name: "Hidden file without extension",
filename: ".gitignore",
want: "gitignore",
},
{
name: "Empty filename",
filename: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetFileExtension(tt.filename)
if got != tt.want {
t.Errorf("GetFileExtension(%q) = %q, want %q", tt.filename, got, tt.want)
}
})
}
}

View File

@@ -90,7 +90,7 @@ func CreateDefaultProfilePicture(firstName, lastName string) (*bytes.Buffer, err
var buf bytes.Buffer var buf bytes.Buffer
err = imaging.Encode(&buf, img, imaging.PNG) err = imaging.Encode(&buf, img, imaging.PNG)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encode image: %v", err) return nil, fmt.Errorf("failed to encode image: %w", err)
} }
return &buf, nil return &buf, nil

View File

@@ -2,8 +2,9 @@ package utils
import ( import (
"crypto/rand" "crypto/rand"
"errors"
"fmt" "fmt"
"math/big" "io"
"net/url" "net/url"
"regexp" "regexp"
"strings" "strings"
@@ -13,23 +14,41 @@ import (
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length // GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
func GenerateRandomAlphanumericString(length int) (string, error) { func GenerateRandomAlphanumericString(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const charsetLength = int64(len(charset))
if length <= 0 { if length <= 0 {
return "", fmt.Errorf("length must be a positive integer") return "", errors.New("length must be a positive integer")
} }
result := make([]byte, length) // The algorithm below is adapted from https://stackoverflow.com/a/35615565
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
)
for i := range result { result := strings.Builder{}
num, err := rand.Int(rand.Reader, big.NewInt(charsetLength)) result.Grow(length)
if err != nil { // Because we discard a bunch of bytes, we read more in the buffer to minimize the changes of performing additional IO
return "", err bufferSize := int(float64(length) * 1.3)
randomBytes := make([]byte, bufferSize)
for i, j := 0, 0; i < length; j++ {
// Fill the buffer if needed
if j%bufferSize == 0 {
_, err := io.ReadFull(rand.Reader, randomBytes)
if err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
}
// Discard bytes that are outside of the range
// This allows making sure that we maintain uniform distribution
idx := int(randomBytes[j%length] & letterIdxMask)
if idx < len(charset) {
result.WriteByte(charset[idx])
i++
} }
result[i] = charset[num.Int64()]
} }
return string(result), nil return result.String(), nil
} }
func GetHostnameFromURL(rawURL string) string { func GetHostnameFromURL(rawURL string) string {
@@ -45,30 +64,40 @@ func StringPointer(s string) *string {
return &s return &s
} }
func CapitalizeFirstLetter(s string) string { func CapitalizeFirstLetter(str string) string {
if s == "" { if str == "" {
return s return ""
} }
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0]) result := strings.Builder{}
return string(runes) result.Grow(len(str))
for i, r := range str {
if i == 0 {
result.WriteRune(unicode.ToUpper(r))
} else {
result.WriteRune(r)
}
}
return result.String()
} }
func CamelCaseToSnakeCase(s string) string { func CamelCaseToSnakeCase(str string) string {
var result []rune result := strings.Builder{}
for i, r := range s { result.Grow(int(float32(len(str)) * 1.1))
for i, r := range str {
if unicode.IsUpper(r) && i > 0 { if unicode.IsUpper(r) && i > 0 {
result = append(result, '_') result.WriteByte('_')
} }
result = append(result, unicode.ToLower(r)) result.WriteRune(unicode.ToLower(r))
} }
return string(result) return result.String()
} }
var camelCaseToScreamingSnakeCaseRe = regexp.MustCompile(`([a-z0-9])([A-Z])`)
func CamelCaseToScreamingSnakeCase(s string) string { func CamelCaseToScreamingSnakeCase(s string) string {
// Insert underscores before uppercase letters (except the first one) // Insert underscores before uppercase letters (except the first one)
re := regexp.MustCompile(`([a-z0-9])([A-Z])`) snake := camelCaseToScreamingSnakeCaseRe.ReplaceAllString(s, `${1}_${2}`)
snake := re.ReplaceAllString(s, `${1}_${2}`)
// Convert to uppercase // Convert to uppercase
return strings.ToUpper(snake) return strings.ToUpper(snake)

View File

@@ -0,0 +1,105 @@
package utils
import (
"regexp"
"testing"
)
func TestGenerateRandomAlphanumericString(t *testing.T) {
t.Run("valid length returns correct string", func(t *testing.T) {
const length = 10
str, err := GenerateRandomAlphanumericString(length)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if len(str) != length {
t.Errorf("Expected length %d, got %d", length, len(str))
}
matched, err := regexp.MatchString(`^[a-zA-Z0-9]+$`, str)
if err != nil {
t.Errorf("Regex match failed: %v", err)
}
if !matched {
t.Errorf("String contains non-alphanumeric characters: %s", str)
}
})
t.Run("zero length returns error", func(t *testing.T) {
_, err := GenerateRandomAlphanumericString(0)
if err == nil {
t.Error("Expected error for zero length, got nil")
}
})
t.Run("negative length returns error", func(t *testing.T) {
_, err := GenerateRandomAlphanumericString(-1)
if err == nil {
t.Error("Expected error for negative length, got nil")
}
})
t.Run("generates different strings", func(t *testing.T) {
str1, _ := GenerateRandomAlphanumericString(10)
str2, _ := GenerateRandomAlphanumericString(10)
if str1 == str2 {
t.Error("Generated strings should be different")
}
})
}
func TestCapitalizeFirstLetter(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", ""},
{"lowercase first letter", "hello", "Hello"},
{"already capitalized", "Hello", "Hello"},
{"single lowercase letter", "h", "H"},
{"single uppercase letter", "H", "H"},
{"starts with number", "123abc", "123abc"},
{"unicode character", "étoile", "Étoile"},
{"special character", "_test", "_test"},
{"multi-word", "hello world", "Hello world"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CapitalizeFirstLetter(tt.input)
if result != tt.expected {
t.Errorf("CapitalizeFirstLetter(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestCamelCaseToSnakeCase(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", ""},
{"simple camelCase", "camelCase", "camel_case"},
{"PascalCase", "PascalCase", "pascal_case"},
{"multipleWordsInCamelCase", "multipleWordsInCamelCase", "multiple_words_in_camel_case"},
{"consecutive uppercase", "HTTPRequest", "h_t_t_p_request"},
{"single lowercase word", "word", "word"},
{"single uppercase word", "WORD", "w_o_r_d"},
{"with numbers", "camel123Case", "camel123_case"},
{"with numbers in middle", "model2Name", "model2_name"},
{"mixed case", "iPhone6sPlus", "i_phone6s_plus"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CamelCaseToSnakeCase(tt.input)
if result != tt.expected {
t.Errorf("CamelCaseToSnakeCase(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}

View File

@@ -1,7 +1,7 @@
{{ define "base" }} {{ define "base" }}
<div class="header"> <div class="header">
<div class="logo"> <div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/> <img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
<h1>{{ .AppName }}</h1> <h1>{{ .AppName }}</h1>
</div> </div>
<div class="warning">Warning</div> <div class="warning">Warning</div>

View File

@@ -1,7 +1,7 @@
{{ define "base" }} {{ define "base" }}
<div class="header"> <div class="header">
<div class="logo"> <div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/> <img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
<h1>{{ .AppName }}</h1> <h1>{{ .AppName }}</h1>
</div> </div>
</div> </div>

View File

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

View File

@@ -16,7 +16,6 @@
let filteredSuggestions: string[] = $state(suggestions.slice(0, suggestionLimit)); let filteredSuggestions: string[] = $state(suggestions.slice(0, suggestionLimit));
let selectedIndex = $state(-1); let selectedIndex = $state(-1);
let keyError: string | undefined = $state();
let isInputFocused = $state(false); let isInputFocused = $state(false);
@@ -26,13 +25,6 @@
} }
function handleOnInput() { function handleOnInput() {
if (value.length > 0 && !/^[A-Za-z0-9]*$/.test(value)) {
keyError = 'Only alphanumeric characters are allowed';
return;
} else {
keyError = undefined;
}
filteredSuggestions = suggestions filteredSuggestions = suggestions
.filter((s) => s.includes(value.toLowerCase())) .filter((s) => s.includes(value.toLowerCase()))
.slice(0, suggestionLimit); .slice(0, suggestionLimit);
@@ -83,9 +75,6 @@
onfocus={() => (isInputFocused = true)} onfocus={() => (isInputFocused = true)}
onblur={() => (isInputFocused = false)} onblur={() => (isInputFocused = false)}
/> />
{#if keyError}
<p class="mt-1 text-sm text-red-500">{keyError}</p>
{/if}
<Popover.Root <Popover.Root
open={isOpen} open={isOpen}
disableFocusTrap disableFocusTrap

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);
} }
@@ -99,9 +110,10 @@
<Card.Root> <Card.Root>
<Card.Content class="pt-6"> <Card.Content class="pt-6">
<ProfilePictureSettings <ProfilePictureSettings
userId="me" 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>