Compare commits

...

8 Commits

Author SHA1 Message Date
Elias Schneider
8973e93cb6 release: 1.11.1 2025-09-18 22:33:22 +02:00
Elias Schneider
8c9cac2655 chore(translations): update translations via Crowdin (#957) 2025-09-18 22:26:38 +02:00
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
56 changed files with 583 additions and 300 deletions

View File

@@ -1 +1 @@
1.10.0
1.11.1

View File

@@ -1,3 +1,36 @@
## [1.11.1](https://github.com/pocket-id/pocket-id/compare/v1.10.0...v1.11.1) (2025-09-18)
### Bug Fixes
- add missing translations ([8c9cac2](https://github.com/pocket-id/pocket-id/commit/8c9cac2655ddbe4872234a1b55fdd51d2f3ac31c))
## [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)
### Features

View File

@@ -32,17 +32,17 @@ const (
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
LogLevel string `env:"LOG_LEVEL"`
AppURL string `env:"APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
AppEnv string `env:"APP_ENV" options:"toLower"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
AppURL string `env:"APP_URL" options:"toLower"`
DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"`
Host string `env:"HOST"`
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
@@ -112,31 +112,40 @@ func parseEnvConfig() error {
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 {
return err
}
// Validate the environment variables
EnvConfig.LogLevel = strings.ToLower(EnvConfig.LogLevel)
if _, err := sloggin.ParseLevel(EnvConfig.LogLevel); err != nil {
return 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'")
}
switch EnvConfig.DbProvider {
switch config.DbProvider {
case DbProviderSqlite:
if EnvConfig.DbConnectionString == "" {
EnvConfig.DbConnectionString = defaultSqliteConnString
if config.DbConnectionString == "" {
config.DbConnectionString = defaultSqliteConnString
}
case DbProviderPostgres:
if EnvConfig.DbConnectionString == "" {
if config.DbConnectionString == "" {
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
default:
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 {
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
if EnvConfig.InternalAppURL == "" {
EnvConfig.InternalAppURL = EnvConfig.AppURL
if config.InternalAppURL == "" {
config.InternalAppURL = config.AppURL
} else {
parsedInternalAppUrl, err := url.Parse(EnvConfig.InternalAppURL)
parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
if err != nil {
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
case "":
EnvConfig.KeysStorage = "file"
config.KeysStorage = "file"
case "database":
if EnvConfig.EncryptionKey == nil {
if config.EncryptionKey == nil {
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
}
case "file":
// All good, these are valid values
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
}
// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets
func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
// prepareEnvConfig processes special options for EnvConfig fields
func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem()
typ := val.Type()
@@ -183,48 +193,65 @@ func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
field := val.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")
if optionsTag != "file" {
continue
}
options := strings.Split(optionsTag, ",")
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
continue
}
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 == "" {
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)
for _, option := range options {
switch option {
case "toLower":
if field.Kind() == reflect.String {
field.SetString(strings.ToLower(field.String()))
}
case "file":
err := resolveFileBasedEnvVariable(field, fieldType)
if err != nil {
return err
}
}
}
}
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) {
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("APP_URL", "http://localhost:3000")
t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
err := parseEnvConfig()
require.NoError(t, err)
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) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_PROVIDER", "POSTGRES")
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
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) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "") // Explicitly empty
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
@@ -192,25 +192,25 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
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("KEYS_PATH", "/custom/keys")
t.Setenv("PORT", "8080")
t.Setenv("HOST", "127.0.0.1")
t.Setenv("HOST", "LOCALHOST")
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
err := parseEnvConfig()
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, "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
tempDir := t.TempDir()
@@ -225,103 +225,34 @@ func TestResolveFileBasedEnvVariables(t *testing.T) {
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err)
// Create a binary file for testing binary data handling
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)
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.AppEnv = "STAGING"
config.Host = "LOCALHOST"
// Set environment variables pointing to files
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
err := prepareEnvConfig(&config)
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, 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) {
config := defaultConfig()
// Set environment variable pointing to binary file
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
err := resolveFileBasedEnvVariables(&config)
err := prepareEnvConfig(&config)
require.NoError(t, err)
// Verify binary data was read correctly without corruption
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
})
}

View File

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

View File

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

View File

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

View File

@@ -77,7 +77,7 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
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":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "min":

View File

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

View File

@@ -13,14 +13,15 @@ import (
type User struct {
Base
Username string `sortable:"true"`
Email string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true"`
Username string `sortable:"true"`
Email string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
DisplayName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true"`
CustomClaims []CustomClaim
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) 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 "" }
@@ -66,7 +72,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
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 {
first := utils.GetFirstCharacter(u.FirstName)

View File

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

View File

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

View File

@@ -78,21 +78,23 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
},
Username: "tim",
Email: "tim.cook@test.com",
FirstName: "Tim",
LastName: "Cook",
IsAdmin: true,
Username: "tim",
Email: "tim.cook@test.com",
FirstName: "Tim",
LastName: "Cook",
DisplayName: "Tim Cook",
IsAdmin: true,
},
{
Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
},
Username: "craig",
Email: "craig.federighi@test.com",
FirstName: "Craig",
LastName: "Federighi",
IsAdmin: false,
Username: "craig",
Email: "craig.federighi@test.com",
FirstName: "Craig",
LastName: "Federighi",
DisplayName: "Craig Federighi",
IsAdmin: false,
},
}
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.LdapAttributeUserLastName.Value,
dbConfig.LdapAttributeUserProfilePicture.Value,
dbConfig.LdapAttributeUserDisplayName.Value,
}
// 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{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
IsAdmin: isAdmin,
LdapID: ldapId,
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
IsAdmin: isAdmin,
LdapID: ldapId,
}
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") {
// 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
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
if err != nil {
@@ -1863,6 +1856,15 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
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") {

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) {
user := model.User{
FirstName: input.FirstName,
LastName: input.LastName,
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
Locale: input.Locale,
FirstName: input.FirstName,
LastName: input.LastName,
DisplayName: input.DisplayName,
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
Locale: input.Locale,
}
if 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
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.DisplayName = updatedUser.DisplayName
user.Email = updatedUser.Email
user.Username = updatedUser.Username
user.Locale = updatedUser.Locale
@@ -600,11 +602,12 @@ func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.Sig
}
userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
}
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{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
}
user, err := s.createUserInternal(ctx, userToCreate, false, tx)

View File

@@ -3,3 +3,11 @@ package utils
func Ptr[T any](v T) *T {
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": "Uživatelské jméno",
"save": "Uložit",
"username_can_only_contain": "Uživatelské jméno může obsahovat pouze malá písmena, číslice, podtržítka, tečky, pomlčky a symbol '@'",
"username_must_start_with": "Uživatelské jméno musí začínat alfanumerickým znakem.",
"username_must_end_with": "Uživatelské jméno musí končit alfanumerickým znakem.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Přihlaste se pomocí následujícího kódu. Platnost kódu vyprší za 15 minut.",
"or_visit": "nebo navštívit",
"added_on": "Přidáno",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Nastavte vlastní ID klienta, pokud to vyžaduje vaše aplikace. V opačném případě pole nechte prázdné, aby bylo vygenerováno náhodné ID.",
"generated": "Vygenerováno",
"administration": "Správa",
"group_rdn_attribute_description": "Atribut použitý v rozlišovacím jménu skupiny (DN). Doporučená hodnota: `cn`",
"group_rdn_attribute_description": "Atribut použitý v rozlišovacím jménu (DN) skupiny.",
"display_name_attribute": "Atribut zobrazovaného jména",
"display_name": "Zobrazované jméno",
"configure_application_images": "Konfigurace obrazů aplikací",
"ui_config_disabled_info_title": "Konfigurace uživatelského rozhraní je deaktivována",
"ui_config_disabled_info_description": "Konfigurace uživatelského rozhraní je deaktivována, protože nastavení konfigurace aplikace se spravuje prostřednictvím proměnných prostředí. Některá nastavení nemusí být editovatelná."
}

View File

@@ -120,6 +120,8 @@
"username": "Brugernavn",
"save": "Gem",
"username_can_only_contain": "Brugernavn må kun indeholde små bogstaver, tal, understregninger (_), punktummer (.), bindestreger (-) og @-tegn",
"username_must_start_with": "Brugernavnet skal begynde med et alfanumerisk tegn",
"username_must_end_with": "Brugernavnet skal slutte med et alfanumerisk tegn",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Log ind med nedenstående kode. Koden udløber om 15 minutter.",
"or_visit": "eller besøg",
"added_on": "Tilføjet den",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Indstil et brugerdefineret klient-id, hvis dette kræves af din applikation. Ellers skal du lade feltet være tomt for at generere et tilfældigt id.",
"generated": "Genereret",
"administration": "Administration",
"group_rdn_attribute_description": "Den attribut, der bruges i gruppernes skelnenavn (DN). Anbefalet værdi: `cn`",
"group_rdn_attribute_description": "Den attribut, der bruges i gruppernes skelnenavn (DN).",
"display_name_attribute": "Visningsnavn-attribut",
"display_name": "Visningsnavn",
"configure_application_images": "Konfigurer applikationsbilleder",
"ui_config_disabled_info_title": "UI-konfiguration deaktiveret",
"ui_config_disabled_info_description": "UI-konfigurationen er deaktiveret, fordi applikationskonfigurationsindstillingerne administreres via miljøvariabler. Nogle indstillinger kan muligvis ikke redigeres."
}

View File

@@ -120,6 +120,8 @@
"username": "Benutzername",
"save": "Speichern",
"username_can_only_contain": "Der Benutzername darf nur Kleinbuchstaben, Ziffern, Unterstriche, Punkte, Bindestriche und das Symbol „@“ enthalten",
"username_must_start_with": "Der Benutzername muss mit einem Buchstaben oder einer Zahl anfangen.",
"username_must_end_with": "Der Benutzername muss mit einem Buchstaben oder einer Zahl enden.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Melde dich mit dem folgenden Code an. Der Code läuft in 15 Minuten ab.",
"or_visit": "oder besuche",
"added_on": "Hinzugefügt am",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Gib eine eigene Client-ID ein, wenn deine App das braucht. Ansonsten lass das Feld leer, damit eine zufällige ID generiert wird.",
"generated": "Generiert",
"administration": "Verwaltung",
"group_rdn_attribute_description": "Das Attribut, das im Distinguished Name (DN) der Gruppen benutzt wird. Empfohlener Wert: `cn`",
"group_rdn_attribute_description": "Das Attribut, das im Distinguished Name (DN) der Gruppen benutzt wird.",
"display_name_attribute": "Anzeigename-Attribut",
"display_name": "Anzeigename",
"configure_application_images": "Anwendungsimages einrichten",
"ui_config_disabled_info_title": "UI-Konfiguration deaktiviert",
"ui_config_disabled_info_description": "Die UI-Konfiguration ist deaktiviert, weil die Anwendungseinstellungen über Umgebungsvariablen verwaltet werden. Manche Einstellungen können vielleicht nicht geändert werden."
}

View File

@@ -120,6 +120,8 @@
"username": "Username",
"save": "Save",
"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.",
"or_visit": "or visit",
"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.",
"generated": "Generated",
"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_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable."
}

View File

@@ -120,6 +120,8 @@
"username": "Nombre de usuario",
"save": "Guardar",
"username_can_only_contain": "El nombre de usuario solo puede contener letras minúsculas, números, guiones bajos, puntos, guiones y símbolos '@'",
"username_must_start_with": "El nombre de usuario debe comenzar con un carácter alfanumérico.",
"username_must_end_with": "El nombre de usuario debe terminar con un carácter alfanumérico.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Inicia sesión usando el siguiente código. El código caducará en 15 minutos.",
"or_visit": "o visita",
"added_on": "Añadido el",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Establece un ID de cliente personalizado si tu aplicación lo requiere. De lo contrario, déjalo en blanco para generar uno aleatorio.",
"generated": "Generado",
"administration": "Administración",
"group_rdn_attribute_description": "El atributo utilizado en el nombre distintivo (DN) de los grupos. Valor recomendado: `cn`",
"group_rdn_attribute_description": "El atributo utilizado en el nombre distintivo (DN) de los grupos.",
"display_name_attribute": "Atributo de nombre para mostrar",
"display_name": "Nombre para mostrar",
"configure_application_images": "Configurar imágenes de aplicaciones",
"ui_config_disabled_info_title": "Configuración de la interfaz de usuario desactivada",
"ui_config_disabled_info_description": "La configuración de la interfaz de usuario está desactivada porque los ajustes de configuración de la aplicación se gestionan a través de variables de entorno. Es posible que algunos ajustes no se puedan editar."
}

View File

@@ -120,6 +120,8 @@
"username": "Nom d'utilisateur",
"save": "Enregistrer",
"username_can_only_contain": "Le nom d'utilisateur ne peut contenir que des lettres minuscules, des chiffres, des tirets, des tirets bas et le symbole '@'",
"username_must_start_with": "Le nom d'utilisateur doit commencer par un caractère alphanumérique.",
"username_must_end_with": "Le nom d'utilisateur doit finir par un caractère alphanumérique.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Connectez-vous avec le code suivant. Le code expirera dans 15 minutes.",
"or_visit": "ou visiter",
"added_on": "Ajoutée le",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Définissez un identifiant client personnalisé si votre application l'exige. Sinon, laissez ce champ vide pour qu'un identifiant aléatoire soit généré.",
"generated": "Généré",
"administration": "Administration",
"group_rdn_attribute_description": "L'attribut utilisé dans le nom distinctif (DN) des groupes. Valeur recommandée : `cn`",
"group_rdn_attribute_description": "L'attribut utilisé dans le nom distinctif (DN) des groupes.",
"display_name_attribute": "Attribut du nom d'affichage",
"display_name": "Nom d'affichage",
"configure_application_images": "Configurer les images d'application",
"ui_config_disabled_info_title": "Configuration de l'interface utilisateur désactivée",
"ui_config_disabled_info_description": "La configuration de l'interface utilisateur est désactivée parce que les paramètres de configuration de l'application sont gérés par des variables d'environnement. Certains paramètres peuvent ne pas être modifiables."
}

View File

@@ -120,6 +120,8 @@
"username": "Nome utente",
"save": "Salva",
"username_can_only_contain": "Il nome utente può contenere solo lettere minuscole, numeri, underscore, punti, trattini e simboli '@'",
"username_must_start_with": "Il nome utente deve iniziare con un carattere alfanumerico.",
"username_must_end_with": "Il nome utente deve finire con un carattere alfanumerico",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Accedi utilizzando il seguente codice. Il codice scadrà tra 15 minuti.",
"or_visit": "o visita",
"added_on": "Aggiunto il",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Imposta un ID cliente personalizzato se la tua app lo richiede. Altrimenti, lascia vuoto per generarne uno casuale.",
"generated": "Generato",
"administration": "Amministrazione",
"group_rdn_attribute_description": "L'attributo usato nel nome distinto (DN) dei gruppi. Valore consigliato: `cn`",
"group_rdn_attribute_description": "L'attributo usato nel nome distinto (DN) dei gruppi.",
"display_name_attribute": "Attributo del nome visualizzato",
"display_name": "Nome visualizzato",
"configure_application_images": "Configurare le immagini dell'applicazione",
"ui_config_disabled_info_title": "Configurazione dell'interfaccia utente disattivata",
"ui_config_disabled_info_description": "La configurazione dell'interfaccia utente è disattivata perché le impostazioni di configurazione dell'applicazione sono gestite tramite variabili di ambiente. Alcune impostazioni potrebbero non essere modificabili."
}

View File

@@ -120,6 +120,8 @@
"username": "사용자 이름",
"save": "저장",
"username_can_only_contain": "사용자 이름은 영어 소문자, 숫자, 밑줄, 점, 하이픈, '@' 기호만 포함할 수 있습니다",
"username_must_start_with": "사용자 이름은 영숫자로 시작해야 합니다",
"username_must_end_with": "사용자 이름은 영숫자로 끝나야 합니다",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "다음 코드를 사용하여 로그인하세요. 이 코드는 15분 후에 만료됩니다.",
"or_visit": "또는",
"added_on": "추가:",
@@ -443,7 +445,10 @@
"custom_client_id_description": "애플리케이션에서 사용자 정의 클라이언트 ID가 요구되는 경우 설정하세요. 그렇지 않으면 빈 상태로 두어서 무작위로 생성할 수 있습니다.",
"generated": "생성됨",
"administration": "관리",
"group_rdn_attribute_description": "그룹의 고유 식별자(DN)에 사용되는 속성. 권장 값: `cn`",
"group_rdn_attribute_description": "그룹의 고유 식별자(DN)에 사용되는 속성.",
"display_name_attribute": "표시 이름 속성",
"display_name": "표시 이름",
"configure_application_images": "애플리케이션 이미지 구성",
"ui_config_disabled_info_title": "UI 구성 비활성화됨",
"ui_config_disabled_info_description": "UI 구성이 비활성화되었습니다. 애플리케이션 구성 설정은 환경 변수를 통해 관리되기 때문입니다. 일부 설정은 편집할 수 없을 수 있습니다."
}

View File

@@ -120,6 +120,8 @@
"username": "Gebruikersnaam",
"save": "Opslaan",
"username_can_only_contain": "Gebruikersnaam mag alleen kleine letters, cijfers, onderstrepingstekens, punten, koppeltekens en '@'-symbolen bevatten",
"username_must_start_with": "Je gebruikersnaam moet beginnen met een letter of cijfer.",
"username_must_end_with": "Je gebruikersnaam moet eindigen met een letter of cijfer.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Meld je aan met de volgende code. De code verloopt over 15 minuten.",
"or_visit": "of bezoek",
"added_on": "Toegevoegd op",
@@ -419,7 +421,7 @@
"created": "Gemaakt",
"token": "Token",
"loading": "Bezig met laden",
"delete_signup_token": "Registratietoken verwijderen",
"delete_signup_token": "Aanmeldtoken verwijderen",
"are_you_sure_you_want_to_delete_this_signup_token": "Weet je zeker dat je dit aanmeldingstoken wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"signup_with_token": "Aanmelden met token",
"signup_with_token_description": "Je kunt je alleen aanmelden met een geldige aanmeldtoken die door een beheerder is aangemaakt.",
@@ -439,11 +441,14 @@
"revoke_access_description": "Toegang intrekken tot <b>{clientName}</b>. <b>{clientName}</b> kan je accountgegevens niet meer gebruiken.",
"revoke_access_successful": "De toegang tot {clientName} is nu succesvol geblokkeerd.",
"last_signed_in_ago": "Laatst ingelogd {time} geleden",
"invalid_client_id": "De client-ID mag alleen letters, cijfers, onderstrepingstekens en koppeltekens bevatten",
"custom_client_id_description": "Stel een aangepaste client-ID in als je app dit nodig heeft. Anders laat je het gewoon leeg en wordt er een willekeurige ID gegenereerd.",
"invalid_client_id": "De Client-ID mag alleen letters, cijfers, onderstrepingstekens en koppeltekens bevatten.",
"custom_client_id_description": "Stel een aangepaste Client-ID in als je app dit nodig heeft. Als je het leeg laat wordt er een willekeurige ID gegenereerd.",
"generated": "Gemaakt",
"administration": "Beheer",
"group_rdn_attribute_description": "Het kenmerk dat je gebruikt in de onderscheidende naam (DN) van de groepen. Aanbevolen waarde: `cn`",
"group_rdn_attribute_description": "Het kenmerk dat wordt gebruikt in de onderscheidende naam (DN) van de groepen.",
"display_name_attribute": "Weergavenaam-attribuut",
"display_name": "Weergavenaam",
"configure_application_images": "Configureer applicatieafbeeldingen",
"ui_config_disabled_info_title": "UI-configuratie uitgeschakeld",
"ui_config_disabled_info_description": "De UI-configuratie is uitgeschakeld omdat de configuratie-instellingen van de app via omgevingsvariabelen worden beheerd. Sommige instellingen kun je misschien niet aanpassen."
}

View File

@@ -120,6 +120,8 @@
"username": "Nazwa użytkownika",
"save": "Zapisz",
"username_can_only_contain": "Nazwa użytkownika może zawierać tylko małe litery, cyfry, podkreślenia, kropki, myślniki i symbole '@'",
"username_must_start_with": "Nazwa użytkownika musi zaczynać się od znaku alfanumerycznego.",
"username_must_end_with": "Nazwa użytkownika musi kończyć się znakiem alfanumerycznym.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Zaloguj się, używając następującego kodu. Kod wygaśnie za 15 minut.",
"or_visit": "lub odwiedź",
"added_on": "Dodano",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Ustaw niestandardowy identyfikator klienta, jeśli jest to wymagane przez twoją aplikację. W przeciwnym razie pozostaw to pole puste, aby wygenerować losowy identyfikator.",
"generated": "Wygenerowano",
"administration": "Administracja",
"group_rdn_attribute_description": "Atrybut używany w nazwie wyróżniającej grupy (DN). Zalecana wartość: `cn`",
"group_rdn_attribute_description": "Atrybut używany w nazwie wyróżniającej grupy (DN).",
"display_name_attribute": "Atrybut nazwy wyświetlanej",
"display_name": "Wyświetlana nazwa",
"configure_application_images": "Konfigurowanie obrazów aplikacji",
"ui_config_disabled_info_title": "Konfiguracja interfejsu użytkownika wyłączona",
"ui_config_disabled_info_description": "Konfiguracja interfejsu użytkownika jest wyłączona, ponieważ ustawienia konfiguracyjne aplikacji są zarządzane za pomocą zmiennych środowiskowych. Niektóre ustawienia mogą nie być edytowalne."
}

View File

@@ -120,6 +120,8 @@
"username": "Nome de usuário",
"save": "Salvar",
"username_can_only_contain": "O nome de usuário só pode conter letras minúsculas, números, underscores, pontos, hífens e símbolos '@'",
"username_must_start_with": "O nome de usuário precisa começar com um caractere alfanumérico.",
"username_must_end_with": "O nome de usuário precisa terminar com um caractere alfanumérico.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Faça o login usando o código a seguir. O código irá expirar em 15 minutos.",
"or_visit": "ou visite",
"added_on": "Adicionado em",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Defina um ID de cliente personalizado se for necessário para o seu aplicativo. Caso contrário, deixe em branco para gerar um aleatório.",
"generated": "Gerado",
"administration": "Administração",
"group_rdn_attribute_description": "O atributo usado no nome distinto (DN) dos grupos. Valor recomendado: `cn`",
"group_rdn_attribute_description": "O atributo usado no nome distinto (DN) dos grupos.",
"display_name_attribute": "Atributo Nome de exibição",
"display_name": "Nome de exibição",
"configure_application_images": "Configurar imagens de aplicativos",
"ui_config_disabled_info_title": "Configuração da interface do usuário desativada",
"ui_config_disabled_info_description": "A configuração da interface do usuário está desativada porque as configurações do aplicativo são gerenciadas por meio de variáveis de ambiente. Algumas configurações podem não ser editáveis."
}

View File

@@ -120,6 +120,8 @@
"username": "Имя пользователя",
"save": "Сохранить",
"username_can_only_contain": "Имя пользователя может содержать только строчные буквы, цифры, знак подчеркивания, точки, дефиса и символ '@'",
"username_must_start_with": "Имя пользователя должно начинаться с буквы или цифры",
"username_must_end_with": "Имя пользователя должно заканчиваться буквой или цифрой",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Войдите, используя следующий код. Код истечет через 15 минут.",
"or_visit": "или посетите",
"added_on": "Добавлен",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Установите пользовательский ID клиента, если это нужно для вашего приложения. Если нет, оставьте поле пустым, чтобы он был сгенерирован случайным образом.",
"generated": "Сгенерированный",
"administration": "Администрирование",
"group_rdn_attribute_description": "Атрибут, который используется в distinguished name (DN) групп. Рекомендуемое значение: `cn`",
"group_rdn_attribute_description": "Атрибут, который используется в различающемся имени группы (DN).",
"display_name_attribute": "Атрибут отображаемого имени",
"display_name": "Отображаемое имя",
"configure_application_images": "Настройка изображений приложения",
"ui_config_disabled_info_title": "Конфигурация пользовательского интерфейса отключена",
"ui_config_disabled_info_description": "Конфигурация пользовательского интерфейса отключена, потому что настройки приложения управляются через переменные среды. Некоторые настройки могут быть недоступны для редактирования."
}

View File

@@ -120,6 +120,8 @@
"username": "Användarnamn",
"save": "Spara",
"username_can_only_contain": "Användarnamnet får endast innehålla små bokstäver, siffror, understreck, punkter, bindestreck och '@'-tecken",
"username_must_start_with": "Användarnamnet måste börja med ett alfanumeriskt tecken",
"username_must_end_with": "Användarnamnet måste sluta med ett alfanumeriskt tecken",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Logga in med följande kod. Koden upphör att gälla om 15 minuter.",
"or_visit": "eller besök",
"added_on": "Tillagd den",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Ange ett anpassat Client ID om detta krävs av din applikation. Annars lämnar du fältet tomt för att generera ett slumpmässigt ID.",
"generated": "Genererad",
"administration": "Administration",
"group_rdn_attribute_description": "Attributet som används i gruppens distinguished name (DN). Rekommenderat värde: `cn`",
"group_rdn_attribute_description": "Attributet som används i gruppernas distinkta namn (DN).",
"display_name_attribute": "Visningsnamnattribut",
"display_name": "Visningsnamn",
"configure_application_images": "Konfigurera applikationsbilder",
"ui_config_disabled_info_title": "UI-konfiguration inaktiverad",
"ui_config_disabled_info_description": "UI-konfigurationen är inaktiverad eftersom applikationens konfigurationsinställningar hanteras via miljövariabler. Vissa inställningar kan inte redigeras."
}

View File

@@ -120,6 +120,8 @@
"username": "Ім’я користувача",
"save": "Зберегти",
"username_can_only_contain": "Ім’я користувача може містити лише малі літери, цифри, підкреслення, крапки, дефіси та символ '@'",
"username_must_start_with": "Ім'я користувача повинно починатися з буквено-цифрового символу",
"username_must_end_with": "Ім'я користувача повинно закінчуватися буквено-цифровим символом",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Увійдіть, використовуючи наступний код. Код дійсний протягом 15 хвилин.",
"or_visit": "або відвідайте",
"added_on": "Додано",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Встановіть власний ідентифікатор клієнта, якщо це потрібно для вашої програми. В іншому випадку залиште поле порожнім, щоб створити випадковий ідентифікатор.",
"generated": "Створено",
"administration": "Адміністрація",
"group_rdn_attribute_description": "Атрибут, що використовується в розпізнавальному імені групи (DN). Рекомендоване значення: `cn`",
"group_rdn_attribute_description": "Атрибут, що використовується в розрізнювальному імені групи (DN).",
"display_name_attribute": "Атрибут імені для відображення",
"display_name": "Ім'я для відображення",
"configure_application_images": "Налаштування зображень додатків",
"ui_config_disabled_info_title": "Конфігурація інтерфейсу користувача вимкнена",
"ui_config_disabled_info_description": "Конфігурація інтерфейсу користувача вимкнена, оскільки налаштування конфігурації програми керуються через змінні середовища. Деякі налаштування можуть бути недоступними для редагування."
}

View File

@@ -120,6 +120,8 @@
"username": "Tên đăng nhập",
"save": "Lưu",
"username_can_only_contain": "Tên người dùng chỉ có thể chứa các ký tự chữ thường, số, dấu gạch dưới, dấu chấm, dấu gạch ngang và ký hiệu '@'.",
"username_must_start_with": "Tên người dùng phải bắt đầu bằng một ký tự alphanumeric.",
"username_must_end_with": "Tên người dùng phải kết thúc bằng một ký tự alphanumeric.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Đăng nhập bằng mã sau. Mã này sẽ hết hạn trong 15 phút.",
"or_visit": "hoặc truy cập",
"added_on": "Đã được thêm vào",
@@ -443,7 +445,10 @@
"custom_client_id_description": "Đặt ID khách hàng tùy chỉnh nếu ứng dụng của bạn yêu cầu. Nếu không, hãy để trống để hệ thống tự động tạo một ID ngẫu nhiên.",
"generated": "Được tạo ra",
"administration": "Quản lý",
"group_rdn_attribute_description": "Thuộc tính được sử dụng trong tên phân biệt (DN) của nhóm. Giá trị được khuyến nghị: `cn`",
"group_rdn_attribute_description": "Thuộc tính được sử dụng trong tên phân biệt (DN) của nhóm.",
"display_name_attribute": "Thuộc tính Tên hiển thị",
"display_name": "Tên hiển thị",
"configure_application_images": "Cấu hình hình ảnh ứng dụng",
"ui_config_disabled_info_title": "Cấu hình giao diện người dùng đã bị vô hiệu hóa",
"ui_config_disabled_info_description": "Cấu hình giao diện người dùng (UI) đã bị vô hiệu hóa vì các thiết lập cấu hình ứng dụng được quản lý thông qua biến môi trường. Một số thiết lập có thể không thể chỉnh sửa."
}

View File

@@ -120,6 +120,8 @@
"username": "用户名",
"save": "保存",
"username_can_only_contain": "用户名只能包含小写字母、数字、下划线、点、连字符和 '@' 符号",
"username_must_start_with": "用户名必须以字母数字字符开头",
"username_must_end_with": "用户名必须以字母数字字符结尾",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代码登录。该代码将在 15 分钟后失效。",
"or_visit": "或访问",
"added_on": "添加于",
@@ -443,7 +445,10 @@
"custom_client_id_description": "此处可根据应用需要设置自定义客户端 ID。留空随机生成。",
"generated": "已生成",
"administration": "管理员选项",
"group_rdn_attribute_description": "在组的区分名称DN中使用的属性。推荐值:`cn`",
"group_rdn_attribute_description": "在组的区分名称DN中使用的属性。",
"display_name_attribute": "显示名称属性",
"display_name": "显示名称",
"configure_application_images": "配置应用程序映像",
"ui_config_disabled_info_title": "用户界面配置已禁用",
"ui_config_disabled_info_description": "用户界面配置已禁用,因为应用程序配置设置通过环境变量进行管理。某些设置可能无法编辑。"
}

View File

@@ -120,6 +120,8 @@
"username": "使用者名稱",
"save": "儲存",
"username_can_only_contain": "使用者名稱僅能包含小寫英文字母、數字、底線_、句點.)、連字號(-)與 @ 符號",
"username_must_start_with": "使用者名稱必須以英數字元開頭",
"username_must_end_with": "使用者名稱必須以英數字元結尾",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代碼登入。 這個代碼將於 15 分鐘後到期。",
"or_visit": "或造訪",
"added_on": "新增於",
@@ -443,7 +445,10 @@
"custom_client_id_description": "如果您的應用程式需要,請設定自訂用戶端 ID。否則請留空以產生隨機 ID。",
"generated": "產生",
"administration": "行政管理",
"group_rdn_attribute_description": "群組別名 (DN) 中使用的屬性。建議值: `cn`",
"group_rdn_attribute_description": "用於群組別名DN的屬性。",
"display_name_attribute": "顯示名稱屬性",
"display_name": "顯示名稱",
"configure_application_images": "設定應用程式映像檔",
"ui_config_disabled_info_title": "使用者介面設定已停用",
"ui_config_disabled_info_description": "使用者介面設定已停用,因為應用程式的設定參數是透過環境變數進行管理。部分設定可能無法編輯。"
}

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ export type User = {
email: string;
firstName: string;
lastName?: string;
displayName: string;
isAdmin: boolean;
userGroups: UserGroup[];
customClaims: CustomClaim[];
@@ -18,6 +19,6 @@ export type User = {
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;
};

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 { z } from 'zod/v4';
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([
import(`../../../node_modules/zod/v4/locales/${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);
}
setParaglideLocale(locale, { reload });
if (dateFnsResult.status === 'fulfilled') {
setDefaultOptions({
locale: dateFnsResult.value.default

View File

@@ -1,8 +1,8 @@
import { m } from '$lib/paraglide/messages';
import z from 'zod/v4';
import { z } from 'zod/v4';
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
.url()
@@ -26,3 +26,11 @@ export const callbackUrlSchema = z
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 appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { setLocaleForLibraries } from '$lib/utils/locale.util';
import type { LayoutLoad } from './$types';
export const ssr = false;
@@ -29,6 +30,8 @@ export const load: LayoutLoad = async () => {
appConfigStore.set(appConfig);
}
await setLocaleForLibraries();
return {
user,
appConfig

View File

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

View File

@@ -120,7 +120,12 @@
</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} />
</CollapsibleCard>
</div>

View File

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

View File

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

View File

@@ -31,7 +31,7 @@
</script>
<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"
>
<Card.Content class=" p-0">
@@ -49,14 +49,14 @@
<div>
<div class="mb-1 flex items-start gap-2">
<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}
</h3>
</div>
{#if client.launchURL}
<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}
</p>

View File

@@ -113,7 +113,7 @@ git add frontend/package.json
# Generate 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
# Commit the changes with the new version

View File

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

View File

@@ -9,8 +9,10 @@ test.beforeEach(async () => await cleanupBackend());
test('Update account details', async ({ page }) => {
await page.goto('/settings/account');
await page.getByLabel('Display Name').fill('Tim Apple');
await page.getByLabel('First name').fill('Timothy');
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('Username').fill('timothy');
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');
});
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 }) => {
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('Email').fill(user.email);
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 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');
});
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 }) => {
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('Last name').fill('Apple');
await page.getByLabel('Display Name').fill('Crack Apple');
await page.getByLabel('Email').fill('crack.apple@test.com');
await page.getByLabel('Username').fill('crack');
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');
});
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 }) => {
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 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 }) => {