-
-
-
- {{template "shared/user/name" .}}
- {{if not $isPublic}}
-
{{ctx.Locale.Tr "org.members.private"}}
- {{end}}
+
{{ctx.AvatarUtils.Avatar . 48}}
+
+
+ {{template "shared/user/name" .}}
+ {{if not $isPublic}}
+ {{ctx.Locale.Tr "org.members.private"}}
+ {{end}}
+
+ {{if not $.PublicOnly}}
+
+ {{ctx.Locale.Tr "org.members.member_role"}}
+ {{if index $.MembersIsUserOrgOwner .ID}}{{svg "octicon-shield-lock"}} {{ctx.Locale.Tr "org.members.owner"}}{{else}}{{ctx.Locale.Tr "org.members.member"}}{{end}}
+
+ {{if $.IsOrganizationOwner}}
+
+ {{ctx.Locale.Tr "admin.users.2fa"}}
+
+ {{if index $.MembersTwoFaStatus .ID}}
+ {{svg "octicon-check"}}
+ {{else}}
+ {{svg "octicon-x"}}
+ {{end}}
+
- {{if not $.PublicOnly}}
-
- {{ctx.Locale.Tr "org.members.member_role"}}
- {{if index $.MembersIsUserOrgOwner .ID}}{{svg "octicon-shield-lock"}} {{ctx.Locale.Tr "org.members.owner"}}{{else}}{{ctx.Locale.Tr "org.members.member"}}{{end}}
-
- {{if $.IsOrganizationOwner}}
-
- {{ctx.Locale.Tr "admin.users.2fa"}}
-
- {{if index $.MembersTwoFaStatus .ID}}
- {{svg "octicon-check"}}
- {{else}}
- {{svg "octicon-x"}}
- {{end}}
-
-
- {{end}}
{{end}}
-
-
+
+ {{end}}
+ {{if eq $.SignedUser.ID .ID}}
+
+ {{else if $.IsOrganizationOwner}}
+
+ {{end}}
{{end}}
diff --git a/tests/e2e/org-members.test.e2e.ts b/tests/e2e/org-members.test.e2e.ts
new file mode 100644
index 0000000000..311ad0f7cc
--- /dev/null
+++ b/tests/e2e/org-members.test.e2e.ts
@@ -0,0 +1,50 @@
+// Copyright 2026 The Forgejo Authors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// @watch start
+
+// @watch end
+
+import {expect} from '@playwright/test';
+import {test} from './utils_e2e.ts';
+
+test.use({user: 'user2'});
+
+test('Toggle visibility', async ({page}) => {
+ page.goto('/org/org3/members');
+
+ // Button "Make hidden" for user2's row
+ const hideUser2 = page.locator('.link-action[data-url="/org/org3/members/action/private?uid=2"]');
+ // Button "Make visible" for user2's row
+ const showUser2 = page.locator('.link-action[data-url="/org/org3/members/action/public?uid=2"]');
+
+ await expect(hideUser2).toBeVisible();
+ await expect(showUser2).toBeHidden();
+ await hideUser2.click();
+
+ // Button action was flipped
+ await expect(hideUser2).toBeHidden();
+ await expect(showUser2).toBeVisible();
+
+ // Revert for repeatability
+ await showUser2.click();
+});
+
+test('Leave org', async ({page}) => {
+ page.goto('/org/org3/members');
+
+ // Button "Leave" for user2's row
+ const leaveButton = page.locator('.delete-button[data-url="/org/org3/members/action/leave"]');
+
+ // Click the button
+ await leaveButton.click();
+
+ // A confirmation modal will appear
+ await expect(page.locator('.modal#leave-organization')).toBeVisible();
+
+ // Proceed leaving the org
+ await page.locator('.modal#leave-organization .actions button.ok').click();
+
+ // Getting error is enough to know that the correct request went though
+ await expect(page.locator('.flash-error').getByText('You cannot remove the last user from the "owners" team.')).toBeVisible();
+});
diff --git a/tests/integration/org_members_test.go b/tests/integration/org_members_test.go
new file mode 100644
index 0000000000..e51850126f
--- /dev/null
+++ b/tests/integration/org_members_test.go
@@ -0,0 +1,48 @@
+// Copyright 2026 The Forgejo Authors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "forgejo.org/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestOrgMembersPage(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ testPage := "/org/org3/members"
+
+ t.Run("Guest PoV", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ doc := NewHTMLParser(t, MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
+ /* No interactive buttons - though such evaluation is easy to break in rename */
+ assert.Equal(t, 0, doc.Find(".members .list .link-action").Length())
+ assert.Equal(t, 0, doc.Find(".members .list .delete-button").Length())
+ })
+
+ t.Run("Member PoV", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, "user4") // user4 is a member of org3
+ doc := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
+ /* Interactive buttons are only available for own entry in the list */
+ assert.Equal(t, 1, doc.Find(".members .list .link-action").Length())
+ assert.Equal(t, 1, doc.Find(".members .list .delete-button").Length())
+ })
+
+ t.Run("Owner PoV", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, "user2") // user2 owns org3
+ doc := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
+ /* Interactive buttons are available for all entries in the list (> 2) */
+ assert.Less(t, 2, doc.Find(".members .list .link-action").Length())
+ assert.Less(t, 2, doc.Find(".members .list .delete-button").Length())
+ })
+}
diff --git a/web_src/css/org.css b/web_src/css/org.css
index 024c2688f5..240ed5c98e 100644
--- a/web_src/css/org.css
+++ b/web_src/css/org.css
@@ -92,6 +92,58 @@
margin-top: 0;
}
+.page-content.organization.members .list {
+ display: grid;
+ grid-template-columns: minmax(min-content, auto) 1fr fit-content(100%) fit-content(100%);
+ color: var(--color-text-light-2);
+
+ .divider {
+ grid-column: 1 / -1;
+ }
+
+ /* Username (+display name) use header */
+ h3 {
+ margin: 0;
+ font-size: 16px;
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text);
+ }
+
+ .actions {
+ display: contents;
+ }
+
+ .link-action, .delete-button {
+ align-self: start;
+ }
+
+ a:has(img) {
+ margin-inline-end: 0.5rem;
+ }
+
+ .delete-button {
+ margin-inline-start: var(--button-spacing);
+ }
+}
+
+@media (max-width: 767.98px) {
+ .page-content.organization.members .list {
+ /* Place both buttons in one column */
+ grid-template-columns: minmax(min-content, auto) 1fr fit-content(100%);
+
+ .actions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--button-spacing);
+ }
+
+ .link-action, .delete-button {
+ width: 100%;
+ margin: 0;
+ }
+ }
+}
+
.page-content.organization .teams .item {
padding: 10px 15px;
}