mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-06 02:48:28 +00:00
fix(namespace): allow super admin namespace detail reads
Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
parent
1577384ffb
commit
35e9ca588a
7 changed files with 106 additions and 15 deletions
|
|
@ -79,9 +79,10 @@ public class NamespaceController extends BaseApiController {
|
|||
@GetMapping("/namespaces/{slug}")
|
||||
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
|
||||
@RequestAttribute(value = "platformRoles", required = false) Set<String> platformRoles) {
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));
|
||||
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles, normalizePlatformRoles(platformRoles)));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces")
|
||||
|
|
|
|||
|
|
@ -135,12 +135,23 @@ public class NamespacePortalQueryAppService {
|
|||
|
||||
@Transactional(readOnly = true)
|
||||
public NamespaceResponse getNamespace(String slug, String userId, Map<Long, NamespaceRole> userNamespaceRoles) {
|
||||
return getNamespace(slug, userId, userNamespaceRoles, Set.of());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public NamespaceResponse getNamespace(String slug,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNamespaceRoles,
|
||||
Set<String> platformRoles) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
||||
Namespace namespace = namespaceService.getNamespaceBySlugForRead(
|
||||
slug,
|
||||
userId,
|
||||
namespaceRoles);
|
||||
if (!namespaceRoles.containsKey(namespace.getId())) {
|
||||
boolean superAdmin = isSuperAdmin(platformRoles);
|
||||
Namespace namespace = superAdmin
|
||||
? namespaceService.getNamespaceBySlug(slug)
|
||||
: namespaceService.getNamespaceBySlugForRead(
|
||||
slug,
|
||||
userId,
|
||||
namespaceRoles);
|
||||
if (!superAdmin && !namespaceRoles.containsKey(namespace.getId())) {
|
||||
throw new DomainForbiddenException("error.namespace.membership.required");
|
||||
}
|
||||
return NamespaceResponse.from(namespace);
|
||||
|
|
|
|||
|
|
@ -147,6 +147,20 @@ class NamespacePortalControllerTest {
|
|||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNamespace_superAdminReadsNamespaceWithoutMembership() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of());
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
|
||||
mockMvc.perform(get("/api/v1/namespaces/team-a")
|
||||
.with(auth("super-1", Set.of("SUPER_ADMIN")))
|
||||
.requestAttr("userId", "super-1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.slug").value("team-a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveNamespace_returnsUpdatedNamespace() throws Exception {
|
||||
Namespace archived = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
|
@ -151,10 +152,22 @@ class NamespacePortalQueryAppServiceTest {
|
|||
when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of()))
|
||||
.thenReturn(namespace);
|
||||
|
||||
assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of()))
|
||||
assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of(), Set.of()))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNamespace_superAdminReadsArchivedNamespaceWithoutMembership() {
|
||||
Namespace archived = namespace(1L, "archived-team");
|
||||
archived.setStatus(NamespaceStatus.ARCHIVED);
|
||||
when(namespaceService.getNamespaceBySlug("archived-team")).thenReturn(archived);
|
||||
|
||||
var response = service.getNamespace("archived-team", "super-1", Map.of(), Set.of("SUPER_ADMIN"));
|
||||
|
||||
assertThat(response.slug()).isEqualTo("archived-team");
|
||||
assertThat(response.status()).isEqualTo(NamespaceStatus.ARCHIVED);
|
||||
}
|
||||
|
||||
private Namespace namespace(Long id, String slug) {
|
||||
Namespace namespace = new Namespace(slug, slug, "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", id);
|
||||
|
|
@ -219,4 +232,16 @@ class NamespacePortalQueryAppServiceTest {
|
|||
.isInstanceOf(DomainForbiddenException.class)
|
||||
.hasMessageContaining("error.namespace.global.members.platformAdmin.required");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMembers_teamNamespaceRejectsSuperAdminWithoutMembership() {
|
||||
Namespace ns = namespace(1L, "team-a");
|
||||
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(ns);
|
||||
doThrow(new DomainForbiddenException("error.namespace.membership.required"))
|
||||
.when(namespaceService).assertMember(1L, "super-1");
|
||||
|
||||
assertThatThrownBy(() -> service.listMembers("team-a", PageRequest.of(0, 20), "super-1", Set.of("SUPER_ADMIN")))
|
||||
.isInstanceOf(DomainForbiddenException.class)
|
||||
.hasMessageContaining("error.namespace.membership.required");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,9 +70,26 @@ test.describe('My Namespaces super admin actions', () => {
|
|||
canDelete: true,
|
||||
},
|
||||
]))
|
||||
|
||||
await page.route('**/api/web/namespaces/visible-no-role', (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',
|
||||
}))
|
||||
|
||||
await page.route('**/api/web/skills?**', (route) => fulfillJson(route, {
|
||||
items: [],
|
||||
page: 0,
|
||||
size: 20,
|
||||
total: 0,
|
||||
}))
|
||||
})
|
||||
|
||||
test('hides namespace-scoped actions for visible namespaces without membership', async ({ page }) => {
|
||||
test('keeps namespace-scoped actions hidden and opens detail for visible namespaces without membership', async ({ page }) => {
|
||||
await page.goto('/dashboard/namespaces')
|
||||
|
||||
const visibleCard = page.getByTestId('namespace-card-visible-no-role')
|
||||
|
|
@ -84,5 +101,11 @@ test.describe('My Namespaces super admin actions', () => {
|
|||
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()
|
||||
|
||||
await visibleCard.click()
|
||||
|
||||
await expect(page).toHaveURL(/\/space\/visible-no-role$/)
|
||||
await expect(page.getByRole('heading', { name: 'Visible Without Membership' })).toBeVisible()
|
||||
await expect(page.getByText('@visible-no-role')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as mod from './user-menu'
|
||||
import { UserMenu } from './user-menu'
|
||||
|
||||
const useMyNamespacesMock = vi.hoisted(() => vi.fn(() => ({ data: [] })))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
|
|
@ -63,7 +65,7 @@ vi.mock('@/api/client', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useMyNamespaces: () => ({ data: [] }),
|
||||
useMyNamespaces: useMyNamespacesMock,
|
||||
}))
|
||||
|
||||
/**
|
||||
|
|
@ -77,6 +79,10 @@ describe('user-menu module exports', () => {
|
|||
})
|
||||
|
||||
describe('UserMenu security settings visibility', () => {
|
||||
beforeEach(() => {
|
||||
useMyNamespacesMock.mockClear()
|
||||
})
|
||||
|
||||
it('shows security settings when password changes are allowed, independent of OAuth provider', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UserMenu
|
||||
|
|
@ -105,4 +111,17 @@ describe('UserMenu security settings visibility', () => {
|
|||
|
||||
expect(html).not.toContain('user.menu.security')
|
||||
})
|
||||
|
||||
it('does not fetch namespace memberships while rendering the global menu', () => {
|
||||
renderToStaticMarkup(
|
||||
<UserMenu
|
||||
user={{
|
||||
displayName: 'Super Admin',
|
||||
platformRoles: ['SUPER_ADMIN'],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(useMyNamespacesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||
import { Link } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { authApi } from '@/api/client'
|
||||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { buildGlobalReviewsPath, canAccessReviewCenter } from '@/features/review/review-paths'
|
||||
import { buildGlobalReviewsPath, canAccessGlobalReviewCenter } from '@/features/review/review-paths'
|
||||
import { clearSessionScopedQueries } from '@/features/notification/notification-session'
|
||||
import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
|
|
@ -25,7 +24,6 @@ interface UserMenuProps {
|
|||
export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: myNamespaces } = useMyNamespaces()
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const closeTimerRef = useRef<number | null>(null)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
|
@ -37,7 +35,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN')
|
||||
const isSuperAdmin = hasRole('SUPER_ADMIN')
|
||||
const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces)
|
||||
const reviewCenterVisible = canAccessGlobalReviewCenter(user.platformRoles)
|
||||
const canChangePassword = user.canChangePassword === true
|
||||
const open = isHovered || isClickOpen
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue