diff --git a/core/ldapauth/ldapauth.go b/core/ldapauth/ldapauth.go index 2be1831ad..33f79f90e 100644 --- a/core/ldapauth/ldapauth.go +++ b/core/ldapauth/ldapauth.go @@ -244,6 +244,7 @@ func authLDAP(ctx context.Context, ds model.DataStore, src Source, username, pas u.IsAdmin = memberAny(du.Groups, src.AdminGroupDNs) u.AuthSource = "ldap" u.AuthSourceID = first(src.ID, src.Name) + u.ExternalSync = true if err := repo.Put(u); err != nil { return nil, err } diff --git a/model/user.go b/model/user.go index 71a4caf9e..95e30a6d8 100644 --- a/model/user.go +++ b/model/user.go @@ -27,6 +27,9 @@ type User struct { NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec // If changing the password, this is also required CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"` + + // ExternalSync allows external auth providers to update read-only identity fields for managed users. + ExternalSync bool `structs:"-" json:"-"` } func (u User) HasLibraryAccess(libraryID int) bool { diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 37f7bf448..abf762f95 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -182,8 +182,24 @@ func (r *userRepository) preserveExternalAuthSource(u *model.User) error { if existing.AuthSource == "" { return nil } + if u.ExternalSync { + return nil + } + validation := &rest.ValidationError{Errors: map[string]string{}} if u.NewPassword != "" { - return &rest.ValidationError{Errors: map[string]string{"password": "resources.user.validation.externalPasswordReadOnly"}} + validation.Errors["password"] = "resources.user.validation.externalFieldReadOnly" + } + if !strings.EqualFold(u.UserName, existing.UserName) { + validation.Errors["userName"] = "resources.user.validation.externalFieldReadOnly" + } + if u.Name != existing.Name { + validation.Errors["name"] = "resources.user.validation.externalFieldReadOnly" + } + if u.Email != existing.Email { + validation.Errors["email"] = "resources.user.validation.externalFieldReadOnly" + } + if len(validation.Errors) > 0 { + return validation } if u.AuthSource == "" { u.AuthSource = existing.AuthSource diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 69ca64abf..e30b92e5c 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -70,26 +70,45 @@ var _ = Describe("UserRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) - It("does not update password for LDAP-sourced users", func() { + It("does not update managed fields for LDAP-sourced users", func() { ldapUser := model.User{ ID: "ldap-user", UserName: "ldap_user", Name: "LDAP User", + Email: "ldap@example.com", NewPassword: "generated", AuthSource: "ldap", AuthSourceID: "ldap01", } Expect(repo.Put(&ldapUser)).To(Succeed()) + ldapUser.UserName = "renamed" + ldapUser.Name = "Renamed User" + ldapUser.Email = "renamed@example.com" ldapUser.NewPassword = "newpass" err := repo.Put(&ldapUser) var verr *rest.ValidationError Expect(errors.As(err, &verr)).To(BeTrue()) - Expect(verr.Errors).To(HaveKeyWithValue("password", "resources.user.validation.externalPasswordReadOnly")) + Expect(verr.Errors).To(HaveKeyWithValue("userName", "resources.user.validation.externalFieldReadOnly")) + Expect(verr.Errors).To(HaveKeyWithValue("name", "resources.user.validation.externalFieldReadOnly")) + Expect(verr.Errors).To(HaveKeyWithValue("email", "resources.user.validation.externalFieldReadOnly")) + Expect(verr.Errors).To(HaveKeyWithValue("password", "resources.user.validation.externalFieldReadOnly")) actual, err := repo.FindByUsernameWithPassword("ldap_user") Expect(err).ToNot(HaveOccurred()) + Expect(actual.UserName).To(Equal("ldap_user")) + Expect(actual.Name).To(Equal("LDAP User")) + Expect(actual.Email).To(Equal("ldap@example.com")) Expect(actual.Password).To(Equal("generated")) + + actual.Name = "Synced User" + actual.Email = "synced@example.com" + actual.ExternalSync = true + Expect(repo.Put(actual)).To(Succeed()) + actual, err = repo.FindByUsername("ldap_user") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Name).To(Equal("Synced User")) + Expect(actual.Email).To(Equal("synced@example.com")) }) }) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 25852c589..36dfa7343 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -168,14 +168,14 @@ }, "validation": { "librariesRequired": "At least one library must be selected for non-admin users", - "externalPasswordReadOnly": "Password cannot be changed for users managed by an external authentication source." + "externalFieldReadOnly": "This field is managed by an external authentication source." }, "message": { "listenBrainzToken": "Enter your ListenBrainz user token.", "clickHereForToken": "Click here to get your token", "selectAllLibraries": "Select all libraries", "adminAutoLibraries": "Admin users automatically have access to all libraries", - "externalPasswordReadOnly": "Password changes are disabled because this user is managed by an external authentication source." + "externalPasswordReadOnly": "Username, display name, email, and password changes are disabled because this user is managed by an external authentication source." } }, "player": { diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx index aeabd1f0e..92fb56aae 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.jsx @@ -121,18 +121,37 @@ const UserEdit = (props) => { validate={validateForm} > {permissions === 'admin' && ( - + + {({ formData }) => ( + + )} + )} - - + + {({ formData }) => ( + + )} + + + {({ formData }) => ( + + )} + {({ formData }) => formData.authSource ? (