From 68373604dd30065947226922233bc1e19e778b01 Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Wed, 17 Sep 2025 10:18:27 -0500 Subject: [PATCH] feat: add user display name field (#898) Co-authored-by: Elias Schneider --- backend/internal/dto/app_config_dto.go | 1 + backend/internal/dto/user_dto.go | 20 ++++--- backend/internal/dto/user_dto_test.go | 57 ++++++++++++------- backend/internal/model/app_config.go | 1 + backend/internal/model/user.go | 28 +++++---- .../internal/service/app_config_service.go | 1 + .../internal/service/custom_claim_service.go | 1 + backend/internal/service/e2etest_service.go | 22 +++---- backend/internal/service/ldap_service.go | 14 +++-- backend/internal/service/oidc_service.go | 16 +++--- backend/internal/service/user_service.go | 34 ++++++----- backend/internal/utils/ptr_util.go | 8 +++ .../20250829120000_user_display_name.down.sql | 1 + .../20250829120000_user_display_name.up.sql | 6 ++ .../20250829120000_user_display_name.down.sql | 3 + .../20250829120000_user_display_name.up.sql | 42 ++++++++++++++ frontend/messages/en.json | 5 +- .../lib/components/signup/signup-form.svelte | 3 +- .../lib/types/application-configuration.ts | 1 + frontend/src/lib/types/user.type.ts | 3 +- frontend/src/lib/utils/locale.util.ts | 15 ++++- frontend/src/lib/utils/zod-util.ts | 4 +- frontend/src/routes/+layout.ts | 3 + .../settings/account/account-form.svelte | 51 ++++++++++------- .../application-configuration/+page.svelte | 7 ++- .../forms/app-config-ldap-form.svelte | 6 ++ .../settings/admin/users/user-form.svelte | 26 +++++++-- .../settings/admin/users/user-list.svelte | 2 + tests/data.ts | 3 + tests/specs/account-settings.spec.ts | 2 + tests/specs/user-settings.spec.ts | 4 ++ tests/specs/user-signup.spec.ts | 2 +- 32 files changed, 280 insertions(+), 112 deletions(-) create mode 100644 backend/resources/migrations/postgres/20250829120000_user_display_name.down.sql create mode 100644 backend/resources/migrations/postgres/20250829120000_user_display_name.up.sql create mode 100644 backend/resources/migrations/sqlite/20250829120000_user_display_name.down.sql create mode 100644 backend/resources/migrations/sqlite/20250829120000_user_display_name.up.sql diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 3a73e810..7b8c1d91 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -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"` diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index f3b2b980..5a39f46c 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -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 { diff --git a/backend/internal/dto/user_dto_test.go b/backend/internal/dto/user_dto_test.go index 181e6e8e..014afa53 100644 --- a/backend/internal/dto/user_dto_test.go +++ b/backend/internal/dto/user_dto_test.go @@ -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", }, diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index 2b5227f0..bfe1e7d7 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -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"` diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 5bc04bda..a692b43f 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -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) diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index 996c8b02..65e845a3 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -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{}, diff --git a/backend/internal/service/custom_claim_service.go b/backend/internal/service/custom_claim_service.go index 03cfb124..42aa38b4 100644 --- a/backend/internal/service/custom_claim_service.go +++ b/backend/internal/service/custom_claim_service.go @@ -25,6 +25,7 @@ func isReservedClaim(key string) bool { "name", "email", "preferred_username", + "display_name", "groups", TokenTypeClaim, "sub", diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 1cb35499..cae91c9c 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -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 { diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index fd564d26..6522efcb 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -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) diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index eaf8c474..d085edfd 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -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") { diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index cec7dc7e..da477bb3 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -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) diff --git a/backend/internal/utils/ptr_util.go b/backend/internal/utils/ptr_util.go index 947538cd..d791f67a 100644 --- a/backend/internal/utils/ptr_util.go +++ b/backend/internal/utils/ptr_util.go @@ -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 +} diff --git a/backend/resources/migrations/postgres/20250829120000_user_display_name.down.sql b/backend/resources/migrations/postgres/20250829120000_user_display_name.down.sql new file mode 100644 index 00000000..bf03c636 --- /dev/null +++ b/backend/resources/migrations/postgres/20250829120000_user_display_name.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN display_name; \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20250829120000_user_display_name.up.sql b/backend/resources/migrations/postgres/20250829120000_user_display_name.up.sql new file mode 100644 index 00000000..8b2fcf52 --- /dev/null +++ b/backend/resources/migrations/postgres/20250829120000_user_display_name.up.sql @@ -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; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250829120000_user_display_name.down.sql b/backend/resources/migrations/sqlite/20250829120000_user_display_name.down.sql new file mode 100644 index 00000000..471b1d6e --- /dev/null +++ b/backend/resources/migrations/sqlite/20250829120000_user_display_name.down.sql @@ -0,0 +1,3 @@ +BEGIN; +ALTER TABLE users DROP COLUMN display_name; +COMMIT; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250829120000_user_display_name.up.sql b/backend/resources/migrations/sqlite/20250829120000_user_display_name.up.sql new file mode 100644 index 00000000..f89b67e3 --- /dev/null +++ b/backend/resources/migrations/sqlite/20250829120000_user_display_name.up.sql @@ -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 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; \ No newline at end of file diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a9646258..41accd76 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -443,7 +443,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." } diff --git a/frontend/src/lib/components/signup/signup-form.svelte b/frontend/src/lib/components/signup/signup-form.svelte index 476abaee..448bc8d6 100644 --- a/frontend/src/lib/components/signup/signup-form.svelte +++ b/frontend/src/lib/components/signup/signup-form.svelte @@ -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 } from '$lib/utils/zod-util'; import { z } from 'zod/v4'; let { @@ -24,7 +25,7 @@ const formSchema = z.object({ firstName: z.string().min(1).max(50), - lastName: z.string().max(50).optional(), + lastName: emptyToUndefined(z.string().max(50).optional()), username: z .string() .min(2) diff --git a/frontend/src/lib/types/application-configuration.ts b/frontend/src/lib/types/application-configuration.ts index a6b68dcd..ef56d157 100644 --- a/frontend/src/lib/types/application-configuration.ts +++ b/frontend/src/lib/types/application-configuration.ts @@ -41,6 +41,7 @@ export type AllAppConfig = AppConfig & { ldapAttributeUserEmail: string; ldapAttributeUserFirstName: string; ldapAttributeUserLastName: string; + ldapAttributeUserDisplayName: string; ldapAttributeUserProfilePicture: string; ldapAttributeGroupMember: string; ldapAttributeGroupUniqueIdentifier: string; diff --git a/frontend/src/lib/types/user.type.ts b/frontend/src/lib/types/user.type.ts index 2a43d632..e283533e 100644 --- a/frontend/src/lib/types/user.type.ts +++ b/frontend/src/lib/types/user.type.ts @@ -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; -export type UserSignUp = Omit & { +export type UserSignUp = Omit & { token?: string; }; diff --git a/frontend/src/lib/utils/locale.util.ts b/frontend/src/lib/utils/locale.util.ts index efa1ddff..5141f9fa 100644 --- a/frontend/src/lib/utils/locale.util.ts +++ b/frontend/src/lib/utils/locale.util.ts @@ -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 diff --git a/frontend/src/lib/utils/zod-util.ts b/frontend/src/lib/utils/zod-util.ts index 6ea18cb8..079e4310 100644 --- a/frontend/src/lib/utils/zod-util.ts +++ b/frontend/src/lib/utils/zod-util.ts @@ -1,8 +1,8 @@ import { m } from '$lib/paraglide/messages'; -import z from 'zod/v4'; +import { z } from 'zod/v4'; export const emptyToUndefined = (validation: z.ZodType) => - z.preprocess((v) => (v === '' ? undefined : v), validation); + z.preprocess((v) => (v === '' ? undefined : v), validation.optional()); export const optionalUrl = z .url() diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts index 48016f73..9e945030 100644 --- a/frontend/src/routes/+layout.ts +++ b/frontend/src/routes/+layout.ts @@ -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 diff --git a/frontend/src/routes/settings/account/account-form.svelte b/frontend/src/routes/settings/account/account-form.svelte index f89bdde7..83abf81b 100644 --- a/frontend/src/routes/settings/account/account-form.svelte +++ b/frontend/src/routes/settings/account/account-form.svelte @@ -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 } from '$lib/utils/zod-util'; import { toast } from 'svelte-sonner'; import { z } from 'zod/v4'; @@ -26,12 +27,14 @@ } = $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(), + lastName: emptyToUndefined(z.string().max(50).optional()), + displayName: z.string().max(100), username: z .string() .min(2) @@ -44,6 +47,14 @@ const { inputs, ...form } = createForm(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 +79,6 @@
- -
-
-
-
-
- -
-
- -
+
+
+
-
-
- -
-
- -
+
+ +
+
+ (hasManualDisplayNameEdit = true)} + /> +
+
+ +
+
+
-
+
diff --git a/frontend/src/routes/settings/admin/application-configuration/+page.svelte b/frontend/src/routes/settings/admin/application-configuration/+page.svelte index ad03d6e4..fea27fe8 100644 --- a/frontend/src/routes/settings/admin/application-configuration/+page.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/+page.svelte @@ -120,7 +120,12 @@
- +
diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-ldap-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-ldap-form.svelte index 5d096ace..cd2cbbd3 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-ldap-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-ldap-form.svelte @@ -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} /> + - 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 } 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,7 +34,8 @@ const formSchema = z.object({ firstName: z.string().min(1).max(50), - lastName: z.string().max(50), + lastName: emptyToUndefined(z.string().max(50).optional()), + displayName: z.string().max(100), username: z .string() .min(2) @@ -53,15 +57,29 @@ if (success && !existingUser) form.reset(); isLoading = false; } + function onNameInput() { + if (!hasManualDisplayNameEdit) { + $inputs.displayName.value = `${$inputs.firstName.value}${ + $inputs.lastName?.value ? ' ' + $inputs.lastName.value : '' + }`; + } + }
- - + + + (hasManualDisplayNameEdit = true)} + bind:input={$inputs.displayName} + /> +
+
{item.firstName} {item.lastName} + {item.displayName} {item.email} {item.username} diff --git a/tests/data.ts b/tests/data.ts index 72b51c02..01b88b6e 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -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' } diff --git a/tests/specs/account-settings.spec.ts b/tests/specs/account-settings.spec.ts index b10776e7..0499ac25 100644 --- a/tests/specs/account-settings.spec.ts +++ b/tests/specs/account-settings.spec.ts @@ -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(); diff --git a/tests/specs/user-settings.spec.ts b/tests/specs/user-settings.spec.ts index e463ac1b..22db892d 100644 --- a/tests/specs/user-settings.spec.ts +++ b/tests/specs/user-settings.spec.ts @@ -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(); @@ -106,6 +109,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(); diff --git a/tests/specs/user-signup.spec.ts b/tests/specs/user-signup.spec.ts index a86a5848..9b7fb019 100644 --- a/tests/specs/user-signup.spec.ts +++ b/tests/specs/user-signup.spec.ts @@ -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 }) => {