diff --git a/templates/org/member/members.tmpl b/templates/org/member/members.tmpl index dccf588f6a..0cbc1b5eb1 100644 --- a/templates/org/member/members.tmpl +++ b/templates/org/member/members.tmpl @@ -4,63 +4,64 @@
{{template "base/alert" .}} -
- {{range .Members}} +
+ {{range $idx, $_ := .Members}} + {{if ne $idx 0}} +
+ {{end}} {{$isPublic := index $.MembersIsPublicMember .ID}} -
- -
-
- {{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}} -
-
- {{if or (eq $.SignedUser.ID .ID) $.IsOrganizationOwner}} - {{if $isPublic}} - {{svg "octicon-eye-closed" 12 "icon"}}{{ctx.Locale.Tr "org.members.public_helper"}} - {{else}} - {{svg "octicon-eye" 12 "icon"}}{{ctx.Locale.Tr "org.members.private_helper"}} - {{end}} + {{end}} +
+
+ {{if or (eq $.SignedUser.ID .ID) $.IsOrganizationOwner}} + {{if $isPublic}} + + {{svg "octicon-eye-closed"}} + {{ctx.Locale.Tr "org.members.public_helper"}} + + {{else}} + + {{svg "octicon-eye"}} + {{ctx.Locale.Tr "org.members.private_helper"}} + {{end}} - {{if eq $.SignedUser.ID .ID}} -
- -
- {{else if $.IsOrganizationOwner}} -
- -
- {{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; }