0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-06-19 11:13:45 +02:00

feat: remove and remove-all func added

This commit is contained in:
karthikbhandary2 2026-06-06 11:47:31 +00:00
parent 5fe77ad309
commit 6d0821a240
6 changed files with 207 additions and 4 deletions

View File

@ -1321,7 +1321,6 @@
"repo.editor.fork_branch_exists": "Branch \"%s\" already exists in your fork. Please choose a new branch name.",
"repo.commits.desc": "Browse source code change history.",
"repo.commits.commits": "Commits",
"repo.commits.history_enable_follow_renames": "Include renames",
"repo.commits.no_commits": "No commits in common. \"%s\" and \"%s\" have entirely different histories.",
"repo.commits.nothing_to_compare": "There are no differences to show.",
"repo.commits.search.tooltip": "You can prefix keywords with \"author:\", \"committer:\", \"after:\", or \"before:\", e.g. \"revert author:Alice before:2019-01-13\".",
@ -2729,7 +2728,6 @@
"graphs.code_frequency.what": "code frequency",
"graphs.contributors.what": "contributions",
"graphs.recent_commits.what": "recent commits",
"graphs.chart_zoom_hint": "drag: zoom, shift+drag: pan, double click: reset zoom",
"org.org_name_holder": "Organization Name",
"org.org_full_name_holder": "Organization Full Name",
"org.org_name_helper": "Organization names should be short and memorable.",
@ -3055,6 +3053,13 @@
"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.",
"admin.users.all_orgs_removed": "User has been removed from all organizations.",
"admin.users.no_orgs_to_remove": "User is not a member of any 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

@ -29,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"
)
@ -521,6 +522,81 @@ func DeleteUser(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/-/admin/users")
}
// RemoveUserFromOrg removes a user from an organization
func RemoveUserFromOrg(ctx *context.Context) {
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
orgID := ctx.PathParamInt64("orgid")
org, err := org_model.GetOrgByID(ctx, orgID)
if err != nil {
ctx.ServerError("GetOrgByID", err)
return
}
if err := org_service.RemoveOrgUser(ctx, org, u); err != nil {
if org_model.IsErrLastOrgOwner(err) {
ctx.Flash.Error(ctx.Tr("form.last_org_owner"))
} else {
ctx.ServerError("RemoveOrgUser", err)
}
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
return
}
ctx.Flash.Success(ctx.Tr("admin.users.org_removed"))
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
}
// RemoveUserFromAllOrgs removes a user from all organizations
func RemoveUserFromAllOrgs(ctx *context.Context) {
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
ListOptions: db.ListOptionsAll,
UserID: u.ID,
IncludeVisibility: structs.VisibleTypePrivate,
})
if err != nil {
ctx.ServerError("FindOrgs", err)
return
}
if len(orgs) == 0 {
ctx.Flash.Info(ctx.Tr("admin.users.no_orgs_to_remove"))
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
return
}
removedCount := 0
for i := range orgs {
if err := org_service.RemoveOrgUser(ctx, orgs[i], u); err != nil {
if org_model.IsErrLastOrgOwner(err) {
log.Warn("Cannot remove user %s from org %s: last owner", u.Name, orgs[i].Name)
continue
}
log.Error("Failed to remove user %s from org %s: %v", u.Name, orgs[i].Name, err)
continue
}
removedCount++
}
if removedCount == 0 {
ctx.Flash.Error(ctx.Tr("admin.users.no_orgs_removed"))
} else 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.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
}
// AvatarPost response for change user's avatar request
func AvatarPost(ctx *context.Context) {
u := prepareUserInfo(ctx)

View File

@ -788,6 +788,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/{orgid}/remove", admin.RemoveUserFromOrg)
m.Post("/{userid}/orgs/remove-all", admin.RemoveUserFromAllOrgs)
})
m.Group("/badges", func() {
@ -863,7 +865,6 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/actions", func() {
m.Get("", misc.LocationRedirect("./actions/runners"))
addSettingsRunnersRoutes()
m.Post("/runners/bulk", shared_actions.RunnerBulkActionPost)
addSettingsVariablesRoutes()
})
}, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))

View File

@ -30,10 +30,33 @@
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.organization"}} ({{ctx.Locale.Tr "admin.total" .OrgsTotal}})
{{if gt .OrgsTotal 0}}
<div class="ui right">
<button class="ui red tiny button show-modal" data-modal="#remove-all-orgs-modal">{{ctx.Locale.Tr "remove_all"}}</button>
</div>
{{end}}
</h4>
<div class="ui attached segment">
{{template "explore/user_list" .}}
{{template "admin/user/view_orgs" .}}
</div>
</div>
{{if gt .OrgsTotal 0}}
<div class="ui small modal" id="remove-all-orgs-modal">
<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 .OrgsTotal}}</p>
</div>
<div class="actions">
<div class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</div>
<form method="post" action="{{.Link}}/orgs/remove-all" style="display: inline;">
{{.CsrfTokenHtml}}
<button class="ui red button" type="submit">{{ctx.Locale.Tr "remove_all"}}</button>
</form>
</div>
</div>
{{end}}
{{template "admin/layout_footer" .}}

View File

@ -0,0 +1,33 @@
<div class="flex-divided-list items-with-main">
{{range .Users}}
<div class="item tw-items-center">
<div class="item-leading">
{{ctx.AvatarUtils.Avatar . 48}}
</div>
<div class="item-main">
<div class="item-title">
{{template "shared/user/name" .}}
{{if .Visibility.IsPrivate}}
<span class="ui basic tiny label">{{ctx.Locale.Tr "repo.desc.private"}}</span>
{{end}}
</div>
<div class="item-body">
{{if .Location}}
<span class="flex-text-inline">{{svg "octicon-location"}}{{.Location}}</span>
{{end}}
<span class="flex-text-inline">{{svg "octicon-calendar"}}{{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .CreatedUnix)}}</span>
</div>
</div>
<div class="item-trailing">
<form method="post" action="{{$.Link}}/orgs/{{.ID}}/remove">
{{$.CsrfTokenHtml}}
<button class="ui red tiny button" type="submit">{{ctx.Locale.Tr "remove"}}</button>
</form>
</div>
</div>
{{else}}
<div class="item">
{{ctx.Locale.Tr "search.no_results"}}
</div>
{{end}}
</div>

View File

@ -0,0 +1,65 @@
// 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)()
// Admin user
session := loginUser(t, "user1")
// User to remove from org
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
// Verify user is in org
isMember, err := organization.IsOrganizationMember(t.Context(), org.ID, user.ID)
assert.NoError(t, err)
assert.True(t, isMember)
// Remove user from org
req := NewRequest(t, "POST", "/-/admin/users/4/orgs/3/remove")
session.MakeRequest(t, req, http.StatusSeeOther)
// Verify user is no longer in org
isMember, err = organization.IsOrganizationMember(t.Context(), org.ID, user.ID)
assert.NoError(t, err)
assert.False(t, isMember)
}
func TestAdminRemoveUserFromAllOrgs(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// Admin user
session := loginUser(t, "user1")
// User to remove from all orgs (user4 is not a last owner)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
// Get count of orgs user is in before removal
orgCount, err := organization.GetOrganizationCount(t.Context(), user)
assert.NoError(t, err)
assert.Positive(t, orgCount, "User should be in at least one org")
// Remove user from all orgs
req := NewRequest(t, "POST", "/-/admin/users/4/orgs/remove-all")
session.MakeRequest(t, req, http.StatusSeeOther)
// Verify user is no longer in any orgs
orgCountAfter, err := organization.GetOrganizationCount(t.Context(), user)
assert.NoError(t, err)
assert.Equal(t, int64(0), orgCountAfter, "User should not be in any orgs")
}