diff --git a/models/organization/org_user.go b/models/organization/org_user.go index 1e623b03e16..88646b51b07 100644 --- a/models/organization/org_user.go +++ b/models/organization/org_user.go @@ -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) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index a887742a6e4..7753c6d71c0 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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", diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index f9ae5975278..839ff70fb56 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -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) diff --git a/routers/web/web.go b/routers/web/web.go index 861df9d866a..7588b53092a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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() { diff --git a/templates/admin/user/view.tmpl b/templates/admin/user/view.tmpl index a86b59d23c1..63011e47c6d 100644 --- a/templates/admin/user/view.tmpl +++ b/templates/admin/user/view.tmpl @@ -3,12 +3,12 @@
-

- {{.Title}} -
+

+ {{.Title}} + {{ctx.Locale.Tr "admin.users.edit"}} -

+

{{template "admin/user/view_details" .}} @@ -29,12 +29,8 @@
{{template "shared/repo/list" .}}
-

- {{ctx.Locale.Tr "settings.organization"}} ({{ctx.Locale.Tr "admin.total" .OrgsTotal}}) -

-
- {{template "explore/user_list" .}} -
+ + {{template "admin/user/view_orgs" dict "User" .User "UserOrgs" .UserOrgs}}
{{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 00000000000..e0f3980fd3e --- /dev/null +++ b/templates/admin/user/view_orgs.tmpl @@ -0,0 +1,53 @@ +{{$user := .User}} +{{$userOrgs := .UserOrgs}} +{{$userOrgsTotal := (len $userOrgs)}} +

+ {{ctx.Locale.Tr "settings.organization"}} ({{ctx.Locale.Tr "admin.total" $userOrgsTotal}}) + {{if $userOrgs}} + + + {{end}} +

+ +{{if $userOrgs}} +
+
+ {{range $org := $userOrgs}} +
+
+ {{ctx.AvatarUtils.Avatar $org 48}} +
+
+
+ {{template "shared/user/name" $org}} + {{if $org.Visibility.IsPrivate}} + {{ctx.Locale.Tr "repo.desc.private"}} + {{end}} +
+
+ + {{svg "octicon-calendar"}} {{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort $org.CreatedUnix)}} + +
+
+
+ +
+
+ {{end}} +
+
+{{end}} diff --git a/tests/integration/admin_user_org_test.go b/tests/integration/admin_user_org_test.go new file mode 100644 index 00000000000..b426bbf0ec2 --- /dev/null +++ b/tests/integration/admin_user_org_test.go @@ -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 + }) +}