mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-06 08:15:57 +00:00
fix(namespace): allow super admin namespace visibility
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
bafb9fe3b9
commit
1577384ffb
7 changed files with 272 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
88
web/e2e/my-namespaces-super-admin-actions.spec.ts
Normal file
88
web/e2e/my-namespaces-super-admin-actions.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue