diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 90c7c71fb7d12..0ef5324dca636 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3055,6 +3055,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..ed05317f8f99b 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,82 @@ 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")) + ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid"))) + return + } + + ctx.ServerError("RemoveOrgUser", err) + 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..9e52d0d5bf069 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,8 +865,8 @@ 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() + m.Post("/runners/bulk", shared_actions.RunnerBulkActionPost) }) }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled)) // ***** END: Admin ***** 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") +}