feat(namespace): add admin namespace management
Some checks failed
Security / Dependency Review (push) Has been cancelled
Security / CodeQL (java-kotlin) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled

Closes #580
This commit is contained in:
XiaoSeS 2026-08-12 12:39:24 +08:00 committed by GitHub
parent 81be20a6b5
commit d2403bb591
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 3204 additions and 32 deletions

View file

@ -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<AdminNamespaceListResponse> 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<AdminNamespaceDetailResponse> getNamespace(@PathVariable String slug,
@AuthenticationPrincipal PlatformPrincipal principal) {
return ok("response.success.read", adminNamespaceAppService.detail(slug, principal.userId()));
}
@GetMapping("/{slug}/members")
public ApiResponse<PageResponse<MemberResponse>> 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<List<NamespaceCandidateUserResponse>> 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<MemberResponse> 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<BatchMemberResponse> 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<MemberResponse> 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<MessageResponse> 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<MessageResponse> 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<AdminNamespaceDetailResponse> 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<AdminNamespaceDetailResponse> 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<AdminNamespaceDetailResponse> 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<AdminNamespaceDetailResponse> restoreNamespace(@PathVariable String slug,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest) {
return ok("response.success.updated",
adminNamespaceAppService.restore(slug, principal.userId(), AuditRequestContext.from(httpRequest)));
}
}

View file

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

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record AdminNamespaceListResponse(
List<AdminNamespaceSummaryResponse> items,
long total,
int page,
int size,
AdminNamespaceListStatsResponse stats
) {}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record AdminNamespaceListStatsResponse(
long total,
long active,
long frozen,
long archived
) {}

View file

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

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.dto;
public record AdminNamespaceStatsResponse(
long memberCount,
long skillCount
) {}

View file

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

View file

@ -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<Namespace> search(String keyword, NamespaceStatus status, NamespaceType type, Pageable pageable);
AdminNamespaceListStatsResponse stats();
Map<Long, Long> countMembersByNamespaceId(Iterable<Long> namespaceIds);
Map<Long, Long> countSkillsByNamespaceId(Iterable<Long> namespaceIds);
}

View file

@ -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<Namespace> search(String keyword, NamespaceStatus status, NamespaceType type, Pageable pageable) {
Map<String, Object> params = new HashMap<>();
String whereClause = buildWhereClause(keyword, status, type, params);
String orderClause = " ORDER BY n.updatedAt DESC, n.slug ASC";
TypedQuery<Namespace> 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<Long> 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<Long, Long> countMembersByNamespaceId(Iterable<Long> namespaceIds) {
return countByNamespaceId("""
SELECT m.namespaceId, COUNT(m)
FROM NamespaceMember m
WHERE m.namespaceId IN :namespaceIds
GROUP BY m.namespaceId
""", namespaceIds);
}
@Override
public Map<Long, Long> countSkillsByNamespaceId(Iterable<Long> 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<String, Object> 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<Long, Long> countByNamespaceId(String jpql, Iterable<Long> namespaceIds) {
java.util.List<Long> ids = new java.util.ArrayList<>();
namespaceIds.forEach(ids::add);
if (ids.isEmpty()) {
return Map.of();
}
Map<Long, Long> 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;
}
}

View file

@ -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<Namespace> namespaces = adminNamespaceQueryRepository.search(
keyword,
parseStatus(status),
parseType(type),
pageRequest);
List<Long> namespaceIds = namespaces.getContent().stream().map(Namespace::getId).toList();
Map<Long, Long> memberCounts = adminNamespaceQueryRepository.countMembersByNamespaceId(namespaceIds);
Map<Long, Long> skillCounts = adminNamespaceQueryRepository.countSkillsByNamespaceId(namespaceIds);
Map<Long, NamespaceRole> roles = loadRoles(namespaceIds, actorUserId);
List<AdminNamespaceSummaryResponse> 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<Long, Long> memberCounts = adminNamespaceQueryRepository.countMembersByNamespaceId(List.of(namespace.getId()));
Map<Long, Long> 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<MemberResponse> listMembers(String slug, int page, int size) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
Page<NamespaceMember> members = namespaceMemberService.listMembers(
namespace.getId(),
PageRequest.of(Math.max(page, 0), normalizePageSize(size)));
List<String> memberUserIds = members.getContent().stream()
.map(NamespaceMember::getUserId)
.toList();
Map<String, UserAccount> 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<NamespaceCandidateUserResponse> 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<BatchMemberResult> 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<Long, Long> memberCounts,
Map<Long, Long> 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<Long, NamespaceRole> loadRoles(List<Long> 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";
}
}

View file

@ -72,6 +72,37 @@ public class NamespaceMemberCandidateService {
.toList();
}
@Transactional(readOnly = true)
public List<NamespaceCandidateUserResponse> 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<NamespaceCandidateUserResponse> findCandidates(Namespace namespace, String search, int size) {
String keyword = normalizeSearch(search);
if (keyword == null) {
return List.of();
}
int pageSize = normalizeSize(size);
Set<String> 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;

View file

@ -93,12 +93,6 @@ public class NamespacePortalQueryAppService {
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
Map<Long, NamespaceRole> 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();
}

View file

@ -96,10 +96,7 @@ class NamespacePortalControllerTest {
}
@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 @@ class NamespacePortalControllerTest {
.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

View file

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

View file

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

View file

@ -6,6 +6,8 @@ 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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.namespace.Namespace;
@ -125,23 +127,11 @@ class NamespacePortalQueryAppServiceTest {
}
@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

View file

@ -49,6 +49,23 @@ public class NamespaceGovernanceService {
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 class NamespaceGovernanceService {
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 class NamespaceGovernanceService {
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 class NamespaceGovernanceService {
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));

View file

@ -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<AdminNamespaceList> {
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<AdminNamespaceList>(`/api/v1/admin/namespaces?${searchParams.toString()}`)
},
async getNamespace(slug: string): Promise<AdminNamespace> {
return fetchJson<AdminNamespace>(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}`)
},
async getNamespaceMembers(slug: string, params?: { page?: number; size?: number }): Promise<PagedResponse<NamespaceMember>> {
const searchParams = new URLSearchParams()
searchParams.set('page', String(params?.page ?? 0))
searchParams.set('size', String(params?.size ?? 20))
return fetchJson<PagedResponse<NamespaceMember>>(
`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/members?${searchParams.toString()}`,
)
},
async searchNamespaceMemberCandidates(slug: string, search: string, size = 10): Promise<NamespaceCandidateUser[]> {
const searchParams = new URLSearchParams({
search: search.trim(),
size: String(size),
})
return fetchJson<NamespaceCandidateUser[]>(
`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/member-candidates?${searchParams.toString()}`,
)
},
async addNamespaceMember(slug: string, request: { userId: string; role: string }): Promise<NamespaceMember> {
return fetchJson<NamespaceMember>(`/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<BatchMemberResponse> {
return fetchJson<BatchMemberResponse>(`/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<NamespaceMember> {
return fetchJson<NamespaceMember>(
`/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<void> {
await fetchJson<void>(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders(),
})
},
async transferNamespaceOwnership(slug: string, newOwnerUserId: string): Promise<void> {
await fetchJson<void>(`/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<AdminNamespace> {
return fetchJson<AdminNamespace>(`/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<AdminNamespace> {
return fetchJson<AdminNamespace>(`/api/v1/admin/namespaces/${normalizeNamespaceSlug(slug)}/unfreeze`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async archiveNamespace(slug: string, reason?: string): Promise<AdminNamespace> {
return fetchJson<AdminNamespace>(`/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<AdminNamespace> {
return fetchJson<AdminNamespace>(`/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)

View file

@ -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,6 +6392,33 @@ export interface operations {
};
};
};
updateMemberRole_2: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
userId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateMemberRoleRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseMemberResponse"];
};
};
};
};
updateLabel: {
parameters: {
query?: never;
@ -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;

View file

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

View file

@ -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({

View file

@ -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<AdminNamespaceList>({
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<PagedResponse<NamespaceMember>>({
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<NamespaceCandidateUser[]>({
queryKey: ['admin', 'namespaces', slug, 'member-candidates', search],
queryFn: () => adminApi.searchNamespaceMemberCandidates(slug, search),
enabled: enabled && !!slug && search.trim().length >= 2,
})
}
function invalidateAdminNamespaceQueries(queryClient: ReturnType<typeof useQueryClient>, 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<BatchMemberResponse, Error, { slug: string; members: Array<{ userId: string; role: string }> }>({
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),
})
}

View file

@ -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"
}
}

View file

@ -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": "恢复命名空间失败"
}
}

View file

@ -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<NamespaceRole, 'OWNER'> | 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<string | null>(null)
const [memberPage, setMemberPage] = useState(0)
const [draftRoles, setDraftRoles] = useState<Record<string, string>>({})
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction | null>(null)
const [pendingRemoval, setPendingRemoval] = useState<PendingRemoval | null>(null)
const [lifecycleReason, setLifecycleReason] = useState('')
const [addMemberDialog, setAddMemberDialog] = useState<AddMemberDialogState>({
open: false,
slug: '',
userId: '',
role: 'MEMBER',
search: '',
})
const [transferDialog, setTransferDialog] = useState<TransferDialogState>({
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<HTMLInputElement>) => {
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 (
<div className="space-y-8 animate-fade-up">
<div>
<h1 className="text-4xl font-bold font-heading mb-2">{t('adminNamespaces.title')}</h1>
<p className="text-muted-foreground text-lg">{t('adminNamespaces.subtitle')}</p>
</div>
<div className="grid gap-4 md:grid-cols-4">
<StatCard label={t('adminNamespaces.statTotal')} value={stats?.total ?? 0} />
<StatCard label={t('adminNamespaces.statActive')} value={stats?.active ?? 0} tone="success" />
<StatCard label={t('adminNamespaces.statFrozen')} value={stats?.frozen ?? 0} tone="warning" />
<StatCard label={t('adminNamespaces.statArchived')} value={stats?.archived ?? 0} tone="muted" />
</div>
<Card className="p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_180px_180px]">
<div className="space-y-2">
<Label htmlFor="admin-namespace-search">{t('adminNamespaces.searchLabel')}</Label>
<div className="flex gap-2">
<Input
id="admin-namespace-search"
placeholder={t('adminNamespaces.searchPlaceholder')}
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleSearchKeyDown}
/>
<Button type="button" onClick={applySearch}>{t('adminNamespaces.searchAction')}</Button>
<Button type="button" variant="outline" onClick={clearSearch} disabled={!keywordInput && !keyword}>
{t('adminNamespaces.clearSearch')}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('adminNamespaces.statusFilter')}</Label>
<Select
value={normalizeSelectValue(statusFilter) ?? ALL_FILTER_VALUE}
onValueChange={(value) => setStatusFilter(value === ALL_FILTER_VALUE ? '' : value)}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>{t('adminNamespaces.filterAll')}</SelectItem>
<SelectItem value="ACTIVE">{t('namespaceStatus.active')}</SelectItem>
<SelectItem value="FROZEN">{t('namespaceStatus.frozen')}</SelectItem>
<SelectItem value="ARCHIVED">{t('namespaceStatus.archived')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('adminNamespaces.typeFilter')}</Label>
<Select
value={normalizeSelectValue(typeFilter) ?? ALL_FILTER_VALUE}
onValueChange={(value) => setTypeFilter(value === ALL_FILTER_VALUE ? '' : value)}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>{t('adminNamespaces.filterAll')}</SelectItem>
<SelectItem value="GLOBAL">{t('adminNamespaces.typeGlobal')}</SelectItem>
<SelectItem value="TEAM">{t('adminNamespaces.typeTeam')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(26rem,0.9fr)]">
<Card className="overflow-hidden">
{namespacesQuery.isLoading ? (
<div className="space-y-3 p-5">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="h-14 animate-shimmer rounded-lg" />
))}
</div>
) : namespaces.length === 0 ? (
<div className="p-12 text-center text-muted-foreground">{t('adminNamespaces.empty')}</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('adminNamespaces.colNamespace')}</TableHead>
<TableHead>{t('adminNamespaces.colType')}</TableHead>
<TableHead>{t('adminNamespaces.colStatus')}</TableHead>
<TableHead>{t('adminNamespaces.colMembers')}</TableHead>
<TableHead>{t('adminNamespaces.colSkills')}</TableHead>
<TableHead>{t('adminNamespaces.colUpdated')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{namespaces.map((namespace) => (
<TableRow
key={namespace.slug}
className={selectedSlug === namespace.slug ? 'bg-secondary/50' : undefined}
onClick={() => setSelectedSlug(namespace.slug)}
>
<TableCell>
<div className="font-medium">{namespace.displayName}</div>
<div className="font-mono text-xs text-muted-foreground">@{namespace.slug}</div>
</TableCell>
<TableCell>{namespaceTypeLabel(t, namespace.type)}</TableCell>
<TableCell>
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-medium ${statusClassName(namespace.status)}`}>
{statusLabel(t, namespace.status)}
</span>
</TableCell>
<TableCell>{namespace.stats.memberCount}</TableCell>
<TableCell>{namespace.stats.skillCount}</TableCell>
<TableCell className="text-muted-foreground">
{formatLocalDateTime(namespace.updatedAt, i18n.language, { dateStyle: 'medium' })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{totalPages > 1 ? <Pagination page={page} totalPages={totalPages} onPageChange={setPage} /> : null}
</>
)}
</Card>
<Card className="p-5">
{!selectedNamespace ? (
<div className="py-20 text-center text-muted-foreground">{t('adminNamespaces.selectNamespace')}</div>
) : (
<div className="space-y-6">
<NamespaceDetailHeader namespace={selectedNamespace} t={t} i18nLanguage={i18n.language} />
<div className="grid grid-cols-2 gap-3">
<SmallFact label={t('adminNamespaces.members')} value={selectedNamespace.stats.memberCount} />
<SmallFact label={t('adminNamespaces.skills')} value={selectedNamespace.stats.skillCount} />
<SmallFact label={t('adminNamespaces.currentRole')} value={displayRole(t, selectedNamespace.permissions.currentUserRole)} />
<SmallFact label={t('adminNamespaces.platformOverride')} value={selectedNamespace.permissions.platformOverride ? t('adminNamespaces.yes') : t('adminNamespaces.no')} />
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-heading text-lg font-semibold">{t('adminNamespaces.membersTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('adminNamespaces.membersDescription')}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!selectedNamespace.permissions.canTransferOwnership}
onClick={() => openTransferDialog(selectedNamespace)}
>
{t('members.transferOwnership')}
</Button>
<Button
size="sm"
disabled={!selectedNamespace.permissions.canManageMembers}
onClick={() => openAddMemberDialog(selectedNamespace)}
>
{t('members.addMember')}
</Button>
</div>
</div>
<MembersTable
members={members}
canManageMembers={selectedNamespace.permissions.canManageMembers}
draftRoles={draftRoles}
onDraftRoleChange={updateDraftRole}
onSaveRole={saveMemberRole}
onRemove={(member) => setPendingRemoval({ slug: selectedNamespace.slug, userId: member.userId })}
t={t}
i18nLanguage={i18n.language}
/>
{memberTotalPages > 1 ? <Pagination page={memberPage} totalPages={memberTotalPages} onPageChange={setMemberPage} /> : null}
</div>
<div className="space-y-3 rounded-xl border border-destructive/20 bg-destructive/5 p-4">
<div>
<h2 className="font-heading text-lg font-semibold">{t('adminNamespaces.governanceTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('adminNamespaces.governanceDescription')}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!selectedNamespace.permissions.canFreeze}
onClick={() => setPendingLifecycleAction({ slug: selectedNamespace.slug, name: selectedNamespace.displayName, action: 'freeze' })}
>
{t('myNamespaces.freeze')}
</Button>
<Button
variant="outline"
size="sm"
disabled={!selectedNamespace.permissions.canUnfreeze}
onClick={() => setPendingLifecycleAction({ slug: selectedNamespace.slug, name: selectedNamespace.displayName, action: 'unfreeze' })}
>
{t('myNamespaces.unfreeze')}
</Button>
<Button
variant="destructive"
size="sm"
disabled={!selectedNamespace.permissions.canArchive}
onClick={() => setPendingLifecycleAction({ slug: selectedNamespace.slug, name: selectedNamespace.displayName, action: 'archive' })}
>
{t('myNamespaces.archive')}
</Button>
<Button
variant="outline"
size="sm"
disabled={!selectedNamespace.permissions.canRestore}
onClick={() => setPendingLifecycleAction({ slug: selectedNamespace.slug, name: selectedNamespace.displayName, action: 'restore' })}
>
{t('myNamespaces.restore')}
</Button>
</div>
</div>
</div>
)}
</Card>
</div>
<Dialog open={addMemberDialog.open} onOpenChange={(open) => (open ? setAddMemberDialog((current) => ({ ...current, open })) : closeAddMemberDialog())}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('members.addDialogTitle')}</DialogTitle>
<DialogDescription>{t('members.addDialogDescription')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('members.searchLabel')}</Label>
<Input
value={addMemberDialog.search}
placeholder={t('members.searchPlaceholder')}
onChange={(event) => setAddMemberDialog((current) => ({ ...current, search: event.target.value }))}
/>
{candidatesQuery.data && candidatesQuery.data.length > 0 ? (
<div className="max-h-44 space-y-2 overflow-y-auto rounded-lg border border-border/60 p-2">
{candidatesQuery.data.map((candidate) => (
<button
key={candidate.userId}
type="button"
className="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-secondary"
onClick={() => setAddMemberDialog((current) => ({ ...current, userId: candidate.userId }))}
>
<div className="font-medium">{candidate.displayName || candidate.userId}</div>
<div className="text-xs text-muted-foreground">{candidate.email || candidate.userId}</div>
</button>
))}
</div>
) : null}
</div>
<div className="space-y-2">
<Label>{t('members.manualUserIdLabel')}</Label>
<Input
value={addMemberDialog.userId}
placeholder={t('members.manualUserIdPlaceholder')}
onChange={(event) => setAddMemberDialog((current) => ({ ...current, userId: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>{t('members.roleLabel')}</Label>
<Select value={addMemberDialog.role} onValueChange={(role) => setAddMemberDialog((current) => ({ ...current, role }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">{t('members.roleMember')}</SelectItem>
<SelectItem value="ADMIN">{t('members.roleAdmin')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeAddMemberDialog}>{t('dialog.cancel')}</Button>
<Button type="button" disabled={addMemberMutation.isPending} onClick={addMember}>{t('members.addMember')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={transferDialog.open} onOpenChange={(open) => (open ? setTransferDialog((current) => ({ ...current, open })) : closeTransferDialog())}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('members.transferDialogTitle')}</DialogTitle>
<DialogDescription>{t('members.transferDialogDescription')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400">
{t('members.transferWarning')}
</div>
<div className="space-y-2">
<Label>{t('members.transferNewOwnerLabel')}</Label>
<Select
value={transferDialog.selectedUserId}
onValueChange={(selectedUserId) => setTransferDialog((current) => ({ ...current, selectedUserId }))}
>
<SelectTrigger><SelectValue placeholder={t('members.transferSelectPlaceholder')} /></SelectTrigger>
<SelectContent>
{transferCandidates.map((candidate) => (
<SelectItem key={candidate.userId} value={candidate.userId}>
{candidate.displayName || candidate.userId} ({candidate.role})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('members.transferConfirmSlugPrompt')}</Label>
<Input
value={transferDialog.confirmSlug}
placeholder={transferDialog.slug}
onChange={(event) => setTransferDialog((current) => ({ ...current, confirmSlug: event.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeTransferDialog}>{t('dialog.cancel')}</Button>
<Button
type="button"
variant="destructive"
disabled={!transferDialog.selectedUserId || transferDialog.confirmSlug !== transferDialog.slug || transferMutation.isPending}
onClick={transferOwnership}
>
{t('members.transferConfirmAction')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={!!pendingRemoval}
onOpenChange={(open) => {
if (!open) setPendingRemoval(null)
}}
title={t('members.removeConfirmTitle')}
description={pendingRemoval ? t('members.removeConfirmDescription', { userId: pendingRemoval.userId }) : ''}
confirmText={t('members.remove')}
variant="destructive"
onConfirm={removeMember}
/>
<Dialog
open={!!pendingLifecycleAction}
onOpenChange={(open) => {
if (!open) {
setPendingLifecycleAction(null)
setLifecycleReason('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{pendingLifecycleAction ? t(`adminNamespaces.${pendingLifecycleAction.action}ConfirmTitle`) : ''}</DialogTitle>
<DialogDescription>
{pendingLifecycleAction ? t(`adminNamespaces.${pendingLifecycleAction.action}ConfirmDescription`, { name: pendingLifecycleAction.name }) : ''}
</DialogDescription>
</DialogHeader>
{pendingLifecycleAction?.action === 'freeze' || pendingLifecycleAction?.action === 'archive' ? (
<div className="space-y-2">
<Label>{t('adminNamespaces.reasonLabel')}</Label>
<Textarea
value={lifecycleReason}
placeholder={t('adminNamespaces.reasonPlaceholder')}
onChange={(event) => setLifecycleReason(event.target.value)}
/>
</div>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPendingLifecycleAction(null)}>{t('dialog.cancel')}</Button>
<Button
type="button"
variant={pendingLifecycleAction?.action === 'archive' ? 'destructive' : 'default'}
disabled={lifecycleMutation.isPending}
onClick={runLifecycleAction}
>
{pendingLifecycleAction ? t(`adminNamespaces.${pendingLifecycleAction.action}Action`) : t('dialog.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function StatCard({ label, value, tone = 'default' }: { label: string; value: number; tone?: 'default' | 'success' | 'warning' | 'muted' }) {
const toneClassName = tone === 'success'
? 'text-emerald-500'
: tone === 'warning'
? 'text-amber-500'
: tone === 'muted'
? 'text-muted-foreground'
: 'text-foreground'
return (
<Card className="p-5">
<div className="text-sm text-muted-foreground">{label}</div>
<div className={`mt-2 text-3xl font-bold ${toneClassName}`}>{value}</div>
</Card>
)
}
function SmallFact({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-border/50 bg-secondary/30 p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-sm font-semibold">{value}</div>
</div>
)
}
function NamespaceDetailHeader({
namespace,
t,
i18nLanguage,
}: {
namespace: AdminNamespace
t: (key: string, options?: Record<string, unknown>) => string
i18nLanguage: string
}) {
return (
<div className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-heading text-2xl font-semibold">{namespace.displayName}</h2>
<p className="font-mono text-sm text-muted-foreground">@{namespace.slug}</p>
</div>
<div className="flex gap-2">
<span className="inline-flex rounded-full border border-border/50 px-2.5 py-1 text-xs font-medium">
{namespaceTypeLabel(t, namespace.type)}
</span>
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-medium ${statusClassName(namespace.status)}`}>
{statusLabel(t, namespace.status)}
</span>
</div>
</div>
{namespace.description ? (
<p className="text-sm text-muted-foreground">{namespace.description}</p>
) : null}
<div className="text-xs text-muted-foreground">
{t('adminNamespaces.createdAt')}: {formatLocalDateTime(namespace.createdAt, i18nLanguage)} · {t('adminNamespaces.updatedAt')}: {formatLocalDateTime(namespace.updatedAt, i18nLanguage)}
</div>
</div>
)
}
function MembersTable({
members,
canManageMembers,
draftRoles,
onDraftRoleChange,
onSaveRole,
onRemove,
t,
i18nLanguage,
}: {
members: NamespaceMember[]
canManageMembers: boolean
draftRoles: Record<string, string>
onDraftRoleChange: (userId: string, role: string) => void
onSaveRole: (member: NamespaceMember) => void
onRemove: (member: NamespaceMember) => void
t: (key: string, options?: Record<string, unknown>) => string
i18nLanguage: string
}) {
if (members.length === 0) {
return <div className="rounded-lg border border-border/60 p-6 text-center text-sm text-muted-foreground">{t('members.empty')}</div>
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('members.colUsername')}</TableHead>
<TableHead>{t('members.colRole')}</TableHead>
<TableHead>{t('members.colJoinedAt')}</TableHead>
<TableHead className="text-right">{t('members.colActions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => {
const isOwner = member.role === 'OWNER'
const roleValue = draftRoles[member.userId] ?? member.role
return (
<TableRow key={member.id}>
<TableCell>
<div className="font-medium">{member.displayName || member.userId}</div>
<div className="text-xs text-muted-foreground">{member.email || member.userId}</div>
</TableCell>
<TableCell>
{canManageMembers && !isOwner ? (
<div className="flex flex-wrap items-center gap-2">
<Select value={roleValue} onValueChange={(role) => onDraftRoleChange(member.userId, role)}>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">{t('members.roleMember')}</SelectItem>
<SelectItem value="ADMIN">{t('members.roleAdmin')}</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
disabled={roleValue === member.role}
onClick={() => onSaveRole(member)}
>
{t('members.saveRole')}
</Button>
</div>
) : (
<span className="inline-flex rounded-full border border-accent/20 bg-accent/10 px-2.5 py-1 text-xs font-medium text-accent">
{displayRole(t, member.role)}
</span>
)}
</TableCell>
<TableCell className="text-muted-foreground">
{formatLocalDateTime(member.createdAt, i18nLanguage, { dateStyle: 'medium' })}
</TableCell>
<TableCell className="text-right">
<Button
type="button"
variant="destructive"
size="sm"
disabled={!canManageMembers || isOwner}
onClick={() => onRemove(member)}
>
{t('members.remove')}
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)
}

View file

@ -190,6 +190,11 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
{t('user.menu.labels')}
</Link>
) : null}
{isSuperAdmin ? (
<Link to="/admin/namespaces" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.namespacesAdmin')}
</Link>
) : null}
{isAuditor ? (
<Link to="/admin/audit-log" className={menuItemClassName} onClick={closeMenu}>
{t('user.menu.auditLog')}