From 16dbcfda7e1a1046351290f01a0eaa92ca88ed33 Mon Sep 17 00:00:00 2001 From: konrad Date: Thu, 13 Aug 2020 15:34:02 +0000 Subject: [PATCH] Manage users via cli (#632) Add users cli commands to docs Fix checking for changing username or user email Add user status change command Make sure only one user exists with a particular email when updating Add password reset Add user id to help Remove user delete (too many possible side effects, postponed until later) Make sure to fail on any errors Fail if changing the username would result in duplicate users Add user update command Add user create command Add command stubs for all commands Render users in a beautiful table Started adding user list command Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/api/pulls/632 --- docs/content/doc/usage/cli.md | 71 ++++++- pkg/cmd/user.go | 249 +++++++++++++++++++++++ pkg/routes/api/v1/user_password_reset.go | 2 +- pkg/user/user.go | 29 ++- pkg/user/user_password_reset.go | 9 +- 5 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 pkg/cmd/user.go 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)