From b28a87f358cb50b386f134b16b8ccd3028190a95 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 30 Nov 2025 18:19:00 -0800 Subject: [PATCH 1/6] feat(cli): add user administration --- cmd/pls.go | 28 +--- cmd/user.go | 418 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/utils.go | 34 ++++ go.mod | 1 + go.sum | 2 + model/user.go | 2 + 6 files changed, 462 insertions(+), 23 deletions(-) create mode 100644 cmd/user.go create mode 100644 cmd/utils.go diff --git a/cmd/pls.go b/cmd/pls.go index fc0f22fba..9fe1f96c5 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -1,7 +1,6 @@ package cmd import ( - "context" "encoding/csv" "encoding/json" "errors" @@ -10,11 +9,8 @@ import ( "strconv" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" "github.com/spf13/cobra" ) @@ -66,9 +62,7 @@ var ( ) func runExporter() { - sqlDB := db.Db() - ds := persistence.New(sqlDB) - ctx := auth.WithAdminUser(context.Background(), ds) + ds, ctx := getContext() playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false) if err != nil && !errors.Is(err, model.ErrNotFound) { log.Fatal("Error retrieving playlist", "name", playlistID, err) @@ -105,26 +99,14 @@ func runList() { log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat) } - sqlDB := db.Db() - ds := persistence.New(sqlDB) - ctx := auth.WithAdminUser(context.Background(), ds) - + ds, ctx := getContext() options := model.QueryOptions{Sort: "owner_name"} if userID != "" { - user, err := ds.User(ctx).FindByUsername(userID) - - if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal("Error retrieving user by name", "name", userID, err) + user, err := getUser(userID, ds, ctx) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) } - - if errors.Is(err, model.ErrNotFound) { - user, err = ds.User(ctx).Get(userID) - if err != nil { - log.Fatal("Error retrieving user by id", "id", userID, err) - } - } - options.Filters = squirrel.Eq{"owner_id": user.ID} } diff --git a/cmd/user.go b/cmd/user.go new file mode 100644 index 000000000..f2a5fe754 --- /dev/null +++ b/cmd/user.go @@ -0,0 +1,418 @@ +package cmd + +import ( + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + "syscall" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var ( + email string + libraryIds []int + name string + password string + + removeEmail bool + removeName bool + setAdmin bool + setPassword bool + setRegularUser bool +) + +func init() { + rootCmd.AddCommand(userRoot) + + userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username") + + userCreateCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI") + userCreateCommand.Flags().StringVarP(&password, "password", "p", "", "Set the user's password. Note that this will be captured in terminal history") + userCreateCommand.MarkFlagsMutuallyExclusive("password", "set-password") + + userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email") + userCreateCommand.Flags().IntSliceVar(&libraryIds, "library-ids", []int{}, "Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries") + + userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library") + userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)") + + _ = userCreateCommand.MarkFlagRequired("username") + userCreateCommand.MarkFlagsOneRequired("password", "set-password") + + userRoot.AddCommand(userCreateCommand) + + userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id") + _ = userDeleteCommand.MarkFlagRequired("user") + userRoot.AddCommand(userDeleteCommand) + + userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id") + + userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin") + userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin") + userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular") + + userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email") + userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email") + userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email") + + userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name") + userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)") + userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name") + + userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI") + userEditCommand.Flags().StringVarP(&password, "password", "p", "", "Set the user's password. Note that this will be captured in terminal history") + userEditCommand.MarkFlagsMutuallyExclusive("password", "set-password") + + userEditCommand.Flags().IntSliceVar(&libraryIds, "library-ids", []int{}, "Set the user's accessible libraries by id") + + _ = userEditCommand.MarkFlagRequired("user") + userRoot.AddCommand(userEditCommand) + + userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]") + userRoot.AddCommand(userListCommand) +} + +var ( + userRoot = &cobra.Command{ + Use: "user", + Short: "Administer users", + Long: "Create, delete, list, or update users", + } + + userCreateCommand = &cobra.Command{ + Use: "create", + Aliases: []string{"c"}, + Short: "Create a new user", + Run: func(cmd *cobra.Command, args []string) { + runCreateUser() + }, + } + + userDeleteCommand = &cobra.Command{ + Use: "delete", + Aliases: []string{"d"}, + Short: "Deletes an existing user", + Run: func(cmd *cobra.Command, args []string) { + runDeleteUser() + }, + } + + userEditCommand = &cobra.Command{ + Use: "edit", + Aliases: []string{"e"}, + Short: "Edit a user", + Long: "Edit the password, admin status, and/or library access", + Run: func(cmd *cobra.Command, args []string) { + runUserEdit() + }, + } + + userListCommand = &cobra.Command{ + Use: "list", + Short: "List users", + Run: func(cmd *cobra.Command, args []string) { + runUserList() + }, + } +) + +func promptPassword() string { + for { + fmt.Print("Enter new password (press enter with no password to cancel): ") + password, err := term.ReadPassword(syscall.Stdin) + + if err != nil { + log.Fatal("Error getting password", err) + } + + fmt.Print("\nConfirm new password (press enter with no password to cancel): ") + confirmation, err := term.ReadPassword(syscall.Stdin) + + if err != nil { + log.Fatal("Error getting password confirmation", err) + } + + fmt.Println() + + pass := string(password) + confirm := string(confirmation) + + if pass == "" { + return "" + } + + if pass == confirm { + return pass + } + + fmt.Print("Username and password do not match") + } +} + +func runCreateUser() { + if password == "" { + password = promptPassword() + if password == "" { + log.Fatal("Empty password provided, user creation cancelled") + } + } + + user := model.User{ + UserName: userID, + Email: email, + Name: name, + IsAdmin: setAdmin, + NewPassword: password, + } + + ds, ctx := getContext() + + err := ds.WithTx(func(tx model.DataStore) error { + existingUser, err := tx.User(ctx).FindByUsername(userID) + if existingUser != nil { + return fmt.Errorf("existing user '%s'", userID) + } + + if err != nil && !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("failed to check existing username: %w", err) + } + + if len(libraryIds) > 0 && !setAdmin { + user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) + if err != nil { + return err + } + + if len(user.Libraries) != len(libraryIds) { + return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %d", libraryIds, len(user.Libraries)) + } + } else { + user.Libraries, err = tx.Library(ctx).GetAll() + if err != nil { + return err + } + } + + err = tx.User(ctx).Put(&user) + if err != nil { + return err + } + + return nil + }) + + if err != nil { + log.Fatal(ctx, err) + } + + log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName) +} + +func runDeleteUser() { + ds, ctx := getContext() + + var err error + var user *model.User + + err = ds.WithTx(func(tx model.DataStore) error { + count, err := tx.User(ctx).CountAll() + if err != nil { + return err + } + + if count == 1 { + return errors.New("refusing to delete the last user") + } + + user, err = getUser(userID, tx, ctx) + if err != nil { + return err + } + + return tx.User(ctx).Delete(user.ID) + }) + + if err != nil { + log.Fatal(ctx, "Failed to delete user", err) + } + + log.Info(ctx, "Deleted user", "username", user.UserName) +} + +func runUserEdit() { + ds, ctx := getContext() + + var err error + var user *model.User + changes := []string{} + + err = ds.WithTx(func(tx model.DataStore) error { + user, err = getUser(userID, tx, ctx) + if err != nil { + return err + } + + if len(libraryIds) > 0 && !setAdmin { + libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) + + if err != nil { + return err + } + + if len(libraries) != len(libraryIds) { + return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %d", libraryIds, len(libraries)) + } + + user.Libraries = libraries + changes = append(changes, "updated library ids") + } + + if setAdmin && !user.IsAdmin { + libraries, err := tx.Library(ctx).GetAll() + if err != nil { + return err + } + + user.IsAdmin = true + user.Libraries = libraries + changes = append(changes, "set admin") + } + + if setRegularUser && user.IsAdmin { + user.IsAdmin = false + changes = append(changes, "set regular user") + } + + if setPassword { + password = promptPassword() + } + + if password != "" { + user.NewPassword = password + changes = append(changes, "updated password") + } + + if email != "" && email != user.Email { + user.Email = email + changes = append(changes, "updated email") + } else if removeEmail && user.Email != "" { + user.Email = "" + changes = append(changes, "removed email") + } + + if name != "" && name != user.Name { + user.Name = name + changes = append(changes, "updated name") + } else if removeName && user.Name != "" { + user.Name = "" + changes = append(changes, "removed name") + } + + if len(changes) == 0 { + log.Info(ctx, "No changes for user", "user", user.UserName) + return nil + } + + err = tx.User(ctx).Put(user) + return err + }) + + if err != nil { + log.Fatal(ctx, "Failed to update user", err) + } + + log.Info(ctx, "Updated user", "user", user.Name, "changes", strings.Join(changes, ", ")) +} + +type displayLibrary struct { + ID int `json:"id"` + Path string `json:"path"` +} + +type displayUser struct { + Id string `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + Admin bool `json:"admin"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastAccess *time.Time `json:"lastAccess"` + LastLogin *time.Time `json:"lastLogin"` + Libraries []displayLibrary `json:"libraries"` +} + +func runUserList() { + if outputFormat != "csv" && outputFormat != "json" { + log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat) + } + + ds, ctx := getContext() + + users, err := ds.User(ctx).ReadAll() + if err != nil { + log.Fatal(ctx, "Failed to retrieve users", err) + } + + userList := users.(model.Users) + + if outputFormat == "csv" { + w := csv.NewWriter(os.Stdout) + _ = w.Write([]string{"user id", "username", "user's name", "user email", "admin", "created at", "updated at", "last access", "last login", "last login", "libraries"}) + for _, user := range userList { + paths := make([]string, len(user.Libraries)) + + for idx, library := range user.Libraries { + paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path) + } + + _ = w.Write([]string{ + user.ID, + user.UserName, + user.Name, + user.Email, + strconv.FormatBool(user.IsAdmin), + user.CreatedAt.Format(time.RFC3339Nano), + user.UpdatedAt.Format(time.RFC3339Nano), + user.LastAccessAt.Format(time.RFC3339Nano), + user.LastLoginAt.Format(time.RFC3339Nano), + fmt.Sprintf("'%s'", strings.Join(paths, ",")), + }) + } + w.Flush() + } else { + users := make([]displayUser, len(userList)) + for idx, user := range userList { + paths := make([]displayLibrary, len(user.Libraries)) + + for idx, library := range user.Libraries { + paths[idx].ID = library.ID + paths[idx].Path = library.Path + } + + users[idx].Id = user.ID + users[idx].Username = user.UserName + users[idx].Name = user.Name + users[idx].Email = user.Email + users[idx].Admin = user.IsAdmin + users[idx].CreatedAt = user.CreatedAt + users[idx].UpdatedAt = user.UpdatedAt + users[idx].LastAccess = user.LastAccessAt + users[idx].LastLogin = user.LastLoginAt + users[idx].Libraries = paths + } + + j, _ := json.Marshal(users) + fmt.Printf("%s\n", j) + } +} diff --git a/cmd/utils.go b/cmd/utils.go new file mode 100644 index 000000000..0b4a33913 --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" +) + +func getContext() (model.DataStore, context.Context) { + sqlDB := db.Db() + ds := persistence.New(sqlDB) + return ds, auth.WithAdminUser(context.Background(), ds) +} + +func getUser(id string, ds model.DataStore, ctx context.Context) (*model.User, error) { + user, err := ds.User(ctx).FindByUsername(id) + + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + + if errors.Is(err, model.ErrNotFound) { + user, err = ds.User(ctx).Get(id) + if err != nil { + return nil, err + } + } + + return user, nil +} diff --git a/go.mod b/go.mod index f680bda51..f3934575c 100644 --- a/go.mod +++ b/go.mod @@ -131,6 +131,7 @@ require ( golang.org/x/crypto v0.45.0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect + golang.org/x/term v0.37.0 // indirect golang.org/x/tools v0.39.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index 77c0cbb40..04e986f94 100644 --- a/go.sum +++ b/go.sum @@ -363,6 +363,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/model/user.go b/model/user.go index aabedc096..c590ba260 100644 --- a/model/user.go +++ b/model/user.go @@ -42,7 +42,9 @@ func (u User) HasLibraryAccess(libraryID int) bool { type Users []User type UserRepository interface { + ResourceRepository CountAll(...QueryOptions) (int64, error) + Delete(id string) error Get(id string) (*User, error) Put(*User) error UpdateLastLoginAt(id string) error From f7c3fe86842c9d3b5f592a2ccaf84cb5577a3788 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 30 Nov 2025 18:31:58 -0800 Subject: [PATCH 2/6] clean go.mod, address comments --- cmd/user.go | 34 ++++++++++++++++++++++++++++++---- go.mod | 2 +- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/cmd/user.go b/cmd/user.go index f2a5fe754..1e1492ddb 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -142,6 +142,7 @@ func promptPassword() string { log.Fatal("Error getting password confirmation", err) } + // clear the line. fmt.Println() pass := string(password) @@ -155,7 +156,7 @@ func promptPassword() string { return pass } - fmt.Print("Username and password do not match") + fmt.Println("Password and password confirmation do not match") } } @@ -368,7 +369,18 @@ func runUserList() { if outputFormat == "csv" { w := csv.NewWriter(os.Stdout) - _ = w.Write([]string{"user id", "username", "user's name", "user email", "admin", "created at", "updated at", "last access", "last login", "last login", "libraries"}) + _ = w.Write([]string{ + "user id", + "username", + "user's name", + "user email", + "admin", + "created at", + "updated at", + "last access", + "last login", + "libraries", + }) for _, user := range userList { paths := make([]string, len(user.Libraries)) @@ -376,6 +388,20 @@ func runUserList() { paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path) } + var lastAccess, lastLogin string + + if user.LastAccessAt != nil { + lastAccess = user.LastAccessAt.Format(time.RFC3339Nano) + } else { + lastAccess = "never" + } + + if user.LastLoginAt != nil { + lastLogin = user.LastLoginAt.Format(time.RFC3339Nano) + } else { + lastLogin = "never" + } + _ = w.Write([]string{ user.ID, user.UserName, @@ -384,8 +410,8 @@ func runUserList() { strconv.FormatBool(user.IsAdmin), user.CreatedAt.Format(time.RFC3339Nano), user.UpdatedAt.Format(time.RFC3339Nano), - user.LastAccessAt.Format(time.RFC3339Nano), - user.LastLoginAt.Format(time.RFC3339Nano), + lastAccess, + lastLogin, fmt.Sprintf("'%s'", strings.Join(paths, ",")), }) } diff --git a/go.mod b/go.mod index f3934575c..2abf5f3a1 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,7 @@ require ( golang.org/x/net v0.47.0 golang.org/x/sync v0.18.0 golang.org/x/sys v0.38.0 + golang.org/x/term v0.37.0 golang.org/x/text v0.31.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.10 @@ -131,7 +132,6 @@ require ( golang.org/x/crypto v0.45.0 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect - golang.org/x/term v0.37.0 // indirect golang.org/x/tools v0.39.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect From df0b685ee4ee4f136cc573466cf07122aef5efa6 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 30 Nov 2025 18:43:31 -0800 Subject: [PATCH 3/6] fix lint, I hope --- cmd/user.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/user.go b/cmd/user.go index 1e1492ddb..6650721ee 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -129,14 +129,15 @@ var ( func promptPassword() string { for { fmt.Print("Enter new password (press enter with no password to cancel): ") - password, err := term.ReadPassword(syscall.Stdin) + // This cast is necessary for some platforms + password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert if err != nil { log.Fatal("Error getting password", err) } fmt.Print("\nConfirm new password (press enter with no password to cancel): ") - confirmation, err := term.ReadPassword(syscall.Stdin) + confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert if err != nil { log.Fatal("Error getting password confirmation", err) From 53641dae88df31e3bf9b1e7381586ac39e8fc26b Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 30 Nov 2025 19:03:00 -0800 Subject: [PATCH 4/6] bump compilation timeoit in adapter_media_agent_test --- plugins/adapter_media_agent_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/adapter_media_agent_test.go b/plugins/adapter_media_agent_test.go index 70b5d275a..e04baf832 100644 --- a/plugins/adapter_media_agent_test.go +++ b/plugins/adapter_media_agent_test.go @@ -3,6 +3,7 @@ package plugins import ( "context" "errors" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -23,6 +24,7 @@ var _ = Describe("Adapter Media Agent", func() { // Ensure plugins folder is set to testdata DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Folder = testDataDir + conf.Server.DevPluginCompilationTimeout = 2 * time.Minute mgr = createManager(nil, metrics.NewNoopInstance()) mgr.ScanPlugins() From 74b8bb812ae26df941fb5bd7121598d38f156aec Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:45:55 -0800 Subject: [PATCH 5/6] address initial comments --- cmd/pls.go | 2 +- cmd/user.go | 63 ++++++++++++++++++++++++++++++++++++++++------------ cmd/utils.go | 7 +++--- 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/cmd/pls.go b/cmd/pls.go index 9fe1f96c5..5d6a05b60 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -103,7 +103,7 @@ func runList() { options := model.QueryOptions{Sort: "owner_name"} if userID != "" { - user, err := getUser(userID, ds, ctx) + user, err := getUser(ctx, userID, ds) if err != nil { log.Fatal(ctx, "Error retrieving user", "username or id", userID) } diff --git a/cmd/user.go b/cmd/user.go index 6650721ee..10ee0d543 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -36,18 +36,15 @@ func init() { userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username") - userCreateCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI") userCreateCommand.Flags().StringVarP(&password, "password", "p", "", "Set the user's password. Note that this will be captured in terminal history") - userCreateCommand.MarkFlagsMutuallyExclusive("password", "set-password") userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email") - userCreateCommand.Flags().IntSliceVar(&libraryIds, "library-ids", []int{}, "Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries") + userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries") userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library") userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)") _ = userCreateCommand.MarkFlagRequired("username") - userCreateCommand.MarkFlagsOneRequired("password", "set-password") userRoot.AddCommand(userCreateCommand) @@ -73,7 +70,7 @@ func init() { userEditCommand.Flags().StringVarP(&password, "password", "p", "", "Set the user's password. Note that this will be captured in terminal history") userEditCommand.MarkFlagsMutuallyExclusive("password", "set-password") - userEditCommand.Flags().IntSliceVar(&libraryIds, "library-ids", []int{}, "Set the user's accessible libraries by id") + userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id") _ = userEditCommand.MarkFlagRequired("user") userRoot.AddCommand(userEditCommand) @@ -161,6 +158,14 @@ func promptPassword() string { } } +func libraryError(libraries model.Libraries) error { + ids := make([]int, len(libraries)) + for idx, library := range libraries { + ids[idx] = library.ID + } + return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids) +} + func runCreateUser() { if password == "" { password = promptPassword() @@ -177,6 +182,10 @@ func runCreateUser() { NewPassword: password, } + if user.Name == "" { + user.Name = userID + } + ds, ctx := getContext() err := ds.WithTx(func(tx model.DataStore) error { @@ -196,7 +205,7 @@ func runCreateUser() { } if len(user.Libraries) != len(libraryIds) { - return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %d", libraryIds, len(user.Libraries)) + return libraryError(user.Libraries) } } else { user.Libraries, err = tx.Library(ctx).GetAll() @@ -210,7 +219,13 @@ func runCreateUser() { return err } - return nil + updatedIds := make([]int, len(user.Libraries)) + for idx, lib := range user.Libraries { + updatedIds[idx] = lib.ID + } + + err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds) + return err }) if err != nil { @@ -236,7 +251,7 @@ func runDeleteUser() { return errors.New("refusing to delete the last user") } - user, err = getUser(userID, tx, ctx) + user, err = getUser(ctx, userID, tx) if err != nil { return err } @@ -259,7 +274,9 @@ func runUserEdit() { changes := []string{} err = ds.WithTx(func(tx model.DataStore) error { - user, err = getUser(userID, tx, ctx) + var newLibraries model.Libraries + + user, err = getUser(ctx, userID, tx) if err != nil { return err } @@ -272,10 +289,10 @@ func runUserEdit() { } if len(libraries) != len(libraryIds) { - return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %d", libraryIds, len(libraries)) + return libraryError(libraries) } - user.Libraries = libraries + newLibraries = libraries changes = append(changes, "updated library ids") } @@ -288,6 +305,8 @@ func runUserEdit() { user.IsAdmin = true user.Libraries = libraries changes = append(changes, "set admin") + + newLibraries = libraries } if setRegularUser && user.IsAdmin { @@ -325,15 +344,31 @@ func runUserEdit() { return nil } - err = tx.User(ctx).Put(user) - return err + err := tx.User(ctx).Put(user) + if err != nil { + return err + } + + if len(newLibraries) > 0 { + updatedIds := make([]int, len(newLibraries)) + for idx, lib := range newLibraries { + updatedIds[idx] = lib.ID + } + + err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds) + if err != nil { + return err + } + } + + return nil }) if err != nil { log.Fatal(ctx, "Failed to update user", err) } - log.Info(ctx, "Updated user", "user", user.Name, "changes", strings.Join(changes, ", ")) + log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", ")) } type displayLibrary struct { diff --git a/cmd/utils.go b/cmd/utils.go index 0b4a33913..2ddc8e30f 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -3,6 +3,7 @@ package cmd import ( "context" "errors" + "fmt" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/db" @@ -16,17 +17,17 @@ func getContext() (model.DataStore, context.Context) { return ds, auth.WithAdminUser(context.Background(), ds) } -func getUser(id string, ds model.DataStore, ctx context.Context) (*model.User, error) { +func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) { user, err := ds.User(ctx).FindByUsername(id) if err != nil && !errors.Is(err, model.ErrNotFound) { - return nil, err + return nil, fmt.Errorf("finding user by name: %w", err) } if errors.Is(err, model.ErrNotFound) { user, err = ds.User(ctx).Get(id) if err != nil { - return nil, err + return nil, fmt.Errorf("finding user by id: %w", err) } } From b1e5ab6fb30ab2fd045c7685917995744775cd0d Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:58:10 -0800 Subject: [PATCH 6/6] feedback 2 --- cmd/user.go | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/cmd/user.go b/cmd/user.go index 10ee0d543..f7947551c 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -22,7 +22,6 @@ var ( email string libraryIds []int name string - password string removeEmail bool removeName bool @@ -36,8 +35,6 @@ func init() { userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username") - userCreateCommand.Flags().StringVarP(&password, "password", "p", "", "Set the user's password. Note that this will be captured in terminal history") - userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email") userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries") @@ -67,8 +64,6 @@ func init() { userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name") userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI") - userEditCommand.Flags().StringVarP(&password, "password", "p", "", "Set the user's password. Note that this will be captured in terminal history") - userEditCommand.MarkFlagsMutuallyExclusive("password", "set-password") userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id") @@ -167,11 +162,9 @@ func libraryError(libraries model.Libraries) error { } func runCreateUser() { + password := promptPassword() if password == "" { - password = promptPassword() - if password == "" { - log.Fatal("Empty password provided, user creation cancelled") - } + log.Fatal("Empty password provided, user creation cancelled") } user := model.User{ @@ -315,12 +308,12 @@ func runUserEdit() { } if setPassword { - password = promptPassword() - } + password := promptPassword() - if password != "" { - user.NewPassword = password - changes = append(changes, "updated password") + if password != "" { + user.NewPassword = password + changes = append(changes, "updated password") + } } if email != "" && email != user.Email { @@ -340,7 +333,6 @@ func runUserEdit() { } if len(changes) == 0 { - log.Info(ctx, "No changes for user", "user", user.UserName) return nil } @@ -368,7 +360,11 @@ func runUserEdit() { log.Fatal(ctx, "Failed to update user", err) } - log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", ")) + if len(changes) == 0 { + log.Info(ctx, "No changes for user", "user", user.UserName) + } else { + log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", ")) + } } type displayLibrary struct { @@ -448,7 +444,7 @@ func runUserList() { user.UpdatedAt.Format(time.RFC3339Nano), lastAccess, lastLogin, - fmt.Sprintf("'%s'", strings.Join(paths, ",")), + fmt.Sprintf("'%s'", strings.Join(paths, "|")), }) } w.Flush()