Merge branch 'main' into fix/namespace-management-gaps

Resolved conflicts by keeping both sides:
- web/src/api/client.ts: preserve paginated listMembers(slug, {page,size})
  and add delete(slug) from main.
- web/src/shared/hooks/use-namespace-queries.ts: keep useUpdateNamespace
  and useTransferNamespaceOwnership from this branch, plus useDeleteNamespace
  from main.
This commit is contained in:
dongmucat 2026-05-09 15:13:44 +08:00
commit ab06c75737
43 changed files with 848 additions and 110 deletions

View file

@ -96,6 +96,14 @@ public class NamespaceController extends BaseApiController {
namespacePortalCommandAppService.updateNamespace(slug, request, userId));
}
@DeleteMapping("/namespaces/{slug}")
public ApiResponse<MessageResponse> deleteNamespace(
@PathVariable String slug,
@RequestAttribute("userId") String userId) {
return ok("response.success.deleted",
namespacePortalCommandAppService.deleteNamespace(slug, userId));
}
@PostMapping("/namespaces/{slug}/freeze")
public ApiResponse<NamespaceResponse> freezeNamespace(@PathVariable String slug,
@RequestBody(required = false) NamespaceLifecycleRequest request,

View file

@ -24,11 +24,13 @@ public record MyNamespaceResponse(
boolean canFreeze,
boolean canUnfreeze,
boolean canArchive,
boolean canRestore
boolean canRestore,
boolean canDelete
) {
public static MyNamespaceResponse from(Namespace namespace,
NamespaceRole currentUserRole,
NamespaceAccessPolicy accessPolicy) {
NamespaceAccessPolicy accessPolicy,
boolean canDelete) {
return new MyNamespaceResponse(
namespace.getId(),
namespace.getSlug(),
@ -45,7 +47,8 @@ public record MyNamespaceResponse(
accessPolicy.canFreeze(namespace, currentUserRole),
accessPolicy.canUnfreeze(namespace, currentUserRole),
accessPolicy.canArchive(namespace, currentUserRole),
accessPolicy.canRestore(namespace, currentUserRole)
accessPolicy.canRestore(namespace, currentUserRole),
canDelete
);
}
}

View file

@ -82,6 +82,13 @@ public class NamespacePortalCommandAppService {
return NamespaceResponse.from(updated);
}
@Transactional
public MessageResponse deleteNamespace(String slug, String userId) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
namespaceService.deleteNamespace(namespace.getId(), userId);
return new MessageResponse("Namespace deleted successfully");
}
@Transactional
public NamespaceResponse freezeNamespace(String slug,
NamespaceLifecycleRequest request,

View file

@ -92,7 +92,8 @@ public class NamespacePortalQueryAppService {
.map(namespace -> MyNamespaceResponse.from(
namespace,
namespaceRoles.get(namespace.getId()),
namespaceAccessPolicy))
namespaceAccessPolicy,
namespaceService.canDelete(namespace, namespaceRoles.get(namespace.getId()))))
.toList();
}

View file

@ -0,0 +1 @@
CREATE INDEX idx_promotion_request_target_namespace ON promotion_request(target_namespace_id);

View file

@ -69,7 +69,9 @@ error.namespace.id.notFound=Namespace not found: {0}
error.namespace.slug.notFound=Namespace not found: {0}
error.namespace.membership.required=Namespace membership required
error.namespace.admin.required=Namespace owner or admin role required
error.namespace.owner.required=Namespace owner role required
error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces
error.namespace.delete.hasDependencies=Namespace cannot be deleted while it still contains skills or governance records
error.namespace.member.owner.assignDirect=Cannot assign OWNER role directly
error.namespace.member.alreadyExists=User is already a namespace member
error.namespace.member.notFound=Member not found

View file

@ -69,7 +69,9 @@ error.namespace.id.notFound=未找到命名空间:{0}
error.namespace.slug.notFound=未找到命名空间:{0}
error.namespace.membership.required=需要先加入该命名空间
error.namespace.admin.required=需要命名空间管理员或所有者权限
error.namespace.owner.required=需要命名空间所有者权限
error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间
error.namespace.delete.hasDependencies=命名空间下仍有技能或治理记录,暂时不能删除
error.namespace.member.owner.assignDirect=不能直接分配 OWNER 角色
error.namespace.member.alreadyExists=用户已经是该命名空间成员
error.namespace.member.notFound=未找到命名空间成员

View file

@ -39,6 +39,7 @@ import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doThrow;
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.delete;
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;
@ -90,7 +91,8 @@ class NamespacePortalControllerTest {
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].slug").value("team-a"))
.andExpect(jsonPath("$.data[0].status").value("ARCHIVED"))
.andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER"));
.andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER"))
.andExpect(jsonPath("$.data[0].canDelete").value(false));
}
@Test
@ -144,6 +146,20 @@ class NamespacePortalControllerTest {
.andExpect(jsonPath("$.data.description").value("Updated description"));
}
@Test
void deleteNamespace_returnsSuccessMessage() throws Exception {
Namespace existing = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(existing);
mockMvc.perform(delete("/api/v1/namespaces/team-a")
.with(csrf())
.with(auth("owner-1"))
.requestAttr("userId", "owner-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.message").value("Namespace deleted successfully"));
}
@Test
void listMembers_forNonMember_returns403() throws Exception {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);

View file

@ -77,6 +77,17 @@ class NamespacePortalCommandAppServiceTest {
verify(namespaceGovernanceService).freezeNamespace("team-alpha", "owner-1", "cleanup", null, "127.0.0.1", "JUnit");
}
@Test
void deleteNamespace_delegatesToDomainService() {
Namespace namespace = namespace(7L, "team-alpha");
when(namespaceService.getNamespaceBySlug("team-alpha")).thenReturn(namespace);
MessageResponse response = service.deleteNamespace("team-alpha", "owner-1");
assertThat(response.message()).isEqualTo("Namespace deleted successfully");
verify(namespaceService).deleteNamespace(7L, "owner-1");
}
private Namespace namespace(Long id, String slug) {
Namespace namespace = new Namespace(slug, "Team Alpha", "owner-1");
ReflectionTestUtils.setField(namespace, "id", id);

View file

@ -55,11 +55,13 @@ class NamespacePortalQueryAppServiceTest {
when(namespaceAccessPolicy.canUnfreeze(alpha, NamespaceRole.OWNER)).thenReturn(false);
when(namespaceAccessPolicy.canArchive(alpha, NamespaceRole.OWNER)).thenReturn(true);
when(namespaceAccessPolicy.canRestore(alpha, NamespaceRole.OWNER)).thenReturn(false);
when(namespaceService.canDelete(alpha, NamespaceRole.OWNER)).thenReturn(true);
when(namespaceAccessPolicy.isImmutable(zeta)).thenReturn(false);
when(namespaceAccessPolicy.canFreeze(zeta, NamespaceRole.ADMIN)).thenReturn(true);
when(namespaceAccessPolicy.canUnfreeze(zeta, NamespaceRole.ADMIN)).thenReturn(false);
when(namespaceAccessPolicy.canArchive(zeta, NamespaceRole.ADMIN)).thenReturn(true);
when(namespaceAccessPolicy.canRestore(zeta, NamespaceRole.ADMIN)).thenReturn(false);
when(namespaceService.canDelete(zeta, NamespaceRole.ADMIN)).thenReturn(false);
var response = service.listMyNamespaces(Map.of(
2L, NamespaceRole.ADMIN,
@ -69,8 +71,10 @@ class NamespacePortalQueryAppServiceTest {
assertThat(response).hasSize(2);
assertThat(response.get(0).slug()).isEqualTo("alpha");
assertThat(response.get(0).currentUserRole()).isEqualTo(NamespaceRole.OWNER);
assertThat(response.get(0).canDelete()).isTrue();
assertThat(response.get(1).slug()).isEqualTo("zeta");
assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN);
assertThat(response.get(1).canDelete()).isFalse();
}
@Test

View file

@ -481,6 +481,11 @@ class ScanTaskConsumerTest {
throw unsupported();
}
@Override
public boolean existsByNamespaceId(Long namespaceId) {
return false;
}
@Override
public void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds) {
throw unsupported();

View file

@ -49,4 +49,9 @@ public class NamespaceAccessPolicy {
&& namespace.getStatus() == NamespaceStatus.ARCHIVED
&& role == NamespaceRole.OWNER;
}
public boolean canDelete(Namespace namespace, NamespaceRole role) {
return namespace.getType() == NamespaceType.TEAM
&& role == NamespaceRole.OWNER;
}
}

View file

@ -16,5 +16,6 @@ public interface NamespaceMemberRepository {
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
List<NamespaceMember> findByNamespaceIdAndRoleIn(Long namespaceId, Collection<NamespaceRole> roles);
NamespaceMember save(NamespaceMember member);
void deleteByNamespaceId(Long namespaceId);
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
}

View file

@ -15,4 +15,5 @@ public interface NamespaceRepository {
Optional<Namespace> findBySlug(String slug);
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
Namespace save(Namespace namespace);
void delete(Namespace namespace);
}

View file

@ -1,7 +1,10 @@
package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -16,13 +19,22 @@ public class NamespaceService {
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final NamespaceAccessPolicy namespaceAccessPolicy;
private final SkillRepository skillRepository;
private final ReviewTaskRepository reviewTaskRepository;
private final PromotionRequestRepository promotionRequestRepository;
public NamespaceService(NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository,
NamespaceAccessPolicy namespaceAccessPolicy) {
NamespaceAccessPolicy namespaceAccessPolicy,
SkillRepository skillRepository,
ReviewTaskRepository reviewTaskRepository,
PromotionRequestRepository promotionRequestRepository) {
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.namespaceAccessPolicy = namespaceAccessPolicy;
this.skillRepository = skillRepository;
this.reviewTaskRepository = reviewTaskRepository;
this.promotionRequestRepository = promotionRequestRepository;
}
/**
@ -102,13 +114,34 @@ public class NamespaceService {
.orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId));
}
/**
* Permanently deletes an empty team namespace after ownership checks.
*/
@Transactional
public void deleteNamespace(Long namespaceId, String operatorUserId) {
Namespace namespace = getNamespace(namespaceId);
assertNotImmutable(namespace);
NamespaceRole role = requireRole(namespaceId, operatorUserId);
if (!namespaceAccessPolicy.canDelete(namespace, role)) {
throw new DomainForbiddenException("error.namespace.owner.required");
}
assertNoDependentData(namespaceId);
namespaceMemberRepository.deleteByNamespaceId(namespaceId);
namespaceRepository.delete(namespace);
}
public boolean canDelete(Namespace namespace, NamespaceRole role) {
return namespaceAccessPolicy.canDelete(namespace, role)
&& !hasDependentData(namespace.getId());
}
/**
* Ensures the caller holds an owner or admin membership in the namespace.
*/
public void assertAdminOrOwner(Long namespaceId, String userId) {
NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
.map(NamespaceMember::getRole)
.orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required"));
NamespaceRole role = requireRole(namespaceId, userId);
if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) {
throw new DomainForbiddenException("error.namespace.admin.required");
}
@ -133,6 +166,24 @@ public class NamespaceService {
}
}
private NamespaceRole requireRole(Long namespaceId, String userId) {
return namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
.map(NamespaceMember::getRole)
.orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required"));
}
private void assertNoDependentData(Long namespaceId) {
if (hasDependentData(namespaceId)) {
throw new DomainBadRequestException("error.namespace.delete.hasDependencies");
}
}
private boolean hasDependentData(Long namespaceId) {
return skillRepository.existsByNamespaceId(namespaceId)
|| reviewTaskRepository.existsByNamespaceId(namespaceId)
|| promotionRequestRepository.existsByTargetNamespaceId(namespaceId);
}
private void assertWritable(Namespace namespace) {
if (!namespaceAccessPolicy.canMutateSettings(namespace)) {
throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug());

View file

@ -14,6 +14,7 @@ public interface PromotionRequestRepository {
Optional<PromotionRequest> findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status);
Optional<PromotionRequest> findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status);
Page<PromotionRequest> findByStatus(ReviewTaskStatus status, Pageable pageable);
boolean existsByTargetNamespaceId(Long namespaceId);
void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId);
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,
String reviewComment, Long targetSkillId, Integer expectedVersion);

View file

@ -15,6 +15,7 @@ public interface ReviewTaskRepository {
Page<ReviewTask> findByStatus(ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
boolean existsByNamespaceId(Long namespaceId);
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
void delete(ReviewTask reviewTask);
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,

View file

@ -16,6 +16,7 @@ public interface SkillRepository {
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status);
boolean existsByNamespaceId(Long namespaceId);
Skill save(Skill skill);
void flush();
void delete(Skill skill);

View file

@ -54,4 +54,16 @@ class NamespaceAccessPolicyTest {
assertThat(policy.canRestore(namespace, NamespaceRole.OWNER)).isTrue();
assertThat(policy.canRestore(namespace, NamespaceRole.ADMIN)).isFalse();
}
@Test
void deleteIsOwnerOnlyForTeamNamespaces() {
Namespace namespace = new Namespace("team-a", "Team A", "owner");
namespace.setType(NamespaceType.TEAM);
assertThat(policy.canDelete(namespace, NamespaceRole.OWNER)).isTrue();
assertThat(policy.canDelete(namespace, NamespaceRole.ADMIN)).isFalse();
namespace.setType(NamespaceType.GLOBAL);
assertThat(policy.canDelete(namespace, NamespaceRole.OWNER)).isFalse();
}
}

View file

@ -2,12 +2,16 @@ package com.iflytek.skillhub.domain.namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Field;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@ -26,6 +30,15 @@ class NamespaceServiceTest {
@Mock
private NamespaceAccessPolicy namespaceAccessPolicy;
@Mock
private SkillRepository skillRepository;
@Mock
private ReviewTaskRepository reviewTaskRepository;
@Mock
private PromotionRequestRepository promotionRequestRepository;
@InjectMocks
private NamespaceService namespaceService;
@ -195,4 +208,93 @@ class NamespaceServiceTest {
assertThrows(DomainForbiddenException.class, () ->
namespaceService.assertMember(namespaceId, userId));
}
@Test
void deleteNamespace_shouldDeleteMembersAndNamespaceForOwner() {
Long namespaceId = 1L;
String operatorUserId = "owner-1";
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
.thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER)));
when(skillRepository.existsByNamespaceId(namespaceId)).thenReturn(false);
when(reviewTaskRepository.existsByNamespaceId(namespaceId)).thenReturn(false);
when(promotionRequestRepository.existsByTargetNamespaceId(namespaceId)).thenReturn(false);
namespaceService.deleteNamespace(namespaceId, operatorUserId);
verify(namespaceMemberRepository).deleteByNamespaceId(namespaceId);
verify(namespaceRepository).delete(namespace);
}
@Test
void deleteNamespace_shouldRejectAdminUser() {
Long namespaceId = 1L;
String operatorUserId = "admin-1";
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.ADMIN)).thenReturn(false);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
.thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.ADMIN)));
DomainForbiddenException exception = assertThrows(DomainForbiddenException.class, () ->
namespaceService.deleteNamespace(namespaceId, operatorUserId));
assertEquals("error.namespace.owner.required", exception.messageCode());
verify(namespaceRepository, never()).delete(any());
}
@Test
void deleteNamespace_shouldRejectNamespaceWithDependentData() {
Long namespaceId = 1L;
String operatorUserId = "owner-1";
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace));
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true);
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId))
.thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER)));
when(skillRepository.existsByNamespaceId(namespaceId)).thenReturn(true);
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () ->
namespaceService.deleteNamespace(namespaceId, operatorUserId));
assertEquals("error.namespace.delete.hasDependencies", exception.messageCode());
verify(namespaceMemberRepository, never()).deleteByNamespaceId(namespaceId);
verify(namespaceRepository, never()).delete(any());
}
@Test
void canDelete_shouldReturnFalseWhenRoleCannotDelete() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
assertFalse(namespaceService.canDelete(namespace, NamespaceRole.ADMIN));
verify(skillRepository, never()).existsByNamespaceId(any());
}
@Test
void canDelete_shouldReflectDependencyChecksForOwner() {
Long namespaceId = 1L;
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
when(namespaceAccessPolicy.canDelete(namespace, NamespaceRole.OWNER)).thenReturn(true);
setField(namespace, "id", namespaceId);
when(skillRepository.existsByNamespaceId(namespaceId)).thenReturn(false);
when(reviewTaskRepository.existsByNamespaceId(namespaceId)).thenReturn(false);
when(promotionRequestRepository.existsByTargetNamespaceId(namespaceId)).thenReturn(true);
assertFalse(namespaceService.canDelete(namespace, NamespaceRole.OWNER));
}
private void setField(Object target, String fieldName, Object value) {
try {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException e) {
fail(e);
}
}
}

View file

@ -57,6 +57,11 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
return delegate.findByNamespaceIdAndStatus(namespaceId, status);
}
@Override
public boolean existsByNamespaceId(Long namespaceId) {
return delegate.existsByNamespaceId(namespaceId);
}
@Override
public Skill save(Skill skill) {
return jpaDelegate.save(skill);

View file

@ -22,5 +22,6 @@ public interface NamespaceMemberJpaRepository
List<NamespaceMember> findByUserId(String userId);
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
List<NamespaceMember> findByNamespaceIdAndRoleIn(Long namespaceId, Collection<NamespaceRole> roles);
void deleteByNamespaceId(Long namespaceId);
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
}

View file

@ -25,6 +25,8 @@ public interface PromotionRequestJpaRepository extends JpaRepository<PromotionRe
Page<PromotionRequest> findByStatus(ReviewTaskStatus status, Pageable pageable);
boolean existsByTargetNamespaceId(Long targetNamespaceId);
void deleteBySourceSkillIdOrTargetSkillId(Long sourceSkillId, Long targetSkillId);
@Modifying(clearAutomatically = true, flushAutomatically = true)

View file

@ -28,6 +28,8 @@ public interface ReviewTaskJpaRepository extends JpaRepository<ReviewTask, Long>
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
boolean existsByNamespaceId(Long namespaceId);
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
@Modifying

View file

@ -23,6 +23,7 @@ public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRep
List<Skill> findByIdIn(List<Long> ids);
List<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
boolean existsByNamespaceId(Long namespaceId);
@Override
default List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) {

4
web/.browserslistrc Normal file
View file

@ -0,0 +1,4 @@
Chrome >= 83
Edge >= 83
Firefox >= 78
Safari >= 14

View file

@ -15,6 +15,7 @@ export interface SeededNamespace {
currentUserRole?: string
canUnfreeze?: boolean
canRestore?: boolean
canDelete?: boolean
}
export interface SeededSkill {

View file

@ -1,12 +1,13 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { registerSession } from './helpers/session'
import { E2eTestDataBuilder } from './helpers/test-data-builder'
test.describe('My Namespaces Data (Real API)', () => {
test.beforeEach(async ({ page }, testInfo) => {
test.beforeEach(async ({ page }) => {
await setEnglishLocale(page)
await registerSession(page, testInfo)
await page.context().setExtraHTTPHeaders({
'X-Mock-User-Id': 'local-admin',
})
})
test('shows namespace created by request helper', async ({ page }, testInfo) => {
@ -23,4 +24,55 @@ test.describe('My Namespaces Data (Real API)', () => {
await builder.cleanup()
}
})
test('deletes a writable namespace from the dashboard', async ({ page }, testInfo) => {
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
try {
const namespace = await builder.createNamespace('e2e-delete')
await page.goto('/dashboard/namespaces')
const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`)
await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible()
await page.getByTestId(`delete-namespace-${namespace.slug}`).click()
await expect(page.getByTestId('namespace-action-dialog-delete')).toBeVisible()
const deleteResponsePromise = page.waitForResponse((response) =>
response.request().method() === 'DELETE'
&& response.url().includes(`/api/web/namespaces/${namespace.slug}`),
)
await page.getByTestId('namespace-action-confirm-delete').click()
const deleteResponse = await deleteResponsePromise
expect(deleteResponse.ok()).toBeTruthy()
await expect(page.getByText(`@${namespace.slug}`)).toHaveCount(0)
} finally {
await builder.cleanup()
}
})
test('hides the delete action when the namespace has dependent skills', async ({ page }, testInfo) => {
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
try {
const namespace = await builder.createNamespace('e2e-delete-guard')
await builder.publishSkill(namespace.slug, {
name: `Delete Guard ${Date.now()}`,
description: 'Prevents namespace deletion during Playwright validation',
})
await page.goto('/dashboard/namespaces')
const namespaceCard = page.getByTestId(`namespace-card-${namespace.slug}`)
await expect(namespaceCard.getByText(`@${namespace.slug}`)).toBeVisible()
await expect(page.getByTestId(`delete-namespace-${namespace.slug}`)).toHaveCount(0)
} finally {
await builder.cleanup()
}
})
})

View file

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const originalWindow = globalThis.window
const originalDocument = globalThis.document
function setMockWindow(runtimeConfig?: Window['__SKILLHUB_RUNTIME_CONFIG__']) {
Object.defineProperty(globalThis, 'window', {
@ -39,6 +40,7 @@ import {
fetchText,
getDirectAuthRuntimeConfig,
getSessionBootstrapRuntimeConfig,
namespaceApi,
} from './client'
beforeEach(() => {
@ -48,6 +50,16 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals()
if (originalDocument) {
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: originalDocument,
})
} else {
Reflect.deleteProperty(globalThis, 'document')
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', {
configurable: true,
@ -116,6 +128,41 @@ describe('fetchText', () => {
})
})
describe('namespaceApi.delete', () => {
it('sends a DELETE request to the normalized namespace endpoint', async () => {
window.__SKILLHUB_RUNTIME_CONFIG__ = { apiBaseUrl: 'https://api.example.com' }
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
cookie: 'XSRF-TOKEN=test-token',
},
})
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
code: 0,
msg: 'ok',
data: null,
timestamp: '2026-05-07T00:00:00Z',
requestId: 'req-test',
}),
})
vi.stubGlobal('fetch', fetchMock)
await namespaceApi.delete('@team-delete')
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/api/web/namespaces/team-delete',
expect.objectContaining({
method: 'DELETE',
headers: expect.any(Headers),
}),
)
})
})
describe('getDirectAuthRuntimeConfig', () => {
it('returns disabled when no runtime config is present', () => {
const config = getDirectAuthRuntimeConfig()

View file

@ -675,6 +675,13 @@ export const namespaceApi = {
})
},
async delete(slug: string): Promise<void> {
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`, {
method: 'DELETE',
headers: await ensureCsrfHeaders(),
})
},
async listMembers(slug: string, params?: { page?: number; size?: number }): Promise<PagedResponse<NamespaceMember>> {
const queryPage = params?.page ?? 0
const querySize = params?.size ?? 20

View file

@ -302,7 +302,7 @@ export interface paths {
get: operations["getNamespace"];
put: operations["updateNamespace"];
post?: never;
delete?: never;
delete: operations["deleteNamespace"];
options?: never;
head?: never;
patch?: never;
@ -318,7 +318,7 @@ export interface paths {
get: operations["getNamespace_1"];
put: operations["updateNamespace_1"];
post?: never;
delete?: never;
delete: operations["deleteNamespace_1"];
options?: never;
head?: never;
patch?: never;
@ -4341,6 +4341,7 @@ export interface components {
canUnfreeze?: boolean;
canArchive?: boolean;
canRestore?: boolean;
canDelete?: boolean;
};
ApiResponseGovernanceSummaryResponse: {
/** Format: int32 */
@ -5641,6 +5642,28 @@ export interface operations {
};
};
};
deleteNamespace: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseMessageResponse"];
};
};
};
};
getNamespace_1: {
parameters: {
query?: never;
@ -5689,6 +5712,28 @@ export interface operations {
};
};
};
deleteNamespace_1: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ApiResponseMessageResponse"];
};
};
};
};
updateExpiration: {
parameters: {
query?: never;

View file

@ -121,6 +121,7 @@ export interface ManagedNamespace extends Namespace {
canUnfreeze: boolean
canArchive: boolean
canRestore: boolean
canDelete: boolean
}
export interface NamespaceMember {

View file

@ -4,6 +4,7 @@
* Deployments inject `/runtime-config.js` at startup, and this file guarantees the app sees either
* that config or a safe fallback object before importing the main entry.
*/
import './legacy-polyfills'
async function loadRuntimeConfig() {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
@ -16,14 +17,16 @@ async function loadRuntimeConfig() {
}
function ensureRuntimeConfigFallback() {
window.__SKILLHUB_RUNTIME_CONFIG__ ??= {
apiBaseUrl: '',
appBaseUrl: '',
authDirectEnabled: 'false',
authDirectProvider: '',
authSessionBootstrapEnabled: 'false',
authSessionBootstrapProvider: '',
authSessionBootstrapAuto: 'false',
if (!window.__SKILLHUB_RUNTIME_CONFIG__) {
window.__SKILLHUB_RUNTIME_CONFIG__ = {
apiBaseUrl: '',
appBaseUrl: '',
authDirectEnabled: 'false',
authDirectProvider: '',
authSessionBootstrapEnabled: 'false',
authSessionBootstrapProvider: '',
authSessionBootstrapAuto: 'false',
}
}
}

View file

@ -45,6 +45,7 @@ describe('review-paths', () => {
canUnfreeze: false,
canArchive: false,
canRestore: false,
canDelete: false,
currentUserRole: 'ADMIN',
createdAt: '',
},
@ -59,6 +60,7 @@ describe('review-paths', () => {
canUnfreeze: false,
canArchive: false,
canRestore: false,
canDelete: false,
currentUserRole: 'OWNER',
createdAt: '',
},
@ -78,6 +80,7 @@ describe('review-paths', () => {
canUnfreeze: false,
canArchive: false,
canRestore: false,
canDelete: false,
currentUserRole: 'MEMBER',
createdAt: '',
},
@ -98,6 +101,7 @@ describe('review-paths', () => {
canUnfreeze: false,
canArchive: false,
canRestore: false,
canDelete: false,
currentUserRole: 'ADMIN',
createdAt: '',
},

View file

@ -428,6 +428,7 @@
"unfreeze": "Unfreeze",
"archive": "Archive",
"restore": "Restore",
"delete": "Delete",
"activeHint": "This namespace is fully active and can continue managing members, reviews, and skill publishing.",
"frozenHint": "This namespace is frozen. Members can still view it, but it is read-only and cannot publish or change membership.",
"archivedHint": "This namespace is archived. Public entry points are hidden, but you can still view and restore it from the dashboard.",
@ -440,6 +441,8 @@
"archiveConfirmDescription": "\"{{name}}\" will be hidden from public entry points and only remain recoverable from the dashboard.",
"restoreConfirmTitle": "Restore namespace",
"restoreConfirmDescription": "\"{{name}}\" will return to an active operating state.",
"deleteConfirmTitle": "Delete namespace",
"deleteConfirmDescription": "\"{{name}}\" will be permanently deleted. This cannot be undone.",
"freezeSuccessTitle": "Namespace frozen",
"freezeSuccessDescription": "\"{{name}}\" is now read-only.",
"freezeErrorTitle": "Failed to freeze namespace",
@ -452,6 +455,9 @@
"restoreSuccessTitle": "Namespace restored",
"restoreSuccessDescription": "\"{{name}}\" can operate normally again.",
"restoreErrorTitle": "Failed to restore namespace",
"deleteSuccessTitle": "Namespace deleted",
"deleteSuccessDescription": "\"{{name}}\" has been permanently deleted.",
"deleteErrorTitle": "Failed to delete namespace",
"emptyTitle": "No namespaces yet",
"emptyDescription": "Create a namespace to organize your skills"
},

View file

@ -428,6 +428,7 @@
"unfreeze": "解冻",
"archive": "归档",
"restore": "恢复",
"delete": "删除",
"activeHint": "命名空间运行正常,可继续管理成员、处理审核和发布技能。",
"frozenHint": "命名空间已冻结。成员仍可查看,但当前处于只读状态,不能继续发布或变更成员。",
"archivedHint": "命名空间已归档。公开入口已隐藏,但你仍可在管理台查看并恢复。",
@ -440,6 +441,8 @@
"archiveConfirmDescription": "归档后“{{name}}”会从公开入口隐藏,仅保留管理台恢复入口。",
"restoreConfirmTitle": "确认恢复命名空间",
"restoreConfirmDescription": "恢复后“{{name}}”会重新进入可运营状态。",
"deleteConfirmTitle": "确认删除命名空间",
"deleteConfirmDescription": "删除后会永久移除“{{name}}”,且无法恢复。",
"freezeSuccessTitle": "命名空间已冻结",
"freezeSuccessDescription": "“{{name}}”已切换为只读状态。",
"freezeErrorTitle": "冻结命名空间失败",
@ -452,6 +455,9 @@
"restoreSuccessTitle": "命名空间已恢复",
"restoreSuccessDescription": "“{{name}}”已重新开放治理能力。",
"restoreErrorTitle": "恢复命名空间失败",
"deleteSuccessTitle": "命名空间已删除",
"deleteSuccessDescription": "“{{name}}”已被永久删除。",
"deleteErrorTitle": "删除命名空间失败",
"emptyTitle": "还没有命名空间",
"emptyDescription": "创建一个命名空间来组织你的技能"
},

View file

@ -0,0 +1,78 @@
/**
* Polyfills for older browsers (e.g. Chromium 83 on Debian 10) that lack a few
* ES2021+ runtime methods used by bundled dependencies. esbuild only transpiles
* syntax, not runtime APIs, so we patch the prototypes here before any other
* module runs.
*
* TypeScript's lib target is ES2020, so the methods we patch are referenced via
* string keys / loose casts to avoid compile-time type errors.
*/
type AnyStringReplacer = (substring: string, ...args: unknown[]) => string
const StringProto = String.prototype as unknown as Record<string, unknown>
const ArrayProto = Array.prototype as unknown as Record<string, unknown>
const ObjectCtor = Object as unknown as Record<string, unknown>
if (typeof StringProto.replaceAll !== 'function') {
Object.defineProperty(String.prototype, 'replaceAll', {
configurable: true,
writable: true,
value: function replaceAll(
this: string,
search: string | RegExp,
replacement: string | AnyStringReplacer,
): string {
if (search instanceof RegExp) {
if (!search.flags.includes('g')) {
throw new TypeError('String.prototype.replaceAll called with a non-global RegExp argument')
}
return this.replace(search, replacement as string)
}
const needle = String(search)
if (needle === '') {
return this.replace(new RegExp('', 'g'), replacement as string)
}
const escaped = needle.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&')
return this.replace(new RegExp(escaped, 'g'), replacement as string)
},
})
}
if (typeof ArrayProto.at !== 'function') {
Object.defineProperty(Array.prototype, 'at', {
configurable: true,
writable: true,
value: function at(this: unknown[], index: number) {
const len = this.length
const i = Math.trunc(index) || 0
const resolved = i < 0 ? len + i : i
if (resolved < 0 || resolved >= len) return undefined
return this[resolved]
},
})
}
if (typeof StringProto.at !== 'function') {
Object.defineProperty(String.prototype, 'at', {
configurable: true,
writable: true,
value: function at(this: string, index: number) {
const len = this.length
const i = Math.trunc(index) || 0
const resolved = i < 0 ? len + i : i
if (resolved < 0 || resolved >= len) return undefined
return this.charAt(resolved)
},
})
}
if (typeof ObjectCtor.hasOwn !== 'function') {
Object.defineProperty(Object, 'hasOwn', {
configurable: true,
writable: true,
value: function hasOwn(target: object, property: PropertyKey) {
return Object.prototype.hasOwnProperty.call(target, property)
},
})
}

View file

@ -1,7 +1,20 @@
import { describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { ManagedNamespace } from '@/api/types'
const navigateMock = vi.fn()
const freezeMutateAsync = vi.fn()
const unfreezeMutateAsync = vi.fn()
const archiveMutateAsync = vi.fn()
const restoreMutateAsync = vi.fn()
const deleteMutateAsync = vi.fn()
let mockNamespaces: ManagedNamespace[] = []
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
useNavigate: () => navigateMock,
}))
vi.mock('react-i18next', async () => {
@ -19,19 +32,25 @@ vi.mock('@/features/auth/use-auth', () => ({
}))
vi.mock('@/shared/ui/button', () => ({
Button: ({ children }: { children: unknown }) => children,
Button: ({ children, ...props }: { children: ReactNode }) => createElement('button', props, children),
}))
vi.mock('@/shared/ui/card', () => ({
Card: ({ children }: { children: unknown }) => children,
Card: ({ children, ...props }: { children: ReactNode }) => createElement('div', props, children),
}))
vi.mock('@/shared/components/namespace-badge', () => ({
NamespaceBadge: () => null,
NamespaceBadge: ({ name }: { name: string }) => createElement('span', null, name),
}))
vi.mock('@/shared/components/empty-state', () => ({
EmptyState: () => null,
EmptyState: ({ title, description, action }: { title: string; description: string; action?: ReactNode }) => createElement(
'section',
null,
title,
description,
action,
),
}))
vi.mock('@/shared/components/confirm-dialog', () => ({
@ -39,19 +58,26 @@ vi.mock('@/shared/components/confirm-dialog', () => ({
}))
vi.mock('@/shared/components/dashboard-page-header', () => ({
DashboardPageHeader: () => null,
DashboardPageHeader: ({ title, subtitle, actions }: { title: string; subtitle: string; actions?: ReactNode }) => createElement(
'header',
null,
title,
subtitle,
actions,
),
}))
vi.mock('@/features/namespace/create-namespace-dialog', () => ({
CreateNamespaceDialog: () => null,
CreateNamespaceDialog: ({ children }: { children: ReactNode }) => children,
}))
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
useArchiveNamespace: () => ({ mutateAsync: vi.fn() }),
useFreezeNamespace: () => ({ mutateAsync: vi.fn() }),
useMyNamespaces: () => ({ data: [], isLoading: false }),
useRestoreNamespace: () => ({ mutateAsync: vi.fn() }),
useUnfreezeNamespace: () => ({ mutateAsync: vi.fn() }),
useArchiveNamespace: () => ({ mutateAsync: archiveMutateAsync }),
useDeleteNamespace: () => ({ mutateAsync: deleteMutateAsync }),
useFreezeNamespace: () => ({ mutateAsync: freezeMutateAsync }),
useMyNamespaces: () => ({ data: mockNamespaces, isLoading: false }),
useRestoreNamespace: () => ({ mutateAsync: restoreMutateAsync }),
useUnfreezeNamespace: () => ({ mutateAsync: unfreezeMutateAsync }),
}))
vi.mock('@/shared/lib/toast', () => ({
@ -59,9 +85,104 @@ vi.mock('@/shared/lib/toast', () => ({
}))
import { MyNamespacesPage } from './my-namespaces'
import { executeNamespaceAction, resolveNamespaceActionCopy } from './my-namespaces'
function buildNamespace(overrides: Partial<ManagedNamespace> = {}): ManagedNamespace {
return {
id: 1,
slug: 'team-ml',
displayName: 'Team ML',
description: 'namespace',
type: 'TEAM',
status: 'ACTIVE',
createdAt: '2026-05-07T00:00:00Z',
immutable: false,
canFreeze: false,
canUnfreeze: false,
canArchive: false,
canRestore: false,
canDelete: false,
...overrides,
}
}
describe('MyNamespacesPage', () => {
beforeEach(() => {
navigateMock.mockReset()
freezeMutateAsync.mockReset()
unfreezeMutateAsync.mockReset()
archiveMutateAsync.mockReset()
restoreMutateAsync.mockReset()
deleteMutateAsync.mockReset()
mockNamespaces = []
})
it('exports a named component function', () => {
expect(typeof MyNamespacesPage).toBe('function')
})
it('renders the delete action when the namespace is deletable', () => {
mockNamespaces = [buildNamespace({ canDelete: true })]
const html = renderToStaticMarkup(createElement(MyNamespacesPage))
expect(html).toContain('myNamespaces.delete')
})
it('hides the delete action when the namespace is not deletable', () => {
mockNamespaces = [buildNamespace({ canDelete: false })]
const html = renderToStaticMarkup(createElement(MyNamespacesPage))
expect(html).not.toContain('myNamespaces.delete')
})
it('routes delete actions to the delete mutation and emits success feedback', async () => {
const t = (key: string) => key
const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML')
deleteMutateAsync.mockResolvedValueOnce(undefined)
const success = vi.fn()
const error = vi.fn()
await executeNamespaceAction(
{ action: 'delete', slug: 'team-ml', name: 'Team ML' },
{
freeze: { mutateAsync: freezeMutateAsync },
unfreeze: { mutateAsync: unfreezeMutateAsync },
archive: { mutateAsync: archiveMutateAsync },
restore: { mutateAsync: restoreMutateAsync },
delete: { mutateAsync: deleteMutateAsync },
},
copy,
{ success, error },
)
expect(deleteMutateAsync).toHaveBeenCalledWith({ slug: 'team-ml' })
expect(success).toHaveBeenCalledWith('myNamespaces.deleteSuccessTitle', 'myNamespaces.deleteSuccessDescription')
expect(error).not.toHaveBeenCalled()
})
it('surfaces delete failures through error feedback and rethrows', async () => {
const t = (key: string) => key
const copy = resolveNamespaceActionCopy(t, 'delete', 'Team ML')
deleteMutateAsync.mockRejectedValueOnce(new Error('blocked'))
const success = vi.fn()
const error = vi.fn()
await expect(executeNamespaceAction(
{ action: 'delete', slug: 'team-ml', name: 'Team ML' },
{
freeze: { mutateAsync: freezeMutateAsync },
unfreeze: { mutateAsync: unfreezeMutateAsync },
archive: { mutateAsync: archiveMutateAsync },
restore: { mutateAsync: restoreMutateAsync },
delete: { mutateAsync: deleteMutateAsync },
},
copy,
{ success, error },
)).rejects.toThrow('blocked')
expect(error).toHaveBeenCalledWith('myNamespaces.deleteErrorTitle', 'blocked')
expect(success).not.toHaveBeenCalled()
})
})

View file

@ -9,7 +9,14 @@ import { EmptyState } from '@/shared/components/empty-state'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { CreateNamespaceDialog } from '@/features/namespace/create-namespace-dialog'
import { useArchiveNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-namespace-queries'
import {
useArchiveNamespace,
useDeleteNamespace,
useFreezeNamespace,
useMyNamespaces,
useRestoreNamespace,
useUnfreezeNamespace,
} from '@/shared/hooks/use-namespace-queries'
import { toast } from '@/shared/lib/toast'
type PendingNamespaceAction =
@ -17,6 +24,120 @@ type PendingNamespaceAction =
| { action: 'unfreeze'; slug: string; name: string }
| { action: 'archive'; slug: string; name: string }
| { action: 'restore'; slug: string; name: string }
| { action: 'delete'; slug: string; name: string }
type NamespaceActionCopy = {
title: string
description: string
confirmText: string
successTitle: string
successDescription: string
errorTitle: string
variant: 'default' | 'destructive'
}
type NamespaceActionMutation = {
mutateAsync: (input: { slug: string }) => Promise<unknown>
}
type NamespaceActionMutations = {
freeze: NamespaceActionMutation
unfreeze: NamespaceActionMutation
archive: NamespaceActionMutation
restore: NamespaceActionMutation
delete: NamespaceActionMutation
}
type NamespaceActionToast = {
success: (title: string, description?: string) => void
error: (title: string, description?: string) => void
}
export function resolveNamespaceActionCopy(
t: (key: string, options?: Record<string, unknown>) => string,
action: PendingNamespaceAction['action'],
name: string,
): NamespaceActionCopy {
if (action === 'freeze') {
return {
title: t('myNamespaces.freezeConfirmTitle'),
description: t('myNamespaces.freezeConfirmDescription', { name }),
confirmText: t('myNamespaces.freeze'),
successTitle: t('myNamespaces.freezeSuccessTitle'),
successDescription: t('myNamespaces.freezeSuccessDescription', { name }),
errorTitle: t('myNamespaces.freezeErrorTitle'),
variant: 'default',
}
}
if (action === 'unfreeze') {
return {
title: t('myNamespaces.unfreezeConfirmTitle'),
description: t('myNamespaces.unfreezeConfirmDescription', { name }),
confirmText: t('myNamespaces.unfreeze'),
successTitle: t('myNamespaces.unfreezeSuccessTitle'),
successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }),
errorTitle: t('myNamespaces.unfreezeErrorTitle'),
variant: 'default',
}
}
if (action === 'archive') {
return {
title: t('myNamespaces.archiveConfirmTitle'),
description: t('myNamespaces.archiveConfirmDescription', { name }),
confirmText: t('myNamespaces.archive'),
successTitle: t('myNamespaces.archiveSuccessTitle'),
successDescription: t('myNamespaces.archiveSuccessDescription', { name }),
errorTitle: t('myNamespaces.archiveErrorTitle'),
variant: 'destructive',
}
}
if (action === 'delete') {
return {
title: t('myNamespaces.deleteConfirmTitle'),
description: t('myNamespaces.deleteConfirmDescription', { name }),
confirmText: t('myNamespaces.delete'),
successTitle: t('myNamespaces.deleteSuccessTitle'),
successDescription: t('myNamespaces.deleteSuccessDescription', { name }),
errorTitle: t('myNamespaces.deleteErrorTitle'),
variant: 'destructive',
}
}
return {
title: t('myNamespaces.restoreConfirmTitle'),
description: t('myNamespaces.restoreConfirmDescription', { name }),
confirmText: t('myNamespaces.restore'),
successTitle: t('myNamespaces.restoreSuccessTitle'),
successDescription: t('myNamespaces.restoreSuccessDescription', { name }),
errorTitle: t('myNamespaces.restoreErrorTitle'),
variant: 'default',
}
}
export async function executeNamespaceAction(
pendingAction: PendingNamespaceAction,
mutations: NamespaceActionMutations,
copy: NamespaceActionCopy,
notifications: NamespaceActionToast,
) {
try {
if (pendingAction.action === 'freeze') {
await mutations.freeze.mutateAsync({ slug: pendingAction.slug })
} else if (pendingAction.action === 'unfreeze') {
await mutations.unfreeze.mutateAsync({ slug: pendingAction.slug })
} else if (pendingAction.action === 'archive') {
await mutations.archive.mutateAsync({ slug: pendingAction.slug })
} else if (pendingAction.action === 'delete') {
await mutations.delete.mutateAsync({ slug: pendingAction.slug })
} else {
await mutations.restore.mutateAsync({ slug: pendingAction.slug })
}
notifications.success(copy.successTitle, copy.successDescription)
} catch (error) {
notifications.error(copy.errorTitle, error instanceof Error ? error.message : '')
throw error
}
}
/**
* Dashboard page for namespaces the current user can manage or review. It owns
@ -34,6 +155,7 @@ export function MyNamespacesPage() {
const unfreezeMutation = useUnfreezeNamespace()
const archiveMutation = useArchiveNamespace()
const restoreMutation = useRestoreNamespace()
const deleteMutation = useDeleteNamespace()
const handleNamespaceClick = (slug: string) => {
navigate({ to: `/space/${encodeURIComponent(slug)}` })
@ -82,79 +204,25 @@ export function MyNamespacesPage() {
return t('myNamespaces.activeHint')
}
/**
* Centralizes dialog copy so lifecycle operations can reuse one confirmation
* component without scattering user-facing strings across event handlers.
*/
const resolveActionCopy = (action: PendingNamespaceAction['action'], name: string) => {
if (action === 'freeze') {
return {
title: t('myNamespaces.freezeConfirmTitle'),
description: t('myNamespaces.freezeConfirmDescription', { name }),
confirmText: t('myNamespaces.freeze'),
successTitle: t('myNamespaces.freezeSuccessTitle'),
successDescription: t('myNamespaces.freezeSuccessDescription', { name }),
errorTitle: t('myNamespaces.freezeErrorTitle'),
variant: 'default' as const,
}
}
if (action === 'unfreeze') {
return {
title: t('myNamespaces.unfreezeConfirmTitle'),
description: t('myNamespaces.unfreezeConfirmDescription', { name }),
confirmText: t('myNamespaces.unfreeze'),
successTitle: t('myNamespaces.unfreezeSuccessTitle'),
successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }),
errorTitle: t('myNamespaces.unfreezeErrorTitle'),
variant: 'default' as const,
}
}
if (action === 'archive') {
return {
title: t('myNamespaces.archiveConfirmTitle'),
description: t('myNamespaces.archiveConfirmDescription', { name }),
confirmText: t('myNamespaces.archive'),
successTitle: t('myNamespaces.archiveSuccessTitle'),
successDescription: t('myNamespaces.archiveSuccessDescription', { name }),
errorTitle: t('myNamespaces.archiveErrorTitle'),
variant: 'destructive' as const,
}
}
return {
title: t('myNamespaces.restoreConfirmTitle'),
description: t('myNamespaces.restoreConfirmDescription', { name }),
confirmText: t('myNamespaces.restore'),
successTitle: t('myNamespaces.restoreSuccessTitle'),
successDescription: t('myNamespaces.restoreSuccessDescription', { name }),
errorTitle: t('myNamespaces.restoreErrorTitle'),
variant: 'default' as const,
}
}
const handleNamespaceAction = async () => {
if (!pendingAction) {
return
}
const copy = resolveActionCopy(pendingAction.action, pendingAction.name)
try {
if (pendingAction.action === 'freeze') {
await freezeMutation.mutateAsync({ slug: pendingAction.slug })
} else if (pendingAction.action === 'unfreeze') {
await unfreezeMutation.mutateAsync({ slug: pendingAction.slug })
} else if (pendingAction.action === 'archive') {
await archiveMutation.mutateAsync({ slug: pendingAction.slug })
} else {
await restoreMutation.mutateAsync({ slug: pendingAction.slug })
}
toast.success(copy.successTitle, copy.successDescription)
setPendingAction(null)
} catch (error) {
toast.error(copy.errorTitle, error instanceof Error ? error.message : '')
throw error
}
const copy = resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name)
await executeNamespaceAction(
pendingAction,
{
freeze: freezeMutation,
unfreeze: unfreezeMutation,
archive: archiveMutation,
restore: restoreMutation,
delete: deleteMutation,
},
copy,
toast,
)
setPendingAction(null)
}
if (isLoading) {
@ -184,6 +252,7 @@ export function MyNamespacesPage() {
{namespaces.map((namespace, idx) => (
<Card
key={namespace.id}
data-testid={`namespace-card-${namespace.slug}`}
className={`p-6 cursor-pointer group animate-fade-up delay-${Math.min(idx + 1, 6)}`}
onClick={() => handleNamespaceClick(namespace.slug)}
>
@ -281,6 +350,19 @@ export function MyNamespacesPage() {
{t('myNamespaces.restore')}
</Button>
)}
{namespace.canDelete && (
<Button
data-testid={`delete-namespace-${namespace.slug}`}
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation()
setPendingAction({ action: 'delete', slug: namespace.slug, name: namespace.displayName })
}}
>
{t('myNamespaces.delete')}
</Button>
)}
</div>
</div>
</Card>
@ -305,10 +387,12 @@ export function MyNamespacesPage() {
setPendingAction(null)
}
}}
title={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).title : ''}
description={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).description : ''}
confirmText={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).confirmText : undefined}
variant={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).variant : 'default'}
title={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).title : ''}
description={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).description : ''}
confirmText={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).confirmText : undefined}
variant={pendingAction ? resolveNamespaceActionCopy(t, pendingAction.action, pendingAction.name).variant : 'default'}
contentTestId={pendingAction ? `namespace-action-dialog-${pendingAction.action}` : undefined}
confirmButtonTestId={pendingAction ? `namespace-action-confirm-${pendingAction.action}` : undefined}
onConfirm={handleNamespaceAction}
/>
</div>

View file

@ -19,6 +19,8 @@ interface ConfirmDialogProps {
cancelText?: string
variant?: 'default' | 'destructive'
onConfirm: () => void | Promise<void>
contentTestId?: string
confirmButtonTestId?: string
}
export function ConfirmDialog({
@ -30,6 +32,8 @@ export function ConfirmDialog({
cancelText,
variant = 'default',
onConfirm,
contentTestId,
confirmButtonTestId,
}: ConfirmDialogProps) {
const { t } = useTranslation()
const resolvedConfirmText = confirmText ?? t('dialog.confirm')
@ -41,7 +45,7 @@ export function ConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent data-testid={contentTestId}>
<DialogHeader className="min-w-0 text-center sm:text-center">
<DialogTitle className="text-center">{title}</DialogTitle>
{description && <DialogDescription className="text-center break-all">{description}</DialogDescription>}
@ -50,7 +54,7 @@ export function ConfirmDialog({
<Button variant="outline" onClick={() => onOpenChange(false)}>
{resolvedCancelText}
</Button>
<Button variant={variant} onClick={handleConfirm}>
<Button data-testid={confirmButtonTestId} variant={variant} onClick={handleConfirm}>
{resolvedConfirmText}
</Button>
</DialogFooter>

View file

@ -36,6 +36,10 @@ async function removeNamespaceMember(params: { slug: string; userId: string }):
return namespaceApi.removeMember(params.slug, params.userId)
}
async function deleteNamespace(params: { slug: string }): Promise<void> {
return namespaceApi.delete(params.slug)
}
async function batchAddNamespaceMembers(params: { slug: string; members: Array<{ userId: string; role: string }> }): Promise<BatchMemberResponse> {
return namespaceApi.batchAddMembers(params.slug, params.members)
}
@ -211,3 +215,15 @@ export function useTransferNamespaceOwnership() {
},
})
}
export function useDeleteNamespace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteNamespace,
onSuccess: (_data, variables) => {
invalidateNamespaceQueries(queryClient, variables.slug)
queryClient.invalidateQueries({ queryKey: ['namespaces'] })
},
})
}

View file

@ -112,6 +112,8 @@ const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTML
<DialogOverlay />
<div
ref={ref}
role="dialog"
aria-modal="true"
className={cn(
'fixed left-1/2 top-1/2 z-50 grid max-h-[calc(100vh-2rem)] w-[min(calc(100vw-2rem),32rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-2xl border border-border/60 bg-card p-8 shadow-card',
className

View file

@ -2,6 +2,8 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
const LEGACY_BROWSER_TARGETS = ['chrome83', 'edge83', 'firefox78', 'safari14']
export default defineConfig({
plugins: [react()],
resolve: {
@ -9,6 +11,15 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
build: {
target: LEGACY_BROWSER_TARGETS,
cssTarget: LEGACY_BROWSER_TARGETS,
},
optimizeDeps: {
esbuildOptions: {
target: LEGACY_BROWSER_TARGETS,
},
},
test: {
exclude: ['**/node_modules/**', '**/e2e/**'],
},