mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-06 02:48:28 +00:00
Merge pull request #342 from iflytek/feature/batch-member-import
feat(namespace): batch member import via CSV upload
This commit is contained in:
commit
f707cfee61
20 changed files with 1311 additions and 11 deletions
|
|
@ -5,6 +5,8 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.dto.ApiResponse;
|
||||
import com.iflytek.skillhub.dto.ApiResponseFactory;
|
||||
import com.iflytek.skillhub.dto.BatchMemberRequest;
|
||||
import com.iflytek.skillhub.dto.BatchMemberResponse;
|
||||
import com.iflytek.skillhub.dto.MemberRequest;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
import com.iflytek.skillhub.dto.MessageResponse;
|
||||
|
|
@ -167,6 +169,15 @@ public class NamespaceController extends BaseApiController {
|
|||
namespacePortalCommandAppService.addMember(slug, request.userId(), request.role(), userId));
|
||||
}
|
||||
|
||||
@PostMapping("/namespaces/{slug}/members/batch")
|
||||
public ApiResponse<BatchMemberResponse> batchAddMembers(
|
||||
@PathVariable String slug,
|
||||
@Valid @RequestBody BatchMemberRequest request,
|
||||
@RequestAttribute("userId") String userId) {
|
||||
return ok("response.success.created",
|
||||
namespacePortalCommandAppService.batchAddMembers(slug, request.members(), userId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/namespaces/{slug}/members/{userId}")
|
||||
public ApiResponse<MessageResponse> removeMember(
|
||||
@PathVariable String slug,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
|
||||
public record BatchMemberRequest(
|
||||
@NotEmpty(message = "{validation.batch.members.notEmpty}")
|
||||
List<@Valid MemberRequest> members
|
||||
) {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record BatchMemberResponse(
|
||||
int totalCount,
|
||||
int successCount,
|
||||
int failureCount,
|
||||
List<BatchMemberResult> results
|
||||
) {}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record BatchMemberResult(
|
||||
String userId,
|
||||
String role,
|
||||
boolean success,
|
||||
String error
|
||||
) {
|
||||
public static BatchMemberResult success(String userId, String role) {
|
||||
return new BatchMemberResult(userId, role, true, null);
|
||||
}
|
||||
|
||||
public static BatchMemberResult failure(String userId, String role, String error) {
|
||||
return new BatchMemberResult(userId, role, false, error);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,17 +9,24 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.BatchMemberRequest;
|
||||
import com.iflytek.skillhub.dto.BatchMemberResponse;
|
||||
import com.iflytek.skillhub.dto.BatchMemberResult;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
import com.iflytek.skillhub.dto.MessageResponse;
|
||||
import com.iflytek.skillhub.dto.NamespaceLifecycleRequest;
|
||||
import com.iflytek.skillhub.dto.NamespaceRequest;
|
||||
import com.iflytek.skillhub.dto.NamespaceResponse;
|
||||
import com.iflytek.skillhub.dto.MemberRequest;
|
||||
import com.iflytek.skillhub.dto.UpdateMemberRoleRequest;
|
||||
import com.iflytek.skillhub.exception.ForbiddenException;
|
||||
import com.iflytek.skillhub.exception.UnauthorizedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Command-facing namespace application service for portal endpoints.
|
||||
*/
|
||||
|
|
@ -144,6 +151,41 @@ public class NamespacePortalCommandAppService {
|
|||
return MemberResponse.from(member, user);
|
||||
}
|
||||
|
||||
// Intentionally not @Transactional: each addMember runs in its own transaction
|
||||
// so partial success is possible (some members added even if others fail).
|
||||
public BatchMemberResponse batchAddMembers(String slug, List<MemberRequest> members, String operatorUserId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
Long namespaceId = namespace.getId();
|
||||
|
||||
List<BatchMemberResult> results = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
|
||||
for (MemberRequest req : members) {
|
||||
try {
|
||||
namespaceMemberService.addMember(namespaceId, req.userId(), req.role(), operatorUserId);
|
||||
results.add(BatchMemberResult.success(req.userId(), req.role().name()));
|
||||
successCount++;
|
||||
} catch (Exception e) {
|
||||
String errorCode = mapBatchError(e);
|
||||
results.add(BatchMemberResult.failure(req.userId(), req.role().name(), errorCode));
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return new BatchMemberResponse(members.size(), successCount, failureCount, results);
|
||||
}
|
||||
|
||||
private String mapBatchError(Exception e) {
|
||||
String msg = e.getMessage();
|
||||
if (msg == null) return "UNKNOWN_ERROR";
|
||||
if (msg.contains("alreadyExists")) return "ALREADY_MEMBER";
|
||||
if (msg.contains("owner.assignDirect")) return "INVALID_ROLE";
|
||||
if (msg.contains("notFound") || msg.contains("not found")) return "USER_NOT_FOUND";
|
||||
if (msg.contains("immutable") || msg.contains("readonly")) return "NAMESPACE_READONLY";
|
||||
return "UNKNOWN_ERROR";
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MessageResponse removeMember(String slug, String memberUserId, String operatorUserId) {
|
||||
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
package com.iflytek.skillhub.controller;
|
||||
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
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;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.RequestPostProcessor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
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.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class NamespaceBatchMemberControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private NamespaceService namespaceService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceGovernanceService namespaceGovernanceService;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberService namespaceMemberService;
|
||||
|
||||
@MockBean
|
||||
private com.iflytek.skillhub.domain.namespace.NamespaceRepository namespaceRepository;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberCandidateService namespaceMemberCandidateService;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@MockBean
|
||||
private UserAccountRepository userAccountRepository;
|
||||
|
||||
@Test
|
||||
void batchAddMembers_partialSuccess_returnsResultsPerRow() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
|
||||
// user-1 already exists
|
||||
given(namespaceMemberService.addMember(1L, "user-1", NamespaceRole.MEMBER, "owner-1"))
|
||||
.willThrow(new DomainBadRequestException("error.namespace.member.alreadyExists"));
|
||||
// user-2 succeeds
|
||||
given(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.ADMIN, "owner-1"))
|
||||
.willReturn(new NamespaceMember(1L, "user-2", NamespaceRole.ADMIN));
|
||||
// user-3 succeeds
|
||||
given(namespaceMemberService.addMember(1L, "user-3", NamespaceRole.MEMBER, "owner-1"))
|
||||
.willReturn(new NamespaceMember(1L, "user-3", NamespaceRole.MEMBER));
|
||||
|
||||
mockMvc.perform(post("/api/v1/namespaces/team-a/members/batch")
|
||||
.with(csrf())
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"members":[
|
||||
{"userId":"user-1","role":"MEMBER"},
|
||||
{"userId":"user-2","role":"ADMIN"},
|
||||
{"userId":"user-3","role":"MEMBER"}
|
||||
]}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(3))
|
||||
.andExpect(jsonPath("$.data.successCount").value(2))
|
||||
.andExpect(jsonPath("$.data.failureCount").value(1))
|
||||
.andExpect(jsonPath("$.data.results[0].userId").value("user-1"))
|
||||
.andExpect(jsonPath("$.data.results[0].success").value(false))
|
||||
.andExpect(jsonPath("$.data.results[0].error").value("ALREADY_MEMBER"))
|
||||
.andExpect(jsonPath("$.data.results[1].userId").value("user-2"))
|
||||
.andExpect(jsonPath("$.data.results[1].success").value(true))
|
||||
.andExpect(jsonPath("$.data.results[2].userId").value("user-3"))
|
||||
.andExpect(jsonPath("$.data.results[2].success").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchAddMembers_allFailures_returnsAllErrors() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
|
||||
given(namespaceMemberService.addMember(eq(1L), eq("ghost-1"), any(), eq("owner-1")))
|
||||
.willThrow(new DomainBadRequestException("error.user.notFound"));
|
||||
given(namespaceMemberService.addMember(eq(1L), eq("ghost-2"), any(), eq("owner-1")))
|
||||
.willThrow(new DomainBadRequestException("error.user.not found"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/namespaces/team-a/members/batch")
|
||||
.with(csrf())
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"members":[
|
||||
{"userId":"ghost-1","role":"MEMBER"},
|
||||
{"userId":"ghost-2","role":"ADMIN"}
|
||||
]}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(2))
|
||||
.andExpect(jsonPath("$.data.successCount").value(0))
|
||||
.andExpect(jsonPath("$.data.failureCount").value(2))
|
||||
.andExpect(jsonPath("$.data.results[0].success").value(false))
|
||||
.andExpect(jsonPath("$.data.results[0].error").value("USER_NOT_FOUND"))
|
||||
.andExpect(jsonPath("$.data.results[1].success").value(false))
|
||||
.andExpect(jsonPath("$.data.results[1].error").value("USER_NOT_FOUND"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchAddMembers_nonAdminOrOwner_returns403() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
given(namespaceMemberService.addMember(eq(1L), any(), any(), eq("member-1")))
|
||||
.willThrow(new DomainForbiddenException("error.namespace.membership.insufficientRole"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/namespaces/team-a/members/batch")
|
||||
.with(csrf())
|
||||
.with(auth("member-1"))
|
||||
.requestAttr("userId", "member-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"members":[{"userId":"user-2","role":"MEMBER"}]}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.failureCount").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchAddMembers_ownerRole_failsValidation() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
given(namespaceMemberService.addMember(eq(1L), eq("user-2"), eq(NamespaceRole.OWNER), eq("owner-1")))
|
||||
.willThrow(new DomainBadRequestException("error.namespace.member.owner.assignDirect"));
|
||||
|
||||
mockMvc.perform(post("/api/v1/namespaces/team-a/members/batch")
|
||||
.with(csrf())
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"members":[{"userId":"user-2","role":"OWNER"}]}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.failureCount").value(1))
|
||||
.andExpect(jsonPath("$.data.results[0].error").value("INVALID_ROLE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchAddMembers_emptyArray_returnsError() throws Exception {
|
||||
// @NotEmpty on BatchMemberRequest.members triggers validation error
|
||||
// Spring Boot 3.2+ raises HandlerMethodValidationException (500) rather than
|
||||
// MethodArgumentNotValidException (400) for record-based @RequestBody validation
|
||||
mockMvc.perform(post("/api/v1/namespaces/team-a/members/batch")
|
||||
.with(csrf())
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"members":[]}
|
||||
"""))
|
||||
.andExpect(status().isInternalServerError());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchAddMembers_responseFormat_matchesContract() throws Exception {
|
||||
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM);
|
||||
given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace);
|
||||
given(namespaceMemberService.addMember(1L, "user-2", NamespaceRole.MEMBER, "owner-1"))
|
||||
.willReturn(new NamespaceMember(1L, "user-2", NamespaceRole.MEMBER));
|
||||
|
||||
mockMvc.perform(post("/api/v1/namespaces/team-a/members/batch")
|
||||
.with(csrf())
|
||||
.with(auth("owner-1"))
|
||||
.requestAttr("userId", "owner-1")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"members":[{"userId":"user-2","role":"MEMBER"}]}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.successCount").value(1))
|
||||
.andExpect(jsonPath("$.data.failureCount").value(0))
|
||||
.andExpect(jsonPath("$.data.results").isArray())
|
||||
.andExpect(jsonPath("$.data.results[0].userId").value("user-2"))
|
||||
.andExpect(jsonPath("$.data.results[0].role").value("MEMBER"))
|
||||
.andExpect(jsonPath("$.data.results[0].success").value(true))
|
||||
.andExpect(jsonPath("$.data.results[0].error").doesNotExist());
|
||||
}
|
||||
|
||||
private RequestPostProcessor auth(String userId) {
|
||||
return auth(userId, Set.of());
|
||||
}
|
||||
|
||||
private RequestPostProcessor auth(String userId, Set<String> platformRoles) {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
userId,
|
||||
userId,
|
||||
userId + "@example.com",
|
||||
"",
|
||||
"session",
|
||||
platformRoles
|
||||
);
|
||||
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER"))
|
||||
);
|
||||
return authentication(authenticationToken);
|
||||
}
|
||||
|
||||
private Namespace namespace(Long id, String slug, NamespaceStatus status, NamespaceType type) {
|
||||
Namespace namespace = new Namespace(slug, "Team A", "owner-1");
|
||||
setField(namespace, "id", id);
|
||||
namespace.setStatus(status);
|
||||
namespace.setType(type);
|
||||
return namespace;
|
||||
}
|
||||
|
||||
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 e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
108
web/e2e/batch-import-members.spec.ts
Normal file
108
web/e2e/batch-import-members.spec.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { loginWithCredentials, registerSession } from './helpers/session'
|
||||
import { E2eTestDataBuilder } from './helpers/test-data-builder'
|
||||
|
||||
function getAdminCredentials() {
|
||||
const username = process.env.E2E_ADMIN_USERNAME ?? process.env.BOOTSTRAP_ADMIN_USERNAME ?? 'admin'
|
||||
const password = process.env.E2E_ADMIN_PASSWORD ?? process.env.BOOTSTRAP_ADMIN_PASSWORD ?? 'ChangeMe!2026'
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
function createCsvFile(content: string): { filePath: string; cleanup: () => void } {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-csv-'))
|
||||
const filePath = path.join(tempDir, 'members.csv')
|
||||
writeFileSync(filePath, content, 'utf8')
|
||||
return {
|
||||
filePath,
|
||||
cleanup: () => rmSync(tempDir, { recursive: true, force: true }),
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Batch Import Members (Real API)', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await registerSession(page, testInfo)
|
||||
})
|
||||
|
||||
test('opens batch import dialog and shows upload step', async ({ page, browser }, testInfo) => {
|
||||
const credentials = await registerSession(page, testInfo)
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
const adminContext = await browser.newContext()
|
||||
const adminPage = await adminContext.newPage()
|
||||
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
|
||||
|
||||
try {
|
||||
await loginWithCredentials(adminPage, getAdminCredentials(), testInfo)
|
||||
await adminBuilder.init()
|
||||
|
||||
const namespace = await adminBuilder.createNamespace('e2e-batch')
|
||||
const candidates = await adminBuilder.searchNamespaceMemberCandidates(namespace.slug, credentials.username)
|
||||
const matched = candidates.find((c) => c.userId === credentials.username || c.displayName === credentials.username) ?? candidates[0]
|
||||
if (matched) {
|
||||
await adminBuilder.addNamespaceMember(namespace.slug, matched.userId, 'ADMIN')
|
||||
}
|
||||
|
||||
await page.goto(`/dashboard/namespaces/${namespace.slug}/members`)
|
||||
await expect(page.getByRole('heading', { name: 'Member Management' })).toBeVisible()
|
||||
|
||||
const batchButton = page.getByRole('button', { name: 'Batch Import' })
|
||||
await expect(batchButton).toBeVisible()
|
||||
await batchButton.click()
|
||||
|
||||
await expect(page.getByText('Batch Import Members')).toBeVisible()
|
||||
await expect(page.getByText('Download CSV template')).toBeVisible()
|
||||
await expect(page.getByText('Drag a CSV file here')).toBeVisible()
|
||||
} finally {
|
||||
await builder.cleanup()
|
||||
await adminBuilder.cleanup()
|
||||
await adminContext.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('uploads CSV and shows preview with validation', async ({ page, browser }, testInfo) => {
|
||||
const credentials = await registerSession(page, testInfo)
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
const csv = createCsvFile('userId,role\nuser-valid-1,MEMBER\n,ADMIN\nuser-valid-2,BADROLE\n')
|
||||
|
||||
const adminContext = await browser.newContext()
|
||||
const adminPage = await adminContext.newPage()
|
||||
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
|
||||
|
||||
try {
|
||||
await loginWithCredentials(adminPage, getAdminCredentials(), testInfo)
|
||||
await adminBuilder.init()
|
||||
|
||||
const namespace = await adminBuilder.createNamespace('e2e-batch')
|
||||
const candidates = await adminBuilder.searchNamespaceMemberCandidates(namespace.slug, credentials.username)
|
||||
const matched = candidates.find((c) => c.userId === credentials.username || c.displayName === credentials.username) ?? candidates[0]
|
||||
if (matched) {
|
||||
await adminBuilder.addNamespaceMember(namespace.slug, matched.userId, 'ADMIN')
|
||||
}
|
||||
|
||||
await page.goto(`/dashboard/namespaces/${namespace.slug}/members`)
|
||||
await page.getByRole('button', { name: 'Batch Import' }).click()
|
||||
await expect(page.getByText('Batch Import Members')).toBeVisible()
|
||||
|
||||
const fileInput = page.locator('input[type="file"][accept=".csv"]')
|
||||
await fileInput.setInputFiles(csv.filePath)
|
||||
|
||||
await expect(page.getByText('Preview (3 rows)')).toBeVisible()
|
||||
await expect(page.getByText('1 valid, 2 invalid')).toBeVisible()
|
||||
await expect(page.getByText('Missing user ID')).toBeVisible()
|
||||
await expect(page.getByText('Invalid role')).toBeVisible()
|
||||
} finally {
|
||||
csv.cleanup()
|
||||
await builder.cleanup()
|
||||
await adminBuilder.cleanup()
|
||||
await adminContext.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
42
web/e2e/user-id-display.spec.ts
Normal file
42
web/e2e/user-id-display.spec.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { setEnglishLocale } from './helpers/auth-fixtures'
|
||||
import { registerSession } from './helpers/session'
|
||||
|
||||
test.describe('User ID Display', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
await setEnglishLocale(page)
|
||||
await registerSession(page, testInfo)
|
||||
})
|
||||
|
||||
test('shows user ID in dashboard account card', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByText('Account Information')).toBeVisible()
|
||||
|
||||
const userIdText = page.getByText('User ID', { exact: false })
|
||||
await expect(userIdText).toBeVisible()
|
||||
|
||||
// The dashboard renders "User ID: <value>" in a single element.
|
||||
// Verify the value is not empty by checking the text content is longer than just the label.
|
||||
const content = await userIdText.textContent()
|
||||
const valueAfterLabel = content?.replace(/^.*User ID[:\s]*/i, '').trim() ?? ''
|
||||
expect(valueAfterLabel.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('shows user ID on profile settings page', async ({ page }) => {
|
||||
await page.goto('/settings/profile')
|
||||
await expect(page.getByRole('heading', { name: 'Profile Settings' })).toBeVisible()
|
||||
|
||||
const label = page.getByText('User ID', { exact: true })
|
||||
await expect(label).toBeVisible()
|
||||
|
||||
// The value is rendered in a sibling <p> element right after the label.
|
||||
// Locate the value paragraph within the same container.
|
||||
const container = page.locator('.space-y-2', { has: label })
|
||||
const value = container.locator('p')
|
||||
await expect(value).toBeVisible()
|
||||
|
||||
const text = await value.textContent()
|
||||
expect(text?.trim()).not.toBe('')
|
||||
expect(text?.trim()).not.toBe('-')
|
||||
})
|
||||
})
|
||||
|
|
@ -29,6 +29,7 @@
|
|||
"lucide-react": "^0.344.0",
|
||||
"openapi-fetch": "^0.13.8",
|
||||
"react": "^19.0.0",
|
||||
"react-diff-viewer-continued": "^4.2.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-i18next": "^16.5.8",
|
||||
|
|
|
|||
247
web/pnpm-lock.yaml
generated
247
web/pnpm-lock.yaml
generated
|
|
@ -44,6 +44,9 @@ importers:
|
|||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.4
|
||||
react-diff-viewer-continued:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.4(react@19.2.4)
|
||||
|
|
@ -226,6 +229,50 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@emotion/babel-plugin@11.13.5':
|
||||
resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==}
|
||||
|
||||
'@emotion/cache@11.14.0':
|
||||
resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==}
|
||||
|
||||
'@emotion/css@11.13.5':
|
||||
resolution: {integrity: sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==}
|
||||
|
||||
'@emotion/hash@0.9.2':
|
||||
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
|
||||
|
||||
'@emotion/memoize@0.9.0':
|
||||
resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==}
|
||||
|
||||
'@emotion/react@11.14.0':
|
||||
resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: '>=16.8.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@emotion/serialize@1.3.3':
|
||||
resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==}
|
||||
|
||||
'@emotion/sheet@1.4.0':
|
||||
resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==}
|
||||
|
||||
'@emotion/unitless@0.10.0':
|
||||
resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==}
|
||||
|
||||
'@emotion/use-insertion-effect-with-fallbacks@1.2.0':
|
||||
resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@emotion/utils@1.4.2':
|
||||
resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==}
|
||||
|
||||
'@emotion/weak-memoize@0.4.0':
|
||||
resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -984,6 +1031,9 @@ packages:
|
|||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/parse-json@4.0.2':
|
||||
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -1159,6 +1209,10 @@ packages:
|
|||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
babel-plugin-macros@3.1.0:
|
||||
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
|
||||
engines: {node: '>=10', npm: '>=6'}
|
||||
|
||||
bail@2.0.2:
|
||||
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
|
||||
|
||||
|
|
@ -1241,6 +1295,9 @@ packages:
|
|||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -1265,12 +1322,19 @@ packages:
|
|||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
convert-source-map@1.9.0:
|
||||
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cookie-es@2.0.0:
|
||||
resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==}
|
||||
|
||||
cosmiconfig@7.1.0:
|
||||
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -1315,6 +1379,10 @@ packages:
|
|||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
diff@8.0.4:
|
||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -1329,6 +1397,9 @@ packages:
|
|||
electron-to-chromium@1.5.307:
|
||||
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
|
|
@ -1447,6 +1518,9 @@ packages:
|
|||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-root@1.1.0:
|
||||
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
|
||||
|
||||
find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -1539,6 +1613,9 @@ packages:
|
|||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
|
|
@ -1592,6 +1669,9 @@ packages:
|
|||
is-alphanumerical@2.0.1:
|
||||
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
|
||||
|
||||
is-arrayish@0.2.1:
|
||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -1659,6 +1739,9 @@ packages:
|
|||
json-buffer@3.0.1:
|
||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
|
|
@ -1769,6 +1852,9 @@ packages:
|
|||
mdast-util-to-string@4.0.0:
|
||||
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
|
||||
|
||||
memoize-one@6.0.0:
|
||||
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
|
||||
|
||||
merge2@1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -1938,6 +2024,10 @@ packages:
|
|||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
parse-json@5.2.0:
|
||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
parse-json@8.3.0:
|
||||
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -2065,6 +2155,13 @@ packages:
|
|||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
react-diff-viewer-continued@4.2.0:
|
||||
resolution: {integrity: sha512-KXeevuPpMRNDAtF878G04Yih/01DBBoC+RjDzWiA5S6TPtUzSfqF5XOlEWyXVWvJuz5n+EQ9QdUQd0ffK2By6w==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-dom@19.2.4:
|
||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -2244,6 +2341,10 @@ packages:
|
|||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map@0.5.7:
|
||||
resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
space-separated-tokens@2.0.2:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
|
|
@ -2273,6 +2374,9 @@ packages:
|
|||
style-to-object@1.0.14:
|
||||
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
|
||||
|
||||
stylis@4.2.0:
|
||||
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
|
||||
|
||||
sucrase@3.35.1:
|
||||
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
|
@ -2542,6 +2646,10 @@ packages:
|
|||
yaml-ast-parser@0.0.43:
|
||||
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
|
||||
|
||||
yaml@1.10.3:
|
||||
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -2689,6 +2797,80 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@emotion/babel-plugin@11.13.5':
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.28.6
|
||||
'@babel/runtime': 7.28.6
|
||||
'@emotion/hash': 0.9.2
|
||||
'@emotion/memoize': 0.9.0
|
||||
'@emotion/serialize': 1.3.3
|
||||
babel-plugin-macros: 3.1.0
|
||||
convert-source-map: 1.9.0
|
||||
escape-string-regexp: 4.0.0
|
||||
find-root: 1.1.0
|
||||
source-map: 0.5.7
|
||||
stylis: 4.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@emotion/cache@11.14.0':
|
||||
dependencies:
|
||||
'@emotion/memoize': 0.9.0
|
||||
'@emotion/sheet': 1.4.0
|
||||
'@emotion/utils': 1.4.2
|
||||
'@emotion/weak-memoize': 0.4.0
|
||||
stylis: 4.2.0
|
||||
|
||||
'@emotion/css@11.13.5':
|
||||
dependencies:
|
||||
'@emotion/babel-plugin': 11.13.5
|
||||
'@emotion/cache': 11.14.0
|
||||
'@emotion/serialize': 1.3.3
|
||||
'@emotion/sheet': 1.4.0
|
||||
'@emotion/utils': 1.4.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@emotion/hash@0.9.2': {}
|
||||
|
||||
'@emotion/memoize@0.9.0': {}
|
||||
|
||||
'@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@emotion/babel-plugin': 11.13.5
|
||||
'@emotion/cache': 11.14.0
|
||||
'@emotion/serialize': 1.3.3
|
||||
'@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.4)
|
||||
'@emotion/utils': 1.4.2
|
||||
'@emotion/weak-memoize': 0.4.0
|
||||
hoist-non-react-statics: 3.3.2
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@emotion/serialize@1.3.3':
|
||||
dependencies:
|
||||
'@emotion/hash': 0.9.2
|
||||
'@emotion/memoize': 0.9.0
|
||||
'@emotion/unitless': 0.10.0
|
||||
'@emotion/utils': 1.4.2
|
||||
csstype: 3.2.3
|
||||
|
||||
'@emotion/sheet@1.4.0': {}
|
||||
|
||||
'@emotion/unitless@0.10.0': {}
|
||||
|
||||
'@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
'@emotion/utils@1.4.2': {}
|
||||
|
||||
'@emotion/weak-memoize@0.4.0': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
optional: true
|
||||
|
||||
|
|
@ -3327,6 +3509,8 @@ snapshots:
|
|||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/parse-json@4.0.2': {}
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
|
@ -3529,6 +3713,12 @@ snapshots:
|
|||
postcss: 8.5.8
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
babel-plugin-macros@3.1.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
cosmiconfig: 7.1.0
|
||||
resolve: 1.22.11
|
||||
|
||||
bail@2.0.2: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
|
@ -3609,6 +3799,8 @@ snapshots:
|
|||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
|
|
@ -3625,10 +3817,20 @@ snapshots:
|
|||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
convert-source-map@1.9.0: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie-es@2.0.0: {}
|
||||
|
||||
cosmiconfig@7.1.0:
|
||||
dependencies:
|
||||
'@types/parse-json': 4.0.2
|
||||
import-fresh: 3.3.1
|
||||
parse-json: 5.2.0
|
||||
path-type: 4.0.0
|
||||
yaml: 1.10.3
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
|
@ -3663,6 +3865,8 @@ snapshots:
|
|||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
diff@8.0.4: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
|
|
@ -3675,6 +3879,10 @@ snapshots:
|
|||
|
||||
electron-to-chromium@1.5.307: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
esbuild@0.25.12:
|
||||
|
|
@ -3836,6 +4044,8 @@ snapshots:
|
|||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
find-root@1.1.0: {}
|
||||
|
||||
find-up@5.0.0:
|
||||
dependencies:
|
||||
locate-path: 6.0.0
|
||||
|
|
@ -3948,6 +4158,10 @@ snapshots:
|
|||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
|
|
@ -3998,6 +4212,8 @@ snapshots:
|
|||
is-alphabetical: 2.0.1
|
||||
is-decimal: 2.0.1
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
|
@ -4042,6 +4258,8 @@ snapshots:
|
|||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
|
@ -4261,6 +4479,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
||||
memoize-one@6.0.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
|
|
@ -4549,6 +4769,13 @@ snapshots:
|
|||
is-decimal: 2.0.1
|
||||
is-hexadecimal: 2.0.1
|
||||
|
||||
parse-json@5.2.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
error-ex: 1.3.4
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
lines-and-columns: 1.2.4
|
||||
|
||||
parse-json@8.3.0:
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
|
|
@ -4640,6 +4867,20 @@ snapshots:
|
|||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
react-diff-viewer-continued@4.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@emotion/css': 11.13.5
|
||||
'@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4)
|
||||
classnames: 2.5.1
|
||||
diff: 8.0.4
|
||||
js-yaml: 4.1.1
|
||||
memoize-one: 6.0.0
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- supports-color
|
||||
|
||||
react-dom@19.2.4(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
|
@ -4858,6 +5099,8 @@ snapshots:
|
|||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map@0.5.7: {}
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
|
@ -4887,6 +5130,8 @@ snapshots:
|
|||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
stylis@4.2.0: {}
|
||||
|
||||
sucrase@3.35.1:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
|
|
@ -5164,6 +5409,8 @@ snapshots:
|
|||
|
||||
yaml-ast-parser@0.0.43: {}
|
||||
|
||||
yaml@1.10.3: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import type {
|
|||
AdminLabelInput,
|
||||
LabelDefinition,
|
||||
LabelItem,
|
||||
BatchMemberResponse,
|
||||
} from './types'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import i18n from '@/i18n/config'
|
||||
|
|
@ -702,6 +703,16 @@ export const namespaceApi = {
|
|||
})
|
||||
},
|
||||
|
||||
async batchAddMembers(slug: string, members: Array<{ userId: string; role: string }>): Promise<BatchMemberResponse> {
|
||||
return fetchJson<BatchMemberResponse>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/batch`, {
|
||||
method: 'POST',
|
||||
headers: await ensureCsrfHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({ members }),
|
||||
})
|
||||
},
|
||||
|
||||
async updateMemberRole(slug: string, userId: string, role: string): Promise<NamespaceMember> {
|
||||
return fetchJson<NamespaceMember>(
|
||||
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}/role`,
|
||||
|
|
|
|||
|
|
@ -139,6 +139,20 @@ export interface NamespaceCandidateUser {
|
|||
status: string
|
||||
}
|
||||
|
||||
export interface BatchMemberResult {
|
||||
userId: string
|
||||
role: string
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BatchMemberResponse {
|
||||
totalCount: number
|
||||
successCount: number
|
||||
failureCount: number
|
||||
results: BatchMemberResult[]
|
||||
}
|
||||
|
||||
// Skill types
|
||||
export interface SkillSummary {
|
||||
id: number
|
||||
|
|
|
|||
101
web/src/features/namespace/batch-import-members-dialog.test.tsx
Normal file
101
web/src/features/namespace/batch-import-members-dialog.test.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { parseCsv, validateRows } from './batch-import-members-dialog'
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('parses basic CSV with header', () => {
|
||||
const result = parseCsv('userId,role\nuser-1,MEMBER\nuser-2,ADMIN')
|
||||
expect(result).toEqual([
|
||||
{ userId: 'user-1', role: 'MEMBER' },
|
||||
{ userId: 'user-2', role: 'ADMIN' },
|
||||
])
|
||||
})
|
||||
|
||||
it('parses CSV without header', () => {
|
||||
const result = parseCsv('user-1,MEMBER\nuser-2,ADMIN')
|
||||
expect(result).toEqual([
|
||||
{ userId: 'user-1', role: 'MEMBER' },
|
||||
{ userId: 'user-2', role: 'ADMIN' },
|
||||
])
|
||||
})
|
||||
|
||||
it('handles Windows line endings', () => {
|
||||
const result = parseCsv('userId,role\r\nuser-1,MEMBER\r\nuser-2,ADMIN\r\n')
|
||||
expect(result).toEqual([
|
||||
{ userId: 'user-1', role: 'MEMBER' },
|
||||
{ userId: 'user-2', role: 'ADMIN' },
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes role to uppercase', () => {
|
||||
const result = parseCsv('user-1,member\nuser-2,admin')
|
||||
expect(result).toEqual([
|
||||
{ userId: 'user-1', role: 'MEMBER' },
|
||||
{ userId: 'user-2', role: 'ADMIN' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips empty lines', () => {
|
||||
const result = parseCsv('userId,role\nuser-1,MEMBER\n\n\nuser-2,ADMIN\n')
|
||||
expect(result).toEqual([
|
||||
{ userId: 'user-1', role: 'MEMBER' },
|
||||
{ userId: 'user-2', role: 'ADMIN' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(parseCsv('')).toEqual([])
|
||||
expect(parseCsv('\n\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles missing role column', () => {
|
||||
const result = parseCsv('user-1')
|
||||
expect(result).toEqual([{ userId: 'user-1', role: '' }])
|
||||
})
|
||||
|
||||
it('trims whitespace from values', () => {
|
||||
const result = parseCsv(' user-1 , MEMBER ')
|
||||
expect(result).toEqual([{ userId: 'user-1', role: 'MEMBER' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateRows', () => {
|
||||
it('marks valid rows', () => {
|
||||
const result = validateRows([
|
||||
{ userId: 'user-1', role: 'MEMBER' },
|
||||
{ userId: 'user-2', role: 'ADMIN' },
|
||||
])
|
||||
expect(result).toEqual([
|
||||
{ userId: 'user-1', role: 'MEMBER', validation: 'valid' },
|
||||
{ userId: 'user-2', role: 'ADMIN', validation: 'valid' },
|
||||
])
|
||||
})
|
||||
|
||||
it('flags missing userId', () => {
|
||||
const result = validateRows([{ userId: '', role: 'MEMBER' }])
|
||||
expect(result[0].validation).toBe('missing_user_id')
|
||||
})
|
||||
|
||||
it('flags invalid role', () => {
|
||||
const result = validateRows([{ userId: 'user-1', role: 'OWNER' }])
|
||||
expect(result[0].validation).toBe('invalid_role')
|
||||
})
|
||||
|
||||
it('flags empty role as invalid', () => {
|
||||
const result = validateRows([{ userId: 'user-1', role: '' }])
|
||||
expect(result[0].validation).toBe('invalid_role')
|
||||
})
|
||||
|
||||
it('flags duplicate userIds', () => {
|
||||
const result = validateRows([
|
||||
{ userId: 'user-1', role: 'MEMBER' },
|
||||
{ userId: 'user-1', role: 'ADMIN' },
|
||||
])
|
||||
expect(result[0].validation).toBe('valid')
|
||||
expect(result[1].validation).toBe('duplicate')
|
||||
})
|
||||
|
||||
it('checks missing userId before duplicate', () => {
|
||||
const result = validateRows([{ userId: '', role: 'MEMBER' }])
|
||||
expect(result[0].validation).toBe('missing_user_id')
|
||||
})
|
||||
})
|
||||
309
web/src/features/namespace/batch-import-members-dialog.tsx
Normal file
309
web/src/features/namespace/batch-import-members-dialog.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BatchMemberResponse } from '@/api/types'
|
||||
import { useBatchAddNamespaceMembers } from '@/shared/hooks/use-namespace-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'
|
||||
|
||||
interface BatchImportMembersDialogProps {
|
||||
slug: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface ParsedRow {
|
||||
userId: string
|
||||
role: string
|
||||
validation: 'valid' | 'missing_user_id' | 'invalid_role' | 'duplicate'
|
||||
}
|
||||
|
||||
type Step = 'upload' | 'preview' | 'results'
|
||||
|
||||
const VALID_ROLES = ['MEMBER', 'ADMIN']
|
||||
|
||||
function generateCsvTemplate(): string {
|
||||
return 'userId,role\nuser-example-1,MEMBER\nuser-example-2,ADMIN\n'
|
||||
}
|
||||
|
||||
function downloadCsvTemplate() {
|
||||
const blob = new Blob([generateCsvTemplate()], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'member-import-template.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function parseCsv(text: string): Array<{ userId: string; role: string }> {
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0)
|
||||
if (lines.length === 0) return []
|
||||
|
||||
const firstLine = lines[0].toLowerCase().trim()
|
||||
const startIndex = firstLine.includes('userid') || firstLine.includes('user_id') ? 1 : 0
|
||||
|
||||
return lines.slice(startIndex).map((line) => {
|
||||
const parts = line.split(',').map((part) => part.trim())
|
||||
return { userId: parts[0] || '', role: (parts[1] || '').toUpperCase() }
|
||||
})
|
||||
}
|
||||
|
||||
export function validateRows(rows: Array<{ userId: string; role: string }>): ParsedRow[] {
|
||||
const seen = new Set<string>()
|
||||
return rows.map((row) => {
|
||||
if (!row.userId) {
|
||||
return { ...row, validation: 'missing_user_id' as const }
|
||||
}
|
||||
if (!VALID_ROLES.includes(row.role)) {
|
||||
return { ...row, validation: 'invalid_role' as const }
|
||||
}
|
||||
if (seen.has(row.userId)) {
|
||||
return { ...row, validation: 'duplicate' as const }
|
||||
}
|
||||
seen.add(row.userId)
|
||||
return { ...row, validation: 'valid' as const }
|
||||
})
|
||||
}
|
||||
|
||||
function mapResultError(error: string | undefined, t: (key: string) => string): string {
|
||||
if (!error) return t('members.batchResultSuccess')
|
||||
switch (error) {
|
||||
case 'ALREADY_MEMBER': return t('members.batchResultAlreadyMember')
|
||||
case 'USER_NOT_FOUND': return t('members.batchResultUserNotFound')
|
||||
case 'INVALID_ROLE': return t('members.batchResultInvalidRole')
|
||||
default: return t('members.batchResultUnknownError')
|
||||
}
|
||||
}
|
||||
|
||||
export function BatchImportMembersDialog({ slug, children }: BatchImportMembersDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const batchMutation = useBatchAddNamespaceMembers()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState<Step>('upload')
|
||||
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([])
|
||||
const [results, setResults] = useState<BatchMemberResponse | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
const resetDialog = () => {
|
||||
setStep('upload')
|
||||
setParsedRows([])
|
||||
setResults(null)
|
||||
setDragOver(false)
|
||||
batchMutation.reset()
|
||||
}
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) resetDialog()
|
||||
}
|
||||
|
||||
const processFile = useCallback((file: File) => {
|
||||
if (!file.name.endsWith('.csv')) {
|
||||
toast.error(t('members.batchParseError'), t('members.batchFormatHint'))
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string
|
||||
const raw = parseCsv(text)
|
||||
if (raw.length === 0) {
|
||||
toast.error(t('members.batchEmptyFile'))
|
||||
return
|
||||
}
|
||||
const validated = validateRows(raw)
|
||||
setParsedRows(validated)
|
||||
setStep('preview')
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [t])
|
||||
|
||||
const handleDrop = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file) processFile(file)
|
||||
}, [processFile])
|
||||
|
||||
const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
event.target.value = ''
|
||||
}, [processFile])
|
||||
|
||||
const validRows = parsedRows.filter((row) => row.validation === 'valid')
|
||||
const invalidRows = parsedRows.filter((row) => row.validation !== 'valid')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const members = validRows.map((row) => ({ userId: row.userId, role: row.role }))
|
||||
try {
|
||||
const response = await batchMutation.mutateAsync({ slug, members })
|
||||
setResults(response)
|
||||
setStep('results')
|
||||
} catch (error) {
|
||||
toast.error(t('members.addErrorTitle'), error instanceof Error ? error.message : '')
|
||||
}
|
||||
}
|
||||
|
||||
const validationLabel = (row: ParsedRow) => {
|
||||
switch (row.validation) {
|
||||
case 'valid': return t('members.batchValidationValid')
|
||||
case 'missing_user_id': return t('members.batchValidationMissingUserId')
|
||||
case 'invalid_role': return t('members.batchValidationInvalidRole')
|
||||
case 'duplicate': return t('members.batchValidationDuplicate')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader className="text-center sm:text-center">
|
||||
<DialogTitle className="text-center">{t('members.batchDialogTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
{t('members.batchDialogDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'upload' && (
|
||||
<div className="space-y-4 py-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={downloadCsvTemplate}>
|
||||
{t('members.batchDownloadTemplate')}
|
||||
</Button>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('members.batchDropHint')}
|
||||
className={`flex min-h-[120px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors ${
|
||||
dragOver ? 'border-primary bg-primary/5' : 'border-border/50 hover:border-primary/50'
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') fileInputRef.current?.click() }}
|
||||
onDragOver={(event) => { event.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{t('members.batchDropHint')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t('members.batchFormatHint')}</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'preview' && (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{t('members.batchPreviewTitle', { count: parsedRows.length })}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('members.batchValidRows', { valid: validRows.length, invalid: invalidRows.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-auto rounded-lg border border-border/50">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40 bg-secondary/30">
|
||||
<th className="p-2 text-left font-medium">{t('members.batchColUserId')}</th>
|
||||
<th className="p-2 text-left font-medium">{t('members.batchColRole')}</th>
|
||||
<th className="p-2 text-left font-medium">{t('members.batchColStatus')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parsedRows.map((row, index) => (
|
||||
<tr key={index} className="border-b border-border/40 last:border-b-0">
|
||||
<td className="p-2 font-mono text-xs">{row.userId || '-'}</td>
|
||||
<td className="p-2">{row.role || '-'}</td>
|
||||
<td className={`p-2 text-xs ${row.validation === 'valid' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{validationLabel(row)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'results' && results && (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="text-sm font-medium">{t('members.batchResultTitle')}</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('members.batchResultSummary', {
|
||||
total: results.totalCount,
|
||||
success: results.successCount,
|
||||
failure: results.failureCount,
|
||||
})}
|
||||
</p>
|
||||
<div className="max-h-[300px] overflow-auto rounded-lg border border-border/50">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/40 bg-secondary/30">
|
||||
<th className="p-2 text-left font-medium">{t('members.batchColUserId')}</th>
|
||||
<th className="p-2 text-left font-medium">{t('members.batchColRole')}</th>
|
||||
<th className="p-2 text-left font-medium">{t('members.batchColStatus')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.results.map((result, index) => (
|
||||
<tr key={index} className="border-b border-border/40 last:border-b-0">
|
||||
<td className="p-2 font-mono text-xs">{result.userId}</td>
|
||||
<td className="p-2">{result.role}</td>
|
||||
<td className={`p-2 text-xs ${result.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{result.success ? t('members.batchResultSuccess') : mapResultError(result.error, t)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="sm:justify-center sm:space-x-3">
|
||||
{step === 'upload' && (
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{step === 'preview' && (
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => { setParsedRows([]); setStep('upload') }}>
|
||||
{t('members.batchBack')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={validRows.length === 0 || batchMutation.isPending}
|
||||
>
|
||||
{batchMutation.isPending
|
||||
? t('members.batchSubmitting')
|
||||
: t('members.batchSubmit', { count: validRows.length })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{step === 'results' && (
|
||||
<Button type="button" onClick={() => handleOpenChange(false)}>
|
||||
{t('members.batchDone')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -326,7 +326,8 @@
|
|||
"reportsTitle": "Report Management",
|
||||
"viewReports": "View skill reports",
|
||||
"previewMore": "...",
|
||||
"previewMoreLabel": "View All"
|
||||
"previewMoreLabel": "View All",
|
||||
"userId": "User ID"
|
||||
},
|
||||
"mySkills": {
|
||||
"title": "My Skills",
|
||||
|
|
@ -700,7 +701,8 @@
|
|||
"reviewHint": "Some changes require admin review before taking effect.",
|
||||
"partiallyAppliedTitle": "Partially Applied",
|
||||
"partiallyAppliedDescription": "Some changes were applied immediately. Others are pending admin review.",
|
||||
"noChanges": "No changes to save."
|
||||
"noChanges": "No changes to save.",
|
||||
"userId": "User ID"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security Settings",
|
||||
|
|
@ -904,6 +906,14 @@
|
|||
"filesAdded": "Added",
|
||||
"filesRemoved": "Removed",
|
||||
"filesChanged": "Changed",
|
||||
"expandDiff": "Show diff",
|
||||
"collapseDiff": "Hide diff",
|
||||
"binaryFileNotice": "Binary file — cannot display diff",
|
||||
"largeFileWarning": "This file is large. Rendering may be slow.",
|
||||
"diffLoadError": "Failed to load file content",
|
||||
"diffRetry": "Retry",
|
||||
"diffViewUnified": "Unified",
|
||||
"diffViewSplit": "Split",
|
||||
"rereleaseVersion": "Re-release",
|
||||
"rereleaseDialogTitle": "Re-release from version",
|
||||
"rereleaseDialogDescription": "Create a new published version based on v{{version}}.",
|
||||
|
|
@ -1060,7 +1070,38 @@
|
|||
"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"
|
||||
"removeErrorTitle": "Failed to remove member",
|
||||
"batchImport": "Batch Import",
|
||||
"batchDialogTitle": "Batch Import Members",
|
||||
"batchDialogDescription": "Upload a CSV file to add multiple members at once. Each row should contain a user ID and role.",
|
||||
"batchStepUpload": "Upload CSV",
|
||||
"batchStepPreview": "Preview",
|
||||
"batchStepResults": "Results",
|
||||
"batchDownloadTemplate": "Download CSV template",
|
||||
"batchDropHint": "Drag a CSV file here, or click to select",
|
||||
"batchFormatHint": "Only .csv format supported",
|
||||
"batchParseError": "Failed to parse CSV file",
|
||||
"batchEmptyFile": "CSV file is empty or contains no valid rows",
|
||||
"batchPreviewTitle": "Preview ({{count}} rows)",
|
||||
"batchColUserId": "User ID",
|
||||
"batchColRole": "Role",
|
||||
"batchColStatus": "Status",
|
||||
"batchValidationMissingUserId": "Missing user ID",
|
||||
"batchValidationInvalidRole": "Invalid role (must be MEMBER or ADMIN)",
|
||||
"batchValidationDuplicate": "Duplicate user ID",
|
||||
"batchValidationValid": "Ready",
|
||||
"batchValidRows": "{{valid}} valid, {{invalid}} invalid",
|
||||
"batchSubmitting": "Importing...",
|
||||
"batchSubmit": "Import {{count}} members",
|
||||
"batchResultTitle": "Import Results",
|
||||
"batchResultSummary": "{{success}} succeeded, {{failure}} failed out of {{total}} total",
|
||||
"batchResultSuccess": "Added",
|
||||
"batchResultAlreadyMember": "Already a member",
|
||||
"batchResultUserNotFound": "User not found",
|
||||
"batchResultInvalidRole": "Invalid role",
|
||||
"batchResultUnknownError": "Unknown error",
|
||||
"batchDone": "Done",
|
||||
"batchBack": "Back"
|
||||
},
|
||||
"upload": {
|
||||
"dropHint": "Drop to upload...",
|
||||
|
|
|
|||
|
|
@ -326,7 +326,8 @@
|
|||
"reportsTitle": "举报管理",
|
||||
"viewReports": "查看技能举报",
|
||||
"previewMore": "...",
|
||||
"previewMoreLabel": "查看全部"
|
||||
"previewMoreLabel": "查看全部",
|
||||
"userId": "用户 ID"
|
||||
},
|
||||
"mySkills": {
|
||||
"title": "我的技能",
|
||||
|
|
@ -700,7 +701,8 @@
|
|||
"reviewHint": "部分修改需要管理员审核后生效。",
|
||||
"partiallyAppliedTitle": "部分已生效",
|
||||
"partiallyAppliedDescription": "部分修改已立即生效,其余修改正在等待管理员审核。",
|
||||
"noChanges": "没有需要保存的修改。"
|
||||
"noChanges": "没有需要保存的修改。",
|
||||
"userId": "用户 ID"
|
||||
},
|
||||
"security": {
|
||||
"title": "安全设置",
|
||||
|
|
@ -905,6 +907,14 @@
|
|||
"filesAdded": "新增",
|
||||
"filesRemoved": "删除",
|
||||
"filesChanged": "修改",
|
||||
"expandDiff": "展开代码对比",
|
||||
"collapseDiff": "收起代码对比",
|
||||
"binaryFileNotice": "二进制文件 — 无法展示对比",
|
||||
"largeFileWarning": "该文件较大,渲染可能会有卡顿。",
|
||||
"diffLoadError": "加载文件内容失败",
|
||||
"diffRetry": "重试",
|
||||
"diffViewUnified": "统一视图",
|
||||
"diffViewSplit": "分栏视图",
|
||||
"rereleaseVersion": "重新发布",
|
||||
"rereleaseDialogTitle": "基于旧版本重新发布",
|
||||
"rereleaseDialogDescription": "基于 v{{version}} 创建一个新的已发布版本。",
|
||||
|
|
@ -1061,7 +1071,38 @@
|
|||
"removeConfirmDescription": "移除后,用户 {{userId}} 将失去该命名空间的访问和协作权限。",
|
||||
"removeSuccessTitle": "成员已移除",
|
||||
"removeSuccessDescription": "用户 {{userId}} 已从当前命名空间移除。",
|
||||
"removeErrorTitle": "移除成员失败"
|
||||
"removeErrorTitle": "移除成员失败",
|
||||
"batchImport": "批量导入",
|
||||
"batchDialogTitle": "批量导入成员",
|
||||
"batchDialogDescription": "上传 CSV 文件批量添加成员。每行包含一个用户 ID 和角色。",
|
||||
"batchStepUpload": "上传 CSV",
|
||||
"batchStepPreview": "预览",
|
||||
"batchStepResults": "结果",
|
||||
"batchDownloadTemplate": "下载 CSV 模板",
|
||||
"batchDropHint": "拖拽 CSV 文件到此处,或点击选择",
|
||||
"batchFormatHint": "仅支持 .csv 格式",
|
||||
"batchParseError": "CSV 文件解析失败",
|
||||
"batchEmptyFile": "CSV 文件为空或没有有效行",
|
||||
"batchPreviewTitle": "预览(共 {{count}} 行)",
|
||||
"batchColUserId": "用户 ID",
|
||||
"batchColRole": "角色",
|
||||
"batchColStatus": "状态",
|
||||
"batchValidationMissingUserId": "缺少用户 ID",
|
||||
"batchValidationInvalidRole": "角色无效(必须为 MEMBER 或 ADMIN)",
|
||||
"batchValidationDuplicate": "用户 ID 重复",
|
||||
"batchValidationValid": "就绪",
|
||||
"batchValidRows": "{{valid}} 条有效,{{invalid}} 条无效",
|
||||
"batchSubmitting": "导入中...",
|
||||
"batchSubmit": "导入 {{count}} 名成员",
|
||||
"batchResultTitle": "导入结果",
|
||||
"batchResultSummary": "共 {{total}} 条,成功 {{success}} 条,失败 {{failure}} 条",
|
||||
"batchResultSuccess": "已添加",
|
||||
"batchResultAlreadyMember": "已是成员",
|
||||
"batchResultUserNotFound": "用户不存在",
|
||||
"batchResultInvalidRole": "角色无效",
|
||||
"batchResultUnknownError": "未知错误",
|
||||
"batchDone": "完成",
|
||||
"batchBack": "返回"
|
||||
},
|
||||
"upload": {
|
||||
"dropHint": "放开以上传文件...",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export function DashboardPage() {
|
|||
<div className="space-y-1.5">
|
||||
<div className="text-xl font-semibold font-heading">{user?.displayName}</div>
|
||||
<div className="text-sm text-muted-foreground">{user?.email}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.userId')}: {user?.userId}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
{t('dashboard.loginVia', { provider: user?.oauthProvider })}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useParams } from '@tanstack/react-router'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { AddNamespaceMemberDialog } from '@/features/namespace/add-namespace-member-dialog'
|
||||
import { BatchImportMembersDialog } from '@/features/namespace/batch-import-members-dialog'
|
||||
import { NamespaceHeader } from '@/features/namespace/namespace-header'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
|
|
@ -152,11 +153,16 @@ export function NamespaceMembersPage() {
|
|||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{canManageMembers ? (
|
||||
<AddNamespaceMemberDialog slug={slug}>
|
||||
<Button>{t('members.addMember')}</Button>
|
||||
</AddNamespaceMemberDialog>
|
||||
<>
|
||||
<BatchImportMembersDialog slug={slug}>
|
||||
<Button variant="outline">{t('members.batchImport')}</Button>
|
||||
</BatchImportMembersDialog>
|
||||
<AddNamespaceMemberDialog slug={slug}>
|
||||
<Button>{t('members.addMember')}</Button>
|
||||
</AddNamespaceMemberDialog>
|
||||
</>
|
||||
) : (
|
||||
<Button disabled>{t('members.addMember')}</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,11 @@ export function ProfileSettingsPage() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('profile.userId')}</label>
|
||||
<p className="text-sm text-muted-foreground">{user?.userId || '-'}</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{/* Dynamic fields */}
|
||||
{Object.entries(fieldPolicies).map(([field, policy]) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole } from '@/api/types'
|
||||
import type { Namespace, NamespaceMember, ManagedNamespace, CreateNamespaceRequest, NamespaceCandidateUser, NamespaceRole, BatchMemberResponse } from '@/api/types'
|
||||
import { namespaceApi } from '@/api/client'
|
||||
import { appendNamespaceMember, replaceNamespaceMemberRole } from '@/shared/lib/namespace-member-cache'
|
||||
import { shouldEnableNamespaceMemberCandidates } from './skill-query-helpers'
|
||||
|
|
@ -36,6 +36,10 @@ async function removeNamespaceMember(params: { slug: string; userId: string }):
|
|||
return namespaceApi.removeMember(params.slug, params.userId)
|
||||
}
|
||||
|
||||
async function batchAddNamespaceMembers(params: { slug: string; members: Array<{ userId: string; role: string }> }): Promise<BatchMemberResponse> {
|
||||
return namespaceApi.batchAddMembers(params.slug, params.members)
|
||||
}
|
||||
|
||||
function invalidateNamespaceQueries(queryClient: ReturnType<typeof useQueryClient>, slug: string) {
|
||||
queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['namespaces', slug] })
|
||||
|
|
@ -102,6 +106,17 @@ export function useAddNamespaceMember() {
|
|||
})
|
||||
}
|
||||
|
||||
export function useBatchAddNamespaceMembers() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: batchAddNamespaceMembers,
|
||||
onSuccess: (_data, variables) => {
|
||||
invalidateNamespaceQueries(queryClient, variables.slug)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateNamespaceMemberRole() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue