feat(web): Add org removal functionality to admin user details page (#38013)

Signed-off-by: techknowlogick <techknowlogick@gitea.com>
Signed-off-by: Karthik Bhandary <34509856+karthikbhandary2@users.noreply.github.com>
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Karthik Bhandary
2026-08-29 13:20:04 +05:30
committed by GitHub
parent 3c0bfe9463
commit 88974d2db9
7 changed files with 189 additions and 19 deletions

View File

@@ -56,6 +56,13 @@ func GetOrganizationCount(ctx context.Context, u *user_model.User) (int64, error
Count(new(OrgUser))
}
func GetUserOrganizations(ctx context.Context, uid int64) (ret []*Organization, err error) {
err = db.GetEngine(ctx).Select("`user`.*").
Join("INNER", "org_user", "org_user.org_id = `user`.id").
Where("org_user.uid=?", uid).Find(&ret)
return ret, err
}
// IsOrganizationOwner returns true if given user is in the owner team.
func IsOrganizationOwner(ctx context.Context, orgID, uid int64) (bool, error) {
ownerTeam, err := GetOwnerTeam(ctx, orgID)

View File

@@ -136,6 +136,7 @@
"show_full_screen": "Show full screen",
"download_logs": "Download logs",
"confirm_delete_selected": "Confirm to delete all selected items?",
"confirm_remove": "Confirm to remove?",
"name": "Name",
"value": "Value",
"readme": "Readme",
@@ -3100,6 +3101,12 @@
"admin.users.purge_help": "Forcibly delete user and any repositories, organizations, and packages owned by the user. All comments will be deleted too.",
"admin.users.still_own_packages": "This user still owns one or more packages. Delete these packages first.",
"admin.users.deletion_success": "The user account has been deleted.",
"admin.users.org_removed": "User has been removed from the organization %s.",
"admin.users.all_orgs_removed": "User has been removed from all organizations.",
"admin.users.no_orgs_removed": "Failed to remove user from organizations (may be last owner).",
"admin.users.some_orgs_removed": "User removed from %d of %d organizations (some may require another owner first).",
"admin.users.remove_all_orgs_title": "Remove from All Organizations?",
"admin.users.remove_all_orgs_desc": "Are you sure you want to remove %s from all %d organizations? This action cannot be undone.",
"admin.users.reset_2fa": "Reset 2FA",
"admin.users.list_status_filter.menu_text": "Filter",
"admin.users.list_status_filter.reset": "Reset",

View File

@@ -21,7 +21,6 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/web"
"gitea.dev/routers/web/explore"
@@ -30,6 +29,7 @@ import (
"gitea.dev/services/context"
"gitea.dev/services/forms"
"gitea.dev/services/mailer"
org_service "gitea.dev/services/org"
user_service "gitea.dev/services/user"
)
@@ -293,19 +293,12 @@ func ViewUser(ctx *context.Context) {
ctx.Data["Emails"] = emails
ctx.Data["EmailsTotal"] = len(emails)
orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
ListOptions: db.ListOptionsAll,
UserID: u.ID,
IncludeVisibility: structs.VisibleTypePrivate,
})
ctx.Data["UserOrgs"], err = org_model.GetUserOrganizations(ctx, u.ID)
if err != nil {
ctx.ServerError("FindOrgs", err)
return
}
ctx.Data["Users"] = orgs // needed to be able to use explore/user_list template
ctx.Data["OrgsTotal"] = len(orgs)
ctx.HTML(http.StatusOK, tplUserView)
}
@@ -518,6 +511,65 @@ func DeleteUser(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/-/admin/users")
}
func RemoveUserFromOrg(ctx *context.Context) {
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
orgID := ctx.PathParamInt64("org_id")
org, err := org_model.GetOrgByID(ctx, orgID)
if err != nil {
ctx.ServerError("GetOrgByID", err)
return
}
err = org_service.RemoveOrgUser(ctx, org, u)
if org_model.IsErrLastOrgOwner(err) {
ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
ctx.JSONRedirect("")
return
} else if err != nil {
ctx.ServerError("RemoveOrgUser", err)
return
}
ctx.Flash.Success(ctx.Tr("admin.users.org_removed", org.Name))
ctx.JSONRedirect("")
}
func RemoveUserFromAllOrgs(ctx *context.Context) {
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
orgs, err := org_model.GetUserOrganizations(ctx, u.ID)
if err != nil {
ctx.ServerError("GetUserOrganizations", err)
return
}
removedCount := 0
for i := range orgs {
err = org_service.RemoveOrgUser(ctx, orgs[i], u)
if org_model.IsErrLastOrgOwner(err) {
continue
} else if err != nil {
log.Error("Failed to remove user %s from org %s: %v", u.Name, orgs[i].Name, err)
continue
}
removedCount++
}
if removedCount < len(orgs) {
ctx.Flash.Warning(ctx.Tr("admin.users.some_orgs_removed", removedCount, len(orgs)))
} else {
ctx.Flash.Success(ctx.Tr("admin.users.all_orgs_removed"))
}
ctx.JSONRedirect("")
}
// AvatarPost response for change user's avatar request
func AvatarPost(ctx *context.Context) {
u := prepareUserInfo(ctx)

View File

@@ -818,6 +818,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/{userid}/delete", admin.DeleteUser)
m.Post("/{userid}/avatar", web.Bind[*forms.AvatarForm](), admin.AvatarPost)
m.Post("/{userid}/avatar/delete", admin.DeleteAvatar)
m.Post("/{userid}/orgs/{org_id}/remove", admin.RemoveUserFromOrg)
m.Post("/{userid}/orgs/remove-all", admin.RemoveUserFromAllOrgs)
})
m.Group("/badges", func() {

View File

@@ -3,12 +3,12 @@
<div class="admin-setting-content">
<div class="admin-responsive-columns">
<div class="tw-flex-1">
<h4 class="ui top attached header">
{{.Title}}
<div class="ui right">
<h4 class="ui top attached header flex-left-right">
<span>{{.Title}}</span>
<span class="flex-text-block">
<button class="ui primary compact tiny basic button link-action" data-url="{{.Link}}/impersonate">{{ctx.Locale.Tr "admin.users.impersonate"}}</button>
<a class="ui primary compact tiny button" href="{{.Link}}/edit">{{ctx.Locale.Tr "admin.users.edit"}}</a>
</div>
</span>
</h4>
<div class="ui attached segment">
{{template "admin/user/view_details" .}}
@@ -29,12 +29,8 @@
<div class="ui attached segment">
{{template "shared/repo/list" .}}
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.organization"}} ({{ctx.Locale.Tr "admin.total" .OrgsTotal}})
</h4>
<div class="ui attached segment">
{{template "explore/user_list" .}}
</div>
{{template "admin/user/view_orgs" dict "User" .User "UserOrgs" .UserOrgs}}
</div>
{{template "admin/layout_footer" .}}

View File

@@ -0,0 +1,53 @@
{{$user := .User}}
{{$userOrgs := .UserOrgs}}
{{$userOrgsTotal := (len $userOrgs)}}
<h4 class="ui top attached header flex-left-right">
<span>{{ctx.Locale.Tr "settings.organization"}} ({{ctx.Locale.Tr "admin.total" $userOrgsTotal}})</span>
{{if $userOrgs}}
<button class="ui red tiny button show-modal" data-modal="#remove-all-orgs-modal">{{ctx.Locale.Tr "remove_all"}}</button>
<form class="ui small modal form-fetch-action" id="remove-all-orgs-modal" method="post" action="{{ctx.RootData.Link}}/orgs/remove-all">
<div class="header">
{{ctx.Locale.Tr "admin.users.remove_all_orgs_title"}}
</div>
<div class="content">
<p>{{ctx.Locale.Tr "admin.users.remove_all_orgs_desc" $user.Name $userOrgsTotal}}</p>
</div>
<div class="actions">
<div class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</div>
<button class="ui red button" type="submit">{{ctx.Locale.Tr "remove_all"}}</button>
</div>
</form>
{{end}}
</h4>
{{if $userOrgs}}
<div class="ui attached segment">
<div class="flex-divided-list items-with-main">
{{range $org := $userOrgs}}
<div class="item tw-items-center">
<div class="item-leading">
{{ctx.AvatarUtils.Avatar $org 48}}
</div>
<div class="item-main">
<div class="item-title">
{{template "shared/user/name" $org}}
{{if $org.Visibility.IsPrivate}}
<span class="ui basic tiny label">{{ctx.Locale.Tr "repo.desc.private"}}</span>
{{end}}
</div>
<div class="item-body">
<span class="flex-text-inline">
{{svg "octicon-calendar"}} {{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort $org.CreatedUnix)}}
</span>
</div>
</div>
<div class="item-trailing">
<button class="ui red tiny button link-action" type="button" data-url="{{ctx.RootData.Link}}/orgs/{{$org.ID}}/remove"
data-modal-confirm="{{ctx.Locale.Tr "confirm_remove"}}"
>{{ctx.Locale.Tr "remove"}}</button>
</div>
</div>
{{end}}
</div>
</div>
{{end}}

View File

@@ -0,0 +1,53 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"testing"
"gitea.dev/models/organization"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/tests"
"github.com/stretchr/testify/assert"
)
func TestAdminRemoveUserFromOrg(t *testing.T) {
defer tests.PrepareTestEnv(t)()
adminSession := loginUser(t, "user1")
t.Run("RemoveFromOrg", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
isMember, err := organization.IsOrganizationMember(t.Context(), org.ID, user.ID)
assert.NoError(t, err)
assert.True(t, isMember)
req := NewRequest(t, "POST", "/-/admin/users/4/orgs/3/remove")
adminSession.MakeRequest(t, req, http.StatusOK)
isMember, err = organization.IsOrganizationMember(t.Context(), org.ID, user.ID)
assert.NoError(t, err)
assert.False(t, isMember)
})
t.Run("RemoveFromAllOrg", func(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
orgCount, err := organization.GetOrganizationCount(t.Context(), user)
assert.EqualValues(t, 4, orgCount)
assert.NoError(t, err)
assert.Positive(t, orgCount, "User should be in at least one org")
req := NewRequest(t, "POST", "/-/admin/users/5/orgs/remove-all")
adminSession.MakeRequest(t, req, http.StatusOK)
orgCountAfter, err := organization.GetOrganizationCount(t.Context(), user)
assert.NoError(t, err)
assert.EqualValues(t, 2, orgCountAfter) // User 5 is the last owner of remaining orgs
})
}