diff --git a/docs/content/doc/usage/cli.md b/docs/content/doc/usage/cli.md index 8de09d905..a32cf7944 100644 --- a/docs/content/doc/usage/cli.md +++ b/docs/content/doc/usage/cli.md @@ -18,6 +18,7 @@ The following commands are available: * [migrate](#migrate) * [restore](#restore) * [testmail](#testmail) +* [user](#user) * [version](#version) * [web](#web) @@ -85,7 +86,7 @@ Usage: $ vikunja restore {{< /highlight >}} -### testmail +### `testmail` Sends a test mail using the configured smtp connection. @@ -94,6 +95,74 @@ Usage: $ vikunja testmail {{< /highlight >}} +### `user` + +Bundles a few commands to manage users. + +#### `user change-status` + +Enable or disable a user. Will toggle the current status if no flag (`--enable` or `--disable`) is provided. + +Usage: +{{< highlight bash >}} +$ vikunja user change-status +{{< /highlight >}} + +Flags: +* `-d`, `--disable`: Disable the user. +* `-e`, `--enable`: Enable the user. + +#### `user create` + +Create a new user. + +Usage: +{{< highlight bash >}} +$ vikunja user create +{{< /highlight >}} + +Flags: +* `-a`, `--avatar-provider`: The avatar provider of the new user. Optional. +* `-e`, `--email`: The email address of the new user. +* `-p`, `--password`: The password of the new user. You will be asked to enter it if not provided through the flag. +* `-u`, `--username`: The username of the new user. + +#### `user list` + +Shows a list of all users. + +Usage: +{{< highlight bash >}} +$ vikunja user list +{{< /highlight >}} + +#### `user reset-password` + +Reset a users password, either through mailing them a reset link or directly. + +Usage: +{{< highlight bash >}} +$ vikunja user reset-password +{{< /highlight >}} + +Flags: +* `-d`, `--direct`: If provided, reset the password directly instead of sending the user a reset mail. +* `-p`, `--password`: The new password of the user. Only used in combination with --direct. You will be asked to enter it if not provided through the flag. + +#### `user update` + +Update an existing user. + +Usage: +{{< highlight bash >}} +$ vikunja user update +{{< /highlight >}} + +Flags: +* `-a`, `--avatar-provider`: The new avatar provider of the new user. +* `-e`, `--email`: The new email address of the user. +* `-u`, `--username`: The new username of the user. + ### `version` Prints the version of Vikunja. diff --git a/pkg/cmd/user.go b/pkg/cmd/user.go new file mode 100644 index 000000000..ee1e40540 --- /dev/null +++ b/pkg/cmd/user.go @@ -0,0 +1,249 @@ +// Copyright 2020 Vikunja and contriubtors. All rights reserved. +// +// This file is part of Vikunja. +// +// Vikunja is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Vikunja is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Vikunja. If not, see . + +package cmd + +import ( + "code.vikunja.io/api/pkg/initialize" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/user" + "fmt" + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh/terminal" + "os" + "strconv" + "strings" + "syscall" + "time" +) + +var ( + userFlagUsername string + userFlagEmail string + userFlagPassword string + userFlagAvatar = "default" + userFlagResetPasswordDirectly bool + userFlagEnableUser bool + userFlagDisableUser bool +) + +func init() { + // User create flags + userCreateCmd.Flags().StringVarP(&userFlagUsername, "username", "u", "", "The username of the new user.") + _ = userCreateCmd.MarkFlagRequired("username") + userCreateCmd.Flags().StringVarP(&userFlagEmail, "email", "e", "", "The email address of the new user.") + _ = userCreateCmd.MarkFlagRequired("email") + userCreateCmd.Flags().StringVarP(&userFlagPassword, "password", "p", "", "The password of the new user. You will be asked to enter it if not provided through the flag.") + userCreateCmd.Flags().StringVarP(&userFlagAvatar, "avatar-provider", "a", "", "The avatar provider of the new user. Optional.") + + // User update flags + userUpdateCmd.Flags().StringVarP(&userFlagUsername, "username", "u", "", "The new username of the user.") + userUpdateCmd.Flags().StringVarP(&userFlagEmail, "email", "e", "", "The new email address of the user.") + userUpdateCmd.Flags().StringVarP(&userFlagAvatar, "avatar-provider", "a", "", "The new avatar provider of the new user.") + + // Reset PW flags + userResetPasswordCmd.Flags().BoolVarP(&userFlagResetPasswordDirectly, "direct", "d", false, "If provided, reset the password directly instead of sending the user a reset mail.") + userResetPasswordCmd.Flags().StringVarP(&userFlagPassword, "password", "p", "", "The new password of the user. Only used in combination with --direct. You will be asked to enter it if not provided through the flag.") + + // Change status flags + userChangeEnabledCmd.Flags().BoolVarP(&userFlagDisableUser, "disable", "d", false, "Disable the user.") + userChangeEnabledCmd.Flags().BoolVarP(&userFlagEnableUser, "enable", "e", false, "Enable the user.") + + userCmd.AddCommand(userListCmd, userCreateCmd, userUpdateCmd, userResetPasswordCmd, userChangeEnabledCmd) + rootCmd.AddCommand(userCmd) +} + +func getPasswordFromFlagOrInput() (pw string) { + pw = userFlagPassword + if userFlagPassword == "" { + fmt.Print("Enter Password: ") + bytePW, err := terminal.ReadPassword(syscall.Stdin) + if err != nil { + log.Fatalf("Error reading password: %s", err) + } + fmt.Printf("\nConfirm Password: ") + byteConfirmPW, err := terminal.ReadPassword(syscall.Stdin) + if err != nil { + log.Fatalf("Error reading password: %s", err) + } + if string(bytePW) != string(byteConfirmPW) { + log.Critical("Passwords don't match!") + } + fmt.Printf("\n") + pw = strings.TrimSpace(string(bytePW)) + } + return +} + +func getUserFromArg(arg string) *user.User { + id, err := strconv.ParseInt(arg, 10, 64) + if err != nil { + log.Fatalf("Invalid user id: %s", err) + } + + u, err := user.GetUserByID(id) + if err != nil { + log.Fatalf("Could not get user: %s", err) + } + return u +} + +var userCmd = &cobra.Command{ + Use: "user", + Short: "Manage users locally through the cli.", +} + +var userListCmd = &cobra.Command{ + Use: "list", + Short: "Shows a list of all users.", + PreRun: func(cmd *cobra.Command, args []string) { + initialize.FullInit() + }, + Run: func(cmd *cobra.Command, args []string) { + users, err := user.ListUsers("") + if err != nil { + log.Fatalf("Error getting users: %s", err) + } + + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{ + "ID", + "Username", + "Email", + "Active", + "Created", + "Updated", + }) + + for _, u := range users { + table.Append([]string{ + strconv.FormatInt(u.ID, 10), + u.Username, + u.Email, + strconv.FormatBool(u.IsActive), + u.Created.Format(time.RFC3339), + u.Updated.Format(time.RFC3339), + }) + } + + table.Render() + }, +} + +var userCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new user.", + PreRun: func(cmd *cobra.Command, args []string) { + initialize.FullInit() + }, + Run: func(cmd *cobra.Command, args []string) { + u := &user.User{ + Username: userFlagUsername, + Email: userFlagEmail, + Password: getPasswordFromFlagOrInput(), + } + _, err := user.CreateUser(u) + if err != nil { + log.Fatalf("Error creating new user: %s", err) + } + + fmt.Printf("\nUser was created successfully.\n") + }, +} + +var userUpdateCmd = &cobra.Command{ + Use: "update [user id]", + Short: "Update an existing user.", + Args: cobra.ExactArgs(1), + PreRun: func(cmd *cobra.Command, args []string) { + initialize.FullInit() + }, + Run: func(cmd *cobra.Command, args []string) { + u := getUserFromArg(args[0]) + + if userFlagUsername != "" { + u.Username = userFlagUsername + } + if userFlagEmail != "" { + u.Email = userFlagEmail + } + if userFlagAvatar != "default" { + u.AvatarProvider = userFlagAvatar + } + + _, err := user.UpdateUser(u) + if err != nil { + log.Fatalf("Error updating the user: %s", err) + } + + fmt.Println("User updated successfully.") + }, +} + +var userResetPasswordCmd = &cobra.Command{ + Use: "reset-password [user id]", + Short: "Reset a users password, either through mailing them a reset link or directly.", + PreRun: func(cmd *cobra.Command, args []string) { + initialize.FullInit() + }, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + u := getUserFromArg(args[0]) + + // By default we reset as usual, only with specific flag directly. + if userFlagResetPasswordDirectly { + err := user.UpdateUserPassword(u, getPasswordFromFlagOrInput()) + if err != nil { + log.Fatalf("Could not update user password: %s", err) + } + fmt.Println("Password updated successfully.") + } else { + err := user.RequestUserPasswordResetToken(u) + if err != nil { + log.Fatalf("Could not send password reset email: %s", err) + } + fmt.Println("Password reset email sent successfully.") + } + }, +} + +var userChangeEnabledCmd = &cobra.Command{ + Use: "change-status [user id]", + Short: "Enable or disable a user. Will toggle the current status if no flag (--enable or --disable) is provided.", + PreRun: func(cmd *cobra.Command, args []string) { + initialize.FullInit() + }, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + u := getUserFromArg(args[0]) + + if userFlagEnableUser { + u.IsActive = true + } else if userFlagDisableUser { + u.IsActive = false + } else { + u.IsActive = !u.IsActive + } + _, err := user.UpdateUser(u) + if err != nil { + log.Fatalf("Could not enable the user") + } + + fmt.Printf("User status successfully changed, user is now active: %t.\n", u.IsActive) + }, +} diff --git a/pkg/routes/api/v1/user_password_reset.go b/pkg/routes/api/v1/user_password_reset.go index 84cdce6ed..10b1af0ff 100644 --- a/pkg/routes/api/v1/user_password_reset.go +++ b/pkg/routes/api/v1/user_password_reset.go @@ -72,7 +72,7 @@ func UserRequestResetPasswordToken(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err) } - err := user.RequestUserPasswordResetToken(&pwTokenReset) + err := user.RequestUserPasswordResetTokenByEmail(&pwTokenReset) if err != nil { return handler.HandleHTTPError(err, c) } diff --git a/pkg/user/user.go b/pkg/user/user.go index 15eac8e95..7dec28f38 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -315,7 +315,7 @@ func hashPassword(password string) (string, error) { func UpdateUser(user *User) (updatedUser *User, err error) { // Check if it exists - theUser, err := GetUserByID(user.ID) + theUser, err := GetUserWithEmail(&User{ID: user.ID}) if err != nil { return &User{}, err } @@ -324,9 +324,29 @@ func UpdateUser(user *User) (updatedUser *User, err error) { if user.Username == "" { //return User{}, ErrNoUsername{user.ID} user.Username = theUser.Username // Dont change the username if we dont have one + } else { + // Check if the new username already exists + uu, err := GetUserByUsername(user.Username) + if err != nil && !IsErrUserDoesNotExist(err) { + return nil, err + } + if uu.ID != 0 && uu.ID != user.ID { + return nil, &ErrUsernameExists{Username: user.Username, UserID: uu.ID} + } } - user.Password = theUser.Password // set the password to the one in the database to not accedently resetting it + // Check if the email is already used + if user.Email == "" { + user.Email = theUser.Email + } else { + uu, err := getUser(&User{Email: user.Email}, true) + if err != nil && !IsErrUserDoesNotExist(err) { + return nil, err + } + if uu.ID != 0 && uu.ID != user.ID { + return nil, &ErrUserEmailExists{Email: user.Email, UserID: uu.ID} + } + } // Validate the avatar type if user.AvatarProvider != "" { @@ -339,7 +359,10 @@ func UpdateUser(user *User) (updatedUser *User, err error) { } // Update it - _, err = x.ID(user.ID).Update(user) + _, err = x. + ID(user.ID). + Cols("username", "email", "avatar_provider", "is_active"). + Update(user) if err != nil { return &User{}, err } diff --git a/pkg/user/user_password_reset.go b/pkg/user/user_password_reset.go index 24d47d2b5..4ec892e07 100644 --- a/pkg/user/user_password_reset.go +++ b/pkg/user/user_password_reset.go @@ -82,8 +82,8 @@ type PasswordTokenRequest struct { Email string `json:"email" valid:"email,length(0|250)" maxLength:"250"` } -// RequestUserPasswordResetToken inserts a random token to reset a users password into the databsse -func RequestUserPasswordResetToken(tr *PasswordTokenRequest) (err error) { +// RequestUserPasswordResetTokenByEmail inserts a random token to reset a users password into the databsse +func RequestUserPasswordResetTokenByEmail(tr *PasswordTokenRequest) (err error) { if tr.Email == "" { return ErrNoUsernamePassword{} } @@ -94,6 +94,11 @@ func RequestUserPasswordResetToken(tr *PasswordTokenRequest) (err error) { return } + return RequestUserPasswordResetToken(user) +} + +// RequestUserPasswordResetToken sends a user a password reset email. +func RequestUserPasswordResetToken(user *User) (err error) { // Generate a token and save it user.PasswordResetToken = utils.MakeRandomString(400)