fix(namespace): allow super admin namespace visibility

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-07-15 10:01:24 +08:00
parent bafb9fe3b9
commit 1577384ffb
7 changed files with 272 additions and 15 deletions

View file

@ -61,15 +61,19 @@ public class NamespaceController extends BaseApiController {
@GetMapping("/namespaces")
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(
Pageable pageable,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
return ok("response.success.read",
namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles, normalizePlatformRoles(platformRoles)));
}
@GetMapping("/me/namespaces")
public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listMyNamespaces(userNsRoles));
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@RequestAttribute(value = "platformRoles", required = false) Set<String> 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<String> normalizePlatformRoles(Set<String> platformRoles) {
return platformRoles != null
? platformRoles
: Set.of();
}
@GetMapping("/namespaces/{slug}/member-candidates")
public ApiResponse<List<NamespaceCandidateUserResponse>> searchMemberCandidates(
@PathVariable String slug,

View file

@ -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<NamespaceResponse> listNamespaces(Pageable pageable, Map<Long, NamespaceRole> userNamespaceRoles) {
return listNamespaces(pageable, userNamespaceRoles, Set.of());
}
@Transactional(readOnly = true)
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable,
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
if (isSuperAdmin(platformRoles)) {
Page<Namespace> 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<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (namespaceRoles.isEmpty()) {
Page<NamespaceResponse> empty = new PageImpl<>(
@ -84,12 +108,22 @@ public class NamespacePortalQueryAppService {
@Transactional(readOnly = true)
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles) {
return listMyNamespaces(userNamespaceRoles, Set.of());
}
@Transactional(readOnly = true)
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
Map<Long, NamespaceRole> 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<Namespace> 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<Namespace> listAllNamespaces() {
return Arrays.stream(NamespaceStatus.values())
.flatMap(status -> namespaceRepository.findByStatus(status, Pageable.unpaged()).getContent().stream())
.toList();
}
private boolean isSuperAdmin(Set<String> platformRoles) {
return platformRoles != null && platformRoles.contains(SUPER_ADMIN_ROLE);
}
}

View file

@ -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);

View file

@ -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");

View file

@ -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()
})
})

View file

@ -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')

View file

@ -286,7 +286,7 @@ export function MyNamespacesPage() {
</div>
</div>
<div className="flex flex-wrap gap-3">
{namespace.type === 'TEAM' && (
{namespace.type === 'TEAM' && Boolean(namespace.currentUserRole) && (
<Button
variant="outline"
size="sm"
@ -295,13 +295,15 @@ export function MyNamespacesPage() {
{t('myNamespaces.manageMembers')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => handleReviewsClick(namespace.slug, e)}
>
{t('myNamespaces.reviewTasks')}
</Button>
{Boolean(namespace.currentUserRole) && (
<Button
variant="outline"
size="sm"
onClick={(e) => handleReviewsClick(namespace.slug, e)}
>
{t('myNamespaces.reviewTasks')}
</Button>
)}
{namespace.canFreeze && (
<Button
variant="outline"