feat(cli): add user management commands for CRUD operations
This commit is contained in:
369
cmd/cli/commands_users.go
Normal file
369
cmd/cli/commands_users.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/models"
|
||||
"git.warky.dev/wdevs/whatshooked/pkg/storage"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var serverConfigPath string
|
||||
|
||||
var usersCmd = &cobra.Command{
|
||||
Use: "users",
|
||||
Short: "Manage users (direct DB access)",
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
initUserDB()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
listUsers()
|
||||
},
|
||||
}
|
||||
|
||||
var usersListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all users",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
listUsers()
|
||||
},
|
||||
}
|
||||
|
||||
var usersAddCmd = &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new user",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
addUser()
|
||||
},
|
||||
}
|
||||
|
||||
var usersSetPasswordCmd = &cobra.Command{
|
||||
Use: "set-password <username>",
|
||||
Short: "Change a user's password",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
setPassword(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var usersDisableCmd = &cobra.Command{
|
||||
Use: "disable <username>",
|
||||
Short: "Disable a user account",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
setUserActive(args[0], false)
|
||||
},
|
||||
}
|
||||
|
||||
var usersEnableCmd = &cobra.Command{
|
||||
Use: "enable <username>",
|
||||
Short: "Enable a user account",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
setUserActive(args[0], true)
|
||||
},
|
||||
}
|
||||
|
||||
var usersUpdateCmd = &cobra.Command{
|
||||
Use: "update <username>",
|
||||
Short: "Update a user's details",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
updateUser(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var usersDeleteCmd = &cobra.Command{
|
||||
Use: "delete <username>",
|
||||
Short: "Delete a user",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
deleteUser(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var usersRemoveCmd = &cobra.Command{
|
||||
Use: "remove <username>",
|
||||
Short: "Remove a user (alias for delete)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
deleteUser(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
usersCmd.PersistentFlags().StringVar(&serverConfigPath, "server-config", "", "server config file (default: config.json or ~/.whatshooked/config.json)")
|
||||
usersCmd.AddCommand(usersListCmd)
|
||||
usersCmd.AddCommand(usersAddCmd)
|
||||
usersCmd.AddCommand(usersUpdateCmd)
|
||||
usersCmd.AddCommand(usersSetPasswordCmd)
|
||||
usersCmd.AddCommand(usersDisableCmd)
|
||||
usersCmd.AddCommand(usersEnableCmd)
|
||||
usersCmd.AddCommand(usersDeleteCmd)
|
||||
usersCmd.AddCommand(usersRemoveCmd)
|
||||
}
|
||||
|
||||
func resolveServerConfigPath() string {
|
||||
if serverConfigPath != "" {
|
||||
return serverConfigPath
|
||||
}
|
||||
if _, err := os.Stat("config.json"); err == nil {
|
||||
return "config.json"
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
p := filepath.Join(home, ".whatshooked", "config.json")
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func initUserDB() {
|
||||
cfgPath := resolveServerConfigPath()
|
||||
if cfgPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: server config not found. Use --server-config to specify path.")
|
||||
os.Exit(1)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading server config %s: %v\n", cfgPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := storage.Initialize(&cfg.Database); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func listUsers() {
|
||||
var users []models.ModelPublicUsers
|
||||
err := storage.DB.NewSelect().Model(&users).OrderExpr("created_at ASC").Scan(context.Background())
|
||||
checkError(err)
|
||||
|
||||
if len(users) == 0 {
|
||||
fmt.Println("No users found")
|
||||
return
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "USERNAME\tEMAIL\tFULL NAME\tROLE\tACTIVE\tCREATED")
|
||||
for _, u := range users {
|
||||
active := "yes"
|
||||
if !u.Active {
|
||||
active = "no"
|
||||
}
|
||||
created := fmt.Sprint(u.CreatedAt)
|
||||
if len(created) > 10 {
|
||||
created = created[:10]
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
u.Username.String(),
|
||||
u.Email.String(),
|
||||
u.FullName.String(),
|
||||
u.Role.String(),
|
||||
active,
|
||||
created,
|
||||
)
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func addUser() {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
|
||||
fmt.Print("Username: ")
|
||||
scanner.Scan()
|
||||
username := strings.TrimSpace(scanner.Text())
|
||||
if username == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: username is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Print("Email: ")
|
||||
scanner.Scan()
|
||||
email := strings.TrimSpace(scanner.Text())
|
||||
if email == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: email is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Print("Full Name (optional): ")
|
||||
scanner.Scan()
|
||||
fullName := strings.TrimSpace(scanner.Text())
|
||||
|
||||
fmt.Print("Role (admin/user) [user]: ")
|
||||
scanner.Scan()
|
||||
role := strings.TrimSpace(scanner.Text())
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
password := readPassword("Password: ")
|
||||
if password == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: password is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
checkError(err)
|
||||
|
||||
now := time.Now()
|
||||
user := &models.ModelPublicUsers{
|
||||
ID: resolvespec_common.NewSqlString(uuid.New().String()),
|
||||
Username: resolvespec_common.NewSqlString(username),
|
||||
Email: resolvespec_common.NewSqlString(email),
|
||||
FullName: resolvespec_common.NewSqlString(fullName),
|
||||
Role: resolvespec_common.NewSqlString(role),
|
||||
Password: resolvespec_common.NewSqlString(string(hashedPw)),
|
||||
Active: true,
|
||||
CreatedAt: resolvespec_common.NewSqlTimeStamp(now),
|
||||
UpdatedAt: resolvespec_common.NewSqlTimeStamp(now),
|
||||
}
|
||||
|
||||
_, err = storage.DB.NewInsert().Model(user).Exec(context.Background())
|
||||
checkError(err)
|
||||
|
||||
fmt.Printf("User '%s' created\n", username)
|
||||
}
|
||||
|
||||
func setPassword(username string) {
|
||||
user := findUserByUsername(username)
|
||||
|
||||
password := readPassword("New password: ")
|
||||
confirm := readPassword("Confirm password: ")
|
||||
if password != confirm {
|
||||
fmt.Fprintln(os.Stderr, "Error: passwords do not match")
|
||||
os.Exit(1)
|
||||
}
|
||||
if password == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: password cannot be empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
hashedPw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
checkError(err)
|
||||
|
||||
_, err = storage.DB.NewUpdate().Model(user).
|
||||
Set("password = ?", string(hashedPw)).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", user.ID.String()).
|
||||
Exec(context.Background())
|
||||
checkError(err)
|
||||
|
||||
fmt.Printf("Password updated for '%s'\n", username)
|
||||
}
|
||||
|
||||
func setUserActive(username string, active bool) {
|
||||
user := findUserByUsername(username)
|
||||
|
||||
_, err := storage.DB.NewUpdate().Model(user).
|
||||
Set("active = ?", active).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", user.ID.String()).
|
||||
Exec(context.Background())
|
||||
checkError(err)
|
||||
|
||||
state := "enabled"
|
||||
if !active {
|
||||
state = "disabled"
|
||||
}
|
||||
fmt.Printf("User '%s' %s\n", username, state)
|
||||
}
|
||||
|
||||
func updateUser(username string) {
|
||||
user := findUserByUsername(username)
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
|
||||
fmt.Printf("Updating user '%s' (press Enter to keep current value)\n", username)
|
||||
|
||||
fmt.Printf("Username [%s]: ", user.Username.String())
|
||||
scanner.Scan()
|
||||
if v := strings.TrimSpace(scanner.Text()); v != "" {
|
||||
user.Username = resolvespec_common.NewSqlString(v)
|
||||
}
|
||||
|
||||
fmt.Printf("Email [%s]: ", user.Email.String())
|
||||
scanner.Scan()
|
||||
if v := strings.TrimSpace(scanner.Text()); v != "" {
|
||||
user.Email = resolvespec_common.NewSqlString(v)
|
||||
}
|
||||
|
||||
fmt.Printf("Full Name [%s]: ", user.FullName.String())
|
||||
scanner.Scan()
|
||||
if v := strings.TrimSpace(scanner.Text()); v != "" {
|
||||
user.FullName = resolvespec_common.NewSqlString(v)
|
||||
}
|
||||
|
||||
fmt.Printf("Role (admin/user) [%s]: ", user.Role.String())
|
||||
scanner.Scan()
|
||||
if v := strings.TrimSpace(scanner.Text()); v != "" {
|
||||
user.Role = resolvespec_common.NewSqlString(v)
|
||||
}
|
||||
|
||||
user.UpdatedAt = resolvespec_common.NewSqlTimeStamp(time.Now())
|
||||
|
||||
_, err := storage.DB.NewUpdate().Model(user).
|
||||
Set("username = ?", user.Username.String()).
|
||||
Set("email = ?", user.Email.String()).
|
||||
Set("full_name = ?", user.FullName.String()).
|
||||
Set("role = ?", user.Role.String()).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("id = ?", user.ID.String()).
|
||||
Exec(context.Background())
|
||||
checkError(err)
|
||||
|
||||
fmt.Printf("User '%s' updated\n", user.Username.String())
|
||||
}
|
||||
|
||||
func deleteUser(username string) {
|
||||
user := findUserByUsername(username)
|
||||
|
||||
fmt.Printf("Delete user '%s'? This cannot be undone. [y/N]: ", username)
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Scan()
|
||||
if strings.ToLower(strings.TrimSpace(scanner.Text())) != "y" {
|
||||
fmt.Println("Cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
_, err := storage.DB.NewDelete().Model(user).Where("id = ?", user.ID.String()).Exec(context.Background())
|
||||
checkError(err)
|
||||
|
||||
fmt.Printf("User '%s' deleted\n", username)
|
||||
}
|
||||
|
||||
func findUserByUsername(username string) *models.ModelPublicUsers {
|
||||
var user models.ModelPublicUsers
|
||||
err := storage.DB.NewSelect().Model(&user).Where("username = ?", username).Scan(context.Background())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: user '%s' not found\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
return &user
|
||||
}
|
||||
|
||||
func readPassword(prompt string) string {
|
||||
fmt.Print(prompt)
|
||||
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Scan()
|
||||
return strings.TrimSpace(scanner.Text())
|
||||
}
|
||||
return string(pw)
|
||||
}
|
||||
@@ -43,4 +43,5 @@ func init() {
|
||||
rootCmd.AddCommand(hooksCmd)
|
||||
rootCmd.AddCommand(accountsCmd)
|
||||
rootCmd.AddCommand(sendCmd)
|
||||
rootCmd.AddCommand(usersCmd)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user