diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 851f99d5..ed20b758 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -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> listMembers(@PathVariable String slug, Pageable pageable) { + public ApiResponse> listMembers(@PathVariable String slug, + Pageable pageable, + @RequestAttribute("userId") String userId) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); + namespaceService.assertMember(namespace.getId(), userId); Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); PageResponse response = PageResponse.from(members.map(MemberResponse::from)); return ok("response.success.read", response); } + @GetMapping("/namespaces/{slug}/member-candidates") + public ApiResponse> 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 addMember( @PathVariable String slug, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceCandidateUserResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceCandidateUserResponse.java new file mode 100644 index 00000000..f65bcda0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceCandidateUserResponse.java @@ -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() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java new file mode 100644 index 00000000..daa2b1b6 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java @@ -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 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 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); + } +} diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 91dbcd7b..d1728926 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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 diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 2de14886..ba3ad200 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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=新所有者不是该命名空间成员 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 7a86d0ae..dd5d41d9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -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, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespaceMemberCandidateServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespaceMemberCandidateServiceTest.java new file mode 100644 index 00000000..7d7b2a81 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespaceMemberCandidateServiceTest.java @@ -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 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); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index 5eb62521..071f92d7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -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()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java index 327e95f7..d3127c52 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java @@ -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)); + } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a0b7410b..fc3755c6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + 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 { + const query = new URLSearchParams({ + search: search.trim(), + size: String(size), + }) + return fetchJson( + `${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/member-candidates?${query.toString()}`, + ) + }, + + async addMember(slug: string, request: { userId: string; role: string }): Promise { + return fetchJson(`${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 { + return fetchJson( + `${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 { + await fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, } export const tokenApi = { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e2667969..aff9eb68 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 diff --git a/web/src/features/namespace/add-namespace-member-dialog.tsx b/web/src/features/namespace/add-namespace-member-dialog.tsx new file mode 100644 index 00000000..11279d55 --- /dev/null +++ b/web/src/features/namespace/add-namespace-member-dialog.tsx @@ -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('MEMBER') + const [userIdError, setUserIdError] = useState(null) + const [searchError, setSearchError] = useState(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 ( + + {children} + + + {t('members.addDialogTitle')} + + {t('members.addDialogDescription')} + + + +
+
+ +
+ { + setSearchInput(event.target.value) + if (searchError) { + setSearchError(null) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleSearch() + } + }} + /> + +
+

+ {searchError ?? t('members.searchHint')} +

+
+ + {appliedSearch ? ( +
+
{t('members.searchResultsTitle')}
+ {isFetching ? ( +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ ))} +
+ ) : candidatesError ? ( +

{candidatesError.message}

+ ) : candidates && candidates.length > 0 ? ( +
+ {candidates.map((candidate) => ( +
+
+
{candidate.displayName}
+
{candidate.email || candidate.userId}
+
{candidate.userId}
+
+ +
+ ))} +
+ ) : ( +

{t('members.searchEmpty')}

+ )} +
+ ) : null} + +
+ + { + setUserId(event.target.value) + if (userIdError) { + setUserIdError(null) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleAddMember() + } + }} + aria-invalid={userIdError ? 'true' : 'false'} + /> +

+ {userIdError ?? t('members.manualUserIdHint')} +

+
+ +
+ + +
+
+ + {addMemberMutation.error ? ( +

{addMemberMutation.error.message}

+ ) : null} + + + + + + +
+ ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b273e20a..74a4fe6d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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...", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 7645008d..8078e767 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "放开以上传文件...", diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index 5fc53cc3..d2e92bd0 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -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>({}) + const [pendingRemoval, setPendingRemoval] = useState(null) + const [savingRoleUserId, setSavingRoleUserId] = useState(null) + const [removingUserId, setRemovingUserId] = useState(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}
- + {canManageMembers ? ( + + + + ) : ( + + )}
- {isLoadingMembers ? ( + {membersError ? ( + + {membersError.message} + + ) : isLoadingMembers ? (
- {Array.from({ length: 3 }).map((_, i) => ( -
+ {Array.from({ length: 3 }).map((_, index) => ( +
))}
) : members && members.length > 0 ? ( @@ -79,24 +171,66 @@ export function NamespaceMembersPage() { - {members.map((member) => ( - - {member.userId} - - - {member.role} - - - - {new Date(member.createdAt).toLocaleDateString(language)} - - - - - - ))} + {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 ( + + {member.userId} + + {canManageMembers && !isOwner ? ( +
+ + +
+ ) : ( + + {member.role === 'OWNER' + ? t('members.roleOwner') + : member.role === 'ADMIN' + ? t('members.roleAdmin') + : t('members.roleMember')} + + )} + + + {new Date(member.createdAt).toLocaleDateString(i18n.language)} + + + + + + ) + })}
@@ -107,6 +241,20 @@ export function NamespaceMembersPage() { )}
+ + { + if (!open) { + setPendingRemoval(null) + } + }} + title={t('members.removeConfirmTitle')} + description={pendingRemoval ? t('members.removeConfirmDescription', { userId: pendingRemoval.userId }) : ''} + confirmText={t('members.remove')} + variant="destructive" + onConfirm={handleRemoveMember} + /> ) } diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index f34bd658..f8cbc5d8 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -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 { } async function getNamespaceMembers(slug: string): Promise { - const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug - const page = await fetchJson>(`${WEB_API_PREFIX}/namespaces/${cleanSlug}/members`) - return page.items + return namespaceApi.listMembers(slug) +} + +async function searchNamespaceMemberCandidates(params: { slug: string; search: string }): Promise { + return namespaceApi.searchMemberCandidates(params.slug, params.search) +} + +async function addNamespaceMember(params: { slug: string; userId: string; role: NamespaceRole }): Promise { + return namespaceApi.addMember(params.slug, { userId: params.userId, role: params.role }) +} + +async function updateNamespaceMemberRole(params: { slug: string; userId: string; role: NamespaceRole }): Promise { + return namespaceApi.updateMemberRole(params.slug, params.userId, params.role) +} + +async function removeNamespaceMember(params: { slug: string; userId: string }): Promise { + return namespaceApi.removeMember(params.slug, params.userId) } async function publishSkill(params: { namespace: string; file: File; visibility: string }): Promise { @@ -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, 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()