diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index c0c34567..bed9d962 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -61,15 +61,19 @@ public class NamespaceController extends BaseApiController { @GetMapping("/namespaces") public ApiResponse> listNamespaces( Pageable pageable, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles)); + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { + return ok("response.success.read", + namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles, normalizePlatformRoles(platformRoles))); } @GetMapping("/me/namespaces") public ApiResponse> listMyNamespaces( @RequestAttribute("userId") String userId, - @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { - return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles)); + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestAttribute(value = "platformRoles", required = false) Set platformRoles) { + return ok("response.success.read", + namespacePortalQueryAppService.listMyNamespaces(userNsRoles, normalizePlatformRoles(platformRoles))); } @GetMapping("/namespaces/{slug}") @@ -165,6 +169,12 @@ public class NamespaceController extends BaseApiController { namespacePortalQueryAppService.listMembers(slug, pageable, userId, platformRoles)); } + private Set normalizePlatformRoles(Set platformRoles) { + return platformRoles != null + ? platformRoles + : Set.of(); + } + @GetMapping("/namespaces/{slug}/member-candidates") public ApiResponse> searchMemberCandidates( @PathVariable String slug, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index e8df0ad9..e11742d1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.dto.MemberResponse; import com.iflytek.skillhub.dto.MyNamespaceResponse; import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -26,6 +27,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,6 +38,9 @@ import org.springframework.transaction.annotation.Transactional; @Service public class NamespacePortalQueryAppService { + private static final String SUPER_ADMIN_ROLE = "SUPER_ADMIN"; + private static final String NAMESPACE_SLUG_SORT = "slug"; + private final NamespaceRepository namespaceRepository; private final NamespaceService namespaceService; private final NamespaceMemberService namespaceMemberService; @@ -56,6 +61,25 @@ public class NamespacePortalQueryAppService { @Transactional(readOnly = true) public PageResponse listNamespaces(Pageable pageable, Map userNamespaceRoles) { + return listNamespaces(pageable, userNamespaceRoles, Set.of()); + } + + @Transactional(readOnly = true) + public PageResponse listNamespaces(Pageable pageable, + Map userNamespaceRoles, + Set platformRoles) { + if (isSuperAdmin(platformRoles)) { + Page namespaces = namespaceRepository.findByStatus( + NamespaceStatus.ACTIVE, + PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by(NAMESPACE_SLUG_SORT).ascending() + ) + ); + return PageResponse.from(namespaces.map(NamespaceResponse::from)); + } + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); if (namespaceRoles.isEmpty()) { Page empty = new PageImpl<>( @@ -84,12 +108,22 @@ public class NamespacePortalQueryAppService { @Transactional(readOnly = true) public List listMyNamespaces(Map userNamespaceRoles) { + return listMyNamespaces(userNamespaceRoles, Set.of()); + } + + @Transactional(readOnly = true) + public List listMyNamespaces(Map userNamespaceRoles, + Set platformRoles) { Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); - if (namespaceRoles.isEmpty()) { + if (namespaceRoles.isEmpty() && !isSuperAdmin(platformRoles)) { return List.of(); } - return namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + List visibleNamespaces = isSuperAdmin(platformRoles) + ? listAllNamespaces() + : namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()); + + return visibleNamespaces.stream() .sorted(Comparator.comparing(Namespace::getSlug)) .map(namespace -> MyNamespaceResponse.from( namespace, @@ -138,4 +172,14 @@ public class NamespacePortalQueryAppService { MemberResponse.from(member, userMap.get(member.getUserId())) )); } + + private List listAllNamespaces() { + return Arrays.stream(NamespaceStatus.values()) + .flatMap(status -> namespaceRepository.findByStatus(status, Pageable.unpaged()).getContent().stream()) + .toList(); + } + + private boolean isSuperAdmin(Set platformRoles) { + return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 566d3ef0..d94a1746 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -77,6 +77,52 @@ class NamespacePortalControllerTest { @MockBean private UserAccountRepository userAccountRepository; + @Test + void listNamespaces_superAdminReturnsAllActiveNamespacesWithoutMembership() throws Exception { + Namespace teamA = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace teamB = namespace(2L, "team-b", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + given(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>( + List.of(teamA, teamB), + org.springframework.data.domain.PageRequest.of(0, 20), + 2 + )); + + mockMvc.perform(get("/api/v1/namespaces") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].slug").value("team-a")) + .andExpect(jsonPath("$.data.items[1].slug").value("team-b")) + .andExpect(jsonPath("$.data.total").value(2)); + } + + @Test + void listMyNamespaces_superAdminReturnsAllNamespacesWithoutMembership() throws Exception { + Namespace active = namespace(1L, "active", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace frozen = namespace(2L, "frozen", NamespaceStatus.FROZEN, NamespaceType.TEAM); + Namespace archived = namespace(3L, "archived", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); + given(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(active))); + given(namespaceRepository.findByStatus(eq(NamespaceStatus.FROZEN), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(frozen))); + given(namespaceRepository.findByStatus(eq(NamespaceStatus.ARCHIVED), any())) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(archived))); + + mockMvc.perform(get("/api/v1/me/namespaces") + .with(auth("super-1", Set.of("SUPER_ADMIN"))) + .requestAttr("userId", "super-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("active")) + .andExpect(jsonPath("$.data[0].currentUserRole").doesNotExist()) + .andExpect(jsonPath("$.data[1].slug").value("archived")) + .andExpect(jsonPath("$.data[2].slug").value("frozen")); + } + @Test void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 05a9fd3c..39453e8f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -25,6 +25,7 @@ import com.iflytek.skillhub.dto.PageResponse; import org.junit.jupiter.api.Test; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -78,6 +79,25 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.get(1).canDelete()).isFalse(); } + @Test + void listNamespaces_superAdminReturnsAllActiveNamespaces() { + Namespace teamB = namespace(2L, "team-b"); + Namespace teamA = namespace(1L, "team-a"); + when(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(teamA, teamB), PageRequest.of(0, 10), 2)); + + var response = service.listNamespaces( + PageRequest.of(0, 10), + Map.of(), + Set.of("SUPER_ADMIN") + ); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).slug()).isEqualTo("team-a"); + assertThat(response.items().get(1).slug()).isEqualTo("team-b"); + assertThat(response.total()).isEqualTo(2); + } + @Test void listNamespaces_returnsOnlyCurrentUsersActiveNamespaces() { Namespace teamA = namespace(1L, "team-a"); @@ -101,6 +121,30 @@ class NamespacePortalQueryAppServiceTest { assertThat(response.items().get(1).slug()).isEqualTo("team-b"); } + @Test + void listMyNamespaces_superAdminReturnsAllNamespacesWithoutGrantingNamespaceRole() { + Namespace active = namespace(1L, "active"); + Namespace frozen = namespace(2L, "frozen"); + frozen.setStatus(NamespaceStatus.FROZEN); + Namespace archived = namespace(3L, "archived"); + archived.setStatus(NamespaceStatus.ARCHIVED); + + when(namespaceRepository.findByStatus(eq(NamespaceStatus.ACTIVE), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(active), PageRequest.of(0, 1), 1)); + when(namespaceRepository.findByStatus(eq(NamespaceStatus.FROZEN), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(frozen), PageRequest.of(0, 1), 1)); + when(namespaceRepository.findByStatus(eq(NamespaceStatus.ARCHIVED), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(archived), PageRequest.of(0, 1), 1)); + + var response = service.listMyNamespaces(Map.of(), Set.of("SUPER_ADMIN")); + + assertThat(response).hasSize(3); + assertThat(response).extracting("slug").containsExactly("active", "archived", "frozen"); + assertThat(response).extracting("currentUserRole").containsOnlyNulls(); + assertThat(response).extracting("canFreeze").containsOnly(false); + assertThat(response).extracting("canDelete").containsOnly(false); + } + @Test void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() { Namespace namespace = namespace(1L, "team-a"); diff --git a/web/e2e/my-namespaces-super-admin-actions.spec.ts b/web/e2e/my-namespaces-super-admin-actions.spec.ts new file mode 100644 index 00000000..02b3ce56 --- /dev/null +++ b/web/e2e/my-namespaces-super-admin-actions.spec.ts @@ -0,0 +1,88 @@ +import { expect, test, type Route } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +function apiEnvelope(data: unknown) { + return { + code: 0, + msg: 'OK', + data, + timestamp: '2026-07-15T00:00:00Z', + requestId: 'e2e-super-admin-namespaces', + } +} + +async function fulfillJson(route: Route, data: unknown) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(apiEnvelope(data)), + }) +} + +test.describe('My Namespaces super admin actions', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + + await page.route('**/api/v1/auth/me', (route) => fulfillJson(route, { + userId: 'super-admin', + username: 'super-admin', + displayName: 'Super Admin', + platformRoles: ['SUPER_ADMIN'], + })) + + await page.route('**/api/web/notifications/sse', (route) => route.fulfill({ + status: 204, + body: '', + })) + + await page.route('**/api/web/notifications/unread-count', (route) => fulfillJson(route, { count: 0 })) + + await page.route('**/api/web/me/namespaces', (route) => fulfillJson(route, [ + { + id: 101, + slug: 'visible-no-role', + displayName: 'Visible Without Membership', + description: 'Returned by SUPER_ADMIN namespace visibility', + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-07-15T00:00:00Z', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + canDelete: false, + }, + { + id: 102, + slug: 'owned-team', + displayName: 'Owned Team', + description: 'Namespace where the user is a member', + type: 'TEAM', + status: 'ACTIVE', + createdAt: '2026-07-15T00:00:00Z', + currentUserRole: 'OWNER', + immutable: false, + canFreeze: true, + canUnfreeze: false, + canArchive: true, + canRestore: false, + canDelete: true, + }, + ])) + }) + + test('hides namespace-scoped actions for visible namespaces without membership', async ({ page }) => { + await page.goto('/dashboard/namespaces') + + const visibleCard = page.getByTestId('namespace-card-visible-no-role') + await expect(visibleCard.getByText('@visible-no-role')).toBeVisible() + await expect(visibleCard.getByText('Current role: Unknown')).toBeVisible() + await expect(visibleCard.getByRole('button', { name: 'Manage Members' })).toHaveCount(0) + await expect(visibleCard.getByRole('button', { name: 'Review Tasks' })).toHaveCount(0) + + const ownedCard = page.getByTestId('namespace-card-owned-team') + await expect(ownedCard.getByRole('button', { name: 'Manage Members' })).toBeVisible() + await expect(ownedCard.getByRole('button', { name: 'Review Tasks' })).toBeVisible() + }) +}) diff --git a/web/src/pages/dashboard/my-namespaces.test.ts b/web/src/pages/dashboard/my-namespaces.test.ts index f3044a6c..bd7689ac 100644 --- a/web/src/pages/dashboard/my-namespaces.test.ts +++ b/web/src/pages/dashboard/my-namespaces.test.ts @@ -12,6 +12,7 @@ const restoreMutateAsync = vi.fn() const deleteMutateAsync = vi.fn() let mockNamespaces: ManagedNamespace[] = [] +let mockPlatformRoles: string[] = [] vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -28,7 +29,7 @@ vi.mock('react-i18next', async () => { }) vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ hasRole: () => false }), + useAuth: () => ({ hasRole: (role: string) => mockPlatformRoles.includes(role) }), })) vi.mock('@/shared/ui/button', () => ({ @@ -115,6 +116,7 @@ describe('MyNamespacesPage', () => { restoreMutateAsync.mockReset() deleteMutateAsync.mockReset() mockNamespaces = [] + mockPlatformRoles = [] }) it('exports a named component function', () => { @@ -137,6 +139,27 @@ describe('MyNamespacesPage', () => { expect(html).not.toContain('myNamespaces.delete') }) + it('hides namespace-scoped actions for super admins without namespace membership', () => { + mockPlatformRoles = ['SUPER_ADMIN'] + mockNamespaces = [buildNamespace({ currentUserRole: undefined })] + + const html = renderToStaticMarkup(createElement(MyNamespacesPage)) + + expect(html).toContain('Team ML') + expect(html).toContain('myNamespaces.roleUnknown') + expect(html).not.toContain('myNamespaces.manageMembers') + expect(html).not.toContain('myNamespaces.reviewTasks') + }) + + it('shows namespace-scoped actions for namespace members', () => { + mockNamespaces = [buildNamespace({ currentUserRole: 'ADMIN' })] + + const html = renderToStaticMarkup(createElement(MyNamespacesPage)) + + expect(html).toContain('myNamespaces.manageMembers') + expect(html).toContain('myNamespaces.reviewTasks') + }) + it('routes delete actions to the delete mutation and emits success feedback', async () => { const t = (key: string) => key const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML') diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index 235f0b11..4e623b5c 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -286,7 +286,7 @@ export function MyNamespacesPage() {
- {namespace.type === 'TEAM' && ( + {namespace.type === 'TEAM' && Boolean(namespace.currentUserRole) && ( + {Boolean(namespace.currentUserRole) && ( + + )} {namespace.canFreeze && (