fix(team): name the team in delete prompts and protect the owner team

team delete confirmed with "delete team 3 on <host>" and deleted
whatever the ID pointed at, including an organization's owner team.
It now looks the team up first and asks to delete team "devs" (ID 5)
from acme. Deleting an owner team requires --force and, interactively,
typing the team's org/name.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 21:54:57 +02:00
1 parent 872ba31607
commit a3f5468628
2 files changed
+85 -8

No files matched your search

+31 -8
View File
@@ -5,6 +5,7 @@ package app
import ( import (
"errors" "errors"
"fmt"
"strconv" "strconv"
"strings" "strings"
@@ -34,7 +35,7 @@ func (a *application) teamCommand() *cobra.Command {
group.AddCommand(list) group.AddCommand(list)
for _, verb := range []string{"view", "create", "edit", "delete"} { for _, verb := range []string{"view", "create", "edit", "delete"} {
var name, description, permission string var name, description, permission string
var createRepo, allRepos bool var createRepo, allRepos, force bool
var units map[string]string var units map[string]string
command := &cobra.Command{Use: verb + " <id>", Short: titleCase(verb) + " an organization team", Args: argsExact(1)} command := &cobra.Command{Use: verb + " <id>", Short: titleCase(verb) + " an organization team", Args: argsExact(1)}
switch verb { switch verb {
@@ -42,6 +43,7 @@ func (a *application) teamCommand() *cobra.Command {
command.Use = "create <org>" command.Use = "create <org>"
case "delete": case "delete":
command.Annotations = textOutput("team deletion does not return a resource") command.Annotations = textOutput("team deletion does not return a resource")
command.Flags().BoolVar(&force, "force", false, "Allow deleting the organization's owner team")
} }
if verb == "create" || verb == "edit" { if verb == "create" || verb == "edit" {
command.Flags().StringVar(&name, "name", "", "Team name") command.Flags().StringVar(&name, "name", "", "Team name")
@@ -120,12 +122,8 @@ func (a *application) teamCommand() *cobra.Command {
return err return err
} }
service := services.Teams{Client: target.client} service := services.Teams{Client: target.client}
if verb == "delete" || prompted { if prompted {
subject := args[0] if err := a.confirm(cmd, "create team "+name+" in "+args[0]+" on "+target.Host); err != nil {
if verb == "create" {
subject = name + " in " + args[0]
}
if err := a.confirm(cmd, verb+" team "+subject+" on "+target.Host); err != nil {
return err return err
} }
} }
@@ -138,7 +136,7 @@ func (a *application) teamCommand() *cobra.Command {
case "edit": case "edit":
item, err = service.Edit(cmd.Context(), id, opts) item, err = service.Edit(cmd.Context(), id, opts)
case "delete": case "delete":
return service.Delete(cmd.Context(), id) return a.deleteTeam(cmd, service, id, force)
} }
if err != nil { if err != nil {
return err return err
@@ -151,6 +149,31 @@ func (a *application) teamCommand() *cobra.Command {
return group return group
} }
// deleteTeam names the team in its confirmation and deletes an organization's owner team only
// with --force, after the user types its name.
func (a *application) deleteTeam(cmd *cobra.Command, service services.Teams, id int64, force bool) error {
team, err := service.View(cmd.Context(), id)
if err != nil {
return err
}
subject, answer := fmt.Sprintf("team %q (ID %d)", team.Name, team.ID), team.Name
if team.Organization != "" {
subject += " from " + team.Organization
answer = team.Organization + "/" + team.Name
}
if team.Permission != "owner" {
err = a.confirm(cmd, "Delete "+subject+"?")
} else if !force {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is the organization's owner team and its members would lose owner access; pass --force to delete it", subject))
} else {
err = a.confirmTyped(cmd, "Delete owner "+subject+"? Its members lose owner access.", answer)
}
if err != nil {
return err
}
return service.Delete(cmd.Context(), id)
}
func (a *application) teamAccessCommand(repository bool) *cobra.Command { func (a *application) teamAccessCommand(repository bool) *cobra.Command {
kind := "member" kind := "member"
if repository { if repository {
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestTeamDeleteNamesTheTeamAndProtectsTheOwnerTeam(t *testing.T) {
var deleted []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "GET /api/v1/teams/3":
_, _ = w.Write([]byte(`{"id":3,"name":"Owners","permission":"owner","organization":{"username":"acme"}}`))
case "GET /api/v1/teams/5":
_, _ = w.Write([]byte(`{"id":5,"name":"devs","permission":"write","organization":{"username":"acme"}}`))
case "DELETE /api/v1/teams/3", "DELETE /api/v1/teams/5":
deleted = append(deleted, r.URL.Path)
w.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusTeapot)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"team", "delete", "3", "--yes"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err)
require.ErrorContains(t, err, `team "Owners" (ID 3) from acme is the organization's owner team`)
require.ErrorContains(t, err, "--force")
require.Empty(t, deleted)
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
prompt := &scriptedPrompter{inputs: map[string]string{`Delete owner team "Owners" (ID 3) from acme? Its members lose owner access. Type acme/Owners to confirm:`: "Owners"}}
deps.Prompter = prompt
err = Execute(context.Background(), deps, []string{"team", "delete", "3", "--force"})
require.Equal(t, clierr.Canceled, clierr.ExitCode(err), "%v", err)
require.Len(t, prompt.asked, 1)
require.Empty(t, deleted)
prompt.inputs[prompt.asked[0]] = "acme/Owners"
require.NoError(t, Execute(context.Background(), deps, []string{"team", "delete", "3", "--force"}))
require.Equal(t, []string{"/api/v1/teams/3"}, deleted)
prompt.accept = true
require.NoError(t, Execute(context.Background(), deps, []string{"team", "delete", "5"}))
require.Equal(t, []string{`Delete team "devs" (ID 5) from acme?`}, prompt.confirmations)
require.Equal(t, []string{"/api/v1/teams/3", "/api/v1/teams/5"}, deleted)
}