mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
feat: complete namespace member management
This commit is contained in:
parent
889a6d3b9d
commit
8fc2c01a32
16 changed files with 900 additions and 37 deletions
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.controller.BaseApiController;
|
|||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.*;
|
||||
import com.iflytek.skillhub.dto.*;
|
||||
import com.iflytek.skillhub.service.NamespaceMemberCandidateService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
|
|
@ -24,12 +25,14 @@ public class NamespaceController extends BaseApiController {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceGovernanceService namespaceGovernanceService;
|
||||
private final NamespaceAccessPolicy namespaceAccessPolicy;
|
||||
private final NamespaceMemberCandidateService namespaceMemberCandidateService;
|
||||
|
||||
public NamespaceController(NamespaceService namespaceService,
|
||||
NamespaceMemberService namespaceMemberService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceGovernanceService namespaceGovernanceService,
|
||||
NamespaceAccessPolicy namespaceAccessPolicy,
|
||||
NamespaceMemberCandidateService namespaceMemberCandidateService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.namespaceService = namespaceService;
|
||||
|
|
@ -37,6 +40,7 @@ public class NamespaceController extends BaseApiController {
|
|||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceGovernanceService = namespaceGovernanceService;
|
||||
this.namespaceAccessPolicy = namespaceAccessPolicy;
|
||||
this.namespaceMemberCandidateService = namespaceMemberCandidateService;
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces")
|
||||
|
|
@ -159,13 +163,25 @@ public class NamespaceController extends BaseApiController {
|
|||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}/members")
|
||||
public ApiResponse<PageResponse<MemberResponse>> listMembers(@PathVariable String slug, Pageable pageable) {
|
||||
public ApiResponse<PageResponse<MemberResponse>> listMembers(@PathVariable String slug,
|
||||
Pageable pageable,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
namespaceService.assertMember(namespace.getId(), userId);
|
||||
Page<NamespaceMember> members = namespaceMemberService.listMembers(namespace.getId(), pageable);
|
||||
PageResponse<MemberResponse> response = PageResponse.from(members.map(MemberResponse::from));
|
||||
return ok("response.success.read", response);
|
||||
}
|
||||
|
||||
@GetMapping("/namespaces/{slug}/member-candidates")
|
||||
public ApiResponse<List<NamespaceCandidateUserResponse>> searchMemberCandidates(
|
||||
@PathVariable String slug,
|
||||
@RequestParam String search,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
return ok("response.success.read", namespaceMemberCandidateService.searchCandidates(slug, search, userId, size));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/members")
|
||||
public ApiResponse<MemberResponse> addMember(
|
||||
@PathVariable String slug,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
|
||||
public record NamespaceCandidateUserResponse(
|
||||
String userId,
|
||||
String displayName,
|
||||
String email,
|
||||
String status
|
||||
) {
|
||||
public static NamespaceCandidateUserResponse from(UserAccount user) {
|
||||
return new NamespaceCandidateUserResponse(
|
||||
user.getId(),
|
||||
user.getDisplayName(),
|
||||
user.getEmail(),
|
||||
user.getStatus().name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
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.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
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.domain.user.UserStatus;
|
||||
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class NamespaceMemberCandidateService {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 10;
|
||||
private static final int MAX_LIMIT = 20;
|
||||
|
||||
private final NamespaceService namespaceService;
|
||||
private final NamespaceAccessPolicy namespaceAccessPolicy;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public NamespaceMemberCandidateService(NamespaceService namespaceService,
|
||||
NamespaceAccessPolicy namespaceAccessPolicy,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
UserAccountRepository userAccountRepository) {
|
||||
this.namespaceService = namespaceService;
|
||||
this.namespaceAccessPolicy = namespaceAccessPolicy;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<NamespaceCandidateUserResponse> searchCandidates(String slug, String search, String operatorUserId, int size) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
namespaceService.assertAdminOrOwner(namespace.getId(), operatorUserId);
|
||||
if (!namespaceAccessPolicy.canManageMembers(namespace)) {
|
||||
if (namespaceAccessPolicy.isImmutable(namespace)) {
|
||||
throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug());
|
||||
}
|
||||
throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug());
|
||||
}
|
||||
|
||||
String keyword = normalizeSearch(search);
|
||||
if (keyword == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
int pageSize = normalizeSize(size);
|
||||
Set<String> existingMemberIds = namespaceMemberRepository.findByNamespaceId(namespace.getId(), PageRequest.of(0, 500))
|
||||
.stream()
|
||||
.map(NamespaceMember::getUserId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
return userAccountRepository.search(keyword, UserStatus.ACTIVE, PageRequest.of(0, pageSize)).stream()
|
||||
.filter(user -> !existingMemberIds.contains(user.getId()))
|
||||
.map(NamespaceCandidateUserResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String normalizeSearch(String search) {
|
||||
if (!StringUtils.hasText(search)) {
|
||||
return null;
|
||||
}
|
||||
String keyword = search.trim();
|
||||
if (keyword.length() < 2) {
|
||||
throw new DomainBadRequestException("error.namespace.member.search.tooShort");
|
||||
}
|
||||
return keyword;
|
||||
}
|
||||
|
||||
private int normalizeSize(int size) {
|
||||
if (size <= 0) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
return Math.min(size, MAX_LIMIT);
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ error.namespace.member.alreadyExists=User is already a namespace member
|
|||
error.namespace.member.notFound=Member not found
|
||||
error.namespace.member.owner.remove=Cannot remove namespace owner
|
||||
error.namespace.member.owner.setDirect=Cannot set OWNER role directly, use ownership transfer instead
|
||||
error.namespace.member.search.tooShort=Search keyword must be at least 2 characters
|
||||
error.namespace.owner.current.notFound=Current owner not found
|
||||
error.namespace.owner.current.invalid=Current user is not the namespace owner
|
||||
error.namespace.owner.new.notFound=New owner is not a namespace member
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ error.namespace.member.alreadyExists=用户已经是该命名空间成员
|
|||
error.namespace.member.notFound=未找到命名空间成员
|
||||
error.namespace.member.owner.remove=不能移除命名空间 OWNER
|
||||
error.namespace.member.owner.setDirect=不能直接设置 OWNER 角色,请使用所有权转移
|
||||
error.namespace.member.search.tooShort=搜索关键词至少需要 2 个字符
|
||||
error.namespace.owner.current.notFound=未找到当前所有者
|
||||
error.namespace.owner.current.invalid=当前用户不是命名空间所有者
|
||||
error.namespace.owner.new.notFound=新所有者不是该命名空间成员
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ 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.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
|
||||
import com.iflytek.skillhub.service.NamespaceMemberCandidateService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
|
|
@ -28,6 +31,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
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.get;
|
||||
|
|
@ -55,6 +59,9 @@ class NamespacePortalControllerTest {
|
|||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberCandidateService namespaceMemberCandidateService;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
|
|
@ -93,6 +100,42 @@ class NamespacePortalControllerTest {
|
|||
.andExpect(jsonPath("$.data.status").value("ARCHIVED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMembers_forNonMember_returns403() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
doThrow(new DomainForbiddenException("error.namespace.membership.required"))
|
||||
.when(namespaceService).assertMember(1L, "guest-1");
|
||||
|
||||
mockMvc.perform(get("/api/v1/namespaces/team-a/members")
|
||||
.with(auth("guest-1"))
|
||||
.requestAttr("userId", "guest-1"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchMemberCandidates_returnsCandidates() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
given(namespaceMemberCandidateService.searchCandidates("team-a", "ali", "owner-1", 10))
|
||||
.willReturn(List.of(new NamespaceCandidateUserResponse(
|
||||
"user-2",
|
||||
"alice",
|
||||
"alice@example.com",
|
||||
"ACTIVE"
|
||||
)));
|
||||
|
||||
mockMvc.perform(get("/api/v1/namespaces/team-a/member-candidates")
|
||||
.param("search", "ali")
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data[0].userId").value("user-2"))
|
||||
.andExpect(jsonPath("$.data[0].displayName").value("alice"));
|
||||
}
|
||||
|
||||
private RequestPostProcessor auth(String userId) {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
userId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
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.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
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.domain.user.UserStatus;
|
||||
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class NamespaceMemberCandidateServiceTest {
|
||||
|
||||
@Mock
|
||||
private NamespaceService namespaceService;
|
||||
|
||||
@Mock
|
||||
private NamespaceAccessPolicy namespaceAccessPolicy;
|
||||
|
||||
@Mock
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@Mock
|
||||
private UserAccountRepository userAccountRepository;
|
||||
|
||||
@Test
|
||||
void searchCandidates_shouldFilterExistingMembers() {
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
setField(namespace, "id", 1L);
|
||||
|
||||
NamespaceMemberCandidateService service = new NamespaceMemberCandidateService(
|
||||
namespaceService,
|
||||
namespaceAccessPolicy,
|
||||
namespaceMemberRepository,
|
||||
userAccountRepository
|
||||
);
|
||||
|
||||
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace);
|
||||
doNothing().when(namespaceService).assertAdminOrOwner(1L, "owner-1");
|
||||
when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true);
|
||||
when(namespaceMemberRepository.findByNamespaceId(1L, PageRequest.of(0, 500)))
|
||||
.thenReturn(new PageImpl<>(List.of(new NamespaceMember(1L, "user-1", NamespaceRole.MEMBER))));
|
||||
when(userAccountRepository.search("ali", UserStatus.ACTIVE, PageRequest.of(0, 10)))
|
||||
.thenReturn(new PageImpl<>(List.of(
|
||||
new UserAccount("user-1", "alice", "alice@example.com", null),
|
||||
new UserAccount("user-2", "alina", "alina@example.com", null)
|
||||
)));
|
||||
|
||||
List<NamespaceCandidateUserResponse> result = service.searchCandidates("team-a", "ali", "owner-1", 10);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("user-2", result.getFirst().userId());
|
||||
verify(namespaceService).assertAdminOrOwner(1L, "owner-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchCandidates_shouldRejectReadonlyNamespace() {
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
setField(namespace, "id", 1L);
|
||||
|
||||
NamespaceMemberCandidateService service = new NamespaceMemberCandidateService(
|
||||
namespaceService,
|
||||
namespaceAccessPolicy,
|
||||
namespaceMemberRepository,
|
||||
userAccountRepository
|
||||
);
|
||||
|
||||
when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace);
|
||||
doNothing().when(namespaceService).assertAdminOrOwner(1L, "owner-1");
|
||||
when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(false);
|
||||
when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false);
|
||||
|
||||
assertThrows(DomainBadRequestException.class, () ->
|
||||
service.searchCandidates("team-a", "ali", "owner-1", 10));
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
} catch (Exception exception) {
|
||||
throw new RuntimeException(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ public class NamespaceService {
|
|||
.orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId));
|
||||
}
|
||||
|
||||
void assertAdminOrOwner(Long namespaceId, String userId) {
|
||||
public void assertAdminOrOwner(Long namespaceId, String userId) {
|
||||
NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
|
||||
.map(NamespaceMember::getRole)
|
||||
.orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required"));
|
||||
|
|
@ -79,6 +79,11 @@ public class NamespaceService {
|
|||
}
|
||||
}
|
||||
|
||||
public void assertMember(Long namespaceId, String userId) {
|
||||
namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)
|
||||
.orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required"));
|
||||
}
|
||||
|
||||
void assertMutable(Namespace namespace) {
|
||||
if (namespaceAccessPolicy.isImmutable(namespace)) {
|
||||
throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug());
|
||||
|
|
|
|||
|
|
@ -160,4 +160,25 @@ class NamespaceServiceTest {
|
|||
assertThrows(DomainBadRequestException.class, () ->
|
||||
namespaceService.getNamespaceBySlug("nonexistent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assertMember_shouldAllowExistingMember() {
|
||||
Long namespaceId = 1L;
|
||||
String userId = "user-1";
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId))
|
||||
.thenReturn(Optional.of(new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER)));
|
||||
|
||||
assertDoesNotThrow(() -> namespaceService.assertMember(namespaceId, userId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assertMember_shouldRejectNonMember() {
|
||||
Long namespaceId = 1L;
|
||||
String userId = "user-404";
|
||||
when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(DomainForbiddenException.class, () ->
|
||||
namespaceService.assertMember(namespaceId, userId));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import type {
|
|||
ManagedNamespace,
|
||||
Namespace,
|
||||
CreateNamespaceRequest,
|
||||
NamespaceMember,
|
||||
NamespaceCandidateUser,
|
||||
} from './types'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import i18n from '@/i18n/config'
|
||||
|
|
@ -520,6 +522,54 @@ export const namespaceApi = {
|
|||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
|
||||
async listMembers(slug: string): Promise<NamespaceMember[]> {
|
||||
const page = await fetchJson<{ items: NamespaceMember[] }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`)
|
||||
return page.items
|
||||
},
|
||||
|
||||
async searchMemberCandidates(slug: string, search: string, size = 10): Promise<NamespaceCandidateUser[]> {
|
||||
const query = new URLSearchParams({
|
||||
search: search.trim(),
|
||||
size: String(size),
|
||||
})
|
||||
return fetchJson<NamespaceCandidateUser[]>(
|
||||
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/member-candidates?${query.toString()}`,
|
||||
)
|
||||
},
|
||||
|
||||
async addMember(slug: string, request: { userId: string; role: string }): Promise<NamespaceMember> {
|
||||
return fetchJson<NamespaceMember>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
userId: request.userId.trim(),
|
||||
role: request.role,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
async updateMemberRole(slug: string, userId: string, role: string): Promise<NamespaceMember> {
|
||||
return fetchJson<NamespaceMember>(
|
||||
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}/role`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ role }),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
async removeMember(slug: string, userId: string): Promise<void> {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}`, {
|
||||
method: 'DELETE',
|
||||
headers: await ensureCsrfHeaders(),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const tokenApi = {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,13 @@ export interface NamespaceMember {
|
|||
createdAt: string
|
||||
}
|
||||
|
||||
export interface NamespaceCandidateUser {
|
||||
userId: string
|
||||
displayName: string
|
||||
email?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
// Skill types
|
||||
export interface SkillSummary {
|
||||
id: number
|
||||
|
|
|
|||
237
web/src/features/namespace/add-namespace-member-dialog.tsx
Normal file
237
web/src/features/namespace/add-namespace-member-dialog.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { NamespaceRole } from '@/api/types'
|
||||
import { useAddNamespaceMember, useNamespaceMemberCandidates } from '@/shared/hooks/use-skill-queries'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select } from '@/shared/ui/select'
|
||||
|
||||
interface AddNamespaceMemberDialogProps {
|
||||
slug: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS: NamespaceRole[] = ['MEMBER', 'ADMIN']
|
||||
|
||||
export function AddNamespaceMemberDialog({ slug, children }: AddNamespaceMemberDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const addMemberMutation = useAddNamespaceMember()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [appliedSearch, setAppliedSearch] = useState('')
|
||||
const [userId, setUserId] = useState('')
|
||||
const [role, setRole] = useState<NamespaceRole>('MEMBER')
|
||||
const [userIdError, setUserIdError] = useState<string | null>(null)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
|
||||
const { data: candidates, isFetching, error: candidatesError } = useNamespaceMemberCandidates(
|
||||
slug,
|
||||
appliedSearch,
|
||||
open,
|
||||
)
|
||||
|
||||
const resetDialog = () => {
|
||||
setSearchInput('')
|
||||
setAppliedSearch('')
|
||||
setUserId('')
|
||||
setRole('MEMBER')
|
||||
setUserIdError(null)
|
||||
setSearchError(null)
|
||||
addMemberMutation.reset()
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
resetDialog()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const keyword = searchInput.trim()
|
||||
if (keyword.length > 0 && keyword.length < 2) {
|
||||
setSearchError(t('members.searchTooShort'))
|
||||
return
|
||||
}
|
||||
setSearchError(null)
|
||||
setAppliedSearch(keyword)
|
||||
}
|
||||
|
||||
const handleAddMember = async () => {
|
||||
const normalizedUserId = userId.trim()
|
||||
if (!normalizedUserId) {
|
||||
setUserIdError(t('members.userIdRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await addMemberMutation.mutateAsync({
|
||||
slug,
|
||||
userId: normalizedUserId,
|
||||
role,
|
||||
})
|
||||
toast.success(
|
||||
t('members.addSuccessTitle'),
|
||||
t('members.addSuccessDescription', { userId: normalizedUserId }),
|
||||
)
|
||||
handleOpenChange(false)
|
||||
} catch (error) {
|
||||
toast.error(t('members.addErrorTitle'), error instanceof Error ? error.message : '')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader className="text-center sm:text-center">
|
||||
<DialogTitle className="text-center">{t('members.addDialogTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
{t('members.addDialogDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="member-search">{t('members.searchLabel')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="member-search"
|
||||
value={searchInput}
|
||||
placeholder={t('members.searchPlaceholder')}
|
||||
onChange={(event) => {
|
||||
setSearchInput(event.target.value)
|
||||
if (searchError) {
|
||||
setSearchError(null)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleSearch()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handleSearch}>
|
||||
{t('members.searchAction')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className={`text-xs ${searchError ? 'text-red-600' : 'text-muted-foreground'}`}>
|
||||
{searchError ?? t('members.searchHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{appliedSearch ? (
|
||||
<div className="space-y-2 rounded-xl border border-border/50 bg-secondary/30 p-3">
|
||||
<div className="text-sm font-medium">{t('members.searchResultsTitle')}</div>
|
||||
{isFetching ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div key={index} className="h-16 animate-shimmer rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : candidatesError ? (
|
||||
<p className="text-sm text-red-600">{candidatesError.message}</p>
|
||||
) : candidates && candidates.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{candidates.map((candidate) => (
|
||||
<div
|
||||
key={candidate.userId}
|
||||
className="flex items-start justify-between gap-3 rounded-lg border border-border/50 bg-background/80 p-3"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="font-medium">{candidate.displayName}</div>
|
||||
<div className="text-sm text-muted-foreground">{candidate.email || candidate.userId}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{candidate.userId}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUserId(candidate.userId)
|
||||
setUserIdError(null)
|
||||
}}
|
||||
>
|
||||
{t('members.selectCandidate')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t('members.searchEmpty')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="member-user-id">{t('members.manualUserIdLabel')}</Label>
|
||||
<Input
|
||||
id="member-user-id"
|
||||
value={userId}
|
||||
placeholder={t('members.manualUserIdPlaceholder')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setUserId(event.target.value)
|
||||
if (userIdError) {
|
||||
setUserIdError(null)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleAddMember()
|
||||
}
|
||||
}}
|
||||
aria-invalid={userIdError ? 'true' : 'false'}
|
||||
/>
|
||||
<p className={`text-xs ${userIdError ? 'text-red-600' : 'text-muted-foreground'}`}>
|
||||
{userIdError ?? t('members.manualUserIdHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="member-role">{t('members.roleLabel')}</Label>
|
||||
<Select
|
||||
id="member-role"
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value as NamespaceRole)}
|
||||
>
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(option === 'ADMIN' ? 'members.roleAdmin' : 'members.roleMember')}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addMemberMutation.error ? (
|
||||
<p className="text-sm text-red-600">{addMemberMutation.error.message}</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="sm:justify-center sm:space-x-3">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleAddMember} disabled={addMemberMutation.isPending}>
|
||||
{addMemberMutation.isPending ? t('members.addingMember') : t('members.addMember')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -593,6 +593,28 @@
|
|||
"members": {
|
||||
"title": "Member Management",
|
||||
"addMember": "Add Member",
|
||||
"addingMember": "Adding...",
|
||||
"addDialogTitle": "Add Namespace Member",
|
||||
"addDialogDescription": "Search platform users or enter a user ID directly. Added users can collaborate in this namespace immediately.",
|
||||
"searchLabel": "Search users",
|
||||
"searchPlaceholder": "Search by username, email, or user ID",
|
||||
"searchHint": "Enter at least 2 characters to search. Results will include the user ID you can add directly.",
|
||||
"searchAction": "Search",
|
||||
"searchTooShort": "Search keywords must be at least 2 characters",
|
||||
"searchResultsTitle": "Candidate users",
|
||||
"searchEmpty": "No active users are available to add",
|
||||
"selectCandidate": "Use this user",
|
||||
"manualUserIdLabel": "Enter user ID manually",
|
||||
"manualUserIdPlaceholder": "For example: user-123 or usr_abcd",
|
||||
"manualUserIdHint": "If you already know the user ID, you can add it directly here.",
|
||||
"userIdRequired": "User ID is required",
|
||||
"roleLabel": "Member role",
|
||||
"roleOwner": "OWNER",
|
||||
"roleAdmin": "ADMIN",
|
||||
"roleMember": "MEMBER",
|
||||
"saveRole": "Save role",
|
||||
"savingRole": "Saving...",
|
||||
"changeRole": "Change role",
|
||||
"colUserId": "User ID",
|
||||
"colRole": "Role",
|
||||
"colJoinedAt": "Joined At",
|
||||
|
|
@ -602,7 +624,19 @@
|
|||
"namespaceNotFound": "Namespace not found",
|
||||
"globalReadOnly": "This is a built-in system namespace. Membership can only be viewed and cannot be changed.",
|
||||
"frozenReadOnly": "This namespace is frozen. You can still view members, but cannot add, remove, or change roles.",
|
||||
"archivedReadOnly": "This namespace is archived. You can still view members, but cannot change membership until it is restored."
|
||||
"archivedReadOnly": "This namespace is archived. You can still view members, but cannot change membership until it is restored.",
|
||||
"memberReadOnly": "You are a regular member in this namespace. You can view the membership list, but only the OWNER or ADMIN can make changes.",
|
||||
"addSuccessTitle": "Member added",
|
||||
"addSuccessDescription": "User {{userId}} has been added to this namespace.",
|
||||
"addErrorTitle": "Failed to add member",
|
||||
"updateRoleSuccessTitle": "Member role updated",
|
||||
"updateRoleSuccessDescription": "User {{userId}} is now {{role}}.",
|
||||
"updateRoleErrorTitle": "Failed to update member role",
|
||||
"removeConfirmTitle": "Remove member",
|
||||
"removeConfirmDescription": "After removal, user {{userId}} will lose access to this namespace.",
|
||||
"removeSuccessTitle": "Member removed",
|
||||
"removeSuccessDescription": "User {{userId}} has been removed from this namespace.",
|
||||
"removeErrorTitle": "Failed to remove member"
|
||||
},
|
||||
"upload": {
|
||||
"dropHint": "Drop to upload...",
|
||||
|
|
|
|||
|
|
@ -593,6 +593,28 @@
|
|||
"members": {
|
||||
"title": "成员管理",
|
||||
"addMember": "添加成员",
|
||||
"addingMember": "添加中...",
|
||||
"addDialogTitle": "添加命名空间成员",
|
||||
"addDialogDescription": "支持搜索平台用户,也支持直接输入 userId。添加后可立即参与该命名空间的协作。",
|
||||
"searchLabel": "搜索用户",
|
||||
"searchPlaceholder": "输入用户名、邮箱或 userId",
|
||||
"searchHint": "至少输入 2 个字符后再搜索,搜索结果会自动带出 userId。",
|
||||
"searchAction": "搜索",
|
||||
"searchTooShort": "搜索关键词至少需要 2 个字符",
|
||||
"searchResultsTitle": "候选用户",
|
||||
"searchEmpty": "没有找到可添加的活跃用户",
|
||||
"selectCandidate": "使用此用户",
|
||||
"manualUserIdLabel": "手动输入 userId",
|
||||
"manualUserIdPlaceholder": "例如 user-123 或 usr_abcd",
|
||||
"manualUserIdHint": "如果你已经知道对方的 userId,可以直接填写并添加。",
|
||||
"userIdRequired": "请输入 userId",
|
||||
"roleLabel": "成员角色",
|
||||
"roleOwner": "OWNER",
|
||||
"roleAdmin": "ADMIN",
|
||||
"roleMember": "MEMBER",
|
||||
"saveRole": "保存角色",
|
||||
"savingRole": "保存中...",
|
||||
"changeRole": "修改角色",
|
||||
"colUserId": "用户 ID",
|
||||
"colRole": "角色",
|
||||
"colJoinedAt": "加入时间",
|
||||
|
|
@ -602,7 +624,19 @@
|
|||
"namespaceNotFound": "命名空间不存在",
|
||||
"globalReadOnly": "这是系统内置命名空间,成员信息仅供查看,不能进行任何变更。",
|
||||
"frozenReadOnly": "命名空间已冻结,成员列表仍可查看,但当前不能添加、移除或调整成员角色。",
|
||||
"archivedReadOnly": "命名空间已归档,成员列表仍可查看,但在恢复前不能修改成员。"
|
||||
"archivedReadOnly": "命名空间已归档,成员列表仍可查看,但在恢复前不能修改成员。",
|
||||
"memberReadOnly": "你当前是普通成员,仅可查看成员列表;如需调整成员,请联系该命名空间的 OWNER 或 ADMIN。",
|
||||
"addSuccessTitle": "成员已添加",
|
||||
"addSuccessDescription": "用户 {{userId}} 已加入当前命名空间。",
|
||||
"addErrorTitle": "添加成员失败",
|
||||
"updateRoleSuccessTitle": "成员角色已更新",
|
||||
"updateRoleSuccessDescription": "用户 {{userId}} 已切换为 {{role}}。",
|
||||
"updateRoleErrorTitle": "更新成员角色失败",
|
||||
"removeConfirmTitle": "确认移除成员",
|
||||
"removeConfirmDescription": "移除后,用户 {{userId}} 将失去该命名空间的访问和协作权限。",
|
||||
"removeSuccessTitle": "成员已移除",
|
||||
"removeSuccessDescription": "用户 {{userId}} 已从当前命名空间移除。",
|
||||
"removeErrorTitle": "移除成员失败"
|
||||
},
|
||||
"upload": {
|
||||
"dropHint": "放开以上传文件...",
|
||||
|
|
|
|||
|
|
@ -1,28 +1,110 @@
|
|||
import { useState } from 'react'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AddNamespaceMemberDialog } from '@/features/namespace/add-namespace-member-dialog'
|
||||
import { NamespaceHeader } from '@/features/namespace/namespace-header'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { useNamespaceDetail, useNamespaceMembers } from '@/shared/hooks/use-skill-queries'
|
||||
import { Select } from '@/shared/ui/select'
|
||||
import {
|
||||
useMyNamespaces,
|
||||
useNamespaceDetail,
|
||||
useNamespaceMembers,
|
||||
useRemoveNamespaceMember,
|
||||
useUpdateNamespaceMemberRole,
|
||||
} from '@/shared/hooks/use-skill-queries'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
|
||||
type PendingRemoval = {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export function NamespaceMembersPage() {
|
||||
const translation = useTranslation()
|
||||
const t = translation.t
|
||||
const language = translation.i18n.language
|
||||
const { t, i18n } = useTranslation()
|
||||
const params = useParams({ from: '/dashboard/namespaces/$slug/members' })
|
||||
const slug = params.slug
|
||||
const [draftRoles, setDraftRoles] = useState<Record<string, string>>({})
|
||||
const [pendingRemoval, setPendingRemoval] = useState<PendingRemoval | null>(null)
|
||||
const [savingRoleUserId, setSavingRoleUserId] = useState<string | null>(null)
|
||||
const [removingUserId, setRemovingUserId] = useState<string | null>(null)
|
||||
|
||||
const { data: namespace, isLoading: isLoadingNamespace } = useNamespaceDetail(slug)
|
||||
const { data: members, isLoading: isLoadingMembers } = useNamespaceMembers(slug)
|
||||
const { data: members, isLoading: isLoadingMembers, error: membersError } = useNamespaceMembers(slug)
|
||||
const { data: myNamespaces } = useMyNamespaces()
|
||||
const updateRoleMutation = useUpdateNamespaceMemberRole()
|
||||
const removeMemberMutation = useRemoveNamespaceMember()
|
||||
|
||||
const currentNamespace = myNamespaces?.find((item) => item.slug === slug)
|
||||
const currentUserRole = currentNamespace?.currentUserRole
|
||||
const isReadOnly = namespace?.type === 'GLOBAL' || namespace?.status !== 'ACTIVE'
|
||||
const canManageMembers = !isReadOnly && (currentUserRole === 'OWNER' || currentUserRole === 'ADMIN')
|
||||
|
||||
const readOnlyMessage = namespace?.type === 'GLOBAL'
|
||||
? t('members.globalReadOnly')
|
||||
: namespace?.status === 'FROZEN'
|
||||
? t('members.frozenReadOnly')
|
||||
: namespace?.status === 'ARCHIVED'
|
||||
? t('members.archivedReadOnly')
|
||||
: null
|
||||
: currentUserRole === 'MEMBER'
|
||||
? t('members.memberReadOnly')
|
||||
: null
|
||||
|
||||
const resolveDraftRole = (userId: string, currentRole: string) => draftRoles[userId] ?? currentRole
|
||||
|
||||
const handleRoleSave = async (userId: string, currentRole: string) => {
|
||||
const nextRole = resolveDraftRole(userId, currentRole)
|
||||
if (nextRole === currentRole) {
|
||||
return
|
||||
}
|
||||
|
||||
setSavingRoleUserId(userId)
|
||||
try {
|
||||
await updateRoleMutation.mutateAsync({
|
||||
slug,
|
||||
userId,
|
||||
role: nextRole,
|
||||
})
|
||||
toast.success(
|
||||
t('members.updateRoleSuccessTitle'),
|
||||
t('members.updateRoleSuccessDescription', { userId, role: nextRole }),
|
||||
)
|
||||
setDraftRoles((current) => {
|
||||
const next = { ...current }
|
||||
delete next[userId]
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(t('members.updateRoleErrorTitle'), error instanceof Error ? error.message : '')
|
||||
} finally {
|
||||
setSavingRoleUserId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveMember = async () => {
|
||||
if (!pendingRemoval) {
|
||||
return
|
||||
}
|
||||
|
||||
setRemovingUserId(pendingRemoval.userId)
|
||||
try {
|
||||
await removeMemberMutation.mutateAsync({
|
||||
slug,
|
||||
userId: pendingRemoval.userId,
|
||||
})
|
||||
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
|
||||
} finally {
|
||||
setRemovingUserId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingNamespace) {
|
||||
return (
|
||||
|
|
@ -57,13 +139,23 @@ export function NamespaceMembersPage() {
|
|||
) : null}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Button disabled={isReadOnly}>{t('members.addMember')}</Button>
|
||||
{canManageMembers ? (
|
||||
<AddNamespaceMemberDialog slug={slug}>
|
||||
<Button>{t('members.addMember')}</Button>
|
||||
</AddNamespaceMemberDialog>
|
||||
) : (
|
||||
<Button disabled>{t('members.addMember')}</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoadingMembers ? (
|
||||
{membersError ? (
|
||||
<Card className="p-6 text-center text-red-600">
|
||||
{membersError.message}
|
||||
</Card>
|
||||
) : isLoadingMembers ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-14 animate-shimmer rounded-lg" />
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-14 animate-shimmer rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : members && members.length > 0 ? (
|
||||
|
|
@ -79,24 +171,66 @@ export function NamespaceMembersPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((member) => (
|
||||
<tr key={member.id} className="border-b border-border/40 last:border-b-0 hover:bg-secondary/30 transition-colors">
|
||||
<td className="p-4 font-medium">{member.userId}</td>
|
||||
<td className="p-4">
|
||||
<span className="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium bg-accent/10 text-accent border border-accent/20">
|
||||
{member.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-muted-foreground">
|
||||
{new Date(member.createdAt).toLocaleDateString(language)}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<Button variant="destructive" size="sm" disabled={isReadOnly}>
|
||||
{t('members.remove')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{members.map((member) => {
|
||||
const roleValue = resolveDraftRole(member.userId, member.role)
|
||||
const isOwner = member.role === 'OWNER'
|
||||
const isSavingRole = savingRoleUserId === member.userId
|
||||
const isRemoving = removingUserId === member.userId
|
||||
|
||||
return (
|
||||
<tr key={member.id} className="border-b border-border/40 last:border-b-0 hover:bg-secondary/30 transition-colors">
|
||||
<td className="p-4 font-medium font-mono">{member.userId}</td>
|
||||
<td className="p-4">
|
||||
{canManageMembers && !isOwner ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
className="w-36"
|
||||
value={roleValue}
|
||||
onChange={(event) => {
|
||||
setDraftRoles((current) => ({
|
||||
...current,
|
||||
[member.userId]: event.target.value,
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<option value="MEMBER">{t('members.roleMember')}</option>
|
||||
<option value="ADMIN">{t('members.roleAdmin')}</option>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={roleValue === member.role || isSavingRole}
|
||||
onClick={() => handleRoleSave(member.userId, member.role)}
|
||||
>
|
||||
{isSavingRole ? t('members.savingRole') : t('members.saveRole')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium bg-accent/10 text-accent border border-accent/20">
|
||||
{member.role === 'OWNER'
|
||||
? t('members.roleOwner')
|
||||
: member.role === 'ADMIN'
|
||||
? t('members.roleAdmin')
|
||||
: t('members.roleMember')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-muted-foreground">
|
||||
{new Date(member.createdAt).toLocaleDateString(i18n.language)}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!canManageMembers || isOwner || isRemoving}
|
||||
onClick={() => setPendingRemoval({ userId: member.userId })}
|
||||
>
|
||||
{t('members.remove')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -107,6 +241,20 @@ export function NamespaceMembersPage() {
|
|||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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={handleRemoveMember}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest } from '@/api/types'
|
||||
import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole } from '@/api/types'
|
||||
import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client'
|
||||
|
||||
const PUBLISH_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
|
@ -69,9 +69,23 @@ async function getNamespaceDetail(slug: string): Promise<Namespace> {
|
|||
}
|
||||
|
||||
async function getNamespaceMembers(slug: string): Promise<NamespaceMember[]> {
|
||||
const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug
|
||||
const page = await fetchJson<PagedResponse<NamespaceMember>>(`${WEB_API_PREFIX}/namespaces/${cleanSlug}/members`)
|
||||
return page.items
|
||||
return namespaceApi.listMembers(slug)
|
||||
}
|
||||
|
||||
async function searchNamespaceMemberCandidates(params: { slug: string; search: string }): Promise<NamespaceCandidateUser[]> {
|
||||
return namespaceApi.searchMemberCandidates(params.slug, params.search)
|
||||
}
|
||||
|
||||
async function addNamespaceMember(params: { slug: string; userId: string; role: NamespaceRole }): Promise<NamespaceMember> {
|
||||
return namespaceApi.addMember(params.slug, { userId: params.userId, role: params.role })
|
||||
}
|
||||
|
||||
async function updateNamespaceMemberRole(params: { slug: string; userId: string; role: NamespaceRole }): Promise<NamespaceMember> {
|
||||
return namespaceApi.updateMemberRole(params.slug, params.userId, params.role)
|
||||
}
|
||||
|
||||
async function removeNamespaceMember(params: { slug: string; userId: string }): Promise<void> {
|
||||
return namespaceApi.removeMember(params.slug, params.userId)
|
||||
}
|
||||
|
||||
async function publishSkill(params: { namespace: string; file: File; visibility: string }): Promise<PublishResult> {
|
||||
|
|
@ -188,6 +202,14 @@ export function useNamespaceMembers(slug: string) {
|
|||
})
|
||||
}
|
||||
|
||||
export function useNamespaceMemberCandidates(slug: string, search: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['namespaces', slug, 'member-candidates', search],
|
||||
queryFn: () => searchNamespaceMemberCandidates({ slug, search }),
|
||||
enabled: enabled && !!slug && search.trim().length >= 2,
|
||||
})
|
||||
}
|
||||
|
||||
function invalidateNamespaceQueries(queryClient: ReturnType<typeof useQueryClient>, slug: string) {
|
||||
queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['namespaces', slug] })
|
||||
|
|
@ -209,6 +231,39 @@ export function usePublishSkill() {
|
|||
})
|
||||
}
|
||||
|
||||
export function useAddNamespaceMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: addNamespaceMember,
|
||||
onSuccess: (_data, variables) => {
|
||||
invalidateNamespaceQueries(queryClient, variables.slug)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateNamespaceMemberRole() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateNamespaceMemberRole,
|
||||
onSuccess: (_data, variables) => {
|
||||
invalidateNamespaceQueries(queryClient, variables.slug)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveNamespaceMember() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: removeNamespaceMember,
|
||||
onSuccess: (_data, variables) => {
|
||||
invalidateNamespaceQueries(queryClient, variables.slug)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useArchiveSkill() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue