Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions options/locale/locale_en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions routers/web/admin/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this option should be given?

@karthikbhandary2 karthikbhandary2 Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is given because we are going to remove the user from all the orgs (including limited and private). Since this is behind admin only route, it is restricted to only admins and regular users cant see this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this option mean it only includes private organizations?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no it means give everything (public, limited and private) and not to filter them out.

})
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)
Expand Down
4 changes: 3 additions & 1 deletion routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 *****
Expand Down
25 changes: 24 additions & 1 deletion templates/admin/user/view.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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" .}}
33 changes: 33 additions & 0 deletions templates/admin/user/view_orgs.tmpl
Original file line number Diff line number Diff line change
@@ -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>
65 changes: 65 additions & 0 deletions tests/integration/admin_user_org_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading