feat: complete namespace member management

This commit is contained in:
yun-zhi-ztl 2026-03-16 12:39:09 +08:00 committed by Xudong Sun
parent 889a6d3b9d
commit 8fc2c01a32
16 changed files with 900 additions and 37 deletions

View file

@ -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,

View file

@ -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()
);
}
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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=新所有者不是该命名空间成员

View file

@ -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,

View file

@ -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);
}
}
}

View file

@ -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());

View file

@ -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));
}
}

View file

@ -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 = {

View file

@ -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

View 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>
)
}

View file

@ -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...",

View file

@ -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": "放开以上传文件...",

View file

@ -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>
)
}

View file

@ -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()