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();
|
.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) {
|
private String normalizeSearch(String search) {
|
||||||
if (!StringUtils.hasText(search)) {
|
if (!StringUtils.hasText(search)) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -93,12 +93,6 @@ public class NamespacePortalQueryAppService {
|
||||||
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles,
|
public List<MyNamespaceResponse> listMyNamespaces(Map<Long, NamespaceRole> userNamespaceRoles,
|
||||||
Set<String> platformRoles) {
|
Set<String> platformRoles) {
|
||||||
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
|
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()) {
|
if (namespaceRoles.isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,10 +96,7 @@ class NamespacePortalControllerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void listMyNamespaces_allowsSuperAdminToSeeAllNamespacesWithoutMembership() throws Exception {
|
void listMyNamespaces_doesNotTreatSuperAdminOverrideAsNamespaceMembership() 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));
|
|
||||||
given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of());
|
given(namespaceMemberRepository.findByUserId("super-1")).willReturn(List.of());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/me/namespaces")
|
mockMvc.perform(get("/api/v1/me/namespaces")
|
||||||
|
|
@ -107,10 +104,7 @@ class NamespacePortalControllerTest {
|
||||||
.requestAttr("userId", "super-1"))
|
.requestAttr("userId", "super-1"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.code").value(0))
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
.andExpect(jsonPath("$.data[0].slug").value("team-a"))
|
.andExpect(jsonPath("$.data").isEmpty());
|
||||||
.andExpect(jsonPath("$.data[0].currentUserRole").doesNotExist())
|
|
||||||
.andExpect(jsonPath("$.data[1].slug").value("team-b"))
|
|
||||||
.andExpect(jsonPath("$.data[1].status").value("ARCHIVED"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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.anyList;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
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 static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||||
|
|
@ -125,23 +127,11 @@ class NamespacePortalQueryAppServiceTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void listMyNamespaces_superAdminSeesAllNamespacesWithoutSyntheticOwnerActions() {
|
void listMyNamespaces_superAdminWithoutMembershipSeesEmptyList() {
|
||||||
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);
|
|
||||||
|
|
||||||
var response = service.listMyNamespaces(Map.of(), Set.of("SUPER_ADMIN"));
|
var response = service.listMyNamespaces(Map.of(), Set.of("SUPER_ADMIN"));
|
||||||
|
|
||||||
assertThat(response).hasSize(2);
|
assertThat(response).isEmpty();
|
||||||
assertThat(response.get(0).slug()).isEqualTo("active-team");
|
verify(namespaceRepository, never()).findAll();
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,23 @@ public class NamespaceGovernanceService {
|
||||||
return updated;
|
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
|
@Transactional
|
||||||
public Namespace unfreezeNamespace(String slug,
|
public Namespace unfreezeNamespace(String slug,
|
||||||
String actorUserId,
|
String actorUserId,
|
||||||
|
|
@ -69,6 +86,22 @@ public class NamespaceGovernanceService {
|
||||||
return updated;
|
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
|
@Transactional
|
||||||
public Namespace archiveNamespace(String slug,
|
public Namespace archiveNamespace(String slug,
|
||||||
String actorUserId,
|
String actorUserId,
|
||||||
|
|
@ -90,6 +123,23 @@ public class NamespaceGovernanceService {
|
||||||
return updated;
|
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
|
@Transactional
|
||||||
public Namespace restoreNamespace(String slug,
|
public Namespace restoreNamespace(String slug,
|
||||||
String actorUserId,
|
String actorUserId,
|
||||||
|
|
@ -110,6 +160,22 @@ public class NamespaceGovernanceService {
|
||||||
return updated;
|
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) {
|
private Namespace loadNamespaceBySlug(String slug) {
|
||||||
Namespace namespace = namespaceRepository.findBySlug(slug)
|
Namespace namespace = namespaceRepository.findBySlug(slug)
|
||||||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
|
.orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug));
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import type {
|
||||||
OAuthProvider,
|
OAuthProvider,
|
||||||
User,
|
User,
|
||||||
ManagedNamespace,
|
ManagedNamespace,
|
||||||
|
AdminNamespace,
|
||||||
|
AdminNamespaceList,
|
||||||
Namespace,
|
Namespace,
|
||||||
CreateNamespaceRequest,
|
CreateNamespaceRequest,
|
||||||
NamespaceMember,
|
NamespaceMember,
|
||||||
|
|
@ -1143,6 +1145,126 @@ export const profileApi = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const adminApi = {
|
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 }) {
|
async getUsers(params: { search?: string; status?: string; page?: number; size?: number }) {
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
if (params.search) searchParams.set('search', params.search)
|
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;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/api/v1/admin/labels/{slug}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -1604,6 +1620,118 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/admin/labels": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -3092,6 +3220,54 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/admin/audit-logs": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -3316,6 +3492,22 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/api/cli/v1/skills/{namespace}/{slug}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -3958,6 +4150,52 @@ export interface components {
|
||||||
id?: number;
|
id?: number;
|
||||||
status?: string;
|
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: {
|
AdminLabelCreateRequest: {
|
||||||
slug: string;
|
slug: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
|
|
@ -4972,6 +5210,52 @@ export interface components {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
reviewedAt?: string;
|
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: {
|
ApiResponsePageResponseAuditLogItemResponse: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
code?: number;
|
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: {
|
updateLabel: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
listLabels_2: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
listAuditLogs: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
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: {
|
deleteRemote: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,46 @@ export interface ManagedNamespace extends Namespace {
|
||||||
canDelete: boolean
|
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 {
|
export interface NamespaceMember {
|
||||||
id: number
|
id: number
|
||||||
userId: string
|
userId: string
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,11 @@ const AdminLabelsPage = createRoleProtectedRouteComponent(
|
||||||
'AdminLabelsPage',
|
'AdminLabelsPage',
|
||||||
['SUPER_ADMIN'],
|
['SUPER_ADMIN'],
|
||||||
)
|
)
|
||||||
|
const AdminNamespacesPage = createRoleProtectedRouteComponent(
|
||||||
|
() => import('@/pages/admin/namespaces'),
|
||||||
|
'AdminNamespacesPage',
|
||||||
|
['SUPER_ADMIN'],
|
||||||
|
)
|
||||||
|
|
||||||
function DefaultNotFound() {
|
function DefaultNotFound() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -445,6 +450,13 @@ const adminLabelsRoute = createRoute({
|
||||||
component: AdminLabelsPage,
|
component: AdminLabelsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const adminNamespacesRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: 'admin/namespaces',
|
||||||
|
beforeLoad: requireAuth,
|
||||||
|
component: AdminNamespacesPage,
|
||||||
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
landingRoute,
|
landingRoute,
|
||||||
skillsRoute,
|
skillsRoute,
|
||||||
|
|
@ -481,6 +493,7 @@ const routeTree = rootRoute.addChildren([
|
||||||
adminUsersRoute,
|
adminUsersRoute,
|
||||||
adminAuditLogRoute,
|
adminAuditLogRoute,
|
||||||
adminLabelsRoute,
|
adminLabelsRoute,
|
||||||
|
adminNamespacesRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({
|
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",
|
"profile": "Profile Settings",
|
||||||
"notifications": "Notification Settings",
|
"notifications": "Notification Settings",
|
||||||
"accounts": "Account Merge",
|
"accounts": "Account Merge",
|
||||||
"logout": "Logout"
|
"logout": "Logout",
|
||||||
|
"namespacesAdmin": "Namespace management"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
|
|
@ -1578,5 +1579,70 @@
|
||||||
"report": "Report Notifications",
|
"report": "Report Notifications",
|
||||||
"reportDesc": "Notify on report submission or resolution"
|
"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": "已提交审核",
|
"submitReviewSuccessTitle": "已提交审核",
|
||||||
"submitReviewSuccessDescription": "版本 {{version}} 已提交审核。",
|
"submitReviewSuccessDescription": "版本 {{version}} 已提交审核。",
|
||||||
"submitReviewErrorTitle": "提交审核失败",
|
"submitReviewErrorTitle": "提交审核失败",
|
||||||
"withdrawReviewErrorTitle": "撤销审核失败",
|
|
||||||
"deleteVersion": "删除版本",
|
"deleteVersion": "删除版本",
|
||||||
"deleteVersionConfirmTitle": "确认删除版本",
|
"deleteVersionConfirmTitle": "确认删除版本",
|
||||||
"deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?",
|
"deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?",
|
||||||
|
|
@ -1338,7 +1337,8 @@
|
||||||
"profile": "个人设置",
|
"profile": "个人设置",
|
||||||
"notifications": "通知设置",
|
"notifications": "通知设置",
|
||||||
"accounts": "账号合并",
|
"accounts": "账号合并",
|
||||||
"logout": "退出登录"
|
"logout": "退出登录",
|
||||||
|
"namespacesAdmin": "命名空间管理"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
|
|
@ -1579,5 +1579,70 @@
|
||||||
"report": "举报通知",
|
"report": "举报通知",
|
||||||
"reportDesc": "举报提交或处理完成时通知"
|
"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')}
|
{t('user.menu.labels')}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isSuperAdmin ? (
|
||||||
|
<Link to="/admin/namespaces" className={menuItemClassName} onClick={closeMenu}>
|
||||||
|
{t('user.menu.namespacesAdmin')}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
{isAuditor ? (
|
{isAuditor ? (
|
||||||
<Link to="/admin/audit-log" className={menuItemClassName} onClick={closeMenu}>
|
<Link to="/admin/audit-log" className={menuItemClassName} onClick={closeMenu}>
|
||||||
{t('user.menu.auditLog')}
|
{t('user.menu.auditLog')}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue