diff --git a/cmd/cli/commands_users.go b/cmd/cli/commands_users.go new file mode 100644 index 0000000..74c1c2e --- /dev/null +++ b/cmd/cli/commands_users.go @@ -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 ", + 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 ", + 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 ", + 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 ", + 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 ", + Short: "Delete a user", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + deleteUser(args[0]) + }, +} + +var usersRemoveCmd = &cobra.Command{ + Use: "remove ", + 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) +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go index d79c13b..9706c60 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -43,4 +43,5 @@ func init() { rootCmd.AddCommand(hooksCmd) rootCmd.AddCommand(accountsCmd) rootCmd.AddCommand(sendCmd) + rootCmd.AddCommand(usersCmd) }