mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
The application caches the settings snapshot and reloads it only after an update through the store, so output and prompt helpers no longer read and parse config.yml and hosts.yml several times per command. canPrompt and chooser replace the inline prompt-policy checks. Checks are tallied by one services.Tally, terminal.Plural replaces the two app copies, refKind.article/noun replace the comment group's own words, list commands use servicePage, and web URLs are built by repositoryURL and webURL in browser.go. Signed-off-by: bircni <bircni@icloud.com>
381 lines
15 KiB
Go
381 lines
15 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.dev/tea/internal/clierr"
|
|
"gitea.dev/tea/internal/services"
|
|
"gitea.dev/tea/internal/terminal"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func (a *application) adminCommand() *cobra.Command {
|
|
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", 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)
|
|
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
|
|
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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
users, err := (services.AdminUsers{Client: target.client}).List(cmd.Context(), query, limit, page)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
spec := listSpec[services.AdminUser]{
|
|
Columns: []terminal.Column{{Header: "ID"}, {Header: "Username"}, {Header: "Name", Flex: true}, {Header: "Email"}, {Header: "Role"}, {Header: "Status"}, {Header: "Created"}},
|
|
Row: func(user services.AdminUser) []terminal.Cell {
|
|
return []terminal.Cell{
|
|
{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 admin, active, restricted, prohibitLogin, mustChangePassword, allowGitHook, allowImportLocal, allowCreateOrganization bool
|
|
var maxRepos int
|
|
opts := services.AdminUserOptions{}
|
|
command := &cobra.Command{
|
|
Use: "edit <username>", Short: "Edit an account", Args: argsExact(1), ValidArgsFunction: a.completeAdminUsers(),
|
|
Example: `$ tea admin user edit bob --full-name "Bob Builder" --restricted
|
|
# reset a password and require a new one on the next sign-in
|
|
$ tea admin user edit bob --password-file pw.txt --must-change-password
|
|
# revoke administrator privileges
|
|
$ tea admin user edit bob --admin=false`,
|
|
}
|
|
if verb == "create" {
|
|
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 {
|
|
name, description string
|
|
value *string
|
|
option **string
|
|
}
|
|
stringsToBind := []stringBinding{{"email", "Email address", &email, &opts.Email}, {"full-name", "Display name", &fullName, &opts.FullName}}
|
|
type boolBinding struct {
|
|
name, description string
|
|
value *bool
|
|
option **bool
|
|
}
|
|
boolsToBind := []boolBinding{{"admin", "Administrator privileges", &admin, &opts.Admin}, {"restricted", "Restrict account access", &restricted, &opts.Restricted}, {"prohibit-login", "Prohibit signing in", &prohibitLogin, &opts.ProhibitLogin}}
|
|
if verb == "edit" {
|
|
stringsToBind = append(stringsToBind, stringBinding{"description", "Profile description", &description, &opts.Description}, stringBinding{"website", "Website URL", &website, &opts.Website}, stringBinding{"location", "Location", &location, &opts.Location})
|
|
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)")
|
|
}
|
|
for _, binding := range stringsToBind {
|
|
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 {
|
|
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(&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.RunE = func(cmd *cobra.Command, args []string) error {
|
|
if verb == "create" {
|
|
if len(args) > 0 {
|
|
if cmd.Flags().Changed("username") {
|
|
return clierr.Wrap(clierr.Usage, errors.New("supply username as an argument or --username, not both"))
|
|
}
|
|
username = args[0]
|
|
}
|
|
if _, err := a.requireInput(cmd, &username, "username", "Username", validUsername); err != nil {
|
|
return err
|
|
}
|
|
if _, err := a.requireInput(cmd, &email, "email", "Email address", validEmail); err != nil {
|
|
return err
|
|
}
|
|
opts.Email, opts.FullName, opts.Visibility = &email, &fullName, &visibility
|
|
} else {
|
|
username = args[0]
|
|
}
|
|
if err := validUsername(username); err != nil {
|
|
return clierr.Wrap(clierr.Usage, err)
|
|
}
|
|
for _, binding := range stringsToBind {
|
|
if cmd.Flags().Changed(binding.name) {
|
|
*binding.option = binding.value
|
|
}
|
|
}
|
|
if cmd.Flags().Changed("visibility") {
|
|
opts.Visibility = &visibility
|
|
}
|
|
for _, binding := range boolsToBind {
|
|
if cmd.Flags().Changed(binding.name) {
|
|
*binding.option = binding.value
|
|
}
|
|
}
|
|
if opts.Email != nil {
|
|
if err := validEmail(email); err != nil {
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("--email: %w", err))
|
|
}
|
|
}
|
|
if cmd.Flags().Changed("max-repo-creation") {
|
|
if maxRepos < -1 {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--max-repo-creation must be -1 or nonnegative"))
|
|
}
|
|
opts.MaxRepoCreation = &maxRepos
|
|
}
|
|
if verb == "create" || cmd.Flags().Changed("password") || cmd.Flags().Changed("password-file") {
|
|
var err error
|
|
if opts.Password, err = a.adminUserPassword(cmd, username, password, passwordFile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if verb == "create" || cmd.Flags().Changed("must-change-password") {
|
|
opts.MustChangePassword = &mustChangePassword
|
|
}
|
|
if verb == "edit" && opts == (services.AdminUserOptions{}) {
|
|
return clierr.Wrap(clierr.Usage, errors.New("supply account fields to edit"))
|
|
}
|
|
target, err := a.resolve(cmd.Context(), false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
service := services.AdminUsers{Client: target.client}
|
|
if verb == "edit" {
|
|
user, err := service.Edit(cmd.Context(), username, opts)
|
|
if err != nil {
|
|
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)
|
|
return a.created(cmd, user, user.URL)
|
|
}
|
|
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") {
|
|
if !a.canPrompt() {
|
|
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}
|
|
user, err := service.View(cmd.Context(), args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
confirmation := terminal.Confirmation{Prompt: "Delete user " + user.Username + "?"}
|
|
if purge {
|
|
confirmation = terminal.Confirmation{
|
|
Prompt: fmt.Sprintf("Delete user %s with their repositories, packages and organization memberships? This cannot be undone.", user.Username),
|
|
Typed: user.Username, NonTTY: "purge user " + user.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 {
|
|
return err
|
|
}
|
|
if purge {
|
|
a.done(cmd, "Deleted user %s and everything they owned", user.Username)
|
|
} else {
|
|
a.done(cmd, "Deleted user %s", user.Username)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&purge, "purge", false, "Also delete the user's repositories, packages and organization memberships")
|
|
return cmd
|
|
}
|