diff --git a/core/ldapauth/ldapauth.go b/core/ldapauth/ldapauth.go
index e5a4dd09b..2be1831ad 100644
--- a/core/ldapauth/ldapauth.go
+++ b/core/ldapauth/ldapauth.go
@@ -242,6 +242,8 @@ func authLDAP(ctx context.Context, ds model.DataStore, src Source, username, pas
u.Name = first(du.Name, du.UserName)
u.Email = du.Email
u.IsAdmin = memberAny(du.Groups, src.AdminGroupDNs)
+ u.AuthSource = "ldap"
+ u.AuthSourceID = first(src.ID, src.Name)
if err := repo.Put(u); err != nil {
return nil, err
}
diff --git a/db/migrations/20260711130000_add_user_auth_source.sql b/db/migrations/20260711130000_add_user_auth_source.sql
new file mode 100644
index 000000000..13f2b730a
--- /dev/null
+++ b/db/migrations/20260711130000_add_user_auth_source.sql
@@ -0,0 +1,11 @@
+-- +goose Up
+-- +goose StatementBegin
+alter table user add column auth_source varchar(32) default '' not null;
+alter table user add column auth_source_id varchar(255) default '' not null;
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+alter table user drop column auth_source;
+alter table user drop column auth_source_id;
+-- +goose StatementEnd
diff --git a/model/user.go b/model/user.go
index 1c8541ccf..71a4caf9e 100644
--- a/model/user.go
+++ b/model/user.go
@@ -10,6 +10,8 @@ type User struct {
Name string `structs:"name" json:"name"`
Email string `structs:"email" json:"email"`
IsAdmin bool `structs:"is_admin" json:"isAdmin"`
+ AuthSource string `structs:"auth_source" json:"authSource,omitempty"`
+ AuthSourceID string `structs:"auth_source_id" json:"authSourceId,omitempty"`
LastLoginAt *time.Time `structs:"last_login_at" json:"lastLoginAt"`
LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
diff --git a/persistence/user_repository.go b/persistence/user_repository.go
index 9decff4e5..37f7bf448 100644
--- a/persistence/user_repository.go
+++ b/persistence/user_repository.go
@@ -116,6 +116,9 @@ func (r *userRepository) Put(u *model.User) error {
u.ID = id.NewRandom()
}
u.UpdatedAt = time.Now()
+ if err := r.preserveExternalAuthSource(u); err != nil {
+ return err
+ }
if u.NewPassword != "" {
_ = r.encryptPassword(u)
}
@@ -165,6 +168,30 @@ func (r *userRepository) Put(u *model.User) error {
return nil
}
+func (r *userRepository) preserveExternalAuthSource(u *model.User) error {
+ if u.ID == "" {
+ return nil
+ }
+ existing, err := r.Get(u.ID)
+ if errors.Is(err, model.ErrNotFound) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if existing.AuthSource == "" {
+ return nil
+ }
+ if u.NewPassword != "" {
+ return &rest.ValidationError{Errors: map[string]string{"password": "resources.user.validation.externalPasswordReadOnly"}}
+ }
+ if u.AuthSource == "" {
+ u.AuthSource = existing.AuthSource
+ u.AuthSourceID = existing.AuthSourceID
+ }
+ return nil
+}
+
func (r *userRepository) FindFirstAdmin() (*model.User, error) {
sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true})
var usr dbUser
diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go
index 6f8ab9161..69ca64abf 100644
--- a/persistence/user_repository_test.go
+++ b/persistence/user_repository_test.go
@@ -70,6 +70,27 @@ var _ = Describe("UserRepository", func() {
Expect(err).ToNot(HaveOccurred())
Expect(actual.Password).To(Equal("newpass"))
})
+ It("does not update password for LDAP-sourced users", func() {
+ ldapUser := model.User{
+ ID: "ldap-user",
+ UserName: "ldap_user",
+ Name: "LDAP User",
+ NewPassword: "generated",
+ AuthSource: "ldap",
+ AuthSourceID: "ldap01",
+ }
+ Expect(repo.Put(&ldapUser)).To(Succeed())
+
+ 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"))
+
+ actual, err := repo.FindByUsernameWithPassword("ldap_user")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(actual.Password).To(Equal("generated"))
+ })
})
Describe("validatePasswordChange", func() {
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 4c5e57cb7..25852c589 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -167,13 +167,15 @@
"deleted": "User deleted"
},
"validation": {
- "librariesRequired": "At least one library must be selected for non-admin users"
+ "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."
},
"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"
+ "adminAutoLibraries": "Admin users automatically have access to all libraries",
+ "externalPasswordReadOnly": "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 d8302a9f9..aeabd1f0e 100644
--- a/ui/src/user/UserEdit.jsx
+++ b/ui/src/user/UserEdit.jsx
@@ -48,14 +48,16 @@ const UserToolbar = ({ showDelete, ...props }) => (
const CurrentPasswordInput = ({ formData, isMyself, ...rest }) => {
const { permissions } = usePermissions()
- return formData.changePassword && (isMyself || permissions !== 'admin') ? (
+ return formData.changePassword &&
+ !formData.authSource &&
+ (isMyself || permissions !== 'admin') ? (
) : null
}
const NewPasswordInput = ({ formData, ...rest }) => {
const translate = useTranslate()
- return formData.changePassword ? (
+ return formData.changePassword && !formData.authSource ? (
{
{...getNameHelperText()}
/>
-
+
+ {({ formData }) =>
+ formData.authSource ? (
+
+ {translate('resources.user.message.externalPasswordReadOnly')}
+
+ ) : (
+
+ )
+ }
+
{(formDataProps) => (