mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-30 11:01:40 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" .}}
|
||||
|
||||
53
templates/admin/user/view_orgs.tmpl
Normal file
53
templates/admin/user/view_orgs.tmpl
Normal 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}}
|
||||
53
tests/integration/admin_user_org_test.go
Normal file
53
tests/integration/admin_user_org_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user