From 6d0821a2405dd5aa29c4e47732cc23f18fc9766e Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Sat, 6 Jun 2026 11:47:31 +0000 Subject: [PATCH 1/4] feat: remove and remove-all func added --- options/locale/locale_en-US.json | 9 ++- routers/web/admin/users.go | 76 ++++++++++++++++++++++++ routers/web/web.go | 3 +- templates/admin/user/view.tmpl | 25 +++++++- templates/admin/user/view_orgs.tmpl | 33 ++++++++++ tests/integration/admin_user_org_test.go | 65 ++++++++++++++++++++ 6 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 templates/admin/user/view_orgs.tmpl create mode 100644 tests/integration/admin_user_org_test.go diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 90c7c71fb7d12..842735dc9a53b 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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", diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index 4b345945089bd..39aff6ebb1b4f 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -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) diff --git a/routers/web/web.go b/routers/web/web.go index 49a83c1fae5b3..6a92dce33d56b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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)) diff --git a/templates/admin/user/view.tmpl b/templates/admin/user/view.tmpl index bb1b4991d8ad4..e6b83ae112740 100644 --- a/templates/admin/user/view.tmpl +++ b/templates/admin/user/view.tmpl @@ -30,10 +30,33 @@

{{ctx.Locale.Tr "settings.organization"}} ({{ctx.Locale.Tr "admin.total" .OrgsTotal}}) + {{if gt .OrgsTotal 0}} +
+ +
+ {{end}}

- {{template "explore/user_list" .}} + {{template "admin/user/view_orgs" .}} +
+ + +{{if gt .OrgsTotal 0}} + +{{end}} {{template "admin/layout_footer" .}} diff --git a/templates/admin/user/view_orgs.tmpl b/templates/admin/user/view_orgs.tmpl new file mode 100644 index 0000000000000..6e5c35592ee7b --- /dev/null +++ b/templates/admin/user/view_orgs.tmpl @@ -0,0 +1,33 @@ +
+ {{range .Users}} +
+
+ {{ctx.AvatarUtils.Avatar . 48}} +
+
+
+ {{template "shared/user/name" .}} + {{if .Visibility.IsPrivate}} + {{ctx.Locale.Tr "repo.desc.private"}} + {{end}} +
+
+ {{if .Location}} + {{svg "octicon-location"}}{{.Location}} + {{end}} + {{svg "octicon-calendar"}}{{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .CreatedUnix)}} +
+
+
+
+ {{$.CsrfTokenHtml}} + +
+
+
+ {{else}} +
+ {{ctx.Locale.Tr "search.no_results"}} +
+ {{end}} +
diff --git a/tests/integration/admin_user_org_test.go b/tests/integration/admin_user_org_test.go new file mode 100644 index 0000000000000..3583a672f4e25 --- /dev/null +++ b/tests/integration/admin_user_org_test.go @@ -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") +} From 7507ed87c138ef7cf9d7e96b74912d9fd4b1dae7 Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Mon, 8 Jun 2026 06:26:04 +0000 Subject: [PATCH 2/4] fix: locale_en-US entries added back --- options/locale/locale_en-US.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 842735dc9a53b..0ef5324dca636 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1321,6 +1321,7 @@ "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\".", @@ -2728,6 +2729,7 @@ "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.", From 8883f15db18f097ef652523d10fb9ea48612571f Mon Sep 17 00:00:00 2001 From: karthikbhandary2 Date: Tue, 9 Jun 2026 10:06:54 +0000 Subject: [PATCH 3/4] fix --- routers/web/admin/users.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index 39aff6ebb1b4f..ed05317f8f99b 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -539,10 +539,11 @@ func RemoveUserFromOrg(ctx *context.Context) { 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.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid"))) + + ctx.ServerError("RemoveOrgUser", err) return } From 11a319bdbc7dd9cd19d14ae7f9b1d51ece3b7b1e Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 8 Jun 2026 20:06:31 +0200 Subject: [PATCH 4/4] keep actions runners bulk endpoint Signed-off-by: techknowlogick --- routers/web/web.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routers/web/web.go b/routers/web/web.go index 6a92dce33d56b..9e52d0d5bf069 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -866,6 +866,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("", misc.LocationRedirect("./actions/runners")) addSettingsRunnersRoutes() addSettingsVariablesRoutes() + m.Post("/runners/bulk", shared_actions.RunnerBulkActionPost) }) }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled)) // ***** END: Admin *****