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 <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/api/pulls/632
This commit is contained in:
parent
0169ecc37e
commit
16dbcfda7e
5 changed files with 353 additions and 7 deletions
|
@ -18,6 +18,7 @@ The following commands are available:
|
||||||
* [migrate](#migrate)
|
* [migrate](#migrate)
|
||||||
* [restore](#restore)
|
* [restore](#restore)
|
||||||
* [testmail](#testmail)
|
* [testmail](#testmail)
|
||||||
|
* [user](#user)
|
||||||
* [version](#version)
|
* [version](#version)
|
||||||
* [web](#web)
|
* [web](#web)
|
||||||
|
|
||||||
|
@ -85,7 +86,7 @@ Usage:
|
||||||
$ vikunja restore <path to dump zip file>
|
$ vikunja restore <path to dump zip file>
|
||||||
{{< /highlight >}}
|
{{< /highlight >}}
|
||||||
|
|
||||||
### testmail
|
### `testmail`
|
||||||
|
|
||||||
Sends a test mail using the configured smtp connection.
|
Sends a test mail using the configured smtp connection.
|
||||||
|
|
||||||
|
@ -94,6 +95,74 @@ Usage:
|
||||||
$ vikunja testmail <email to send the test mail to>
|
$ vikunja testmail <email to send the test mail to>
|
||||||
{{< /highlight >}}
|
{{< /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 <user id> <flags>
|
||||||
|
{{< /highlight >}}
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
* `-d`, `--disable`: Disable the user.
|
||||||
|
* `-e`, `--enable`: Enable the user.
|
||||||
|
|
||||||
|
#### `user create`
|
||||||
|
|
||||||
|
Create a new user.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
{{< highlight bash >}}
|
||||||
|
$ vikunja user create <flags>
|
||||||
|
{{< /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 <flags>
|
||||||
|
{{< /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 <user id>
|
||||||
|
{{< /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`
|
### `version`
|
||||||
|
|
||||||
Prints the version of Vikunja.
|
Prints the version of Vikunja.
|
||||||
|
|
249
pkg/cmd/user.go
Normal file
249
pkg/cmd/user.go
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
|
@ -72,7 +72,7 @@ func UserRequestResetPasswordToken(c echo.Context) error {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, err)
|
return echo.NewHTTPError(http.StatusBadRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := user.RequestUserPasswordResetToken(&pwTokenReset)
|
err := user.RequestUserPasswordResetTokenByEmail(&pwTokenReset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return handler.HandleHTTPError(err, c)
|
return handler.HandleHTTPError(err, c)
|
||||||
}
|
}
|
||||||
|
|
|
@ -315,7 +315,7 @@ func hashPassword(password string) (string, error) {
|
||||||
func UpdateUser(user *User) (updatedUser *User, err error) {
|
func UpdateUser(user *User) (updatedUser *User, err error) {
|
||||||
|
|
||||||
// Check if it exists
|
// Check if it exists
|
||||||
theUser, err := GetUserByID(user.ID)
|
theUser, err := GetUserWithEmail(&User{ID: user.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &User{}, err
|
return &User{}, err
|
||||||
}
|
}
|
||||||
|
@ -324,9 +324,29 @@ func UpdateUser(user *User) (updatedUser *User, err error) {
|
||||||
if user.Username == "" {
|
if user.Username == "" {
|
||||||
//return User{}, ErrNoUsername{user.ID}
|
//return User{}, ErrNoUsername{user.ID}
|
||||||
user.Username = theUser.Username // Dont change the username if we dont have one
|
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
|
// Validate the avatar type
|
||||||
if user.AvatarProvider != "" {
|
if user.AvatarProvider != "" {
|
||||||
|
@ -339,7 +359,10 @@ func UpdateUser(user *User) (updatedUser *User, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update it
|
// 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 {
|
if err != nil {
|
||||||
return &User{}, err
|
return &User{}, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,8 +82,8 @@ type PasswordTokenRequest struct {
|
||||||
Email string `json:"email" valid:"email,length(0|250)" maxLength:"250"`
|
Email string `json:"email" valid:"email,length(0|250)" maxLength:"250"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestUserPasswordResetToken inserts a random token to reset a users password into the databsse
|
// RequestUserPasswordResetTokenByEmail inserts a random token to reset a users password into the databsse
|
||||||
func RequestUserPasswordResetToken(tr *PasswordTokenRequest) (err error) {
|
func RequestUserPasswordResetTokenByEmail(tr *PasswordTokenRequest) (err error) {
|
||||||
if tr.Email == "" {
|
if tr.Email == "" {
|
||||||
return ErrNoUsernamePassword{}
|
return ErrNoUsernamePassword{}
|
||||||
}
|
}
|
||||||
|
@ -94,6 +94,11 @@ func RequestUserPasswordResetToken(tr *PasswordTokenRequest) (err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return RequestUserPasswordResetToken(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestUserPasswordResetToken sends a user a password reset email.
|
||||||
|
func RequestUserPasswordResetToken(user *User) (err error) {
|
||||||
// Generate a token and save it
|
// Generate a token and save it
|
||||||
user.PasswordResetToken = utils.MakeRandomString(400)
|
user.PasswordResetToken = utils.MakeRandomString(400)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue