fix(admin): offer --purge for users who own repositories and show roles

admin user delete of a user who owns repositories failed with Gitea's raw
HTTP 422. The error now says what blocks the deletion and how to proceed,
and --purge deletes the account with its repositories, packages and
organization memberships after typing the username on a terminal.

admin user list shows ROLE and STATUS columns (admin, restricted, user;
active, inactive, login prohibited) with the email and creation date
instead of admin=true/active=true cells, and --query filters by username,
name or email. admin user view shows the account instead of its list
row. create asks for a missing username and email address with readable
labels and re-asks for invalid answers, mutations print ✓ lines, usernames
complete, and every command has examples.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:42:49 +02:00
1 parent ef8cfbc4b7
commit c18dae6685
6 files changed
+380 -110

No files matched your search

+270 -89
View File
@@ -4,7 +4,9 @@
package app package app
import ( import (
"context"
"errors" "errors"
"fmt"
"net/mail" "net/mail"
"strconv" "strconv"
"strings" "strings"
@@ -16,43 +18,168 @@ import (
) )
func (a *application) adminCommand() *cobra.Command { func (a *application) adminCommand() *cobra.Command {
group := &cobra.Command{Use: "admin", Short: "Manage instance administration"} group := &cobra.Command{Use: "admin", Short: "Manage instance administration", Long: "Administer the Gitea instance. These commands need an administrator account."}
users := &cobra.Command{Use: "user", Short: "Manage registered accounts"} users := &cobra.Command{Use: "user", Short: "Manage registered accounts", Long: `List, view, create, edit and delete the accounts of the instance. These commands need an
administrator account.`}
users.AddCommand(a.adminUserListCommand(), a.adminUserViewCommand())
for _, verb := range []string{"create", "edit"} {
users.AddCommand(a.adminUserChangeCommand(verb))
}
users.AddCommand(a.adminUserDeleteCommand())
group.AddCommand(users) group.AddCommand(users)
return group
}
// completeAdminUsers completes the usernames of the instance with their names.
func (a *application) completeAdminUsers() cobra.CompletionFunc {
return a.apiCompletion(false, func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error) {
users, err := (services.AdminUsers{Client: t.client}).List(ctx, toComplete, completionLimit, 1)
return describe(users.Items, func(user services.AdminUser) (string, string) { return user.Username, user.FullName }), err
})
}
// userRole is admin, restricted or user.
func userRole(user services.AdminUser) terminal.Cell {
switch {
case user.Admin:
return terminal.Cell{Text: "admin", Color: "magenta"}
case user.Restricted:
return terminal.Cell{Text: "restricted", Color: "yellow"}
}
return terminal.Cell{Text: "user"}
}
// userStatus is active, inactive or login prohibited.
func userStatus(user services.AdminUser) terminal.Cell {
switch {
case user.ProhibitLogin:
return terminal.Cell{Text: "login prohibited", Color: "red"}
case !user.Active:
return terminal.Cell{Text: "inactive", Color: "yellow"}
}
return terminal.Cell{Text: "active", Color: "green"}
}
func (a *application) adminUserListCommand() *cobra.Command {
var query string
var limit, page int var limit, page int
list := &cobra.Command{Use: "list", Short: "List registered accounts", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { cmd := &cobra.Command{
Use: "list", Short: "List registered accounts", Args: argsExact(0),
Example: `$ tea admin user list
# find accounts by username, name or email
$ tea admin user list --query bob
# print the email addresses
$ tea admin user list --jq '.[].email'`,
RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolve(cmd.Context(), false) target, err := a.resolve(cmd.Context(), false)
if err != nil { if err != nil {
return err return err
} }
items, err := (services.AdminUsers{Client: target.client}).List(cmd.Context(), limit, page) users, err := (services.AdminUsers{Client: target.client}).List(cmd.Context(), query, limit, page)
if err != nil { if err != nil {
return err return err
} }
return writeList(a, cmd, items, adminUserRow) spec := listSpec[services.AdminUser]{
}} Columns: []terminal.Column{{Header: "ID"}, {Header: "Username"}, {Header: "Name", Flex: true}, {Header: "Email"}, {Header: "Role"}, {Header: "Status"}, {Header: "Created"}},
pageFlags(list, &limit, &page) Row: func(user services.AdminUser) []terminal.Cell {
users.AddCommand(list) return []terminal.Cell{
for _, verb := range []string{"view", "create", "edit", "delete"} { {Text: strconv.FormatInt(user.ID, 10), Link: user.URL},
{Text: user.Username, Bold: true},
{Text: user.FullName},
{Text: user.Email},
userRole(user), userStatus(user),
{Time: terminal.ParseTime(user.CreatedAt)},
}
},
Context: "users on " + target.Host, Empty: "no users on " + target.Host,
}
if query != "" {
spec.Context, spec.Empty = fmt.Sprintf("users matching %q", query), fmt.Sprintf("no users match %q", query)
}
return writeTable(a, cmd, users.Items, spec, servicePage(users, page))
},
}
cmd.Flags().StringVar(&query, "query", "", "Only list accounts whose username, name or email contains this text")
pageFlags(cmd, &limit, &page)
return cmd
}
func (a *application) adminUserViewCommand() *cobra.Command {
return &cobra.Command{
Use: "view <username>", Short: "View an account", Args: argsExact(1), ValidArgsFunction: a.completeAdminUsers(),
Example: `$ tea admin user view bob
$ tea admin user view bob --json email,admin`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validUsername(args[0]); err != nil {
return clierr.Wrap(clierr.Usage, err)
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
user, err := (services.AdminUsers{Client: target.client}).View(cmd.Context(), args[0])
if err != nil {
return err
}
d := a.display()
return writeView(a, cmd, user, func(user services.AdminUser) terminal.View {
created := timeField("Created", user.CreatedAt, d)
meta := ""
if created.Value != "" {
meta = "joined " + created.Value
}
role, status := userRole(user), userStatus(user)
created.PipedOnly = true
return terminal.View{
Title: user.Username, ID: fmt.Sprintf("(ID %d)", user.ID), Badges: []terminal.Cell{role, status}, Meta: meta,
Fields: []terminal.Field{
{Label: "Name", Value: user.FullName},
{Label: "Email", Value: user.Email},
{Label: "Role", Value: role.Text, PipedOnly: true},
{Label: "Status", Value: status.Text, PipedOnly: true},
{Label: "Visibility", Value: user.Visibility},
{Label: "Website", Value: user.Website},
{Label: "Location", Value: user.Location},
created,
},
Body: user.Description, Kind: "user", URL: user.URL,
}
})
},
}
}
// adminUserChangeCommand creates or edits an account.
func (a *application) adminUserChangeCommand(verb string) *cobra.Command {
var username, email, fullName, description, website, location, visibility, password, passwordFile string var username, email, fullName, description, website, location, visibility, password, passwordFile string
var admin, active, restricted, prohibitLogin, mustChangePassword, allowGitHook, allowImportLocal, allowCreateOrganization bool var admin, active, restricted, prohibitLogin, mustChangePassword, allowGitHook, allowImportLocal, allowCreateOrganization bool
var maxRepos int var maxRepos int
opts := services.AdminUserOptions{} opts := services.AdminUserOptions{}
command := &cobra.Command{Use: verb + " <username>", Short: titleCase(verb) + " an account", Args: argsExact(1)} command := &cobra.Command{
if verb == "create" { Use: "edit <username>", Short: "Edit an account", Args: argsExact(1), ValidArgsFunction: a.completeAdminUsers(),
command.Use = "create [username]" Example: `$ tea admin user edit bob --full-name "Bob Builder" --restricted
command.Args = usageArgs(cobra.MaximumNArgs(1)) # reset a password and require a new one on the next sign-in
command.Flags().StringVar(&username, "username", "", "Account username") $ tea admin user edit bob --password-file pw.txt --must-change-password
# revoke administrator privileges
$ tea admin user edit bob --admin=false`,
} }
if verb == "delete" { if verb == "create" {
command.Annotations = textOutput("account deletion does not return a resource") command.Use, command.Short, command.ValidArgsFunction = "create [<username>]", "Create an account", nil
command.Args = usageArgs(cobra.MaximumNArgs(1))
command.Long = `Create an account. On a terminal, tea asks for a missing username, email address and
password; the new account must change its password on its first sign-in unless
--must-change-password=false is passed.`
command.Example = `$ tea admin user create carol --email carol@example.com
# read the password from a file instead of a prompt
$ tea admin user create ci-bot --email ci@example.com --password-file pw.txt --must-change-password=false`
command.Flags().StringVar(&username, "username", "", "Account username")
} }
type stringBinding struct { type stringBinding struct {
name, description string name, description string
value *string value *string
option **string option **string
} }
stringsToBind := []stringBinding{{"email", "Email address", &email, &opts.Email}, {"full-name", "Display name", &fullName, &opts.FullName}, {"visibility", "Profile visibility: public, limited, private", &visibility, &opts.Visibility}} stringsToBind := []stringBinding{{"email", "Email address", &email, &opts.Email}, {"full-name", "Display name", &fullName, &opts.FullName}}
type boolBinding struct { type boolBinding struct {
name, description string name, description string
value *bool value *bool
@@ -64,17 +191,20 @@ func (a *application) adminCommand() *cobra.Command {
boolsToBind = append(boolsToBind, boolBinding{"active", "Activate account", &active, &opts.Active}, boolBinding{"allow-git-hook", "Allow Git hooks", &allowGitHook, &opts.AllowGitHook}, boolBinding{"allow-import-local", "Allow local repository imports", &allowImportLocal, &opts.AllowImportLocal}, boolBinding{"allow-create-organization", "Allow creating organizations", &allowCreateOrganization, &opts.AllowCreateOrganization}) boolsToBind = append(boolsToBind, boolBinding{"active", "Activate account", &active, &opts.Active}, boolBinding{"allow-git-hook", "Allow Git hooks", &allowGitHook, &opts.AllowGitHook}, boolBinding{"allow-import-local", "Allow local repository imports", &allowImportLocal, &opts.AllowImportLocal}, boolBinding{"allow-create-organization", "Allow creating organizations", &allowCreateOrganization, &opts.AllowCreateOrganization})
command.Flags().IntVar(&maxRepos, "max-repo-creation", 0, "Repository creation limit (-1 for unlimited)") command.Flags().IntVar(&maxRepos, "max-repo-creation", 0, "Repository creation limit (-1 for unlimited)")
} }
if verb == "create" || verb == "edit" {
for _, binding := range stringsToBind { for _, binding := range stringsToBind {
command.Flags().StringVar(binding.value, binding.name, "", binding.description) command.Flags().StringVar(binding.value, binding.name, "", binding.description)
} }
initialVisibility := ""
if verb == "create" {
initialVisibility = "public"
}
enumFlag(command, &visibility, "visibility", "", initialVisibility, "Profile visibility", visibilities...)
for _, binding := range boolsToBind { for _, binding := range boolsToBind {
command.Flags().BoolVar(binding.value, binding.name, false, binding.description+" (use =false to revoke)") command.Flags().BoolVar(binding.value, binding.name, false, binding.description+" (use =false to revoke)")
} }
command.Flags().StringVar(&password, "password", "", "Password (prefer --password-file)") command.Flags().StringVar(&password, "password", "", "Password (prefer --password-file)")
command.Flags().StringVar(&passwordFile, "password-file", "", "Read password from file or - for stdin; remove one trailing line ending") command.Flags().StringVar(&passwordFile, "password-file", "", "Read password from file or - for stdin; remove one trailing line ending")
command.Flags().BoolVar(&mustChangePassword, "must-change-password", verb == "create", "Require password change on next sign-in (use =false to disable)") command.Flags().BoolVar(&mustChangePassword, "must-change-password", verb == "create", "Require password change on next sign-in (use =false to disable)")
}
command.RunE = func(cmd *cobra.Command, args []string) error { command.RunE = func(cmd *cobra.Command, args []string) error {
if verb == "create" { if verb == "create" {
if len(args) > 0 { if len(args) > 0 {
@@ -83,46 +213,35 @@ func (a *application) adminCommand() *cobra.Command {
} }
username = args[0] username = args[0]
} }
for _, field := range []struct { if _, err := a.requireInput(cmd, &username, "username", "Username", validUsername); err != nil {
value *string
flag string
}{{&username, "username"}, {&email, "email"}} {
if _, err := a.require(cmd, field.value, field.flag); err != nil {
return err return err
} }
if _, err := a.requireInput(cmd, &email, "email", "Email address", validEmail); err != nil {
return err
} }
opts.Email = &email opts.Email, opts.FullName, opts.Visibility = &email, &fullName, &visibility
opts.FullName = &fullName
if visibility == "" {
visibility = "public"
}
opts.Visibility = &visibility
} else { } else {
username = args[0] username = args[0]
} }
if strings.TrimSpace(username) == "" || strings.ContainsAny(username, "/\\ \t\r\n") { if err := validUsername(username); err != nil {
return clierr.Wrap(clierr.Usage, errors.New("invalid account username")) return clierr.Wrap(clierr.Usage, err)
} }
if verb == "create" || verb == "edit" {
for _, binding := range stringsToBind { for _, binding := range stringsToBind {
if cmd.Flags().Changed(binding.name) { if cmd.Flags().Changed(binding.name) {
*binding.option = binding.value *binding.option = binding.value
} }
} }
if cmd.Flags().Changed("visibility") {
opts.Visibility = &visibility
}
for _, binding := range boolsToBind { for _, binding := range boolsToBind {
if cmd.Flags().Changed(binding.name) { if cmd.Flags().Changed(binding.name) {
*binding.option = binding.value *binding.option = binding.value
} }
} }
if opts.Email != nil { if opts.Email != nil {
address, err := mail.ParseAddress(email) if err := validEmail(email); err != nil {
if err != nil || address.Address != email { return clierr.Wrap(clierr.Usage, fmt.Errorf("--email: %w", err))
return clierr.Wrap(clierr.Usage, errors.New("--email must contain one email address"))
}
}
if opts.Visibility != nil {
if err := oneOf("visibility", visibility, "public", "limited", "private"); err != nil {
return err
} }
} }
if cmd.Flags().Changed("max-repo-creation") { if cmd.Flags().Changed("max-repo-creation") {
@@ -131,76 +250,138 @@ func (a *application) adminCommand() *cobra.Command {
} }
opts.MaxRepoCreation = &maxRepos opts.MaxRepoCreation = &maxRepos
} }
passwordChange := verb == "create" || cmd.Flags().Changed("password") || cmd.Flags().Changed("password-file") if verb == "create" || cmd.Flags().Changed("password") || cmd.Flags().Changed("password-file") {
if passwordChange {
var err error var err error
if password, err = a.readSecret(cmd, password, passwordFile, "password", "password-file"); err != nil { if opts.Password, err = a.adminUserPassword(cmd, username, password, passwordFile); err != nil {
return err return err
} }
if password == "" && !cmd.Flags().Changed("password-file") {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
if !a.policy(snapshot).CanPrompt(a.deps.Streams) {
return clierr.Wrap(clierr.Usage, errors.New("missing password: supply --password-file or --password"))
}
password, err = a.deps.Prompter.SecretInput(cmd.Context(), "Password for "+username)
if err != nil {
return err
}
confirmation, err := a.deps.Prompter.SecretInput(cmd.Context(), "Confirm password")
if err != nil {
return err
}
if password != confirmation {
return clierr.Wrap(clierr.Usage, errors.New("passwords do not match"))
}
}
if password == "" {
return clierr.Wrap(clierr.Usage, errors.New("password cannot be empty"))
}
opts.Password = password
} }
if verb == "create" || cmd.Flags().Changed("must-change-password") { if verb == "create" || cmd.Flags().Changed("must-change-password") {
opts.MustChangePassword = &mustChangePassword opts.MustChangePassword = &mustChangePassword
} }
if verb == "edit" && opts == (services.AdminUserOptions{}) { if verb == "edit" && opts == (services.AdminUserOptions{}) {
return clierr.Wrap(clierr.Usage, errors.New("supply account fields to edit")) return clierr.Wrap(clierr.Usage, errors.New("supply account fields to edit"))
} }
}
target, err := a.resolve(cmd.Context(), false) target, err := a.resolve(cmd.Context(), false)
if err != nil { if err != nil {
return err return err
} }
if verb == "delete" { service := services.AdminUsers{Client: target.client}
if err := a.confirm(cmd, verb+" account "+username+" on "+target.Host); err != nil { if verb == "edit" {
user, err := service.Edit(cmd.Context(), username, opts)
if err != nil {
return err return err
} }
a.done(cmd, "Edited user %s", user.Username)
return a.jsonResult(cmd, user)
}
user, err := service.Create(cmd.Context(), username, opts)
if err != nil {
return err
}
a.done(cmd, "Created user %s", user.Username)
if err := a.printURL(cmd, user.URL); err != nil {
return err
}
return a.jsonResult(cmd, user)
}
return command
}
// adminUserPassword reads the password from its flags, or asks twice on a terminal.
func (a *application) adminUserPassword(cmd *cobra.Command, username, password, passwordFile string) (string, error) {
password, err := a.readSecret(cmd, password, passwordFile, "password", "password-file")
if err != nil {
return "", err
}
if password == "" && !cmd.Flags().Changed("password-file") {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return "", err
}
if !a.policy(snapshot).CanPrompt(a.deps.Streams) {
return "", clierr.Wrap(clierr.Usage, errors.New("missing password: supply --password-file or --password"))
}
if password, err = a.deps.Prompter.SecretInput(cmd.Context(), "Password for "+username); err != nil {
return "", err
}
confirmation, err := a.deps.Prompter.SecretInput(cmd.Context(), "Confirm password")
if err != nil {
return "", err
}
if password != confirmation {
return "", clierr.Wrap(clierr.Usage, errors.New("passwords do not match"))
}
}
if password == "" {
return "", clierr.Wrap(clierr.Usage, errors.New("password cannot be empty"))
}
return password, nil
}
func validUsername(username string) error {
if strings.TrimSpace(username) == "" || strings.ContainsAny(username, "/\\ \t\r\n") {
return errors.New("invalid account username")
}
return nil
}
func validEmail(email string) error {
if address, err := mail.ParseAddress(email); err != nil || address.Address != email {
return errors.New("enter one email address, such as carol@example.com")
}
return nil
}
func (a *application) adminUserDeleteCommand() *cobra.Command {
var purge bool
cmd := &cobra.Command{
Use: "delete <username>", Short: "Delete an account", Args: argsExact(1), ValidArgsFunction: a.completeAdminUsers(),
Long: `Delete an account. Gitea refuses while the user owns repositories or is the last owner of an
organization: transfer or delete those first, or pass --purge to delete the account with
everything it owns. A purge cannot be undone, so a terminal asks you to type the username.`,
Example: `$ tea admin user delete spammer
# also delete the user's repositories, packages and organization memberships
$ tea admin user delete spammer --purge`,
Annotations: map[string]string{"tea.text": "account deletion does not return a resource", "tea.confirm": "true"},
RunE: func(cmd *cobra.Command, args []string) error {
if err := validUsername(args[0]); err != nil {
return clierr.Wrap(clierr.Usage, err)
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
} }
service := services.AdminUsers{Client: target.client} service := services.AdminUsers{Client: target.client}
var item services.AdminUser user, err := service.View(cmd.Context(), args[0])
switch verb { if err != nil {
case "view": return err
item, err = service.View(cmd.Context(), username) }
case "create": confirmation := terminal.Confirmation{Prompt: fmt.Sprintf("Delete user %s?", user.Username), NonTTY: "delete user " + user.Username}
item, err = service.Create(cmd.Context(), username, opts) if purge {
case "edit": confirmation = terminal.Confirmation{
item, err = service.Edit(cmd.Context(), username, opts) Prompt: fmt.Sprintf("Delete user %s with their repositories, packages and organization memberships? This cannot be undone.", user.Username),
case "delete": Typed: user.Username, NonTTY: "purge user " + user.Username,
return service.Delete(cmd.Context(), username) }
}
if err := a.confirmAction(cmd, confirmation); err != nil {
return err
}
err = service.Delete(cmd.Context(), user.Username, purge)
if conflict, ok := errors.AsType[*services.ConflictError](err); ok && conflict.Reason == services.ReasonOwnsRepos {
conflict.Hint = "transfer or delete them, or pass --purge to delete them with the user"
} }
if err != nil { if err != nil {
return err return err
} }
return writeItem(a, cmd, item, adminUserRow) if purge {
a.done(cmd, "Deleted user %s and everything they owned", user.Username)
} else {
a.done(cmd, "Deleted user %s", user.Username)
} }
users.AddCommand(command) return nil
},
} }
return group cmd.Flags().BoolVar(&purge, "purge", false, "Also delete the user's repositories, packages and organization memberships")
} return cmd
func adminUserRow(item services.AdminUser) []terminal.Cell {
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Username, item.FullName, "admin="+strconv.FormatBool(item.Admin), "active="+strconv.FormatBool(item.Active))
} }
+51 -1
View File
@@ -28,7 +28,7 @@ func TestAdminPromptAndFlagsReachEquivalentPayloads(t *testing.T) {
deps.Streams.InTTY = true deps.Streams.InTTY = true
deps.Streams.ErrTTY = true deps.Streams.ErrTTY = true
require.NoError(t, Execute(context.Background(), deps, []string{"admin", "user", "create", "account", "--full-name", "Test Account"})) require.NoError(t, Execute(context.Background(), deps, []string{"admin", "user", "create", "account", "--full-name", "Test Account"}))
require.Equal(t, []string{"email"}, prompt.asked) require.Equal(t, []string{"Email address"}, prompt.asked)
require.Len(t, prompt.secrets, 2) require.Len(t, prompt.secrets, 2)
require.Empty(t, prompt.confirmations) require.Empty(t, prompt.confirmations)
out.Reset() out.Reset()
@@ -60,3 +60,53 @@ func TestAdminUserEditSetsMustChangePasswordOnlyWhenPassed(t *testing.T) {
require.Equal(t, true, edits[1]["must_change_password"]) require.Equal(t, true, edits[1]["must_change_password"])
require.Equal(t, false, edits[2]["must_change_password"]) require.Equal(t, false, edits[2]["must_change_password"])
} }
func TestAdminUserListShowsRolesAndStatuses(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/admin/users", r.URL.Path)
require.Equal(t, "li", r.URL.Query().Get("q"))
_, _ = w.Write([]byte(`[
{"id":1,"login":"alice","full_name":"Alice","email":"alice@example.test","is_admin":true,"active":true,"created":"2026-10-01T12:00:00Z"},
{"id":2,"login":"elisa","email":"elisa@example.test","restricted":true,"active":true,"created":"2026-10-02T12:00:00Z"},
{"id":3,"login":"lisbeth","email":"lisbeth@example.test","prohibit_login":true,"active":true,"created":"2026-10-03T12:00:00Z"},
{"id":4,"login":"lin","email":"lin@example.test","active":false,"created":"2026-10-04T12:00:00Z"}]`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"admin", "user", "list", "--query", "li"}))
require.Equal(t, "1\talice\tAlice\talice@example.test\tadmin\tactive\t2026-10-01T12:00:00Z\n"+
"2\telisa\t\telisa@example.test\trestricted\tactive\t2026-10-02T12:00:00Z\n"+
"3\tlisbeth\t\tlisbeth@example.test\tuser\tlogin prohibited\t2026-10-03T12:00:00Z\n"+
"4\tlin\t\tlin@example.test\tuser\tinactive\t2026-10-04T12:00:00Z\n", out.String())
}
func TestAdminUserDeleteOffersPurgeForOwners(t *testing.T) {
var deletes []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
require.Equal(t, "/api/v1/users/bob", r.URL.Path)
_, _ = w.Write([]byte(`{"id":2,"login":"bob"}`))
case http.MethodDelete:
deletes = append(deletes, r.URL.RequestURI())
if r.URL.Query().Get("purge") != "true" {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"user still has ownership of repositories [uid: 2]"}`))
return
}
w.WriteHeader(http.StatusNoContent)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"admin", "user", "delete", "bob", "--yes"})
require.EqualError(t, err, `user "bob" still owns repositories; transfer or delete them, or pass --purge to delete them with the user`)
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
const typed = "Delete user bob with their repositories, packages and organization memberships? This cannot be undone. Type bob to confirm:"
prompt := &scriptedPrompter{inputs: map[string]string{typed: "bob"}}
deps.Prompter = prompt
require.NoError(t, Execute(context.Background(), deps, []string{"admin", "user", "delete", "bob", "--purge"}))
require.Equal(t, []string{typed}, prompt.asked)
require.Equal(t, []string{"/api/v1/admin/users/bob", "/api/v1/admin/users/bob?purge=true"}, deletes)
}
-7
View File
@@ -422,11 +422,6 @@ const (
pr review cancel-request pr review cancel-request
` `
examplesToWrite = ` examplesToWrite = `
admin user create
admin user delete
admin user edit
admin user list
admin user view
alias delete alias delete
alias list alias list
alias set alias set
@@ -604,8 +599,6 @@ const (
workflow view workflow view
` `
longsToWrite = ` longsToWrite = `
admin
admin user
alias alias
auth auth
branch branch
+26
View File
@@ -10,6 +10,7 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
@@ -35,6 +36,22 @@ func (a *application) jsonResult(cmd *cobra.Command, item any) error {
return a.writeJSON(cmd, item) return a.writeJSON(cmd, item)
} }
// timeField shows an API timestamp relative to now on a TTY, and as RFC3339 for scripts.
func timeField(label, raw string, d terminal.Display) terminal.Field {
t := terminal.ParseTime(raw)
if t.IsZero() {
return terminal.Field{Label: label}
}
if !d.TTY {
return terminal.Field{Label: label, Value: terminal.Timestamp(t)}
}
now := d.Now
if now.IsZero() {
now = time.Now()
}
return terminal.Field{Label: label, Value: terminal.RelativeTime(now, t)}
}
func yesNo(value bool) string { func yesNo(value bool) string {
if value { if value {
return "yes" return "yes"
@@ -111,6 +128,7 @@ $ tea org list --all`,
} }
cmd.Flags().BoolVar(&all, "all", false, "List every organization you can see instead of your memberships") cmd.Flags().BoolVar(&all, "all", false, "List every organization you can see instead of your memberships")
cmd.Flags().StringVar(&username, "username", "", "List the organizations of this user") cmd.Flags().StringVar(&username, "username", "", "List the organizations of this user")
_ = cmd.RegisterFlagCompletionFunc("username", a.completeInstanceUsers())
pageFlags(cmd, &limit, &page) pageFlags(cmd, &limit, &page)
return cmd return cmd
} }
@@ -469,3 +487,11 @@ func (a *application) organizationMemberRemoveCommand() *cobra.Command {
}, },
} }
} }
// completeInstanceUsers completes the usernames of the instance that contain the typed text.
func (a *application) completeInstanceUsers() cobra.CompletionFunc {
return a.apiCompletion(false, func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error) {
users, err := (services.AdminUsers{Client: t.client}).Search(ctx, toComplete, completionLimit)
return describe(users, func(user services.AdminUser) (string, string) { return user.Username, user.FullName }), err
})
}
+2 -2
View File
@@ -20,13 +20,13 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
args []string args []string
want string want string
}{ }{
{[]string{"admin", "user", "create", "account", "--email", "invalid", "--password", "local-password"}, "--email must contain one email address"}, {[]string{"admin", "user", "create", "account", "--email", "invalid", "--password", "local-password"}, "--email: enter one email address"},
{[]string{"admin", "user", "create", "account", "--email", "account@example.test", "--password", "one", "--password-file", "-"}, "--password and --password-file are mutually exclusive"}, {[]string{"admin", "user", "create", "account", "--email", "account@example.test", "--password", "one", "--password-file", "-"}, "--password and --password-file are mutually exclusive"},
{[]string{"admin", "user", "create", "account", "--email", "account@example.test", "--password-file", ""}, "--password-file must name a file or -"}, {[]string{"admin", "user", "create", "account", "--email", "account@example.test", "--password-file", ""}, "--password-file must name a file or -"},
{[]string{"admin", "user", "create", "account", "--email", "account@example.test", "--password", "local-password", "--visibility", "invalid"}, "--visibility must be public, limited, or private"}, {[]string{"admin", "user", "create", "account", "--email", "account@example.test", "--password", "local-password", "--visibility", "invalid"}, "--visibility must be public, limited, or private"},
{[]string{"admin", "user", "create", "account", "--username", "different"}, "supply username as an argument or --username, not both"}, {[]string{"admin", "user", "create", "account", "--username", "different"}, "supply username as an argument or --username, not both"},
{[]string{"admin", "user", "edit", "account"}, "supply account fields to edit"}, {[]string{"admin", "user", "edit", "account"}, "supply account fields to edit"},
{[]string{"admin", "user", "edit", "account", "--email="}, "--email must contain one email address"}, {[]string{"admin", "user", "edit", "account", "--email="}, "--email: enter one email address"},
{[]string{"admin", "user", "edit", "account", "--max-repo-creation", "-2"}, "--max-repo-creation must be -1 or nonnegative"}, {[]string{"admin", "user", "edit", "account", "--max-repo-creation", "-2"}, "--max-repo-creation must be -1 or nonnegative"},
{[]string{"admin", "user", "edit", "account", "--password="}, "missing password: supply --password-file or --password"}, {[]string{"admin", "user", "edit", "account", "--password="}, "missing password: supply --password-file or --password"},
{[]string{"admin", "user", "view", "bad/account"}, "invalid account username"}, {[]string{"admin", "user", "view", "bad/account"}, "invalid account username"},
+31 -11
View File
@@ -6,6 +6,9 @@ package services
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"net/url"
"strconv"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport" "gitea.dev/tea/internal/transport"
@@ -68,15 +71,24 @@ func visibilityOf(visibility *string) *gitea.VisibleType {
// AdminUsers provides administrator account operations. // AdminUsers provides administrator account operations.
type AdminUsers struct{ Client *transport.Client } type AdminUsers struct{ Client *transport.Client }
// List returns a page of users visible to the instance administrator. // List returns a page of the users whose username, full name or email contains query, or of
func (s AdminUsers) List(ctx context.Context, limit, page int) ([]AdminUser, error) { // every user without one.
users, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) { func (s AdminUsers) List(ctx context.Context, query string, limit, page int) (Page[AdminUser], error) {
return c.Admin.ListUsers(ctx, gitea.AdminListUsersOptions{ListOptions: listOptions(limit, page)}) users, err := listPage(limit, page, func(limit, page int) ([]*gitea.User, http.Header, error) {
users, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) {
return c.Admin.ListUsers(ctx, gitea.AdminListUsersOptions{ListOptions: listOptions(limit, page), Query: query})
}) })
if err != nil { return users, headerOf(resp), err
return nil, err })
} return convertPage(users, adminUserFrom), err
return convertAll(users, adminUserFrom), nil }
// Search finds accounts whose name contains query; unlike List it needs no administrator rights.
func (s AdminUsers) Search(ctx context.Context, query string, limit int) ([]AdminUser, error) {
users, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.User, *gitea.Response, error) {
return c.Users.SearchUsers(ctx, gitea.SearchUsersOption{ListOptions: listOptions(limit, 1), KeyWord: query})
})
return convertAll(users, adminUserFrom), err
} }
func (s AdminUsers) get(ctx context.Context, username string) (*gitea.User, error) { func (s AdminUsers) get(ctx context.Context, username string) (*gitea.User, error) {
@@ -144,9 +156,17 @@ func (s AdminUsers) Edit(ctx context.Context, username string, opts AdminUserOpt
return s.View(ctx, username) return s.View(ctx, username)
} }
// Delete removes an account. Gitea enforces ownership constraints. // Delete removes an account. Gitea refuses to delete users who own repositories or organizations
func (s AdminUsers) Delete(ctx context.Context, username string) error { // unless purge also deletes what they own.
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) { func (s AdminUsers) Delete(ctx context.Context, username string, purge bool) error {
var err error
if purge {
// TODO(sdk): DeleteUser needs a purge option.
err = sendJSON(ctx, s.Client, http.MethodDelete, "admin/users/"+url.PathEscape(username)+"?purge=true", nil, nil)
} else {
err = do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
return c.Admin.DeleteUser(ctx, username) return c.Admin.DeleteUser(ctx, username)
}) })
}
return Classify(err, "user", strconv.Quote(username), "")
} }