Compare commits

...

6 Commits

Author SHA1 Message Date
Elias Schneider
ed8547ccc1 release: 1.11.0 2025-09-18 22:16:32 +02:00
Elias Schneider
e7e53a8b8c fix: my apps card shouldn't take full width if only one item exists 2025-09-18 21:55:43 +02:00
Elias Schneider
02249491f8 feat: allow uppercase usernames (#958) 2025-09-17 14:43:12 -05:00
Elias Schneider
cf0892922b chore: include version in changelog 2025-09-17 18:00:04 +02:00
Elias Schneider
99f31a7c26 fix: make environment variables case insensitive where necessary (#954)
fix #935
2025-09-17 08:21:54 -07:00
Kyle Mendell
68373604dd feat: add user display name field (#898)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-17 17:18:27 +02:00
40 changed files with 478 additions and 281 deletions

View File

@@ -1 +1 @@
1.10.0 1.11.0

View File

@@ -1,3 +1,30 @@
## [1.11.0](https://github.com/pocket-id/pocket-id/compare/v1.10.0...v1.11.0) (2025-09-18)
### Features
* add CSP header ([#908](https://github.com/pocket-id/pocket-id/issues/908)) ([6215e1a](https://github.com/pocket-id/pocket-id/commit/6215e1ac01c03866f8b2e89ac084ddd6a3c3ac9e))
* add custom base url ([#858](https://github.com/pocket-id/pocket-id/issues/858)) ([a3979f6](https://github.com/pocket-id/pocket-id/commit/a3979f63e07d418ee9eb1cb1abc37aede5799fc8))
* add info box to app settings if UI config is disabled ([a1d8538](https://github.com/pocket-id/pocket-id/commit/a1d8538c64beb4d7e8559934985772fba27623ca))
* add PWA support ([#938](https://github.com/pocket-id/pocket-id/issues/938)) ([5367463](https://github.com/pocket-id/pocket-id/commit/5367463239b354640fd65390bc409e4a0ac13fd1))
* add support for `LOG_LEVEL` env variable ([#942](https://github.com/pocket-id/pocket-id/issues/942)) ([2d6d5df](https://github.com/pocket-id/pocket-id/commit/2d6d5df0e7f104a148fb4eeac89a2fbb7db8047a))
* add user display name field ([#898](https://github.com/pocket-id/pocket-id/issues/898)) ([6837360](https://github.com/pocket-id/pocket-id/commit/68373604dd30065947226922233bc1e19e778b01))
* allow uppercase usernames ([#958](https://github.com/pocket-id/pocket-id/issues/958)) ([0224949](https://github.com/pocket-id/pocket-id/commit/02249491f86c289adf596d9d9922dfa04779edee))
* client_credentials flow support ([#901](https://github.com/pocket-id/pocket-id/issues/901)) ([901333f](https://github.com/pocket-id/pocket-id/commit/901333f7e43b4e925ed6dfd890dee2caa1947934))
* return new id_token when using refresh token ([#925](https://github.com/pocket-id/pocket-id/issues/925)) ([307caaa](https://github.com/pocket-id/pocket-id/commit/307caaa3efbc966341b95ee4b5ff18c81ed98e54))
### Bug Fixes
* add validation for callback URLs ([#929](https://github.com/pocket-id/pocket-id/issues/929)) ([6c91474](https://github.com/pocket-id/pocket-id/commit/6c9147483c0a370e2b5011d13898279d2acc445d))
* disable sign up options in UI if `UI_CONFIG_DISABLED` ([1d7cbc2](https://github.com/pocket-id/pocket-id/commit/1d7cbc2a4ecf352d46087f30b477f6bbaa23adf5))
* ensure users imported from LDAP have fields validated ([#923](https://github.com/pocket-id/pocket-id/issues/923)) ([4215523](https://github.com/pocket-id/pocket-id/commit/42155238b750b015b0547294f397e1e285594e3e))
* key-rotate doesn't work with database storage ([#940](https://github.com/pocket-id/pocket-id/issues/940)) ([c018f29](https://github.com/pocket-id/pocket-id/commit/c018f29ad7c61a3ef1b235b0d404a3a2024a26ca))
* list items on previous page get unselected if other items selected on next page ([6c696b4](https://github.com/pocket-id/pocket-id/commit/6c696b46c8b60b3dc4af35c9c6cf1b8e1322f4cd))
* make environment variables case insensitive where necessary ([#954](https://github.com/pocket-id/pocket-id/issues/954)) ([99f31a7](https://github.com/pocket-id/pocket-id/commit/99f31a7c26c63dec76682ddf450d88e6ee40876f)), closes [#935](https://github.com/pocket-id/pocket-id/issues/935)
* my apps card shouldn't take full width if only one item exists ([e7e53a8](https://github.com/pocket-id/pocket-id/commit/e7e53a8b8c87bee922167d24556aef3ea219b1a2))
* update localized name and description of ldap group name attribute ([#892](https://github.com/pocket-id/pocket-id/issues/892)) ([e88be7e](https://github.com/pocket-id/pocket-id/commit/e88be7e61a8aafabcae70adf9265023c50626705))
## [](https://github.com/pocket-id/pocket-id/compare/v1.9.1...v) (2025-08-27) ## [](https://github.com/pocket-id/pocket-id/compare/v1.9.1...v) (2025-08-27)
### Features ### Features

View File

@@ -32,17 +32,17 @@ const (
) )
type EnvConfigSchema struct { type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"` AppEnv string `env:"APP_ENV" options:"toLower"`
LogLevel string `env:"LOG_LEVEL"` LogLevel string `env:"LOG_LEVEL" options:"toLower"`
AppURL string `env:"APP_URL"` AppURL string `env:"APP_URL" options:"toLower"`
DbProvider DbProvider `env:"DB_PROVIDER"` DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"` DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"` KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"` KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"` EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"` Port string `env:"PORT"`
Host string `env:"HOST"` Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"` UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"` UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
@@ -112,31 +112,40 @@ func parseEnvConfig() error {
return fmt.Errorf("error parsing env config: %w", err) return fmt.Errorf("error parsing env config: %w", err)
} }
err = resolveFileBasedEnvVariables(&EnvConfig) err = prepareEnvConfig(&EnvConfig)
if err != nil {
return fmt.Errorf("error preparing env config: %w", err)
}
err = validateEnvConfig(&EnvConfig)
if err != nil { if err != nil {
return err return err
} }
// Validate the environment variables return nil
EnvConfig.LogLevel = strings.ToLower(EnvConfig.LogLevel)
if _, err := sloggin.ParseLevel(EnvConfig.LogLevel); err != nil { }
// validateEnvConfig checks the EnvConfig for required fields and valid values
func validateEnvConfig(config *EnvConfigSchema) error {
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'") return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
} }
switch EnvConfig.DbProvider { switch config.DbProvider {
case DbProviderSqlite: case DbProviderSqlite:
if EnvConfig.DbConnectionString == "" { if config.DbConnectionString == "" {
EnvConfig.DbConnectionString = defaultSqliteConnString config.DbConnectionString = defaultSqliteConnString
} }
case DbProviderPostgres: case DbProviderPostgres:
if EnvConfig.DbConnectionString == "" { if config.DbConnectionString == "" {
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database") return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
} }
default: default:
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'") return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
} }
parsedAppUrl, err := url.Parse(EnvConfig.AppURL) parsedAppUrl, err := url.Parse(config.AppURL)
if err != nil { if err != nil {
return errors.New("APP_URL is not a valid URL") return errors.New("APP_URL is not a valid URL")
} }
@@ -145,10 +154,10 @@ func parseEnvConfig() error {
} }
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided // Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
if EnvConfig.InternalAppURL == "" { if config.InternalAppURL == "" {
EnvConfig.InternalAppURL = EnvConfig.AppURL config.InternalAppURL = config.AppURL
} else { } else {
parsedInternalAppUrl, err := url.Parse(EnvConfig.InternalAppURL) parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
if err != nil { if err != nil {
return errors.New("INTERNAL_APP_URL is not a valid URL") return errors.New("INTERNAL_APP_URL is not a valid URL")
} }
@@ -157,25 +166,26 @@ func parseEnvConfig() error {
} }
} }
switch EnvConfig.KeysStorage { switch config.KeysStorage {
// KeysStorage defaults to "file" if empty // KeysStorage defaults to "file" if empty
case "": case "":
EnvConfig.KeysStorage = "file" config.KeysStorage = "file"
case "database": case "database":
if EnvConfig.EncryptionKey == nil { if config.EncryptionKey == nil {
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database") return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
} }
case "file": case "file":
// All good, these are valid values // All good, these are valid values
default: default:
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", EnvConfig.KeysStorage) return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
} }
return nil return nil
} }
// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets // prepareEnvConfig processes special options for EnvConfig fields
func resolveFileBasedEnvVariables(config *EnvConfigSchema) error { func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem() val := reflect.ValueOf(config).Elem()
typ := val.Type() typ := val.Type()
@@ -183,48 +193,65 @@ func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
field := val.Field(i) field := val.Field(i)
fieldType := typ.Field(i) fieldType := typ.Field(i)
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
continue
}
// Only process fields with the "options" tag set to "file"
optionsTag := fieldType.Tag.Get("options") optionsTag := fieldType.Tag.Get("options")
if optionsTag != "file" { options := strings.Split(optionsTag, ",")
continue
}
// Only process fields with the "env" tag for _, option := range options {
envTag := fieldType.Tag.Get("env") switch option {
if envTag == "" { case "toLower":
continue if field.Kind() == reflect.String {
} field.SetString(strings.ToLower(field.String()))
}
envVarName := envTag case "file":
if commaIndex := len(envTag); commaIndex > 0 { err := resolveFileBasedEnvVariable(field, fieldType)
envVarName = envTag[:commaIndex] if err != nil {
} return err
}
// If the file environment variable is not set, skip }
envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" {
continue
}
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
} }
} }
return nil return nil
} }
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
// reads the content of the file specified by that variable, and sets the corresponding field's value.
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error {
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
return nil
}
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
return nil
}
envVarName := envTag
if commaIndex := len(envTag); commaIndex > 0 {
envVarName = envTag[:commaIndex]
}
// If the file environment variable is not set, skip
envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" {
return nil
}
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
}
return nil
}

View File

@@ -17,18 +17,19 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should parse valid SQLite config correctly", func(t *testing.T) { t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
EnvConfig = defaultConfig() EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite") t.Setenv("DB_PROVIDER", "SQLITE") // should be lowercased automatically
t.Setenv("DB_CONNECTION_STRING", "file:test.db") t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000") t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
err := parseEnvConfig() err := parseEnvConfig()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider) assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
}) })
t.Run("should parse valid Postgres config correctly", func(t *testing.T) { t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
EnvConfig = defaultConfig() EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "postgres") t.Setenv("DB_PROVIDER", "POSTGRES")
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db") t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
t.Setenv("APP_URL", "https://example.com") t.Setenv("APP_URL", "https://example.com")
@@ -51,7 +52,6 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) { t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
EnvConfig = defaultConfig() EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite") t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "") // Explicitly empty
t.Setenv("APP_URL", "http://localhost:3000") t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig() err := parseEnvConfig()
@@ -192,25 +192,25 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("DB_PROVIDER", "postgres") t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_CONNECTION_STRING", "postgres://test") t.Setenv("DB_CONNECTION_STRING", "postgres://test")
t.Setenv("APP_URL", "https://prod.example.com") t.Setenv("APP_URL", "https://prod.example.com")
t.Setenv("APP_ENV", "staging") t.Setenv("APP_ENV", "STAGING")
t.Setenv("UPLOAD_PATH", "/custom/uploads") t.Setenv("UPLOAD_PATH", "/custom/uploads")
t.Setenv("KEYS_PATH", "/custom/keys") t.Setenv("KEYS_PATH", "/custom/keys")
t.Setenv("PORT", "8080") t.Setenv("PORT", "8080")
t.Setenv("HOST", "127.0.0.1") t.Setenv("HOST", "LOCALHOST")
t.Setenv("UNIX_SOCKET", "/tmp/app.sock") t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license") t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb") t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
err := parseEnvConfig() err := parseEnvConfig()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "staging", EnvConfig.AppEnv) assert.Equal(t, "staging", EnvConfig.AppEnv) // lowercased
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath) assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port) assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "127.0.0.1", EnvConfig.Host) assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
}) })
} }
func TestResolveFileBasedEnvVariables(t *testing.T) { func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
// Create temporary directory for test files // Create temporary directory for test files
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -225,103 +225,34 @@ func TestResolveFileBasedEnvVariables(t *testing.T) {
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600) err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err) require.NoError(t, err)
// Create a binary file for testing binary data handling
binaryKeyFile := tempDir + "/binary_key.bin" binaryKeyFile := tempDir + "/binary_key.bin"
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10} binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04}
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600) err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
require.NoError(t, err) require.NoError(t, err)
t.Run("should read file content for fields with options:file tag", func(t *testing.T) { t.Run("should process toLower and file options", func(t *testing.T) {
config := defaultConfig() config := defaultConfig()
config.AppEnv = "STAGING"
config.Host = "LOCALHOST"
// Set environment variables pointing to files
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile) t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile) t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config) err := prepareEnvConfig(&config)
require.NoError(t, err) require.NoError(t, err)
// Verify file contents were read correctly assert.Equal(t, "staging", config.AppEnv)
assert.Equal(t, "localhost", config.Host)
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey) assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString) assert.Equal(t, dbConnContent, config.DbConnectionString)
}) })
t.Run("should skip fields without options:file tag", func(t *testing.T) {
config := defaultConfig()
originalAppURL := config.AppURL
// Set a file for a field that doesn't have options:file tag
t.Setenv("APP_URL_FILE", "/tmp/nonexistent.txt")
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// AppURL should remain unchanged
assert.Equal(t, originalAppURL, config.AppURL)
})
t.Run("should skip non-string fields", func(t *testing.T) {
// This test verifies that non-string fields are skipped
// We test this indirectly by ensuring the function doesn't error
// when processing the actual EnvConfigSchema which has bool fields
config := defaultConfig()
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
})
t.Run("should skip when _FILE environment variable is not set", func(t *testing.T) {
config := defaultConfig()
originalEncryptionKey := config.EncryptionKey
// Don't set ENCRYPTION_KEY_FILE environment variable
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// EncryptionKey should remain unchanged
assert.Equal(t, originalEncryptionKey, config.EncryptionKey)
})
t.Run("should handle multiple file-based variables simultaneously", func(t *testing.T) {
config := defaultConfig()
// Set multiple file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// All should be resolved correctly
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should handle mixed file and non-file environment variables", func(t *testing.T) {
config := defaultConfig()
// Set both file and non-file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// File-based should be resolved, others should remain as set by env parser
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, "http://localhost:1411", config.AppURL)
})
t.Run("should handle binary data correctly", func(t *testing.T) { t.Run("should handle binary data correctly", func(t *testing.T) {
config := defaultConfig() config := defaultConfig()
// Set environment variable pointing to binary file
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile) t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
err := resolveFileBasedEnvVariables(&config) err := prepareEnvConfig(&config)
require.NoError(t, err) require.NoError(t, err)
// Verify binary data was read correctly without corruption
assert.Equal(t, binaryKeyContent, config.EncryptionKey) assert.Equal(t, binaryKeyContent, config.EncryptionKey)
}) })
} }

View File

@@ -41,6 +41,7 @@ type AppConfigUpdateDto struct {
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`

View File

@@ -12,7 +12,8 @@ type UserDto struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email" ` Email string `json:"email" `
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName string `json:"lastName"` LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"` Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
@@ -22,14 +23,15 @@ type UserDto struct {
} }
type UserCreateDto struct { type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email string `json:"email" binding:"required,email" unorm:"nfc"` Email string `json:"email" binding:"required,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"` DisplayName string `json:"displayName" binding:"required,max=100" unorm:"nfc"`
Locale *string `json:"locale"` IsAdmin bool `json:"isAdmin"`
Disabled bool `json:"disabled"` Locale *string `json:"locale"`
LdapID string `json:"-"` Disabled bool `json:"disabled"`
LdapID string `json:"-"`
} }
func (u UserCreateDto) Validate() error { func (u UserCreateDto) Validate() error {

View File

@@ -15,59 +15,74 @@ func TestUserCreateDto_Validate(t *testing.T) {
{ {
name: "valid input", name: "valid input",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: "test@example.com", Email: "test@example.com",
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe",
}, },
wantErr: "", wantErr: "",
}, },
{ {
name: "missing username", name: "missing username",
input: UserCreateDto{ input: UserCreateDto{
Email: "test@example.com", Email: "test@example.com",
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe",
}, },
wantErr: "Field validation for 'Username' failed on the 'required' tag", wantErr: "Field validation for 'Username' failed on the 'required' tag",
}, },
{ {
name: "username contains invalid characters", name: "missing display name",
input: UserCreateDto{ input: UserCreateDto{
Username: "test/ser",
Email: "test@example.com", Email: "test@example.com",
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
}, },
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
},
{
name: "username contains invalid characters",
input: UserCreateDto{
Username: "test/ser",
Email: "test@example.com",
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Username' failed on the 'username' tag", wantErr: "Field validation for 'Username' failed on the 'username' tag",
}, },
{ {
name: "invalid email", name: "invalid email",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: "not-an-email", Email: "not-an-email",
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe",
}, },
wantErr: "Field validation for 'Email' failed on the 'email' tag", wantErr: "Field validation for 'Email' failed on the 'email' tag",
}, },
{ {
name: "first name too short", name: "first name too short",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: "test@example.com", Email: "test@example.com",
FirstName: "", FirstName: "",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe",
}, },
wantErr: "Field validation for 'FirstName' failed on the 'required' tag", wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
}, },
{ {
name: "last name too long", name: "last name too long",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: "test@example.com", Email: "test@example.com",
FirstName: "John", FirstName: "John",
LastName: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
DisplayName: "John Doe",
}, },
wantErr: "Field validation for 'LastName' failed on the 'max' tag", wantErr: "Field validation for 'LastName' failed on the 'max' tag",
}, },

View File

@@ -77,7 +77,7 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
case "email": case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName) errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username": case "username":
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName) errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url": case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName) errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "min": case "min":

View File

@@ -74,6 +74,7 @@ type AppConfig struct {
LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"` LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"` LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"` LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName AppConfigVariable `key:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"` LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"` LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`

View File

@@ -13,14 +13,15 @@ import (
type User struct { type User struct {
Base Base
Username string `sortable:"true"` Username string `sortable:"true"`
Email string `sortable:"true"` Email string `sortable:"true"`
FirstName string `sortable:"true"` FirstName string `sortable:"true"`
LastName string `sortable:"true"` LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"` DisplayName string `sortable:"true"`
Locale *string IsAdmin bool `sortable:"true"`
LdapID *string Locale *string
Disabled bool `sortable:"true"` LdapID *string
Disabled bool `sortable:"true"`
CustomClaims []CustomClaim CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -31,7 +32,12 @@ func (u User) WebAuthnID() []byte { return []byte(u.ID) }
func (u User) WebAuthnName() string { return u.Username } func (u User) WebAuthnName() string { return u.Username }
func (u User) WebAuthnDisplayName() string { return u.FirstName + " " + u.LastName } func (u User) WebAuthnDisplayName() string {
if u.DisplayName != "" {
return u.DisplayName
}
return u.FirstName + " " + u.LastName
}
func (u User) WebAuthnIcon() string { return "" } func (u User) WebAuthnIcon() string { return "" }
@@ -66,7 +72,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
return descriptors return descriptors
} }
func (u User) FullName() string { return u.FirstName + " " + u.LastName } func (u User) FullName() string {
return u.FirstName + " " + u.LastName
}
func (u User) Initials() string { func (u User) Initials() string {
first := utils.GetFirstCharacter(u.FirstName) first := utils.GetFirstCharacter(u.FirstName)

View File

@@ -100,6 +100,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
LdapAttributeUserEmail: model.AppConfigVariable{}, LdapAttributeUserEmail: model.AppConfigVariable{},
LdapAttributeUserFirstName: model.AppConfigVariable{}, LdapAttributeUserFirstName: model.AppConfigVariable{},
LdapAttributeUserLastName: model.AppConfigVariable{}, LdapAttributeUserLastName: model.AppConfigVariable{},
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"},
LdapAttributeUserProfilePicture: model.AppConfigVariable{}, LdapAttributeUserProfilePicture: model.AppConfigVariable{},
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"}, LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{}, LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},

View File

@@ -25,6 +25,7 @@ func isReservedClaim(key string) bool {
"name", "name",
"email", "email",
"preferred_username", "preferred_username",
"display_name",
"groups", "groups",
TokenTypeClaim, TokenTypeClaim,
"sub", "sub",

View File

@@ -78,21 +78,23 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{ Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
}, },
Username: "tim", Username: "tim",
Email: "tim.cook@test.com", Email: "tim.cook@test.com",
FirstName: "Tim", FirstName: "Tim",
LastName: "Cook", LastName: "Cook",
IsAdmin: true, DisplayName: "Tim Cook",
IsAdmin: true,
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
}, },
Username: "craig", Username: "craig",
Email: "craig.federighi@test.com", Email: "craig.federighi@test.com",
FirstName: "Craig", FirstName: "Craig",
LastName: "Federighi", LastName: "Federighi",
IsAdmin: false, DisplayName: "Craig Federighi",
IsAdmin: false,
}, },
} }
for _, user := range users { for _, user := range users {

View File

@@ -278,6 +278,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
dbConfig.LdapAttributeUserFirstName.Value, dbConfig.LdapAttributeUserFirstName.Value,
dbConfig.LdapAttributeUserLastName.Value, dbConfig.LdapAttributeUserLastName.Value,
dbConfig.LdapAttributeUserProfilePicture.Value, dbConfig.LdapAttributeUserProfilePicture.Value,
dbConfig.LdapAttributeUserDisplayName.Value,
} }
// Filters must start and finish with ()! // Filters must start and finish with ()!
@@ -346,12 +347,13 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
} }
newUser := dto.UserCreateDto{ newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value), Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
IsAdmin: isAdmin, DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
LdapID: ldapId, IsAdmin: isAdmin,
LdapID: ldapId,
} }
dto.Normalize(newUser) dto.Normalize(newUser)

View File

@@ -1838,13 +1838,6 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
} }
if slices.Contains(scopes, "profile") { if slices.Contains(scopes, "profile") {
// Add profile claims
claims["given_name"] = user.FirstName
claims["family_name"] = user.LastName
claims["name"] = user.FullName()
claims["preferred_username"] = user.Username
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
// Add custom claims // Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx) customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
if err != nil { if err != nil {
@@ -1863,6 +1856,15 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
claims[customClaim.Key] = customClaim.Value claims[customClaim.Key] = customClaim.Value
} }
} }
// Add profile claims
claims["given_name"] = user.FirstName
claims["family_name"] = user.LastName
claims["name"] = user.FullName()
claims["display_name"] = user.DisplayName
claims["preferred_username"] = user.Username
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
} }
if slices.Contains(scopes, "email") { if slices.Contains(scopes, "email") {

View File

@@ -245,12 +245,13 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) { func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
user := model.User{ user := model.User{
FirstName: input.FirstName, FirstName: input.FirstName,
LastName: input.LastName, LastName: input.LastName,
Email: input.Email, DisplayName: input.DisplayName,
Username: input.Username, Email: input.Email,
IsAdmin: input.IsAdmin, Username: input.Username,
Locale: input.Locale, IsAdmin: input.IsAdmin,
Locale: input.Locale,
} }
if input.LdapID != "" { if input.LdapID != "" {
user.LdapID = &input.LdapID user.LdapID = &input.LdapID
@@ -362,6 +363,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
// Full update: Allow updating all personal fields // Full update: Allow updating all personal fields
user.FirstName = updatedUser.FirstName user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName user.LastName = updatedUser.LastName
user.DisplayName = updatedUser.DisplayName
user.Email = updatedUser.Email user.Email = updatedUser.Email
user.Username = updatedUser.Username user.Username = updatedUser.Username
user.Locale = updatedUser.Locale user.Locale = updatedUser.Locale
@@ -600,11 +602,12 @@ func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.Sig
} }
userToCreate := dto.UserCreateDto{ userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName, FirstName: signUpData.FirstName,
LastName: signUpData.LastName, LastName: signUpData.LastName,
Username: signUpData.Username, DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Email: signUpData.Email, Username: signUpData.Username,
IsAdmin: true, Email: signUpData.Email,
IsAdmin: true,
} }
user, err := s.createUserInternal(ctx, userToCreate, false, tx) user, err := s.createUserInternal(ctx, userToCreate, false, tx)
@@ -736,10 +739,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
} }
userToCreate := dto.UserCreateDto{ userToCreate := dto.UserCreateDto{
Username: signupData.Username, Username: signupData.Username,
Email: signupData.Email, Email: signupData.Email,
FirstName: signupData.FirstName, FirstName: signupData.FirstName,
LastName: signupData.LastName, LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
} }
user, err := s.createUserInternal(ctx, userToCreate, false, tx) user, err := s.createUserInternal(ctx, userToCreate, false, tx)

View File

@@ -3,3 +3,11 @@ package utils
func Ptr[T any](v T) *T { func Ptr[T any](v T) *T {
return &v return &v
} }
func PtrValueOrZero[T any](ptr *T) T {
if ptr == nil {
var zero T
return zero
}
return *ptr
}

View File

@@ -0,0 +1,3 @@
ALTER TABLE users DROP COLUMN display_name;
ALTER TABLE users ALTER COLUMN username TYPE TEXT;

View File

@@ -0,0 +1,6 @@
ALTER TABLE users ADD COLUMN display_name TEXT;
UPDATE users SET display_name = trim(coalesce(first_name,'') || ' ' || coalesce(last_name,''));
ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;
CREATE EXTENSION IF NOT EXISTS citext;
ALTER TABLE users ALTER COLUMN username TYPE CITEXT COLLATE "C";

View File

@@ -0,0 +1,3 @@
BEGIN;
ALTER TABLE users DROP COLUMN display_name;
COMMIT;

View File

@@ -0,0 +1,42 @@
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE users_new
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
email TEXT NOT NULL UNIQUE,
first_name TEXT,
last_name TEXT NOT NULL,
display_name TEXT NOT NULL,
is_admin NUMERIC NOT NULL DEFAULT FALSE,
ldap_id TEXT,
locale TEXT,
disabled NUMERIC NOT NULL DEFAULT FALSE
);
INSERT INTO users_new (id, created_at, username, email, first_name, last_name, display_name, is_admin, ldap_id, locale,
disabled)
SELECT id,
created_at,
username,
email,
first_name,
COALESCE(last_name, ''),
TRIM(COALESCE(first_name, '') || ' ' || COALESCE(last_name, '')),
is_admin,
ldap_id,
locale,
disabled
FROM users;
DROP TABLE users;
ALTER TABLE users_new
RENAME TO users;
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
COMMIT;
PRAGMA foreign_keys = ON;

View File

@@ -120,6 +120,8 @@
"username": "Username", "username": "Username",
"save": "Save", "save": "Save",
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols", "username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
"username_must_start_with": "Username must start with an alphanumeric character",
"username_must_end_with": "Username must end with an alphanumeric character",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.", "sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
"or_visit": "or visit", "or_visit": "or visit",
"added_on": "Added on", "added_on": "Added on",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Set a custom client ID if this is required by your application. Otherwise, leave it blank to generate a random one.", "custom_client_id_description": "Set a custom client ID if this is required by your application. Otherwise, leave it blank to generate a random one.",
"generated": "Generated", "generated": "Generated",
"administration": "Administration", "administration": "Administration",
"group_rdn_attribute_description": "The attribute used in the groups distinguished name (DN). Recommended value: `cn`", "group_rdn_attribute_description": "The attribute used in the groups distinguished name (DN).",
"display_name_attribute": "Display Name Attribute",
"display_name": "Display Name",
"configure_application_images": "Configure Application Images",
"ui_config_disabled_info_title": "UI Configuration Disabled", "ui_config_disabled_info_title": "UI Configuration Disabled",
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable." "ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable."
} }

View File

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

View File

@@ -5,6 +5,7 @@
import { preventDefault } from '$lib/utils/event-util'; import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { tryCatch } from '$lib/utils/try-catch-util'; import { tryCatch } from '$lib/utils/try-catch-util';
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
let { let {
@@ -24,12 +25,8 @@
const formSchema = z.object({ const formSchema = z.object({
firstName: z.string().min(1).max(50), firstName: z.string().min(1).max(50),
lastName: z.string().max(50).optional(), lastName: emptyToUndefined(z.string().max(50).optional()),
username: z username: usernameSchema,
.string()
.min(2)
.max(30)
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
email: z.email() email: z.email()
}); });
type FormSchema = typeof formSchema; type FormSchema = typeof formSchema;

View File

@@ -41,6 +41,7 @@ export type AllAppConfig = AppConfig & {
ldapAttributeUserEmail: string; ldapAttributeUserEmail: string;
ldapAttributeUserFirstName: string; ldapAttributeUserFirstName: string;
ldapAttributeUserLastName: string; ldapAttributeUserLastName: string;
ldapAttributeUserDisplayName: string;
ldapAttributeUserProfilePicture: string; ldapAttributeUserProfilePicture: string;
ldapAttributeGroupMember: string; ldapAttributeGroupMember: string;
ldapAttributeGroupUniqueIdentifier: string; ldapAttributeGroupUniqueIdentifier: string;

View File

@@ -8,6 +8,7 @@ export type User = {
email: string; email: string;
firstName: string; firstName: string;
lastName?: string; lastName?: string;
displayName: string;
isAdmin: boolean; isAdmin: boolean;
userGroups: UserGroup[]; userGroups: UserGroup[];
customClaims: CustomClaim[]; customClaims: CustomClaim[];
@@ -18,6 +19,6 @@ export type User = {
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>; export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
export type UserSignUp = Omit<UserCreate, 'isAdmin' | 'disabled'> & { export type UserSignUp = Omit<UserCreate, 'isAdmin' | 'disabled' | 'displayName'> & {
token?: string; token?: string;
}; };

View File

@@ -1,8 +1,19 @@
import { setLocale as setParaglideLocale, type Locale } from '$lib/paraglide/runtime'; import {
extractLocaleFromCookie,
setLocale as setParaglideLocale,
type Locale
} from '$lib/paraglide/runtime';
import { setDefaultOptions } from 'date-fns'; import { setDefaultOptions } from 'date-fns';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
export async function setLocale(locale: Locale, reload = true) { export async function setLocale(locale: Locale, reload = true) {
await setLocaleForLibraries(locale);
setParaglideLocale(locale, { reload });
}
export async function setLocaleForLibraries(
locale: Locale = (extractLocaleFromCookie() as Locale) || 'en'
) {
const [zodResult, dateFnsResult] = await Promise.allSettled([ const [zodResult, dateFnsResult] = await Promise.allSettled([
import(`../../../node_modules/zod/v4/locales/${locale}.js`), import(`../../../node_modules/zod/v4/locales/${locale}.js`),
import(`../../../node_modules/date-fns/locale/${locale}.js`) import(`../../../node_modules/date-fns/locale/${locale}.js`)
@@ -14,8 +25,6 @@ export async function setLocale(locale: Locale, reload = true) {
console.warn(`Failed to load zod locale for ${locale}:`, zodResult.reason); console.warn(`Failed to load zod locale for ${locale}:`, zodResult.reason);
} }
setParaglideLocale(locale, { reload });
if (dateFnsResult.status === 'fulfilled') { if (dateFnsResult.status === 'fulfilled') {
setDefaultOptions({ setDefaultOptions({
locale: dateFnsResult.value.default locale: dateFnsResult.value.default

View File

@@ -1,8 +1,8 @@
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import z from 'zod/v4'; import { z } from 'zod/v4';
export const emptyToUndefined = <T>(validation: z.ZodType<T>) => export const emptyToUndefined = <T>(validation: z.ZodType<T>) =>
z.preprocess((v) => (v === '' ? undefined : v), validation); z.preprocess((v) => (v === '' ? undefined : v), validation.optional());
export const optionalUrl = z export const optionalUrl = z
.url() .url()
@@ -26,3 +26,11 @@ export const callbackUrlSchema = z
message: m.invalid_redirect_url() message: m.invalid_redirect_url()
} }
); );
export const usernameSchema = z
.string()
.min(2)
.max(30)
.regex(/^[a-zA-Z0-9]/, m.username_must_start_with())
.regex(/[a-zA-Z0-9]$/, m.username_must_end_with())
.regex(/^[a-zA-Z0-9_.@-]+$/, m.username_can_only_contain());

View File

@@ -2,6 +2,7 @@ import AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { setLocaleForLibraries } from '$lib/utils/locale.util';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
export const ssr = false; export const ssr = false;
@@ -29,6 +30,8 @@ export const load: LayoutLoad = async () => {
appConfigStore.set(appConfig); appConfigStore.set(appConfig);
} }
await setLocaleForLibraries();
return { return {
user, user,
appConfig appConfig

View File

@@ -8,6 +8,7 @@
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util'; import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
@@ -26,17 +27,15 @@
} = $props(); } = $props();
let isLoading = $state(false); let isLoading = $state(false);
let hasManualDisplayNameEdit = $state(!!account.displayName);
const userService = new UserService(); const userService = new UserService();
const formSchema = z.object({ const formSchema = z.object({
firstName: z.string().min(1).max(50), firstName: z.string().min(1).max(50),
lastName: z.string().max(50).optional(), lastName: emptyToUndefined(z.string().max(50).optional()),
username: z displayName: z.string().max(100),
.string() username: usernameSchema,
.min(2)
.max(30)
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
email: z.email(), email: z.email(),
isAdmin: z.boolean() isAdmin: z.boolean()
}); });
@@ -44,6 +43,14 @@
const { inputs, ...form } = createForm<FormSchema>(formSchema, account); const { inputs, ...form } = createForm<FormSchema>(formSchema, account);
function onNameInput() {
if (!hasManualDisplayNameEdit) {
$inputs.displayName.value = `${$inputs.firstName.value}${
$inputs.lastName?.value ? ' ' + $inputs.lastName.value : ''
}`;
}
}
async function onSubmit() { async function onSubmit() {
const data = form.validate(); const data = form.validate();
if (!data) return; if (!data) return;
@@ -68,7 +75,6 @@
</script> </script>
<form onsubmit={preventDefault(onSubmit)} class="space-y-6"> <form onsubmit={preventDefault(onSubmit)} class="space-y-6">
<!-- Profile Picture Section -->
<ProfilePictureSettings <ProfilePictureSettings
{userId} {userId}
{isLdapUser} {isLdapUser}
@@ -76,31 +82,32 @@
resetCallback={resetProfilePicture} resetCallback={resetProfilePicture}
/> />
<!-- Divider -->
<hr class="border-border" /> <hr class="border-border" />
<!-- User Information -->
<fieldset disabled={userInfoInputDisabled}> <fieldset disabled={userInfoInputDisabled}>
<div> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-3 sm:flex-row"> <div>
<div class="w-full"> <FormInput label={m.first_name()} bind:input={$inputs.firstName} onInput={onNameInput} />
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
</div>
<div class="w-full">
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
</div>
</div> </div>
<div class="mt-3 flex flex-col gap-3 sm:flex-row"> <div>
<div class="w-full"> <FormInput label={m.last_name()} bind:input={$inputs.lastName} onInput={onNameInput} />
<FormInput label={m.email()} bind:input={$inputs.email} /> </div>
</div> <div>
<div class="w-full"> <FormInput
<FormInput label={m.username()} bind:input={$inputs.username} /> label={m.display_name()}
</div> bind:input={$inputs.displayName}
onInput={() => (hasManualDisplayNameEdit = true)}
/>
</div>
<div>
<FormInput label={m.username()} bind:input={$inputs.username} />
</div>
<div>
<FormInput label={m.email()} bind:input={$inputs.email} />
</div> </div>
</div> </div>
<div class="flex justify-end pt-2"> <div class="flex justify-end pt-4">
<Button {isLoading} type="submit">{m.save()}</Button> <Button {isLoading} type="submit">{m.save()}</Button>
</div> </div>
</fieldset> </fieldset>

View File

@@ -120,7 +120,12 @@
</div> </div>
<div> <div>
<CollapsibleCard id="application-configuration-images" icon={LucideImage} title={m.images()}> <CollapsibleCard
id="application-configuration-images"
icon={LucideImage}
title={m.images()}
description={m.configure_application_images()}
>
<UpdateApplicationImages callback={updateImages} /> <UpdateApplicationImages callback={updateImages} />
</CollapsibleCard> </CollapsibleCard>
</div> </div>

View File

@@ -38,6 +38,7 @@
ldapAttributeUserEmail: z.string().min(1), ldapAttributeUserEmail: z.string().min(1),
ldapAttributeUserFirstName: z.string().min(1), ldapAttributeUserFirstName: z.string().min(1),
ldapAttributeUserLastName: z.string().min(1), ldapAttributeUserLastName: z.string().min(1),
ldapAttributeUserDisplayName: z.string().min(1),
ldapAttributeUserProfilePicture: z.string(), ldapAttributeUserProfilePicture: z.string(),
ldapAttributeGroupMember: z.string(), ldapAttributeGroupMember: z.string(),
ldapAttributeGroupUniqueIdentifier: z.string().min(1), ldapAttributeGroupUniqueIdentifier: z.string().min(1),
@@ -159,6 +160,11 @@
placeholder="sn" placeholder="sn"
bind:input={$inputs.ldapAttributeUserLastName} bind:input={$inputs.ldapAttributeUserLastName}
/> />
<FormInput
label={m.display_name_attribute()}
placeholder="displayName"
bind:input={$inputs.ldapAttributeUserDisplayName}
/>
<FormInput <FormInput
label={m.user_profile_picture_attribute()} label={m.user_profile_picture_attribute()}
description={m.the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image()} description={m.the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image()}

View File

@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { User, UserCreate } from '$lib/types/user.type'; import type { User, UserCreate } from '$lib/types/user.type';
import { preventDefault } from '$lib/utils/event-util'; import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
let { let {
@@ -19,10 +20,12 @@
let isLoading = $state(false); let isLoading = $state(false);
let inputDisabled = $derived(!!existingUser?.ldapId && $appConfigStore.ldapEnabled); let inputDisabled = $derived(!!existingUser?.ldapId && $appConfigStore.ldapEnabled);
let hasManualDisplayNameEdit = $state(!!existingUser?.displayName);
const user = { const user = {
firstName: existingUser?.firstName || '', firstName: existingUser?.firstName || '',
lastName: existingUser?.lastName || '', lastName: existingUser?.lastName || '',
displayName: existingUser?.displayName || '',
email: existingUser?.email || '', email: existingUser?.email || '',
username: existingUser?.username || '', username: existingUser?.username || '',
isAdmin: existingUser?.isAdmin || false, isAdmin: existingUser?.isAdmin || false,
@@ -31,12 +34,9 @@
const formSchema = z.object({ const formSchema = z.object({
firstName: z.string().min(1).max(50), firstName: z.string().min(1).max(50),
lastName: z.string().max(50), lastName: emptyToUndefined(z.string().max(50).optional()),
username: z displayName: z.string().max(100),
.string() username: usernameSchema,
.min(2)
.max(30)
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
email: z.email(), email: z.email(),
isAdmin: z.boolean(), isAdmin: z.boolean(),
disabled: z.boolean() disabled: z.boolean()
@@ -53,15 +53,29 @@
if (success && !existingUser) form.reset(); if (success && !existingUser) form.reset();
isLoading = false; isLoading = false;
} }
function onNameInput() {
if (!hasManualDisplayNameEdit) {
$inputs.displayName.value = `${$inputs.firstName.value}${
$inputs.lastName?.value ? ' ' + $inputs.lastName.value : ''
}`;
}
}
</script> </script>
<form onsubmit={preventDefault(onSubmit)}> <form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={inputDisabled}> <fieldset disabled={inputDisabled}>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} /> <FormInput label={m.first_name()} oninput={onNameInput} bind:input={$inputs.firstName} />
<FormInput label={m.last_name()} bind:input={$inputs.lastName} /> <FormInput label={m.last_name()} oninput={onNameInput} bind:input={$inputs.lastName} />
<FormInput
label={m.display_name()}
oninput={() => (hasManualDisplayNameEdit = true)}
bind:input={$inputs.displayName}
/>
<FormInput label={m.username()} bind:input={$inputs.username} /> <FormInput label={m.username()} bind:input={$inputs.username} />
<FormInput label={m.email()} bind:input={$inputs.email} /> <FormInput label={m.email()} bind:input={$inputs.email} />
</div>
<div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<SwitchWithLabel <SwitchWithLabel
id="admin-privileges" id="admin-privileges"
label={m.admin_privileges()} label={m.admin_privileges()}

View File

@@ -103,6 +103,7 @@
columns={[ columns={[
{ label: m.first_name(), sortColumn: 'firstName' }, { label: m.first_name(), sortColumn: 'firstName' },
{ label: m.last_name(), sortColumn: 'lastName' }, { label: m.last_name(), sortColumn: 'lastName' },
{ label: m.display_name(), sortColumn: 'displayName' },
{ label: m.email(), sortColumn: 'email' }, { label: m.email(), sortColumn: 'email' },
{ label: m.username(), sortColumn: 'username' }, { label: m.username(), sortColumn: 'username' },
{ label: m.role(), sortColumn: 'isAdmin' }, { label: m.role(), sortColumn: 'isAdmin' },
@@ -114,6 +115,7 @@
{#snippet rows({ item })} {#snippet rows({ item })}
<Table.Cell>{item.firstName}</Table.Cell> <Table.Cell>{item.firstName}</Table.Cell>
<Table.Cell>{item.lastName}</Table.Cell> <Table.Cell>{item.lastName}</Table.Cell>
<Table.Cell>{item.displayName}</Table.Cell>
<Table.Cell>{item.email}</Table.Cell> <Table.Cell>{item.email}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell> <Table.Cell>{item.username}</Table.Cell>
<Table.Cell> <Table.Cell>

View File

@@ -31,7 +31,7 @@
</script> </script>
<Card.Root <Card.Root
class="border-muted group relative h-[140px] p-5 transition-all duration-200 hover:shadow-md" class="border-muted group relative h-[140px] p-5 transition-all duration-200 hover:shadow-md sm:max-w-[50vw] md:max-w-[400px]"
data-testid="authorized-oidc-client-card" data-testid="authorized-oidc-client-card"
> >
<Card.Content class=" p-0"> <Card.Content class=" p-0">
@@ -49,14 +49,14 @@
<div> <div>
<div class="mb-1 flex items-start gap-2"> <div class="mb-1 flex items-start gap-2">
<h3 <h3
class="text-foreground line-clamp-2 text-ellipsis break-words break-all font-semibold leading-tight" class="text-foreground line-clamp-2 leading-tight font-semibold break-words break-all text-ellipsis"
> >
{client.name} {client.name}
</h3> </h3>
</div> </div>
{#if client.launchURL} {#if client.launchURL}
<p <p
class="text-muted-foreground line-clamp-1 text-ellipsis break-words break-all text-xs" class="text-muted-foreground line-clamp-1 text-xs break-words break-all text-ellipsis"
> >
{new URL(client.launchURL).hostname} {new URL(client.launchURL).hostname}
</p> </p>

View File

@@ -113,7 +113,7 @@ git add frontend/package.json
# Generate changelog # Generate changelog
echo "Generating changelog..." echo "Generating changelog..."
conventional-changelog -p conventionalcommits -i CHANGELOG.md -s conventional-changelog -p conventionalcommits -i CHANGELOG.md -s --pkg frontend/package.json
git add CHANGELOG.md git add CHANGELOG.md
# Commit the changes with the new version # Commit the changes with the new version

View File

@@ -3,6 +3,7 @@ export const users = {
id: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e', id: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
firstname: 'Tim', firstname: 'Tim',
lastname: 'Cook', lastname: 'Cook',
displayName: 'Tim Cook',
email: 'tim.cook@test.com', email: 'tim.cook@test.com',
username: 'tim' username: 'tim'
}, },
@@ -10,12 +11,14 @@ export const users = {
id: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036', id: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
firstname: 'Craig', firstname: 'Craig',
lastname: 'Federighi', lastname: 'Federighi',
displayName: 'Craig Federighi',
email: 'craig.federighi@test.com', email: 'craig.federighi@test.com',
username: 'craig' username: 'craig'
}, },
steve: { steve: {
firstname: 'Steve', firstname: 'Steve',
lastname: 'Jobs', lastname: 'Jobs',
displayName: 'Steve Jobs',
email: 'steve.jobs@test.com', email: 'steve.jobs@test.com',
username: 'steve' username: 'steve'
} }

View File

@@ -9,8 +9,10 @@ test.beforeEach(async () => await cleanupBackend());
test('Update account details', async ({ page }) => { test('Update account details', async ({ page }) => {
await page.goto('/settings/account'); await page.goto('/settings/account');
await page.getByLabel('Display Name').fill('Tim Apple');
await page.getByLabel('First name').fill('Timothy'); await page.getByLabel('First name').fill('Timothy');
await page.getByLabel('Last name').fill('Apple'); await page.getByLabel('Last name').fill('Apple');
await page.getByLabel('Display Name').fill('Timothy Apple');
await page.getByLabel('Email').fill('timothy.apple@test.com'); await page.getByLabel('Email').fill('timothy.apple@test.com');
await page.getByLabel('Username').fill('timothy'); await page.getByLabel('Username').fill('timothy');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
@@ -40,6 +42,18 @@ test('Update account details fails with already taken username', async ({ page }
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use'); await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
}); });
test('Update account details fails with already taken username in different casing', async ({
page
}) => {
await page.goto('/settings/account');
await page.getByLabel('Username').fill(users.craig.username.toUpperCase());
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
});
test('Change Locale', async ({ page }) => { test('Change Locale', async ({ page }) => {
await page.goto('/settings/account'); await page.goto('/settings/account');

View File

@@ -14,6 +14,9 @@ test('Create user', async ({ page }) => {
await page.getByLabel('Last name').fill(user.lastname); await page.getByLabel('Last name').fill(user.lastname);
await page.getByLabel('Email').fill(user.email); await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Username').fill(user.username); await page.getByLabel('Username').fill(user.username);
await expect(page.getByLabel('Display Name')).toHaveValue(`${user.firstname} ${user.lastname}`);
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('row', { name: `${user.firstname} ${user.lastname}` })).toBeVisible(); await expect(page.getByRole('row', { name: `${user.firstname} ${user.lastname}` })).toBeVisible();
@@ -50,6 +53,21 @@ test('Create user fails with already taken username', async ({ page }) => {
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use'); await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
}); });
test('Create user fails with already taken username in different casing', async ({ page }) => {
const user = users.steve;
await page.goto('/settings/admin/users');
await page.getByRole('button', { name: 'Add User' }).click();
await page.getByLabel('First name').fill(user.firstname);
await page.getByLabel('Last name').fill(user.lastname);
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Username').fill(users.tim.username.toUpperCase());
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
});
test('Create one time access token', async ({ page, context }) => { test('Create one time access token', async ({ page, context }) => {
await page.goto('/settings/admin/users'); await page.goto('/settings/admin/users');
@@ -106,6 +124,7 @@ test('Update user', async ({ page }) => {
await page.getByLabel('First name').fill('Crack'); await page.getByLabel('First name').fill('Crack');
await page.getByLabel('Last name').fill('Apple'); await page.getByLabel('Last name').fill('Apple');
await page.getByLabel('Display Name').fill('Crack Apple');
await page.getByLabel('Email').fill('crack.apple@test.com'); await page.getByLabel('Email').fill('crack.apple@test.com');
await page.getByLabel('Username').fill('crack'); await page.getByLabel('Username').fill('crack');
await page.getByRole('button', { name: 'Save' }).first().click(); await page.getByRole('button', { name: 'Save' }).first().click();
@@ -147,6 +166,23 @@ test('Update user fails with already taken username', async ({ page }) => {
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use'); await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
}); });
test('Update user fails with already taken username in different casing', async ({ page }) => {
const user = users.craig;
await page.goto('/settings/admin/users');
await page
.getByRole('row', { name: `${user.firstname} ${user.lastname}` })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Username').fill(users.tim.username.toUpperCase());
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
});
test('Update user custom claims', async ({ page }) => { test('Update user custom claims', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`); await page.goto(`/settings/admin/users/${users.craig.id}`);

View File

@@ -124,7 +124,7 @@ test.describe('User Signup', () => {
await page.getByRole('button', { name: 'Sign Up' }).click(); await page.getByRole('button', { name: 'Sign Up' }).click();
await expect(page.getByText('Invalid input').first()).toBeVisible(); await expect(page.getByText('Invalid email address').first()).toBeVisible();
}); });
test('Open signup - duplicate email shows error', async ({ page }) => { test('Open signup - duplicate email shows error', async ({ page }) => {