2025-12-03 15:58:33 -09:00
package cmd
import (
"context"
"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
removeEmail bool
removeName bool
setAdmin bool
setPassword bool
setRegularUser bool
)
func init ( ) {
rootCmd . AddCommand ( userRoot )
userCreateCommand . Flags ( ) . StringVarP ( & userID , "username" , "u" , "" , "username" )
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" )
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" )
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 ( ) . 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 )
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 ( cmd . Context ( ) )
} ,
}
userDeleteCommand = & cobra . Command {
Use : "delete" ,
Aliases : [ ] string { "d" } ,
Short : "Deletes an existing user" ,
Run : func ( cmd * cobra . Command , args [ ] string ) {
runDeleteUser ( cmd . Context ( ) )
} ,
}
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 ( cmd . Context ( ) )
} ,
}
userListCommand = & cobra . Command {
Use : "list" ,
Short : "List users" ,
Run : func ( cmd * cobra . Command , args [ ] string ) {
runUserList ( cmd . Context ( ) )
} ,
}
)
func promptPassword ( ) string {
for {
fmt . Print ( "Enter new password (press enter with no password to cancel): " )
// This cast is necessary for some platforms
2026-05-20 14:33:42 -08:00
password , err := term . ReadPassword ( int ( syscall . Stdin ) ) //nolint:unconvert
2025-12-03 15:58:33 -09:00
if err != nil {
log . Fatal ( "Error getting password" , err )
}
fmt . Print ( "\nConfirm new password (press enter with no password to cancel): " )
2026-05-20 14:33:42 -08:00
confirmation , err := term . ReadPassword ( int ( syscall . Stdin ) ) //nolint:unconvert
2025-12-03 15:58:33 -09:00
if err != nil {
log . Fatal ( "Error getting password confirmation" , err )
}
// clear the line.
fmt . Println ( )
pass := string ( password )
confirm := string ( confirmation )
if pass == "" {
return ""
}
if pass == confirm {
return pass
}
fmt . Println ( "Password and password confirmation do not match" )
}
}
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 ( ctx context . Context ) {
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 ,
}
if user . Name == "" {
user . Name = userID
}
ds , ctx := getAdminContext ( ctx )
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 libraryError ( 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
}
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 {
log . Fatal ( ctx , err )
}
log . Info ( ctx , "Successfully created user" , "id" , user . ID , "username" , user . UserName )
}
func runDeleteUser ( ctx context . Context ) {
ds , ctx := getAdminContext ( ctx )
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 ( ctx , userID , tx )
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 ( ctx context . Context ) {
ds , ctx := getAdminContext ( ctx )
var err error
var user * model . User
changes := [ ] string { }
err = ds . WithTx ( func ( tx model . DataStore ) error {
var newLibraries model . Libraries
user , err = getUser ( ctx , userID , tx )
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 libraryError ( libraries )
}
newLibraries = 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" )
newLibraries = libraries
}
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 {
return nil
}
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 )
}
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 {
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 ( ctx context . Context ) {
if outputFormat != "csv" && outputFormat != "json" {
log . Fatal ( "Invalid output format. Must be one of csv, json" , "format" , outputFormat )
}
ds , ctx := getAdminContext ( ctx )
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" ,
"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 )
}
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 ,
user . Name ,
user . Email ,
strconv . FormatBool ( user . IsAdmin ) ,
user . CreatedAt . Format ( time . RFC3339Nano ) ,
user . UpdatedAt . Format ( time . RFC3339Nano ) ,
lastAccess ,
lastLogin ,
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 )
}
}