diff --git a/cmd/admin.go b/cmd/admin.go index 212dc3b..728cad7 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -39,6 +39,9 @@ var cmdAdminUsers = cli.Command{ }, Commands: []*cli.Command{ &users.CmdUserList, + &users.CmdUserCreate, + &users.CmdUserEdit, + &users.CmdUserDelete, }, Flags: users.CmdUserList.Flags, } diff --git a/cmd/admin/users/create.go b/cmd/admin/users/create.go new file mode 100644 index 0000000..5630587 --- /dev/null +++ b/cmd/admin/users/create.go @@ -0,0 +1,220 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package users + +import ( + stdctx "context" + "fmt" + "io" + "os" + "strings" + "syscall" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + + "code.gitea.io/sdk/gitea" + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// CmdUserCreate represents a sub command of users to create a user +var CmdUserCreate = cli.Command{ + Name: "create", + Aliases: []string{"add", "new"}, + Usage: "Create a new user", + Description: "Create a new user account", + ArgsUsage: " ", // command does not accept arguments + Action: RunUserCreate, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "Username for the new user (required)", + Required: true, + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Usage: "Password for the new user (will prompt if not provided)", + }, + &cli.StringFlag{ + Name: "password-file", + Usage: "Read password from file", + }, + &cli.BoolFlag{ + Name: "password-stdin", + Usage: "Read password from stdin", + }, + &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "Email address for the new user (required)", + Required: true, + }, + &cli.StringFlag{ + Name: "full-name", + Usage: "Full name for the new user", + }, + &cli.BoolFlag{ + Name: "admin", + Usage: "Make the user an administrator", + }, + &cli.BoolFlag{ + Name: "restricted", + Usage: "Make the user restricted", + }, + &cli.BoolFlag{ + Name: "prohibit-login", + Usage: "Prohibit the user from logging in", + }, + &cli.BoolFlag{ + Name: "no-must-change-password", + Usage: "Don't require the user to change password on first login (default: password change required)", + }, + &cli.StringFlag{ + Name: "visibility", + Usage: "Visibility of the user profile (public, limited, private)", + Value: "public", + }, + }, flags.AllDefaultFlags...), +} + +// RunUserCreate creates a new user +func RunUserCreate(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + username := ctx.String("username") + password := ctx.String("password") + email := ctx.String("email") + fullName := ctx.String("full-name") + isAdmin := ctx.Bool("admin") + restricted := ctx.Bool("restricted") + prohibitLogin := ctx.Bool("prohibit-login") + noMustChangePassword := ctx.Bool("no-must-change-password") + visibility := ctx.String("visibility") + + // Get password from various sources in priority order + if password == "" { + if ctx.String("password-file") != "" { + // Read from file + content, err := os.ReadFile(ctx.String("password-file")) + if err != nil { + return fmt.Errorf("failed to read password file: %w", err) + } + password = strings.TrimSpace(string(content)) + } else if ctx.Bool("password-stdin") { + // Read from stdin + content, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read password from stdin: %w", err) + } + password = strings.TrimSpace(string(content)) + } else { + // Interactive prompt (hidden input) + fmt.Printf("Enter password for '%s': ", username) + bytePassword, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + fmt.Println() // Add newline after hidden input + password = string(bytePassword) + + if password == "" { + return fmt.Errorf("password cannot be empty") + } + + // Confirm password (only for interactive mode) + fmt.Printf("Confirm password for '%s': ", username) + bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password confirmation: %w", err) + } + fmt.Println() // Add newline after hidden input + passwordConfirm := string(bytePasswordConfirm) + + if password != passwordConfirm { + return fmt.Errorf("passwords do not match") + } + } + } + + if password == "" { + return fmt.Errorf("password cannot be empty") + } + + if email == "" { + return fmt.Errorf("email is required") + } + + client := ctx.Login.Client() + + // Build create options + createOpts := gitea.CreateUserOption{ + LoginName: username, + Username: username, + Password: password, + Email: email, + FullName: fullName, + SendNotify: false, + } + + // Set must change password flag (pointer to bool required) + // By default, require user to change password on first login + // Only set to false if --no-must-change-password flag is explicitly set + mustChangePassword := !noMustChangePassword + createOpts.MustChangePassword = &mustChangePassword + + vis, err := parseUserVisibility(visibility) + if err != nil { + return err + } + createOpts.Visibility = vis + + // Create the user + user, _, err := client.AdminCreateUser(createOpts) + if err != nil { + return err + } + + // Admin, Restricted, and ProhibitLogin cannot be set during user creation + // We need to update them via AdminEditUser after creation if any of these flags are set + if isAdmin || restricted || prohibitLogin { + editOpts := gitea.EditUserOption{ + LoginName: username, // Required field + } + + if isAdmin { + editOpts.Admin = &isAdmin + } + + if restricted { + editOpts.Restricted = &restricted + } + + if prohibitLogin { + editOpts.ProhibitLogin = &prohibitLogin + } + + // Update user with admin/restricted/prohibit-login settings + _, err = client.AdminEditUser(username, editOpts) + if err != nil { + return fmt.Errorf("user created but failed to update admin/restricted/prohibit-login status: %w", err) + } + + // Refresh user info to reflect the changes + user, _, err = client.GetUserInfo(username) + if err != nil { + return fmt.Errorf("user updated but failed to retrieve updated user info: %w", err) + } + } + + print.UserDetails(user) + + return nil +} diff --git a/cmd/admin/users/delete.go b/cmd/admin/users/delete.go new file mode 100644 index 0000000..d0df7ee --- /dev/null +++ b/cmd/admin/users/delete.go @@ -0,0 +1,77 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package users + +import ( + stdctx "context" + "fmt" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdUserDelete represents a sub command of users to delete a user +var CmdUserDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm", "remove"}, + Usage: "Delete a user", + Description: "Delete a user account", + ArgsUsage: "", + Action: RunUserDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm deletion without prompting", + }, + }, flags.AllDefaultFlags...), +} + +// RunUserDelete deletes a user +func RunUserDelete(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("username is required") + } + + client := ctx.Login.Client() + username := ctx.Args().First() + + // Get user details first to show what we're deleting + user, _, err := client.GetUserInfo(username) + if err != nil { + return fmt.Errorf("failed to get user info: %w", err) + } + + if !ctx.Bool("confirm") { + userInfo := fmt.Sprintf("%s (ID: %d)", user.UserName, user.ID) + if user.Email != "" { + userInfo += fmt.Sprintf(" - %s", user.Email) + } + if user.IsAdmin { + userInfo += " [admin]" + } + fmt.Printf("Are you sure you want to delete user %s? [y/N] ", userInfo) + var response string + fmt.Scanln(&response) + if !isConfirmationAccepted(response) { + fmt.Println("Deletion canceled.") + return nil + } + } + + _, err = client.AdminDeleteUser(username) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + + fmt.Printf("User '%s' deleted successfully\n", username) + return nil +} diff --git a/cmd/admin/users/edit.go b/cmd/admin/users/edit.go new file mode 100644 index 0000000..169939d --- /dev/null +++ b/cmd/admin/users/edit.go @@ -0,0 +1,374 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package users + +import ( + stdctx "context" + "fmt" + "io" + "os" + "strings" + "syscall" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + + "code.gitea.io/sdk/gitea" + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// CmdUserEdit represents a sub command of users to edit a user +var CmdUserEdit = cli.Command{ + Name: "edit", + Aliases: []string{"update", "e", "u"}, + Usage: "Edit a user", + Description: "Edit user account properties", + ArgsUsage: "", + Action: RunUserEdit, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "password", + Usage: "New password (use empty value --password=\"\" to trigger interactive prompt)", + Value: "", + }, + &cli.StringFlag{ + Name: "password-file", + Usage: "Read password from file", + }, + &cli.BoolFlag{ + Name: "password-stdin", + Usage: "Read password from stdin", + }, + &cli.StringFlag{ + Name: "email", + Aliases: []string{"e"}, + Usage: "Email address", + }, + &cli.StringFlag{ + Name: "full-name", + Usage: "Full name", + }, + &cli.StringFlag{ + Name: "description", + Usage: "User description", + }, + &cli.StringFlag{ + Name: "website", + Usage: "Website URL", + }, + &cli.StringFlag{ + Name: "location", + Usage: "Location", + }, + &cli.BoolFlag{ + Name: "admin", + Usage: "Make the user an administrator", + }, + &cli.BoolFlag{ + Name: "no-admin", + Usage: "Remove administrator status", + }, + &cli.BoolFlag{ + Name: "restricted", + Usage: "Make the user restricted", + }, + &cli.BoolFlag{ + Name: "no-restricted", + Usage: "Remove restricted status", + }, + &cli.BoolFlag{ + Name: "prohibit-login", + Usage: "Prohibit the user from logging in", + }, + &cli.BoolFlag{ + Name: "allow-login", + Usage: "Allow the user to log in", + }, + &cli.BoolFlag{ + Name: "active", + Usage: "Activate the user", + }, + &cli.BoolFlag{ + Name: "inactive", + Usage: "Deactivate the user", + }, + &cli.BoolFlag{ + Name: "no-must-change-password", + Usage: "Don't require the user to change password on next login (default: password change required)", + }, + &cli.StringFlag{ + Name: "visibility", + Usage: "Visibility of the user profile (public, limited, private)", + }, + &cli.IntFlag{ + Name: "max-repo-creation", + Usage: "Maximum number of repositories the user can create (-1 for unlimited)", + }, + &cli.BoolFlag{ + Name: "allow-git-hook", + Usage: "Allow the user to use git hooks", + }, + &cli.BoolFlag{ + Name: "no-allow-git-hook", + Usage: "Disallow the user from using git hooks", + }, + &cli.BoolFlag{ + Name: "allow-import-local", + Usage: "Allow the user to import local repositories", + }, + &cli.BoolFlag{ + Name: "no-allow-import-local", + Usage: "Disallow the user from importing local repositories", + }, + &cli.BoolFlag{ + Name: "allow-create-organization", + Usage: "Allow the user to create organizations", + }, + &cli.BoolFlag{ + Name: "no-allow-create-organization", + Usage: "Disallow the user from creating organizations", + }, + }, flags.AllDefaultFlags...), +} + +// RunUserEdit edits an existing user +func RunUserEdit(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + if ctx.Args().Len() == 0 { + return fmt.Errorf("username is required") + } + + client := ctx.Login.Client() + username := ctx.Args().First() + + // Verify the user exists before attempting an update. + _, _, err = client.GetUserInfo(username) + if err != nil { + return fmt.Errorf("failed to get user info: %w", err) + } + + // Build edit options, starting with required LoginName + editOpts := gitea.EditUserOption{ + LoginName: username, + } + + // Update email if set + if ctx.IsSet("email") { + email := ctx.String("email") + editOpts.Email = &email + } + + // Update full name if set + if ctx.IsSet("full-name") { + fullName := ctx.String("full-name") + editOpts.FullName = &fullName + } + + // Update description if set + if ctx.IsSet("description") { + description := ctx.String("description") + editOpts.Description = &description + } + + // Update website if set + if ctx.IsSet("website") { + website := ctx.String("website") + editOpts.Website = &website + } + + // Update location if set + if ctx.IsSet("location") { + location := ctx.String("location") + editOpts.Location = &location + } + + // Handle admin status + if ctx.IsSet("admin") { + admin := ctx.Bool("admin") + editOpts.Admin = &admin + } else if ctx.IsSet("no-admin") { + admin := false + editOpts.Admin = &admin + } + + // Handle restricted status + if ctx.IsSet("restricted") { + restricted := ctx.Bool("restricted") + editOpts.Restricted = &restricted + } else if ctx.IsSet("no-restricted") { + restricted := false + editOpts.Restricted = &restricted + } + + // Handle prohibit login status + if ctx.IsSet("prohibit-login") { + prohibitLogin := ctx.Bool("prohibit-login") + editOpts.ProhibitLogin = &prohibitLogin + } else if ctx.IsSet("allow-login") { + prohibitLogin := false + editOpts.ProhibitLogin = &prohibitLogin + } + + // Handle active status + if ctx.IsSet("active") { + active := ctx.Bool("active") + editOpts.Active = &active + } else if ctx.IsSet("inactive") { + active := false + editOpts.Active = &active + } + + // Handle must change password - will be set when password is changed unless flag is set + + // Handle visibility + if ctx.IsSet("visibility") { + vis, err := parseUserVisibility(ctx.String("visibility")) + if err != nil { + return err + } + editOpts.Visibility = vis + } + + // Handle max repo creation + if ctx.IsSet("max-repo-creation") { + maxRepoCreation := ctx.Int("max-repo-creation") + editOpts.MaxRepoCreation = &maxRepoCreation + } + + // Handle allow git hook + if ctx.IsSet("allow-git-hook") { + allowGitHook := ctx.Bool("allow-git-hook") + editOpts.AllowGitHook = &allowGitHook + } else if ctx.IsSet("no-allow-git-hook") { + allowGitHook := false + editOpts.AllowGitHook = &allowGitHook + } + + // Handle allow import local + if ctx.IsSet("allow-import-local") { + allowImportLocal := ctx.Bool("allow-import-local") + editOpts.AllowImportLocal = &allowImportLocal + } else if ctx.IsSet("no-allow-import-local") { + allowImportLocal := false + editOpts.AllowImportLocal = &allowImportLocal + } + + // Handle allow create organization + if ctx.IsSet("allow-create-organization") { + allowCreateOrg := ctx.Bool("allow-create-organization") + editOpts.AllowCreateOrganization = &allowCreateOrg + } else if ctx.IsSet("no-allow-create-organization") { + allowCreateOrg := false + editOpts.AllowCreateOrganization = &allowCreateOrg + } + + // Handle password if any password flag is set or if password flag was provided (even without value) + shouldChangePassword := ctx.IsSet("password") || ctx.IsSet("password-file") || ctx.Bool("password-stdin") + if shouldChangePassword { + password := ctx.String("password") + + // Get password from various sources in priority order + if password == "" { + if ctx.IsSet("password-file") && ctx.String("password-file") != "" { + // Read from file + content, err := os.ReadFile(ctx.String("password-file")) + if err != nil { + return fmt.Errorf("failed to read password file: %w", err) + } + password = strings.TrimSpace(string(content)) + } else if ctx.Bool("password-stdin") { + // Read from stdin + content, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read password from stdin: %w", err) + } + password = strings.TrimSpace(string(content)) + } else { + // Interactive prompt (hidden input) - triggered when --password is used without value + fmt.Printf("Enter new password for '%s': ", username) + bytePassword, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + fmt.Println() // Add newline after hidden input + password = string(bytePassword) + + if password == "" { + return fmt.Errorf("password cannot be empty") + } + + // Confirm password (only for interactive mode) + fmt.Printf("Confirm new password for '%s': ", username) + bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password confirmation: %w", err) + } + fmt.Println() // Add newline after hidden input + passwordConfirm := string(bytePasswordConfirm) + + if password != passwordConfirm { + return fmt.Errorf("passwords do not match") + } + } + } + + if password == "" { + return fmt.Errorf("password cannot be empty") + } + + editOpts.Password = password + + // When password is changed, require user to change password on next login by default + // Only set to false if --no-must-change-password flag is explicitly set + if !ctx.IsSet("no-must-change-password") { + mustChangePassword := true + editOpts.MustChangePassword = &mustChangePassword + } else { + mustChangePassword := false + editOpts.MustChangePassword = &mustChangePassword + } + } + + // Only proceed with update if at least one field is being modified + hasChanges := editOpts.Email != nil || + editOpts.FullName != nil || + editOpts.Description != nil || + editOpts.Website != nil || + editOpts.Location != nil || + editOpts.Admin != nil || + editOpts.Restricted != nil || + editOpts.ProhibitLogin != nil || + editOpts.Active != nil || + editOpts.Visibility != nil || + editOpts.MaxRepoCreation != nil || + editOpts.AllowGitHook != nil || + editOpts.AllowImportLocal != nil || + editOpts.AllowCreateOrganization != nil || + editOpts.Password != "" + + if !hasChanges { + return fmt.Errorf("no changes specified") + } + + // Update the user + _, err = client.AdminEditUser(username, editOpts) + if err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + // Refresh user info to reflect the changes + updatedUser, _, err := client.GetUserInfo(username) + if err != nil { + return fmt.Errorf("user updated but failed to retrieve updated user info: %w", err) + } + + print.UserDetails(updatedUser) + return nil +} diff --git a/cmd/admin/users/shared.go b/cmd/admin/users/shared.go new file mode 100644 index 0000000..e03f89b --- /dev/null +++ b/cmd/admin/users/shared.go @@ -0,0 +1,32 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package users + +import ( + "fmt" + "strings" + + "code.gitea.io/sdk/gitea" +) + +func parseUserVisibility(visibility string) (*gitea.VisibleType, error) { + switch visibility { + case "public": + vis := gitea.VisibleTypePublic + return &vis, nil + case "limited": + vis := gitea.VisibleTypeLimited + return &vis, nil + case "private": + vis := gitea.VisibleTypePrivate + return &vis, nil + default: + return nil, fmt.Errorf("invalid visibility: %s (must be public, limited, or private)", visibility) + } +} + +func isConfirmationAccepted(response string) bool { + trimmed := strings.TrimSpace(response) + return strings.EqualFold(trimmed, "y") || strings.EqualFold(trimmed, "yes") +} diff --git a/docs/CLI.md b/docs/CLI.md index 9950ada..94474b8 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -2029,6 +2029,116 @@ List Users **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +#### create, add, new + +Create a new user + +**--admin**: Make the user an administrator + +**--email, -e**="": Email address for the new user (required) + +**--full-name**="": Full name for the new user + +**--login, -l**="": Use a different Gitea Login. Optional + +**--no-must-change-password**: Don't require the user to change password on first login (default: password change required) + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--password, -p**="": Password for the new user (will prompt if not provided) + +**--password-file**="": Read password from file + +**--password-stdin**: Read password from stdin + +**--prohibit-login**: Prohibit the user from logging in + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +**--restricted**: Make the user restricted + +**--username, -u**="": Username for the new user (required) + +**--visibility**="": Visibility of the user profile (public, limited, private) (default: "public") + +#### edit, update, e, u + +Edit a user + +**--active**: Activate the user + +**--admin**: Make the user an administrator + +**--allow-create-organization**: Allow the user to create organizations + +**--allow-git-hook**: Allow the user to use git hooks + +**--allow-import-local**: Allow the user to import local repositories + +**--allow-login**: Allow the user to log in + +**--description**="": User description + +**--email, -e**="": Email address + +**--full-name**="": Full name + +**--inactive**: Deactivate the user + +**--location**="": Location + +**--login, -l**="": Use a different Gitea Login. Optional + +**--max-repo-creation**="": Maximum number of repositories the user can create (-1 for unlimited) (default: 0) + +**--no-admin**: Remove administrator status + +**--no-allow-create-organization**: Disallow the user from creating organizations + +**--no-allow-git-hook**: Disallow the user from using git hooks + +**--no-allow-import-local**: Disallow the user from importing local repositories + +**--no-must-change-password**: Don't require the user to change password on next login (default: password change required) + +**--no-restricted**: Remove restricted status + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--password**="": New password (use empty value --password="" to trigger interactive prompt) + +**--password-file**="": Read password from file + +**--password-stdin**: Read password from stdin + +**--prohibit-login**: Prohibit the user from logging in + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +**--restricted**: Make the user restricted + +**--visibility**="": Visibility of the user profile (public, limited, private) + +**--website**="": Website URL + +#### delete, rm, remove + +Delete a user + +**--confirm, -y**: confirm deletion without prompting + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + ## api Make an authenticated API request diff --git a/tests/integration/admin_users_test.go b/tests/integration/admin_users_test.go new file mode 100644 index 0000000..bd84941 --- /dev/null +++ b/tests/integration/admin_users_test.go @@ -0,0 +1,139 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "code.gitea.io/sdk/gitea" + teacmd "code.gitea.io/tea/cmd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func runAdminCommand(t *testing.T, args []string) error { + t.Helper() + + adminCmd := teacmd.CmdAdmin + return adminCmd.Run(context.Background(), args) +} + +func createAdminTestUser(t *testing.T, client *gitea.Client, username, password string) { + t.Helper() + + mustChangePassword := false + user, _, err := client.AdminCreateUser(gitea.CreateUserOption{ + LoginName: username, + Username: username, + Email: username + "@example.com", + Password: password, + MustChangePassword: &mustChangePassword, + }) + require.NoError(t, err) + require.Equal(t, username, user.UserName) + + t.Cleanup(func() { + if _, err := client.AdminDeleteUser(username); err != nil { + t.Logf("failed to delete integration test user %q: %v", username, err) + } + }) +} + +func TestAdminUsersCreateRequiresEmail(t *testing.T) { + login := createIntegrationLogin(t) + + err := runAdminCommand(t, []string{ + "admin", "users", "create", + "--username", fmt.Sprintf("create-no-email-%d", time.Now().UnixNano()), + "--password", "secret123", + "--login", login.Name, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "email") +} + +func TestAdminUsersCreateAndDelete(t *testing.T) { + login := createIntegrationLogin(t) + client := login.Client() + username := fmt.Sprintf("tea-admin-create-%d", time.Now().UnixNano()) + + err := runAdminCommand(t, []string{ + "admin", "users", "create", + "--username", username, + "--email", username + "@example.com", + "--password", "secret123", + "--admin", + "--prohibit-login", + "--visibility", "limited", + "--login", login.Name, + }) + require.NoError(t, err) + + createdUser, _, err := client.GetUserInfo(username) + require.NoError(t, err) + assert.Equal(t, username, createdUser.UserName) + assert.Equal(t, username+"@example.com", createdUser.Email) + assert.True(t, createdUser.IsAdmin) + assert.True(t, createdUser.ProhibitLogin) + assert.Equal(t, gitea.VisibleTypeLimited, createdUser.Visibility) + + err = runAdminCommand(t, []string{ + "admin", "users", "delete", username, + "--confirm", + "--login", login.Name, + }) + require.NoError(t, err) + + _, _, err = client.GetUserInfo(username) + require.Error(t, err) +} + +func TestAdminUsersEdit(t *testing.T) { + login := createIntegrationLogin(t) + client := login.Client() + username := fmt.Sprintf("tea-admin-edit-%d", time.Now().UnixNano()) + oldPassword := "old-secret" + newPassword := "new-secret" + createAdminTestUser(t, client, username, oldPassword) + + passwordFile := filepath.Join(t.TempDir(), "password.txt") + require.NoError(t, os.WriteFile(passwordFile, []byte(newPassword+"\n"), 0o600)) + + err := runAdminCommand(t, []string{ + "admin", "users", "edit", username, + "--email", username + "+new@example.com", + "--full-name", "Tea Integration", + "--restricted", + "--password-file", passwordFile, + "--no-must-change-password", + "--visibility", "private", + "--login", login.Name, + }) + require.NoError(t, err) + + updatedUser, _, err := client.GetUserInfo(username) + require.NoError(t, err) + assert.Equal(t, username+"+new@example.com", updatedUser.Email) + assert.Equal(t, "Tea Integration", updatedUser.FullName) + assert.True(t, updatedUser.IsActive) + assert.True(t, updatedUser.Restricted) + assert.False(t, updatedUser.ProhibitLogin) + assert.Equal(t, gitea.VisibleTypePrivate, updatedUser.Visibility) + + passwordClient, err := gitea.NewClient( + integrationGiteaURL, + gitea.SetBasicAuth(username, newPassword), + gitea.SetGiteaVersion(""), + ) + require.NoError(t, err) + + me, _, err := passwordClient.GetMyUserInfo() + require.NoError(t, err) + assert.Equal(t, username, me.UserName) +}