mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
parent
81be20a6b5
commit
d2403bb591
26 changed files with 3204 additions and 32 deletions
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminNamespaceListStatsResponse(
|
||||
long total,
|
||||
long active,
|
||||
long frozen,
|
||||
long archived
|
||||
) {}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminNamespaceStatsResponse(
|
||||
long memberCount,
|
||||
long skillCount
|
||||
) {}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
606
web/src/api/generated/schema.d.ts
vendored
606
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
122
web/src/features/admin/use-admin-namespaces.ts
Normal file
122
web/src/features/admin/use-admin-namespaces.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "恢复命名空间失败"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
841
web/src/pages/admin/namespaces.tsx
Normal file
841
web/src/pages/admin/namespaces.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue