From c48518f1a76e330dc8e84bab02acc83dbad11e8d Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:54:31 +0800 Subject: [PATCH] feat(namespace): add admin namespace management Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../admin/AdminNamespaceController.java | 151 ++++ .../dto/AdminNamespaceDetailResponse.java | 38 + .../dto/AdminNamespaceListResponse.java | 11 + .../dto/AdminNamespaceListStatsResponse.java | 8 + .../AdminNamespacePermissionsResponse.java | 40 + .../dto/AdminNamespaceStatsResponse.java | 6 + .../dto/AdminNamespaceSummaryResponse.java | 38 + .../AdminNamespaceQueryRepository.java | 19 + .../JpaAdminNamespaceQueryRepository.java | 115 +++ .../service/AdminNamespaceAppService.java | 347 ++++++++ .../NamespaceMemberCandidateService.java | 31 + .../NamespacePortalQueryAppService.java | 6 - .../NamespacePortalControllerTest.java | 10 +- .../admin/AdminNamespaceControllerTest.java | 241 +++++ .../service/AdminNamespaceAppServiceTest.java | 203 +++++ .../NamespacePortalQueryAppServiceTest.java | 20 +- .../namespace/NamespaceGovernanceService.java | 66 ++ web/src/api/client.ts | 122 +++ web/src/api/generated/schema.d.ts | 624 ++++++++++++- web/src/api/types.ts | 40 + web/src/app/router.tsx | 13 + .../features/admin/use-admin-namespaces.ts | 122 +++ web/src/i18n/locales/en.json | 68 +- web/src/i18n/locales/zh.json | 69 +- web/src/pages/admin/namespaces.tsx | 841 ++++++++++++++++++ web/src/shared/components/user-menu.tsx | 5 + 26 files changed, 3213 insertions(+), 41 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminNamespaceController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceDetailResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceListResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceListStatsResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespacePermissionsResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceStatsResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceSummaryResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminNamespaceQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaAdminNamespaceQueryRepository.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminNamespaceControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java create mode 100644 web/src/features/admin/use-admin-namespaces.ts create mode 100644 web/src/pages/admin/namespaces.tsx diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminNamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminNamespaceController.java new file mode 100644 index 000000000..9b1ed8ae1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminNamespaceController.java @@ -0,0 +1,151 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.AdminNamespaceDetailResponse; +import com.iflytek.skillhub.dto.AdminNamespaceListResponse; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.BatchMemberRequest; +import com.iflytek.skillhub.dto.BatchMemberResponse; +import com.iflytek.skillhub.dto.MemberRequest; +import com.iflytek.skillhub.dto.MemberResponse; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import com.iflytek.skillhub.dto.NamespaceLifecycleRequest; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.TransferOwnershipRequest; +import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; +import com.iflytek.skillhub.service.AdminNamespaceAppService; +import com.iflytek.skillhub.service.AuditRequestContext; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/namespaces") +@PreAuthorize("hasRole('SUPER_ADMIN')") +public class AdminNamespaceController extends BaseApiController { + + private final AdminNamespaceAppService adminNamespaceAppService; + + public AdminNamespaceController(AdminNamespaceAppService adminNamespaceAppService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.adminNamespaceAppService = adminNamespaceAppService; + } + + @GetMapping + public ApiResponse listNamespaces( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) String type, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.read", + adminNamespaceAppService.list(keyword, status, type, page, size, principal.userId())); + } + + @GetMapping("/{slug}") + public ApiResponse getNamespace(@PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.read", adminNamespaceAppService.detail(slug, principal.userId())); + } + + @GetMapping("/{slug}/members") + public ApiResponse> listMembers(@PathVariable String slug, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok("response.success.read", adminNamespaceAppService.listMembers(slug, page, size)); + } + + @GetMapping("/{slug}/member-candidates") + public ApiResponse> searchMemberCandidates( + @PathVariable String slug, + @RequestParam String search, + @RequestParam(defaultValue = "10") int size) { + return ok("response.success.read", adminNamespaceAppService.searchMemberCandidates(slug, search, size)); + } + + @PostMapping("/{slug}/members") + public ApiResponse addMember(@PathVariable String slug, + @Valid @RequestBody MemberRequest request, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.created", adminNamespaceAppService.addMember(slug, request, principal.userId())); + } + + @PostMapping("/{slug}/members/batch") + public ApiResponse batchAddMembers(@PathVariable String slug, + @Valid @RequestBody BatchMemberRequest request, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.created", adminNamespaceAppService.batchAddMembers(slug, request, principal.userId())); + } + + @PutMapping("/{slug}/members/{userId}/role") + public ApiResponse updateMemberRole(@PathVariable String slug, + @PathVariable String userId, + @Valid @RequestBody UpdateMemberRoleRequest request, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.updated", adminNamespaceAppService.updateMemberRole(slug, userId, request, principal.userId())); + } + + @DeleteMapping("/{slug}/members/{userId}") + public ApiResponse removeMember(@PathVariable String slug, + @PathVariable String userId, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.deleted", adminNamespaceAppService.removeMember(slug, userId, principal.userId())); + } + + @PostMapping("/{slug}/transfer-ownership") + public ApiResponse transferOwnership(@PathVariable String slug, + @Valid @RequestBody TransferOwnershipRequest request, + @AuthenticationPrincipal PlatformPrincipal principal) { + return ok("response.success.updated", adminNamespaceAppService.transferOwnership(slug, request.newOwnerId(), principal.userId())); + } + + @PostMapping("/{slug}/freeze") + public ApiResponse freezeNamespace(@PathVariable String slug, + @RequestBody(required = false) NamespaceLifecycleRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + adminNamespaceAppService.freeze(slug, request, principal.userId(), AuditRequestContext.from(httpRequest))); + } + + @PostMapping("/{slug}/unfreeze") + public ApiResponse unfreezeNamespace(@PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + adminNamespaceAppService.unfreeze(slug, principal.userId(), AuditRequestContext.from(httpRequest))); + } + + @PostMapping("/{slug}/archive") + public ApiResponse archiveNamespace(@PathVariable String slug, + @RequestBody(required = false) NamespaceLifecycleRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + adminNamespaceAppService.archive(slug, request, principal.userId(), AuditRequestContext.from(httpRequest))); + } + + @PostMapping("/{slug}/restore") + public ApiResponse restoreNamespace(@PathVariable String slug, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + adminNamespaceAppService.restore(slug, principal.userId(), AuditRequestContext.from(httpRequest))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceDetailResponse.java new file mode 100644 index 000000000..4fec0037a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceDetailResponse.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import java.time.Instant; + +public record AdminNamespaceDetailResponse( + Long id, + String slug, + String displayName, + String status, + String description, + String type, + String avatarUrl, + String createdBy, + Instant createdAt, + Instant updatedAt, + AdminNamespaceStatsResponse stats, + AdminNamespacePermissionsResponse permissions +) { + public static AdminNamespaceDetailResponse from(Namespace namespace, + AdminNamespaceStatsResponse stats, + AdminNamespacePermissionsResponse permissions) { + return new AdminNamespaceDetailResponse( + namespace.getId(), + namespace.getSlug(), + namespace.getDisplayName(), + namespace.getStatus().name(), + namespace.getDescription(), + namespace.getType().name(), + namespace.getAvatarUrl(), + namespace.getCreatedBy(), + namespace.getCreatedAt(), + namespace.getUpdatedAt(), + stats, + permissions + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceListResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceListResponse.java new file mode 100644 index 000000000..8cae4ca9c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceListResponse.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +public record AdminNamespaceListResponse( + List items, + long total, + int page, + int size, + AdminNamespaceListStatsResponse stats +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceListStatsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceListStatsResponse.java new file mode 100644 index 000000000..434a5460b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceListStatsResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record AdminNamespaceListStatsResponse( + long total, + long active, + long frozen, + long archived +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespacePermissionsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespacePermissionsResponse.java new file mode 100644 index 000000000..7a5348a0c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespacePermissionsResponse.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; + +public record AdminNamespacePermissionsResponse( + NamespaceRole currentUserRole, + boolean platformOverride, + boolean immutable, + boolean canManageMembers, + boolean canGovernNamespace, + boolean canPublish, + boolean canTransferOwnership, + boolean canFreeze, + boolean canUnfreeze, + boolean canArchive, + boolean canRestore +) { + public static AdminNamespacePermissionsResponse forSuperAdmin(Namespace namespace, + NamespaceRole currentUserRole, + NamespaceAccessPolicy accessPolicy) { + boolean mutableTeam = namespace.getType() == NamespaceType.TEAM; + return new AdminNamespacePermissionsResponse( + currentUserRole, + true, + accessPolicy.isImmutable(namespace), + mutableTeam && namespace.getStatus() == NamespaceStatus.ACTIVE, + mutableTeam, + mutableTeam && namespace.getStatus() == NamespaceStatus.ACTIVE, + mutableTeam && namespace.getStatus() == NamespaceStatus.ACTIVE, + mutableTeam && namespace.getStatus() == NamespaceStatus.ACTIVE, + mutableTeam && namespace.getStatus() == NamespaceStatus.FROZEN, + mutableTeam && namespace.getStatus() != NamespaceStatus.ARCHIVED, + mutableTeam && namespace.getStatus() == NamespaceStatus.ARCHIVED + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceStatsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceStatsResponse.java new file mode 100644 index 000000000..84b0585b3 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceStatsResponse.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.dto; + +public record AdminNamespaceStatsResponse( + long memberCount, + long skillCount +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceSummaryResponse.java new file mode 100644 index 000000000..ed225461a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminNamespaceSummaryResponse.java @@ -0,0 +1,38 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import java.time.Instant; + +public record AdminNamespaceSummaryResponse( + Long id, + String slug, + String displayName, + String status, + String description, + String type, + String avatarUrl, + String createdBy, + Instant createdAt, + Instant updatedAt, + AdminNamespaceStatsResponse stats, + AdminNamespacePermissionsResponse permissions +) { + public static AdminNamespaceSummaryResponse from(Namespace namespace, + AdminNamespaceStatsResponse stats, + AdminNamespacePermissionsResponse permissions) { + return new AdminNamespaceSummaryResponse( + namespace.getId(), + namespace.getSlug(), + namespace.getDisplayName(), + namespace.getStatus().name(), + namespace.getDescription(), + namespace.getType().name(), + namespace.getAvatarUrl(), + namespace.getCreatedBy(), + namespace.getCreatedAt(), + namespace.getUpdatedAt(), + stats, + permissions + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminNamespaceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminNamespaceQueryRepository.java new file mode 100644 index 000000000..6295652fe --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/AdminNamespaceQueryRepository.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.dto.AdminNamespaceListStatsResponse; +import java.util.Map; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface AdminNamespaceQueryRepository { + Page search(String keyword, NamespaceStatus status, NamespaceType type, Pageable pageable); + + AdminNamespaceListStatsResponse stats(); + + Map countMembersByNamespaceId(Iterable namespaceIds); + + Map countSkillsByNamespaceId(Iterable namespaceIds); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaAdminNamespaceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaAdminNamespaceQueryRepository.java new file mode 100644 index 000000000..8697227e0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaAdminNamespaceQueryRepository.java @@ -0,0 +1,115 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.dto.AdminNamespaceListStatsResponse; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +@Repository +public class JpaAdminNamespaceQueryRepository implements AdminNamespaceQueryRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + public Page search(String keyword, NamespaceStatus status, NamespaceType type, Pageable pageable) { + Map params = new HashMap<>(); + String whereClause = buildWhereClause(keyword, status, type, params); + String orderClause = " ORDER BY n.updatedAt DESC, n.slug ASC"; + + TypedQuery query = entityManager.createQuery( + "SELECT n FROM Namespace n" + whereClause + orderClause, + Namespace.class); + params.forEach(query::setParameter); + query.setFirstResult((int) pageable.getOffset()); + query.setMaxResults(pageable.getPageSize()); + + TypedQuery countQuery = entityManager.createQuery( + "SELECT COUNT(n) FROM Namespace n" + whereClause, + Long.class); + params.forEach(countQuery::setParameter); + + return new PageImpl<>(query.getResultList(), pageable, countQuery.getSingleResult()); + } + + @Override + public AdminNamespaceListStatsResponse stats() { + long total = entityManager.createQuery("SELECT COUNT(n) FROM Namespace n", Long.class).getSingleResult(); + long active = countByStatus(NamespaceStatus.ACTIVE); + long frozen = countByStatus(NamespaceStatus.FROZEN); + long archived = countByStatus(NamespaceStatus.ARCHIVED); + return new AdminNamespaceListStatsResponse(total, active, frozen, archived); + } + + @Override + public Map countMembersByNamespaceId(Iterable namespaceIds) { + return countByNamespaceId(""" + SELECT m.namespaceId, COUNT(m) + FROM NamespaceMember m + WHERE m.namespaceId IN :namespaceIds + GROUP BY m.namespaceId + """, namespaceIds); + } + + @Override + public Map countSkillsByNamespaceId(Iterable namespaceIds) { + return countByNamespaceId(""" + SELECT s.namespaceId, COUNT(s) + FROM Skill s + WHERE s.namespaceId IN :namespaceIds + GROUP BY s.namespaceId + """, namespaceIds); + } + + private String buildWhereClause(String keyword, + NamespaceStatus status, + NamespaceType type, + Map params) { + StringBuilder where = new StringBuilder(" WHERE 1 = 1"); + if (StringUtils.hasText(keyword)) { + where.append(" AND (LOWER(n.slug) LIKE :keyword OR LOWER(n.displayName) LIKE :keyword OR LOWER(n.description) LIKE :keyword)"); + params.put("keyword", "%" + keyword.trim().toLowerCase() + "%"); + } + if (status != null) { + where.append(" AND n.status = :status"); + params.put("status", status); + } + if (type != null) { + where.append(" AND n.type = :type"); + params.put("type", type); + } + return where.toString(); + } + + private long countByStatus(NamespaceStatus status) { + return entityManager.createQuery("SELECT COUNT(n) FROM Namespace n WHERE n.status = :status", Long.class) + .setParameter("status", status) + .getSingleResult(); + } + + private Map countByNamespaceId(String jpql, Iterable namespaceIds) { + java.util.List ids = new java.util.ArrayList<>(); + namespaceIds.forEach(ids::add); + if (ids.isEmpty()) { + return Map.of(); + } + Map counts = new LinkedHashMap<>(); + for (Object[] row : entityManager.createQuery(jpql, Object[].class) + .setParameter("namespaceIds", ids) + .getResultList()) { + counts.put((Long) row[0], (Long) row[1]); + } + return counts; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java new file mode 100644 index 000000000..e67cd87f8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminNamespaceAppService.java @@ -0,0 +1,347 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.AdminNamespaceDetailResponse; +import com.iflytek.skillhub.dto.AdminNamespaceListResponse; +import com.iflytek.skillhub.dto.AdminNamespacePermissionsResponse; +import com.iflytek.skillhub.dto.AdminNamespaceStatsResponse; +import com.iflytek.skillhub.dto.AdminNamespaceSummaryResponse; +import com.iflytek.skillhub.dto.BatchMemberRequest; +import com.iflytek.skillhub.dto.BatchMemberResponse; +import com.iflytek.skillhub.dto.BatchMemberResult; +import com.iflytek.skillhub.dto.MemberRequest; +import com.iflytek.skillhub.dto.MemberResponse; +import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import com.iflytek.skillhub.dto.NamespaceLifecycleRequest; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; +import com.iflytek.skillhub.repository.AdminNamespaceQueryRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +public class AdminNamespaceAppService { + + private final AdminNamespaceQueryRepository adminNamespaceQueryRepository; + private final NamespaceService namespaceService; + private final NamespaceGovernanceService namespaceGovernanceService; + private final NamespaceMemberService namespaceMemberService; + private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceMemberCandidateService namespaceMemberCandidateService; + private final NamespaceAccessPolicy namespaceAccessPolicy; + private final UserAccountRepository userAccountRepository; + + public AdminNamespaceAppService(AdminNamespaceQueryRepository adminNamespaceQueryRepository, + NamespaceService namespaceService, + NamespaceGovernanceService namespaceGovernanceService, + NamespaceMemberService namespaceMemberService, + NamespaceMemberRepository namespaceMemberRepository, + NamespaceMemberCandidateService namespaceMemberCandidateService, + NamespaceAccessPolicy namespaceAccessPolicy, + UserAccountRepository userAccountRepository) { + this.adminNamespaceQueryRepository = adminNamespaceQueryRepository; + this.namespaceService = namespaceService; + this.namespaceGovernanceService = namespaceGovernanceService; + this.namespaceMemberService = namespaceMemberService; + this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceMemberCandidateService = namespaceMemberCandidateService; + this.namespaceAccessPolicy = namespaceAccessPolicy; + this.userAccountRepository = userAccountRepository; + } + + @Transactional(readOnly = true) + public AdminNamespaceListResponse list(String keyword, + String status, + String type, + int page, + int size, + String actorUserId) { + PageRequest pageRequest = PageRequest.of(Math.max(page, 0), normalizePageSize(size)); + Page namespaces = adminNamespaceQueryRepository.search( + keyword, + parseStatus(status), + parseType(type), + pageRequest); + List namespaceIds = namespaces.getContent().stream().map(Namespace::getId).toList(); + Map memberCounts = adminNamespaceQueryRepository.countMembersByNamespaceId(namespaceIds); + Map skillCounts = adminNamespaceQueryRepository.countSkillsByNamespaceId(namespaceIds); + Map roles = loadRoles(namespaceIds, actorUserId); + + List items = namespaces.getContent().stream() + .map(namespace -> AdminNamespaceSummaryResponse.from( + namespace, + stats(namespace, memberCounts, skillCounts), + permissions(namespace, roles.get(namespace.getId())))) + .toList(); + + return new AdminNamespaceListResponse( + items, + namespaces.getTotalElements(), + namespaces.getNumber(), + namespaces.getSize(), + adminNamespaceQueryRepository.stats()); + } + + @Transactional(readOnly = true) + public AdminNamespaceDetailResponse detail(String slug, String actorUserId) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + Map memberCounts = adminNamespaceQueryRepository.countMembersByNamespaceId(List.of(namespace.getId())); + Map skillCounts = adminNamespaceQueryRepository.countSkillsByNamespaceId(List.of(namespace.getId())); + NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), actorUserId) + .map(NamespaceMember::getRole) + .orElse(null); + return AdminNamespaceDetailResponse.from(namespace, stats(namespace, memberCounts, skillCounts), permissions(namespace, role)); + } + + @Transactional(readOnly = true) + public PageResponse listMembers(String slug, int page, int size) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + Page members = namespaceMemberService.listMembers( + namespace.getId(), + PageRequest.of(Math.max(page, 0), normalizePageSize(size))); + + List memberUserIds = members.getContent().stream() + .map(NamespaceMember::getUserId) + .toList(); + Map userMap = memberUserIds.isEmpty() + ? Map.of() + : userAccountRepository.findByIdIn(memberUserIds).stream() + .collect(Collectors.toMap(UserAccount::getId, Function.identity())); + + return PageResponse.from(members.map(member -> MemberResponse.from(member, userMap.get(member.getUserId())))); + } + + @Transactional(readOnly = true) + public List searchMemberCandidates(String slug, String search, int size) { + return namespaceMemberCandidateService.searchCandidatesForPlatformAdmin(slug, search, size); + } + + @Transactional + public MemberResponse addMember(String slug, MemberRequest request, String actorUserId) { + Namespace namespace = requireMutableTeamNamespace(slug); + if (request.role() == NamespaceRole.OWNER) { + throw new DomainBadRequestException("error.namespace.member.owner.assignDirect"); + } + if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), request.userId()).isPresent()) { + throw new DomainBadRequestException("error.namespace.member.alreadyExists"); + } + NamespaceMember member = namespaceMemberRepository.save(new NamespaceMember(namespace.getId(), request.userId(), request.role())); + return MemberResponse.from(member, userAccountRepository.findById(member.getUserId()).orElse(null)); + } + + @Transactional + public BatchMemberResponse batchAddMembers(String slug, BatchMemberRequest request, String actorUserId) { + Namespace namespace = requireMutableTeamNamespace(slug); + List results = new ArrayList<>(); + int successCount = 0; + int failureCount = 0; + + for (MemberRequest member : request.members()) { + try { + if (member.role() == NamespaceRole.OWNER) { + throw new DomainBadRequestException("error.namespace.member.owner.assignDirect"); + } + if (namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), member.userId()).isPresent()) { + throw new DomainBadRequestException("error.namespace.member.alreadyExists"); + } + namespaceMemberRepository.save(new NamespaceMember(namespace.getId(), member.userId(), member.role())); + results.add(BatchMemberResult.success(member.userId(), member.role().name())); + successCount++; + } catch (Exception e) { + results.add(BatchMemberResult.failure(member.userId(), member.role().name(), mapBatchError(e))); + failureCount++; + } + } + return new BatchMemberResponse(request.members().size(), successCount, failureCount, results); + } + + @Transactional + public MemberResponse updateMemberRole(String slug, String userId, UpdateMemberRoleRequest request, String actorUserId) { + Namespace namespace = requireMutableTeamNamespace(slug); + if (request.role() == NamespaceRole.OWNER) { + throw new DomainBadRequestException("error.namespace.member.owner.setDirect"); + } + NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound")); + if (member.getRole() == NamespaceRole.OWNER) { + throw new DomainBadRequestException("error.namespace.member.owner.setDirect"); + } + member.setRole(request.role()); + NamespaceMember saved = namespaceMemberRepository.save(member); + return MemberResponse.from(saved, userAccountRepository.findById(saved.getUserId()).orElse(null)); + } + + @Transactional + public MessageResponse removeMember(String slug, String userId, String actorUserId) { + Namespace namespace = requireMutableTeamNamespace(slug); + NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.member.notFound")); + if (member.getRole() == NamespaceRole.OWNER) { + throw new DomainBadRequestException("error.namespace.member.owner.remove"); + } + namespaceMemberRepository.deleteByNamespaceIdAndUserId(namespace.getId(), userId); + return new MessageResponse("Member removed successfully"); + } + + @Transactional + public MessageResponse transferOwnership(String slug, String newOwnerId, String actorUserId) { + Namespace namespace = requireMutableTeamNamespace(slug); + NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndRoleIn(namespace.getId(), List.of(NamespaceRole.OWNER)) + .stream() + .findFirst() + .orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound")); + NamespaceMember newOwner = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), newOwnerId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.new.notFound")); + currentOwner.setRole(NamespaceRole.ADMIN); + newOwner.setRole(NamespaceRole.OWNER); + namespaceMemberRepository.save(currentOwner); + namespaceMemberRepository.save(newOwner); + return new MessageResponse("Ownership transferred successfully"); + } + + @Transactional + public AdminNamespaceDetailResponse freeze(String slug, + NamespaceLifecycleRequest request, + String actorUserId, + AuditRequestContext auditContext) { + Namespace namespace = namespaceGovernanceService.freezeNamespaceByPlatformAdmin( + slug, + actorUserId, + request != null ? request.reason() : null, + null, + auditContext.clientIp(), + auditContext.userAgent()); + return detail(namespace.getSlug(), actorUserId); + } + + @Transactional + public AdminNamespaceDetailResponse unfreeze(String slug, String actorUserId, AuditRequestContext auditContext) { + Namespace namespace = namespaceGovernanceService.unfreezeNamespaceByPlatformAdmin( + slug, + actorUserId, + null, + auditContext.clientIp(), + auditContext.userAgent()); + return detail(namespace.getSlug(), actorUserId); + } + + @Transactional + public AdminNamespaceDetailResponse archive(String slug, + NamespaceLifecycleRequest request, + String actorUserId, + AuditRequestContext auditContext) { + Namespace namespace = namespaceGovernanceService.archiveNamespaceByPlatformAdmin( + slug, + actorUserId, + request != null ? request.reason() : null, + null, + auditContext.clientIp(), + auditContext.userAgent()); + return detail(namespace.getSlug(), actorUserId); + } + + @Transactional + public AdminNamespaceDetailResponse restore(String slug, String actorUserId, AuditRequestContext auditContext) { + Namespace namespace = namespaceGovernanceService.restoreNamespaceByPlatformAdmin( + slug, + actorUserId, + null, + auditContext.clientIp(), + auditContext.userAgent()); + return detail(namespace.getSlug(), actorUserId); + } + + private AdminNamespaceStatsResponse stats(Namespace namespace, + Map memberCounts, + Map skillCounts) { + return new AdminNamespaceStatsResponse( + memberCounts.getOrDefault(namespace.getId(), 0L), + skillCounts.getOrDefault(namespace.getId(), 0L)); + } + + private AdminNamespacePermissionsResponse permissions(Namespace namespace, NamespaceRole currentUserRole) { + return AdminNamespacePermissionsResponse.forSuperAdmin(namespace, currentUserRole, namespaceAccessPolicy); + } + + private Map loadRoles(List namespaceIds, String userId) { + if (!StringUtils.hasText(userId) || namespaceIds.isEmpty()) { + return Map.of(); + } + return namespaceIds.stream() + .map(id -> namespaceMemberRepository.findByNamespaceIdAndUserId(id, userId) + .orElse(null)) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toMap(NamespaceMember::getNamespaceId, NamespaceMember::getRole)); + } + + private Namespace requireMutableTeamNamespace(String slug) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canManageMembers(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + return namespace; + } + + private NamespaceStatus parseStatus(String status) { + if (!StringUtils.hasText(status)) { + return null; + } + try { + return NamespaceStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("error.namespace.status.invalid", status); + } + } + + private NamespaceType parseType(String type) { + if (!StringUtils.hasText(type)) { + return null; + } + try { + return NamespaceType.valueOf(type.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("error.namespace.type.invalid", type); + } + } + + private int normalizePageSize(int size) { + if (size <= 0) { + return 20; + } + return Math.min(size, 100); + } + + private String mapBatchError(Exception e) { + String msg = e.getMessage(); + if (msg == null) return "UNKNOWN_ERROR"; + if (msg.contains("alreadyExists")) return "ALREADY_MEMBER"; + if (msg.contains("owner.assignDirect")) return "INVALID_ROLE"; + if (msg.contains("notFound") || msg.contains("not found")) return "USER_NOT_FOUND"; + if (msg.contains("immutable") || msg.contains("readonly")) return "NAMESPACE_READONLY"; + return "UNKNOWN_ERROR"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java index 34eb60f7b..dd2ce2b31 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java @@ -72,6 +72,37 @@ public List searchCandidates(String slug, String .toList(); } + @Transactional(readOnly = true) + public List searchCandidatesForPlatformAdmin(String slug, String search, int size) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canManageMembers(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + + return findCandidates(namespace, search, size); + } + + private List findCandidates(Namespace namespace, String search, int size) { + String keyword = normalizeSearch(search); + if (keyword == null) { + return List.of(); + } + + int pageSize = normalizeSize(size); + Set existingMemberIds = namespaceMemberRepository.findByNamespaceId(namespace.getId(), PageRequest.of(0, 500)) + .stream() + .map(NamespaceMember::getUserId) + .collect(Collectors.toSet()); + + return userAccountRepository.search(keyword, UserStatus.ACTIVE, PageRequest.of(0, pageSize)).stream() + .filter(user -> !existingMemberIds.contains(user.getId())) + .map(NamespaceCandidateUserResponse::from) + .toList(); + } + private String normalizeSearch(String search) { if (!StringUtils.hasText(search)) { return null; 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 6382eecb7..7946c9432 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 @@ -93,12 +93,6 @@ public PageResponse listNamespaces(Pageable pageable, public List listMyNamespaces(Map userNamespaceRoles, Set platformRoles) { Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); - if (isSuperAdmin(platformRoles)) { - return namespaceRepository.findAll().stream() - .sorted(Comparator.comparing(Namespace::getSlug)) - .map(namespace -> toMyNamespaceResponse(namespace, namespaceRoles.get(namespace.getId()))) - .toList(); - } if (namespaceRoles.isEmpty()) { return List.of(); } 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 5754f16c7..d6353a7ff 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 @@ -96,10 +96,7 @@ void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws } @Test - void listMyNamespaces_allowsSuperAdminToSeeAllNamespacesWithoutMembership() throws Exception { - Namespace teamA = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); - Namespace teamB = namespace(2L, "team-b", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); - given(namespaceRepository.findAll()).willReturn(List.of(teamB, teamA)); + void listMyNamespaces_doesNotTreatSuperAdminOverrideAsNamespaceMembership() throws Exception { given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of()); mockMvc.perform(get("/api/v1/me/namespaces") @@ -107,10 +104,7 @@ void listMyNamespaces_allowsSuperAdminToSeeAllNamespacesWithoutMembership() thro .requestAttr("userId", "super-1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data[0].slug").value("team-a")) - .andExpect(jsonPath("$.data[0].currentUserRole").doesNotExist()) - .andExpect(jsonPath("$.data[1].slug").value("team-b")) - .andExpect(jsonPath("$.data[1].status").value("ARCHIVED")); + .andExpect(jsonPath("$.data").isEmpty()); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminNamespaceControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminNamespaceControllerTest.java new file mode 100644 index 000000000..17bc27aab --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminNamespaceControllerTest.java @@ -0,0 +1,241 @@ +package com.iflytek.skillhub.controller.admin; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.AdminNamespaceListResponse; +import com.iflytek.skillhub.dto.AdminNamespaceListStatsResponse; +import com.iflytek.skillhub.dto.AdminNamespacePermissionsResponse; +import com.iflytek.skillhub.dto.AdminNamespaceStatsResponse; +import com.iflytek.skillhub.dto.AdminNamespaceSummaryResponse; +import com.iflytek.skillhub.dto.MemberResponse; +import com.iflytek.skillhub.service.AdminNamespaceAppService; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminNamespaceControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AdminNamespaceAppService adminNamespaceAppService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void listNamespaces_returnsAllNamespacesForSuperAdmin() throws Exception { + when(adminNamespaceAppService.list("team", "ACTIVE", "TEAM", 0, 20, "admin")) + .thenReturn(new AdminNamespaceListResponse( + List.of(namespaceSummary("team-a").toSummary()), + 1, + 0, + 20, + new AdminNamespaceListStatsResponse(3, 2, 1, 0))); + + mockMvc.perform(get("/api/v1/admin/namespaces") + .queryParam("keyword", "team") + .queryParam("status", "ACTIVE") + .queryParam("type", "TEAM") + .with(authentication(superAdminAuth()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.stats.total").value(3)) + .andExpect(jsonPath("$.data.items[0].slug").value("team-a")) + .andExpect(jsonPath("$.data.items[0].permissions.platformOverride").value(true)); + + verify(adminNamespaceAppService).list("team", "ACTIVE", "TEAM", 0, 20, "admin"); + } + + @Test + void listNamespaces_rejectsNonSuperAdmin() throws Exception { + mockMvc.perform(get("/api/v1/admin/namespaces") + .with(authentication(userAdminAuth()))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void addMember_delegatesPlatformAdminMutation() throws Exception { + when(adminNamespaceAppService.addMember(eq("team-a"), any(), eq("admin"))) + .thenReturn(new MemberResponse( + 10L, + 1L, + "user-2", + "Alice", + "alice@example.com", + NamespaceRole.MEMBER, + Instant.parse("2026-08-12T00:00:00Z"), + Instant.parse("2026-08-12T00:00:00Z"))); + + mockMvc.perform(post("/api/v1/admin/namespaces/team-a/members") + .with(authentication(superAdminAuth())) + .with(csrf()) + .contentType("application/json") + .content(""" + { + "userId": "user-2", + "role": "MEMBER" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-2")) + .andExpect(jsonPath("$.data.role").value("MEMBER")); + + verify(adminNamespaceAppService).addMember(eq("team-a"), any(), eq("admin")); + } + + @Test + void freezeNamespace_passesAuditContextAndReason() throws Exception { + when(adminNamespaceAppService.freeze(eq("team-a"), any(), eq("admin"), any())) + .thenReturn(namespaceSummary("team-a").toDetail()); + + mockMvc.perform(post("/api/v1/admin/namespaces/team-a/freeze") + .with(authentication(superAdminAuth())) + .with(csrf()) + .contentType("application/json") + .content(""" + { + "reason": "temporary governance hold" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-a")); + + verify(adminNamespaceAppService).freeze(eq("team-a"), any(), eq("admin"), any()); + } + + private AdminNamespaceSummary namespaceSummary(String slug) { + return new AdminNamespaceSummary( + 1L, + slug, + "Team A", + "ACTIVE", + "Team namespace", + "TEAM", + null, + "owner-1", + Instant.parse("2026-08-12T00:00:00Z"), + Instant.parse("2026-08-12T00:00:00Z"), + new AdminNamespaceStatsResponse(2, 5), + new AdminNamespacePermissionsResponse( + null, + true, + false, + true, + true, + true, + true, + true, + false, + true, + false)); + } + + private UsernamePasswordAuthenticationToken superAdminAuth() { + PlatformPrincipal principal = new PlatformPrincipal( + "admin", + "admin", + "admin@example.com", + "", + "github", + Set.of("SUPER_ADMIN")); + return new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))); + } + + private UsernamePasswordAuthenticationToken userAdminAuth() { + PlatformPrincipal principal = new PlatformPrincipal( + "user-admin", + "user admin", + "user-admin@example.com", + "", + "github", + Set.of("USER_ADMIN")); + return new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN"))); + } + + private record AdminNamespaceSummary( + Long id, + String slug, + String displayName, + String status, + String description, + String type, + String avatarUrl, + String createdBy, + Instant createdAt, + Instant updatedAt, + AdminNamespaceStatsResponse stats, + AdminNamespacePermissionsResponse permissions + ) { + AdminNamespaceSummaryResponse toSummary() { + return new AdminNamespaceSummaryResponse( + id, + slug, + displayName, + status, + description, + type, + avatarUrl, + createdBy, + createdAt, + updatedAt, + stats, + permissions); + } + + com.iflytek.skillhub.dto.AdminNamespaceDetailResponse toDetail() { + return new com.iflytek.skillhub.dto.AdminNamespaceDetailResponse( + id, + slug, + displayName, + status, + description, + type, + avatarUrl, + createdBy, + createdAt, + updatedAt, + stats, + permissions); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java new file mode 100644 index 000000000..149f885d9 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminNamespaceAppServiceTest.java @@ -0,0 +1,203 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +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.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.AdminNamespaceListStatsResponse; +import com.iflytek.skillhub.dto.MemberRequest; +import com.iflytek.skillhub.dto.UpdateMemberRoleRequest; +import com.iflytek.skillhub.repository.AdminNamespaceQueryRepository; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +class AdminNamespaceAppServiceTest { + + private final AdminNamespaceQueryRepository adminNamespaceQueryRepository = mock(AdminNamespaceQueryRepository.class); + private final NamespaceService namespaceService = mock(NamespaceService.class); + private final NamespaceGovernanceService namespaceGovernanceService = mock(NamespaceGovernanceService.class); + private final NamespaceMemberService namespaceMemberService = mock(NamespaceMemberService.class); + private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); + private final NamespaceMemberCandidateService namespaceMemberCandidateService = mock(NamespaceMemberCandidateService.class); + private final NamespaceAccessPolicy namespaceAccessPolicy = mock(NamespaceAccessPolicy.class); + private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + private final AdminNamespaceAppService service = new AdminNamespaceAppService( + adminNamespaceQueryRepository, + namespaceService, + namespaceGovernanceService, + namespaceMemberService, + namespaceMemberRepository, + namespaceMemberCandidateService, + namespaceAccessPolicy, + userAccountRepository + ); + + @Test + void listProjectsPlatformAdminGovernancePermissionsWithoutMembership() { + Namespace active = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + when(adminNamespaceQueryRepository.search(null, null, null, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(active), PageRequest.of(0, 20), 1)); + when(adminNamespaceQueryRepository.countMembersByNamespaceId(List.of(1L))).thenReturn(Map.of(1L, 2L)); + when(adminNamespaceQueryRepository.countSkillsByNamespaceId(List.of(1L))).thenReturn(Map.of(1L, 5L)); + when(adminNamespaceQueryRepository.stats()).thenReturn(new AdminNamespaceListStatsResponse(1, 1, 0, 0)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "super-1")).thenReturn(Optional.empty()); + when(namespaceAccessPolicy.isImmutable(active)).thenReturn(false); + + var response = service.list(null, null, null, 0, 20, "super-1"); + + assertThat(response.items()).hasSize(1); + var item = response.items().getFirst(); + assertThat(item.slug()).isEqualTo("team-a"); + assertThat(item.stats().memberCount()).isEqualTo(2); + assertThat(item.stats().skillCount()).isEqualTo(5); + assertThat(item.permissions().currentUserRole()).isNull(); + assertThat(item.permissions().platformOverride()).isTrue(); + assertThat(item.permissions().canManageMembers()).isTrue(); + assertThat(item.permissions().canFreeze()).isTrue(); + assertThat(item.permissions().canArchive()).isTrue(); + } + + @Test + void addMemberAllowsSuperAdminToManageNonMemberTeamNamespace() { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + NamespaceMember saved = member(10L, 1L, "user-2", NamespaceRole.ADMIN); + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")).thenReturn(Optional.empty()); + when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenReturn(saved); + + var response = service.addMember("team-a", new MemberRequest("user-2", NamespaceRole.ADMIN), "super-1"); + + assertThat(response.userId()).isEqualTo("user-2"); + assertThat(response.role()).isEqualTo(NamespaceRole.ADMIN); + verify(namespaceMemberRepository).save(any(NamespaceMember.class)); + } + + @Test + void updateMemberRoleRejectsDirectOwnerAssignment() { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); + + assertThatThrownBy(() -> service.updateMemberRole( + "team-a", + "user-2", + new UpdateMemberRoleRequest(NamespaceRole.OWNER), + "super-1")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("error.namespace.member.owner.setDirect"); + } + + @Test + void transferOwnershipDemotesCurrentOwnerAndPromotesExistingMember() { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + NamespaceMember currentOwner = member(10L, 1L, "owner-1", NamespaceRole.OWNER); + NamespaceMember newOwner = member(11L, 1L, "admin-1", NamespaceRole.ADMIN); + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); + when(namespaceMemberRepository.findByNamespaceIdAndRoleIn(1L, List.of(NamespaceRole.OWNER))) + .thenReturn(List.of(currentOwner)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) + .thenReturn(Optional.of(newOwner)); + + var response = service.transferOwnership("team-a", "admin-1", "super-1"); + + assertThat(response.message()).isEqualTo("Ownership transferred successfully"); + assertThat(currentOwner.getRole()).isEqualTo(NamespaceRole.ADMIN); + assertThat(newOwner.getRole()).isEqualTo(NamespaceRole.OWNER); + verify(namespaceMemberRepository).save(currentOwner); + verify(namespaceMemberRepository).save(newOwner); + } + + @Test + void globalNamespaceCannotBeMutatedFromAdminMemberApi() { + Namespace global = namespace(1L, "global", NamespaceStatus.ACTIVE, NamespaceType.GLOBAL); + when(namespaceService.getNamespaceBySlug("global")).thenReturn(global); + when(namespaceAccessPolicy.isImmutable(global)).thenReturn(true); + + assertThatThrownBy(() -> service.addMember( + "global", + new MemberRequest("user-2", NamespaceRole.MEMBER), + "super-1")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("error.namespace.system.immutable"); + } + + @Test + void freezeUsesPlatformAdminGovernancePath() { + Namespace frozen = namespace(1L, "team-a", NamespaceStatus.FROZEN, NamespaceType.TEAM); + when(namespaceGovernanceService.freezeNamespaceByPlatformAdmin( + eq("team-a"), + eq("super-1"), + eq("reviewing abuse report"), + any(), + eq("127.0.0.1"), + eq("JUnit"))) + .thenReturn(frozen); + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(frozen); + when(adminNamespaceQueryRepository.countMembersByNamespaceId(anyList())).thenReturn(Map.of()); + when(adminNamespaceQueryRepository.countSkillsByNamespaceId(anyList())).thenReturn(Map.of()); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "super-1")).thenReturn(Optional.empty()); + when(namespaceAccessPolicy.isImmutable(frozen)).thenReturn(false); + + var response = service.freeze( + "team-a", + new com.iflytek.skillhub.dto.NamespaceLifecycleRequest("reviewing abuse report"), + "super-1", + new AuditRequestContext("127.0.0.1", "JUnit")); + + assertThat(response.slug()).isEqualTo("team-a"); + assertThat(response.status()).isEqualTo("FROZEN"); + assertThat(response.permissions().platformOverride()).isTrue(); + verify(namespaceGovernanceService).freezeNamespaceByPlatformAdmin( + "team-a", + "super-1", + "reviewing abuse report", + null, + "127.0.0.1", + "JUnit"); + } + + private Namespace namespace(Long id, String slug, NamespaceStatus status, NamespaceType type) { + Namespace namespace = new Namespace(slug, "Team " + slug, "owner-1"); + ReflectionTestUtils.setField(namespace, "id", id); + ReflectionTestUtils.setField(namespace, "createdAt", java.time.Instant.parse("2026-08-12T00:00:00Z")); + ReflectionTestUtils.setField(namespace, "updatedAt", java.time.Instant.parse("2026-08-12T00:00:00Z")); + namespace.setStatus(status); + namespace.setType(type); + return namespace; + } + + private NamespaceMember member(Long id, Long namespaceId, String userId, NamespaceRole role) { + NamespaceMember member = new NamespaceMember(namespaceId, userId, role); + ReflectionTestUtils.setField(member, "id", id); + ReflectionTestUtils.setField(member, "createdAt", java.time.Instant.parse("2026-08-12T00:00:00Z")); + ReflectionTestUtils.setField(member, "updatedAt", java.time.Instant.parse("2026-08-12T00:00:00Z")); + return member; + } +} 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 af3681406..678de2ec8 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 @@ -6,6 +6,8 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.iflytek.skillhub.domain.namespace.Namespace; @@ -125,23 +127,11 @@ void listNamespaces_superAdminSeesAllActiveNamespacesWithoutMembership() { } @Test - void listMyNamespaces_superAdminSeesAllNamespacesWithoutSyntheticOwnerActions() { - Namespace active = namespace(1L, "active-team"); - Namespace archived = namespace(2L, "archived-team"); - archived.setStatus(NamespaceStatus.ARCHIVED); - when(namespaceRepository.findAll()).thenReturn(List.of(archived, active)); - when(namespaceAccessPolicy.isImmutable(active)).thenReturn(false); - when(namespaceAccessPolicy.isImmutable(archived)).thenReturn(false); - + void listMyNamespaces_superAdminWithoutMembershipSeesEmptyList() { var response = service.listMyNamespaces(Map.of(), Set.of("SUPER_ADMIN")); - assertThat(response).hasSize(2); - assertThat(response.get(0).slug()).isEqualTo("active-team"); - assertThat(response.get(0).currentUserRole()).isNull(); - assertThat(response.get(0).canFreeze()).isFalse(); - assertThat(response.get(0).canArchive()).isFalse(); - assertThat(response.get(0).canDelete()).isFalse(); - assertThat(response.get(1).slug()).isEqualTo("archived-team"); + assertThat(response).isEmpty(); + verify(namespaceRepository, never()).findAll(); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java index c9db527f3..4809a264f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java @@ -49,6 +49,23 @@ public Namespace freezeNamespace(String slug, return updated; } + @Transactional + public Namespace freezeNamespaceByPlatformAdmin(String slug, + String actorUserId, + String reason, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.FROZEN); + Namespace updated = namespaceRepository.save(namespace); + record("FREEZE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, reason); + return updated; + } + @Transactional public Namespace unfreezeNamespace(String slug, String actorUserId, @@ -69,6 +86,22 @@ public Namespace unfreezeNamespace(String slug, return updated; } + @Transactional + public Namespace unfreezeNamespaceByPlatformAdmin(String slug, + String actorUserId, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + if (namespace.getStatus() != NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ACTIVE); + Namespace updated = namespaceRepository.save(namespace); + record("UNFREEZE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, null); + return updated; + } + @Transactional public Namespace archiveNamespace(String slug, String actorUserId, @@ -90,6 +123,23 @@ public Namespace archiveNamespace(String slug, return updated; } + @Transactional + public Namespace archiveNamespaceByPlatformAdmin(String slug, + String actorUserId, + String reason, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ARCHIVED); + Namespace updated = namespaceRepository.save(namespace); + record("ARCHIVE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, reason); + return updated; + } + @Transactional public Namespace restoreNamespace(String slug, String actorUserId, @@ -110,6 +160,22 @@ public Namespace restoreNamespace(String slug, return updated; } + @Transactional + public Namespace restoreNamespaceByPlatformAdmin(String slug, + String actorUserId, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ACTIVE); + Namespace updated = namespaceRepository.save(namespace); + record("RESTORE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, null); + return updated; + } + private Namespace loadNamespaceBySlug(String slug) { Namespace namespace = namespaceRepository.findBySlug(slug) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b0fe1a2e6..5e4c77567 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -33,6 +33,8 @@ import type { OAuthProvider, User, ManagedNamespace, + AdminNamespace, + AdminNamespaceList, Namespace, CreateNamespaceRequest, NamespaceMember, @@ -1143,6 +1145,126 @@ export const profileApi = { } export const adminApi = { + async getNamespaces(params: { keyword?: string; status?: string; type?: string; page?: number; size?: number }): Promise { + const searchParams = new URLSearchParams() + if (params.keyword) searchParams.set('keyword', params.keyword) + if (params.status) searchParams.set('status', params.status) + if (params.type) searchParams.set('type', params.type) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson(`/api/v1/admin/namespaces?${searchParams.toString()}`) + }, + + async getNamespace(slug: string): Promise { + return fetchJson(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}`) + }, + + async getNamespaceMembers(slug: string, params?: { page?: number; size?: number }): Promise> { + const searchParams = new URLSearchParams() + searchParams.set('page', String(params?.page ?? 0)) + searchParams.set('size', String(params?.size ?? 20)) + return fetchJson>( + `/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/members?${searchParams.toString()}`, + ) + }, + + async searchNamespaceMemberCandidates(slug: string, search: string, size = 10): Promise { + const searchParams = new URLSearchParams({ + search: search.trim(), + size: String(size), + }) + return fetchJson( + `/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/member-candidates?${searchParams.toString()}`, + ) + }, + + async addNamespaceMember(slug: string, request: { userId: string; role: string }): Promise { + return fetchJson(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/members`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + userId: request.userId.trim(), + role: request.role, + }), + }) + }, + + async batchAddNamespaceMembers(slug: string, members: Array<{ userId: string; role: string }>): Promise { + return fetchJson(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/members/batch`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ members }), + }) + }, + + async updateNamespaceMemberRole(slug: string, userId: string, role: string): Promise { + return fetchJson( + `/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}/role`, + { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ role }), + }, + ) + }, + + async removeNamespaceMember(slug: string, userId: string): Promise { + await fetchJson(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, + + async transferNamespaceOwnership(slug: string, newOwnerUserId: string): Promise { + await fetchJson(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/transfer-ownership`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ newOwnerId: newOwnerUserId.trim() }), + }) + }, + + async freezeNamespace(slug: string, reason?: string): Promise { + return fetchJson(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/freeze`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}), + }) + }, + + async unfreezeNamespace(slug: string): Promise { + return fetchJson(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/unfreeze`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async archiveNamespace(slug: string, reason?: string): Promise { + return fetchJson(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/archive`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}), + }) + }, + + async restoreNamespace(slug: string): Promise { + return fetchJson(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/restore`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + async getUsers(params: { search?: string; status?: string; page?: number; size?: number }) { const searchParams = new URLSearchParams() if (params.search) searchParams.set('search', params.search) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 62216527b..3c4f5b8e2 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -372,6 +372,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/namespaces/{slug}/members/{userId}/role": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateMemberRole_2"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/labels/{slug}": { parameters: { query?: never; @@ -1604,6 +1620,118 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/namespaces/{slug}/unfreeze": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["unfreezeNamespace_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/namespaces/{slug}/transfer-ownership": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["transferOwnership_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/namespaces/{slug}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["restoreNamespace_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/namespaces/{slug}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMembers_2"]; + put?: never; + post: operations["addMember_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/namespaces/{slug}/members/batch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["batchAddMembers_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/namespaces/{slug}/freeze": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["freezeNamespace_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/namespaces/{slug}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["archiveNamespace_2"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/labels": { parameters: { query?: never; @@ -3092,6 +3220,54 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/namespaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listNamespaces_2"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/namespaces/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getNamespace_2"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/namespaces/{slug}/member-candidates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["searchMemberCandidates_2"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/audit-logs": { parameters: { query?: never; @@ -3316,6 +3492,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/namespaces/{slug}/members/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["removeMember_2"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/cli/v1/skills/{namespace}/{slug}": { parameters: { query?: never; @@ -3958,6 +4150,52 @@ export interface components { id?: number; status?: string; }; + AdminNamespaceDetailResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + status?: string; + description?: string; + type?: string; + avatarUrl?: string; + createdBy?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + stats?: components["schemas"]["AdminNamespaceStatsResponse"]; + permissions?: components["schemas"]["AdminNamespacePermissionsResponse"]; + }; + AdminNamespacePermissionsResponse: { + /** @enum {string} */ + currentUserRole?: "OWNER" | "ADMIN" | "MEMBER"; + platformOverride?: boolean; + immutable?: boolean; + canManageMembers?: boolean; + canGovernNamespace?: boolean; + canPublish?: boolean; + canTransferOwnership?: boolean; + canFreeze?: boolean; + canUnfreeze?: boolean; + canArchive?: boolean; + canRestore?: boolean; + }; + AdminNamespaceStatsResponse: { + /** Format: int64 */ + memberCount?: number; + /** Format: int64 */ + skillCount?: number; + }; + ApiResponseAdminNamespaceDetailResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AdminNamespaceDetailResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; AdminLabelCreateRequest: { slug: string; /** @enum {string} */ @@ -4972,6 +5210,52 @@ export interface components { /** Format: date-time */ reviewedAt?: string; }; + AdminNamespaceListResponse: { + items?: components["schemas"]["AdminNamespaceSummaryResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + stats?: components["schemas"]["AdminNamespaceListStatsResponse"]; + }; + AdminNamespaceListStatsResponse: { + /** Format: int64 */ + total?: number; + /** Format: int64 */ + active?: number; + /** Format: int64 */ + frozen?: number; + /** Format: int64 */ + archived?: number; + }; + AdminNamespaceSummaryResponse: { + /** Format: int64 */ + id?: number; + slug?: string; + displayName?: string; + status?: string; + description?: string; + type?: string; + avatarUrl?: string; + createdBy?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + stats?: components["schemas"]["AdminNamespaceStatsResponse"]; + permissions?: components["schemas"]["AdminNamespacePermissionsResponse"]; + }; + ApiResponseAdminNamespaceListResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["AdminNamespaceListResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponsePageResponseAuditLogItemResponse: { /** Format: int32 */ code?: number; @@ -6108,18 +6392,19 @@ export interface operations { }; }; }; - updateLabel: { + updateMemberRole_2: { parameters: { query?: never; header?: never; path: { slug: string; + userId: string; }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["AdminLabelUpdateRequest"]; + "application/json": components["schemas"]["UpdateMemberRoleRequest"]; }; }; responses: { @@ -6129,12 +6414,12 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseLabelDefinitionResponse"]; + "*/*": components["schemas"]["ApiResponseMemberResponse"]; }; }; }; }; - deleteLabel: { + updateLabel: { parameters: { query?: never; header?: never; @@ -6143,7 +6428,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["AdminLabelUpdateRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -6151,19 +6440,41 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApiResponseMessageResponse"]; + "*/*": components["schemas"]["ApiResponseLabelDefinitionResponse"]; }; }; }; }; - updateSortOrder: { + deleteLabel: { parameters: { query?: never; header?: never; - path?: never; + path: { + slug: string; + }; cookie?: never; }; - requestBody: { + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + updateSortOrder: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { content: { "application/json": components["schemas"]["LabelSortOrderUpdateRequest"]; }; @@ -8303,6 +8614,205 @@ export interface operations { }; }; }; + unfreezeNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + transferOwnership_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnershipRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; + restoreNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + listMembers_2: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseMemberResponse"]; + }; + }; + }; + }; + addMember_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + }; + }; + batchAddMembers_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchMemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseBatchMemberResponse"]; + }; + }; + }; + }; + freezeNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceLifecycleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + archiveNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NamespaceLifecycleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; listLabels_2: { parameters: { query?: never; @@ -10642,6 +11152,79 @@ export interface operations { }; }; }; + listNamespaces_2: { + parameters: { + query?: { + keyword?: string; + status?: string; + type?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceListResponse"]; + }; + }; + }; + }; + getNamespace_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseAdminNamespaceDetailResponse"]; + }; + }; + }; + }; + searchMemberCandidates_2: { + parameters: { + query: { + search: string; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListNamespaceCandidateUserResponse"]; + }; + }; + }; + }; listAuditLogs: { parameters: { query?: { @@ -10964,6 +11547,29 @@ export interface operations { }; }; }; + removeMember_2: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMessageResponse"]; + }; + }; + }; + }; deleteRemote: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 418672448..3f9663e67 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -124,6 +124,46 @@ export interface ManagedNamespace extends Namespace { canDelete: boolean } +export interface AdminNamespacePermissions { + currentUserRole?: NamespaceRole + platformOverride: boolean + immutable: boolean + canManageMembers: boolean + canGovernNamespace: boolean + canPublish: boolean + canTransferOwnership: boolean + canFreeze: boolean + canUnfreeze: boolean + canArchive: boolean + canRestore: boolean +} + +export interface AdminNamespaceStats { + memberCount: number + skillCount: number +} + +export interface AdminNamespace extends Namespace { + createdBy?: string + stats: AdminNamespaceStats + permissions: AdminNamespacePermissions +} + +export interface AdminNamespaceListStats { + total: number + active: number + frozen: number + archived: number +} + +export interface AdminNamespaceList { + items: AdminNamespace[] + total: number + page: number + size: number + stats: AdminNamespaceListStats +} + export interface NamespaceMember { id: number userId: string diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 440fa56a7..bb1aac30c 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -146,6 +146,11 @@ const AdminLabelsPage = createRoleProtectedRouteComponent( 'AdminLabelsPage', ['SUPER_ADMIN'], ) +const AdminNamespacesPage = createRoleProtectedRouteComponent( + () => import('@/pages/admin/namespaces'), + 'AdminNamespacesPage', + ['SUPER_ADMIN'], +) function DefaultNotFound() { return ( @@ -445,6 +450,13 @@ const adminLabelsRoute = createRoute({ component: AdminLabelsPage, }) +const adminNamespacesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'admin/namespaces', + beforeLoad: requireAuth, + component: AdminNamespacesPage, +}) + const routeTree = rootRoute.addChildren([ landingRoute, skillsRoute, @@ -481,6 +493,7 @@ const routeTree = rootRoute.addChildren([ adminUsersRoute, adminAuditLogRoute, adminLabelsRoute, + adminNamespacesRoute, ]) export const router = createRouter({ diff --git a/web/src/features/admin/use-admin-namespaces.ts b/web/src/features/admin/use-admin-namespaces.ts new file mode 100644 index 000000000..097ada5b5 --- /dev/null +++ b/web/src/features/admin/use-admin-namespaces.ts @@ -0,0 +1,122 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { adminApi } from '@/api/client' +import type { + AdminNamespaceList, + BatchMemberResponse, + NamespaceCandidateUser, + NamespaceMember, + NamespaceRole, + PagedResponse, +} from '@/api/types' + +export interface AdminNamespacesParams { + keyword?: string + status?: string + type?: string + page?: number + size?: number +} + +export function useAdminNamespaces(params: AdminNamespacesParams) { + return useQuery({ + queryKey: ['admin', 'namespaces', params], + queryFn: () => adminApi.getNamespaces(params), + }) +} + +export function useAdminNamespace(slug: string) { + return useQuery({ + queryKey: ['admin', 'namespaces', slug], + queryFn: () => adminApi.getNamespace(slug), + enabled: !!slug, + }) +} + +export function useAdminNamespaceMembers(slug: string, page = 0, size = 20) { + return useQuery>({ + queryKey: ['admin', 'namespaces', slug, 'members', { page, size }], + queryFn: () => adminApi.getNamespaceMembers(slug, { page, size }), + enabled: !!slug, + }) +} + +export function useAdminNamespaceMemberCandidates(slug: string, search: string, enabled = true) { + return useQuery({ + queryKey: ['admin', 'namespaces', slug, 'member-candidates', search], + queryFn: () => adminApi.searchNamespaceMemberCandidates(slug, search), + enabled: enabled && !!slug && search.trim().length >= 2, + }) +} + +function invalidateAdminNamespaceQueries(queryClient: ReturnType, slug?: string) { + queryClient.invalidateQueries({ queryKey: ['admin', 'namespaces'] }) + queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] }) + if (slug) { + queryClient.invalidateQueries({ queryKey: ['admin', 'namespaces', slug] }) + queryClient.invalidateQueries({ queryKey: ['admin', 'namespaces', slug, 'members'] }) + queryClient.invalidateQueries({ queryKey: ['namespaces', slug] }) + queryClient.invalidateQueries({ queryKey: ['namespaces', slug, 'members'] }) + } +} + +export function useAddAdminNamespaceMember() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ slug, userId, role }: { slug: string; userId: string; role: NamespaceRole }) => + adminApi.addNamespaceMember(slug, { userId, role }), + onSuccess: (_member, variables) => invalidateAdminNamespaceQueries(queryClient, variables.slug), + }) +} + +export function useBatchAddAdminNamespaceMembers() { + const queryClient = useQueryClient() + return useMutation }>({ + mutationFn: ({ slug, members }) => adminApi.batchAddNamespaceMembers(slug, members), + onSuccess: (_result, variables) => invalidateAdminNamespaceQueries(queryClient, variables.slug), + }) +} + +export function useUpdateAdminNamespaceMemberRole() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ slug, userId, role }: { slug: string; userId: string; role: NamespaceRole }) => + adminApi.updateNamespaceMemberRole(slug, userId, role), + onSuccess: (_member, variables) => invalidateAdminNamespaceQueries(queryClient, variables.slug), + }) +} + +export function useRemoveAdminNamespaceMember() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ slug, userId }: { slug: string; userId: string }) => adminApi.removeNamespaceMember(slug, userId), + onSuccess: (_result, variables) => invalidateAdminNamespaceQueries(queryClient, variables.slug), + }) +} + +export function useTransferAdminNamespaceOwnership() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ slug, newOwnerUserId }: { slug: string; newOwnerUserId: string }) => + adminApi.transferNamespaceOwnership(slug, newOwnerUserId), + onSuccess: (_result, variables) => invalidateAdminNamespaceQueries(queryClient, variables.slug), + }) +} + +export function useAdminNamespaceLifecycleAction() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ slug, action, reason }: { slug: string; action: 'freeze' | 'unfreeze' | 'archive' | 'restore'; reason?: string }) => { + if (action === 'freeze') { + return adminApi.freezeNamespace(slug, reason) + } + if (action === 'unfreeze') { + return adminApi.unfreezeNamespace(slug) + } + if (action === 'archive') { + return adminApi.archiveNamespace(slug, reason) + } + return adminApi.restoreNamespace(slug) + }, + onSuccess: (_namespace, variables) => invalidateAdminNamespaceQueries(queryClient, variables.slug), + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2e8e53da5..dcfc28bfe 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1337,7 +1337,8 @@ "profile": "Profile Settings", "notifications": "Notification Settings", "accounts": "Account Merge", - "logout": "Logout" + "logout": "Logout", + "namespacesAdmin": "Namespace management" } }, "footer": { @@ -1578,5 +1579,70 @@ "report": "Report Notifications", "reportDesc": "Notify on report submission or resolution" } + }, + "adminNamespaces": { + "title": "Namespace management", + "subtitle": "View every namespace from the admin surface and manage members, lifecycle, and governance actions.", + "statTotal": "Total namespaces", + "statActive": "Active", + "statFrozen": "Frozen", + "statArchived": "Archived", + "searchLabel": "Search namespaces", + "searchPlaceholder": "Search by slug, display name, or description", + "searchAction": "Search", + "clearSearch": "Clear", + "statusFilter": "Status", + "typeFilter": "Type", + "filterAll": "All", + "typeGlobal": "Global", + "typeTeam": "Team", + "colNamespace": "Namespace", + "colType": "Type", + "colStatus": "Status", + "colMembers": "Members", + "colSkills": "Skills", + "colUpdated": "Updated", + "empty": "No namespaces match the current filters", + "selectNamespace": "Select a namespace to view details", + "members": "Members", + "skills": "Skills", + "currentRole": "Current role", + "platformOverride": "Platform override", + "yes": "Yes", + "no": "No", + "noMembership": "Not a member", + "createdAt": "Created", + "updatedAt": "Updated", + "membersTitle": "Member management", + "membersDescription": "Platform administrators can manage team namespace members and roles. The global namespace remains read-only.", + "governanceTitle": "Governance actions", + "governanceDescription": "Freeze, archive, and restore affect publishing and ordinary visibility. Actions are audited.", + "reasonLabel": "Reason", + "reasonPlaceholder": "Explain why this governance action is needed for audit review.", + "memberRoleUpdated": "Member role updated", + "freezeAction": "Freeze namespace", + "unfreezeAction": "Unfreeze namespace", + "archiveAction": "Archive namespace", + "restoreAction": "Restore namespace", + "freezeConfirmTitle": "Freeze namespace?", + "freezeConfirmDescription": "“{{name}}” will become read-only and members will not be able to publish new skill versions.", + "unfreezeConfirmTitle": "Unfreeze namespace?", + "unfreezeConfirmDescription": "“{{name}}” will regain publishing and member-management capabilities.", + "archiveConfirmTitle": "Archive namespace?", + "archiveConfirmDescription": "“{{name}}” will be hidden from ordinary product surfaces and remain visible from admin management.", + "restoreConfirmTitle": "Restore namespace?", + "restoreConfirmDescription": "“{{name}}” will return to ordinary product visibility.", + "freezeSuccessTitle": "Namespace frozen", + "freezeSuccessDescription": "“{{name}}” is now read-only.", + "unfreezeSuccessTitle": "Namespace unfrozen", + "unfreezeSuccessDescription": "“{{name}}” is active again.", + "archiveSuccessTitle": "Namespace archived", + "archiveSuccessDescription": "“{{name}}” is hidden from ordinary product surfaces.", + "restoreSuccessTitle": "Namespace restored", + "restoreSuccessDescription": "“{{name}}” is visible again.", + "freezeErrorTitle": "Failed to freeze namespace", + "unfreezeErrorTitle": "Failed to unfreeze namespace", + "archiveErrorTitle": "Failed to archive namespace", + "restoreErrorTitle": "Failed to restore namespace" } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 884419222..e2846cae8 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -930,7 +930,6 @@ "submitReviewSuccessTitle": "已提交审核", "submitReviewSuccessDescription": "版本 {{version}} 已提交审核。", "submitReviewErrorTitle": "提交审核失败", - "withdrawReviewErrorTitle": "撤销审核失败", "deleteVersion": "删除版本", "deleteVersionConfirmTitle": "确认删除版本", "deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?", @@ -1338,7 +1337,8 @@ "profile": "个人设置", "notifications": "通知设置", "accounts": "账号合并", - "logout": "退出登录" + "logout": "退出登录", + "namespacesAdmin": "命名空间管理" } }, "footer": { @@ -1579,5 +1579,70 @@ "report": "举报通知", "reportDesc": "举报提交或处理完成时通知" } + }, + "adminNamespaces": { + "title": "命名空间管理", + "subtitle": "后台视角查看全部命名空间,管理成员、状态和治理动作。", + "statTotal": "全部命名空间", + "statActive": "正常", + "statFrozen": "已冻结", + "statArchived": "已归档", + "searchLabel": "搜索命名空间", + "searchPlaceholder": "输入 slug、显示名称或描述", + "searchAction": "搜索", + "clearSearch": "清空", + "statusFilter": "状态", + "typeFilter": "类型", + "filterAll": "全部", + "typeGlobal": "全局", + "typeTeam": "团队", + "colNamespace": "命名空间", + "colType": "类型", + "colStatus": "状态", + "colMembers": "成员", + "colSkills": "技能", + "colUpdated": "更新时间", + "empty": "没有找到符合条件的命名空间", + "selectNamespace": "请选择一个命名空间查看详情", + "members": "成员数", + "skills": "技能数", + "currentRole": "当前成员角色", + "platformOverride": "平台越权", + "yes": "是", + "no": "否", + "noMembership": "非成员", + "createdAt": "创建时间", + "updatedAt": "更新时间", + "membersTitle": "成员管理", + "membersDescription": "后台管理员可以管理团队命名空间的成员与角色;全局空间保持只读。", + "governanceTitle": "治理操作", + "governanceDescription": "冻结、归档和恢复会影响发布与普通业务可见性,操作会记录审计日志。", + "reasonLabel": "操作原因", + "reasonPlaceholder": "说明为什么需要执行该治理动作,便于后续审计追溯。", + "memberRoleUpdated": "成员角色已更新", + "freezeAction": "确认冻结", + "unfreezeAction": "确认解冻", + "archiveAction": "确认归档", + "restoreAction": "确认恢复", + "freezeConfirmTitle": "确认冻结命名空间", + "freezeConfirmDescription": "冻结后“{{name}}”会进入只读状态,成员不能继续发布新技能版本。", + "unfreezeConfirmTitle": "确认解冻命名空间", + "unfreezeConfirmDescription": "解冻后“{{name}}”会恢复正常发布和成员管理能力。", + "archiveConfirmTitle": "确认归档命名空间", + "archiveConfirmDescription": "归档后“{{name}}”将从普通业务入口隐藏,仅后台可见并可恢复。", + "restoreConfirmTitle": "确认恢复命名空间", + "restoreConfirmDescription": "恢复后“{{name}}”会重新回到正常业务入口。", + "freezeSuccessTitle": "命名空间已冻结", + "freezeSuccessDescription": "“{{name}}”已进入只读状态。", + "unfreezeSuccessTitle": "命名空间已解冻", + "unfreezeSuccessDescription": "“{{name}}”已恢复正常状态。", + "archiveSuccessTitle": "命名空间已归档", + "archiveSuccessDescription": "“{{name}}”已从普通业务入口隐藏。", + "restoreSuccessTitle": "命名空间已恢复", + "restoreSuccessDescription": "“{{name}}”已恢复正常业务可见性。", + "freezeErrorTitle": "冻结命名空间失败", + "unfreezeErrorTitle": "解冻命名空间失败", + "archiveErrorTitle": "归档命名空间失败", + "restoreErrorTitle": "恢复命名空间失败" } } diff --git a/web/src/pages/admin/namespaces.tsx b/web/src/pages/admin/namespaces.tsx new file mode 100644 index 000000000..47cd8ea72 --- /dev/null +++ b/web/src/pages/admin/namespaces.tsx @@ -0,0 +1,841 @@ +import { KeyboardEvent, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { AdminNamespace, NamespaceMember, NamespaceRole } from '@/api/types' +import { formatLocalDateTime } from '@/shared/lib/date-time' +import { toast } from '@/shared/lib/toast' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Textarea } from '@/shared/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + normalizeSelectValue, +} from '@/shared/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/shared/ui/table' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { Pagination } from '@/shared/components/pagination' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { + useAddAdminNamespaceMember, + useAdminNamespace, + useAdminNamespaceLifecycleAction, + useAdminNamespaceMemberCandidates, + useAdminNamespaceMembers, + useAdminNamespaces, + useRemoveAdminNamespaceMember, + useTransferAdminNamespaceOwnership, + useUpdateAdminNamespaceMemberRole, +} from '@/features/admin/use-admin-namespaces' + +const PAGE_SIZE = 20 +const MEMBER_PAGE_SIZE = 10 +const ALL_FILTER_VALUE = '__all__' +const EMPTY_NAMESPACES: AdminNamespace[] = [] +const EMPTY_MEMBERS: NamespaceMember[] = [] + +type PendingLifecycleAction = { + slug: string + name: string + action: 'freeze' | 'unfreeze' | 'archive' | 'restore' +} + +type PendingRemoval = { + slug: string + userId: string +} + +type AddMemberDialogState = { + open: boolean + slug: string + userId: string + role: Exclude | string + search: string +} + +type TransferDialogState = { + open: boolean + slug: string + selectedUserId: string + confirmSlug: string +} + +function statusLabel(t: (key: string) => string, status: string) { + if (status === 'FROZEN') return t('namespaceStatus.frozen') + if (status === 'ARCHIVED') return t('namespaceStatus.archived') + return t('namespaceStatus.active') +} + +function statusClassName(status: string) { + if (status === 'FROZEN') return 'bg-amber-500/10 text-amber-500 border-amber-500/20' + if (status === 'ARCHIVED') return 'bg-slate-500/10 text-slate-500 border-slate-500/20' + return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' +} + +function namespaceTypeLabel(t: (key: string) => string, type: string) { + return type === 'GLOBAL' ? t('adminNamespaces.typeGlobal') : t('adminNamespaces.typeTeam') +} + +function displayRole(t: (key: string) => string, role?: string) { + if (!role) return t('adminNamespaces.noMembership') + if (role === 'OWNER') return t('members.roleOwner') + if (role === 'ADMIN') return t('members.roleAdmin') + return t('members.roleMember') +} + +export function AdminNamespacesPage() { + const { t, i18n } = useTranslation() + const [keywordInput, setKeywordInput] = useState('') + const [keyword, setKeyword] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [typeFilter, setTypeFilter] = useState('') + const [page, setPage] = useState(0) + const [selectedSlug, setSelectedSlug] = useState(null) + const [memberPage, setMemberPage] = useState(0) + const [draftRoles, setDraftRoles] = useState>({}) + const [pendingLifecycleAction, setPendingLifecycleAction] = useState(null) + const [pendingRemoval, setPendingRemoval] = useState(null) + const [lifecycleReason, setLifecycleReason] = useState('') + const [addMemberDialog, setAddMemberDialog] = useState({ + open: false, + slug: '', + userId: '', + role: 'MEMBER', + search: '', + }) + const [transferDialog, setTransferDialog] = useState({ + open: false, + slug: '', + selectedUserId: '', + confirmSlug: '', + }) + + const namespacesQuery = useAdminNamespaces({ + keyword, + status: statusFilter || undefined, + type: typeFilter || undefined, + page, + size: PAGE_SIZE, + }) + const selectedNamespaceQuery = useAdminNamespace(selectedSlug ?? '') + const membersQuery = useAdminNamespaceMembers(selectedSlug ?? '', memberPage, MEMBER_PAGE_SIZE) + const candidatesQuery = useAdminNamespaceMemberCandidates( + addMemberDialog.slug, + addMemberDialog.search, + addMemberDialog.open && addMemberDialog.search.trim().length >= 2, + ) + + const lifecycleMutation = useAdminNamespaceLifecycleAction() + const addMemberMutation = useAddAdminNamespaceMember() + const updateRoleMutation = useUpdateAdminNamespaceMemberRole() + const removeMemberMutation = useRemoveAdminNamespaceMember() + const transferMutation = useTransferAdminNamespaceOwnership() + + const namespaces = namespacesQuery.data?.items ?? EMPTY_NAMESPACES + const selectedNamespace = selectedNamespaceQuery.data ?? namespaces.find((item) => item.slug === selectedSlug) ?? null + const members = membersQuery.data?.items ?? EMPTY_MEMBERS + const totalMembers = membersQuery.data?.total ?? 0 + const totalPages = Math.max(1, Math.ceil((namespacesQuery.data?.total ?? 0) / PAGE_SIZE)) + const memberTotalPages = Math.max(1, Math.ceil(totalMembers / MEMBER_PAGE_SIZE)) + + useEffect(() => { + setPage(0) + }, [keyword, statusFilter, typeFilter]) + + useEffect(() => { + if (!selectedSlug && namespaces.length > 0) { + setSelectedSlug(namespaces[0].slug) + } + }, [selectedSlug, namespaces]) + + useEffect(() => { + setMemberPage(0) + setDraftRoles({}) + }, [selectedSlug]) + + const stats = namespacesQuery.data?.stats + const transferCandidates = useMemo( + () => members.filter((member) => member.role !== 'OWNER'), + [members], + ) + + const applySearch = () => { + setKeyword(keywordInput.trim()) + } + + const clearSearch = () => { + setKeywordInput('') + setKeyword('') + } + + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + applySearch() + } + } + + const openAddMemberDialog = (namespace: AdminNamespace) => { + setAddMemberDialog({ + open: true, + slug: namespace.slug, + userId: '', + role: 'MEMBER', + search: '', + }) + } + + const closeAddMemberDialog = () => { + setAddMemberDialog({ + open: false, + slug: '', + userId: '', + role: 'MEMBER', + search: '', + }) + } + + const openTransferDialog = (namespace: AdminNamespace) => { + setTransferDialog({ + open: true, + slug: namespace.slug, + selectedUserId: '', + confirmSlug: '', + }) + } + + const closeTransferDialog = () => { + setTransferDialog({ + open: false, + slug: '', + selectedUserId: '', + confirmSlug: '', + }) + } + + const updateDraftRole = (userId: string, role: string) => { + setDraftRoles((current) => ({ ...current, [userId]: role })) + } + + const saveMemberRole = async (member: NamespaceMember) => { + if (!selectedSlug) return + const nextRole = draftRoles[member.userId] ?? member.role + if (nextRole === member.role) return + try { + await updateRoleMutation.mutateAsync({ slug: selectedSlug, userId: member.userId, role: nextRole }) + setDraftRoles((current) => { + const next = { ...current } + delete next[member.userId] + return next + }) + toast.success(t('adminNamespaces.memberRoleUpdated'), t('members.updateRoleSuccessDescription', { userId: member.userId, role: nextRole })) + } catch (error) { + toast.error(t('members.updateRoleErrorTitle'), error instanceof Error ? error.message : '') + } + } + + const addMember = async () => { + if (!addMemberDialog.slug || !addMemberDialog.userId.trim()) { + toast.error(t('members.userIdRequired')) + return + } + try { + await addMemberMutation.mutateAsync({ + slug: addMemberDialog.slug, + userId: addMemberDialog.userId, + role: addMemberDialog.role, + }) + toast.success(t('members.addSuccessTitle'), t('members.addSuccessDescription', { userId: addMemberDialog.userId })) + closeAddMemberDialog() + } catch (error) { + toast.error(t('members.addErrorTitle'), error instanceof Error ? error.message : '') + } + } + + const removeMember = async () => { + if (!pendingRemoval) return + try { + await removeMemberMutation.mutateAsync(pendingRemoval) + toast.success(t('members.removeSuccessTitle'), t('members.removeSuccessDescription', { userId: pendingRemoval.userId })) + setPendingRemoval(null) + } catch (error) { + toast.error(t('members.removeErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + + const transferOwnership = async () => { + if (!transferDialog.slug || !transferDialog.selectedUserId) return + try { + await transferMutation.mutateAsync({ + slug: transferDialog.slug, + newOwnerUserId: transferDialog.selectedUserId, + }) + toast.success(t('members.transferSuccessTitle'), t('members.transferSuccessDescription', { userId: transferDialog.selectedUserId })) + closeTransferDialog() + } catch (error) { + toast.error(t('members.transferErrorTitle'), error instanceof Error ? error.message : '') + } + } + + const runLifecycleAction = async () => { + if (!pendingLifecycleAction) return + try { + await lifecycleMutation.mutateAsync({ + slug: pendingLifecycleAction.slug, + action: pendingLifecycleAction.action, + reason: lifecycleReason, + }) + toast.success( + t(`adminNamespaces.${pendingLifecycleAction.action}SuccessTitle`), + t(`adminNamespaces.${pendingLifecycleAction.action}SuccessDescription`, { name: pendingLifecycleAction.name }), + ) + setPendingLifecycleAction(null) + setLifecycleReason('') + } catch (error) { + toast.error(t(`adminNamespaces.${pendingLifecycleAction.action}ErrorTitle`), error instanceof Error ? error.message : '') + throw error + } + } + + return ( +
+
+

{t('adminNamespaces.title')}

+

{t('adminNamespaces.subtitle')}

+
+ +
+ + + + +
+ + +
+
+ +
+ setKeywordInput(event.target.value)} + onKeyDown={handleSearchKeyDown} + /> + + +
+
+
+ + +
+
+ + +
+
+
+ +
+ + {namespacesQuery.isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ ) : namespaces.length === 0 ? ( +
{t('adminNamespaces.empty')}
+ ) : ( + <> + + + + {t('adminNamespaces.colNamespace')} + {t('adminNamespaces.colType')} + {t('adminNamespaces.colStatus')} + {t('adminNamespaces.colMembers')} + {t('adminNamespaces.colSkills')} + {t('adminNamespaces.colUpdated')} + + + + {namespaces.map((namespace) => ( + setSelectedSlug(namespace.slug)} + > + +
{namespace.displayName}
+
@{namespace.slug}
+
+ {namespaceTypeLabel(t, namespace.type)} + + + {statusLabel(t, namespace.status)} + + + {namespace.stats.memberCount} + {namespace.stats.skillCount} + + {formatLocalDateTime(namespace.updatedAt, i18n.language, { dateStyle: 'medium' })} + +
+ ))} +
+
+ {totalPages > 1 ? : null} + + )} + + + + {!selectedNamespace ? ( +
{t('adminNamespaces.selectNamespace')}
+ ) : ( +
+ +
+ + + + +
+ +
+
+
+

{t('adminNamespaces.membersTitle')}

+

{t('adminNamespaces.membersDescription')}

+
+
+ + +
+
+ + setPendingRemoval({ slug: selectedNamespace.slug, userId: member.userId })} + t={t} + i18nLanguage={i18n.language} + /> + {memberTotalPages > 1 ? : null} +
+ +
+
+

{t('adminNamespaces.governanceTitle')}

+

{t('adminNamespaces.governanceDescription')}

+
+
+ + + + +
+
+
+ )} +
+
+ + (open ? setAddMemberDialog((current) => ({ ...current, open })) : closeAddMemberDialog())}> + + + {t('members.addDialogTitle')} + {t('members.addDialogDescription')} + +
+
+ + setAddMemberDialog((current) => ({ ...current, search: event.target.value }))} + /> + {candidatesQuery.data && candidatesQuery.data.length > 0 ? ( +
+ {candidatesQuery.data.map((candidate) => ( + + ))} +
+ ) : null} +
+
+ + setAddMemberDialog((current) => ({ ...current, userId: event.target.value }))} + /> +
+
+ + +
+
+ + + + +
+
+ + (open ? setTransferDialog((current) => ({ ...current, open })) : closeTransferDialog())}> + + + {t('members.transferDialogTitle')} + {t('members.transferDialogDescription')} + +
+
+ {t('members.transferWarning')} +
+
+ + +
+
+ + setTransferDialog((current) => ({ ...current, confirmSlug: event.target.value }))} + /> +
+
+ + + + +
+
+ + { + if (!open) setPendingRemoval(null) + }} + title={t('members.removeConfirmTitle')} + description={pendingRemoval ? t('members.removeConfirmDescription', { userId: pendingRemoval.userId }) : ''} + confirmText={t('members.remove')} + variant="destructive" + onConfirm={removeMember} + /> + + { + if (!open) { + setPendingLifecycleAction(null) + setLifecycleReason('') + } + }} + > + + + {pendingLifecycleAction ? t(`adminNamespaces.${pendingLifecycleAction.action}ConfirmTitle`) : ''} + + {pendingLifecycleAction ? t(`adminNamespaces.${pendingLifecycleAction.action}ConfirmDescription`, { name: pendingLifecycleAction.name }) : ''} + + + {pendingLifecycleAction?.action === 'freeze' || pendingLifecycleAction?.action === 'archive' ? ( +
+ +