diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 2f2c603e..4b3faf70 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -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 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 removeMember( @PathVariable String slug, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BatchMemberRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BatchMemberRequest.java new file mode 100644 index 00000000..a2ce9a63 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BatchMemberRequest.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BatchMemberResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BatchMemberResponse.java new file mode 100644 index 00000000..739fdf69 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BatchMemberResponse.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +public record BatchMemberResponse( + int totalCount, + int successCount, + int failureCount, + List results +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BatchMemberResult.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BatchMemberResult.java new file mode 100644 index 00000000..1801cb2b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BatchMemberResult.java @@ -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); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java index 3e81884b..b6d4caea 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalCommandAppService.java @@ -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 members, String operatorUserId) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + Long namespaceId = namespace.getId(); + + List 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); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java new file mode 100644 index 00000000..aa4a542b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java @@ -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 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); + } + } +} diff --git a/web/e2e/batch-import-members.spec.ts b/web/e2e/batch-import-members.spec.ts new file mode 100644 index 00000000..72cb7d4b --- /dev/null +++ b/web/e2e/batch-import-members.spec.ts @@ -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() + } + }) +}) diff --git a/web/e2e/user-id-display.spec.ts b/web/e2e/user-id-display.spec.ts new file mode 100644 index 00000000..0f3f0485 --- /dev/null +++ b/web/e2e/user-id-display.spec.ts @@ -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: " 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

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('-') + }) +}) diff --git a/web/package.json b/web/package.json index 728f7a23..aef915a4 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a2c150da..5a90170f 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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: {} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1e7351ff..56011165 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + return fetchJson(`${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 { return fetchJson( `${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}/role`, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index be4a9938..0cba6fcc 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 diff --git a/web/src/features/namespace/batch-import-members-dialog.test.tsx b/web/src/features/namespace/batch-import-members-dialog.test.tsx new file mode 100644 index 00000000..acd5591f --- /dev/null +++ b/web/src/features/namespace/batch-import-members-dialog.test.tsx @@ -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') + }) +}) diff --git a/web/src/features/namespace/batch-import-members-dialog.tsx b/web/src/features/namespace/batch-import-members-dialog.tsx new file mode 100644 index 00000000..01cb7ada --- /dev/null +++ b/web/src/features/namespace/batch-import-members-dialog.tsx @@ -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() + 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(null) + const [open, setOpen] = useState(false) + const [step, setStep] = useState('upload') + const [parsedRows, setParsedRows] = useState([]) + const [results, setResults] = useState(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) => { + 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 ( +

+ {children} + + + {t('members.batchDialogTitle')} + + {t('members.batchDialogDescription')} + + + + {step === 'upload' && ( +
+ +
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} + > +

{t('members.batchDropHint')}

+

{t('members.batchFormatHint')}

+
+ +
+ )} + + {step === 'preview' && ( +
+
+ + {t('members.batchPreviewTitle', { count: parsedRows.length })} + + + {t('members.batchValidRows', { valid: validRows.length, invalid: invalidRows.length })} + +
+
+ + + + + + + + + + {parsedRows.map((row, index) => ( + + + + + + ))} + +
{t('members.batchColUserId')}{t('members.batchColRole')}{t('members.batchColStatus')}
{row.userId || '-'}{row.role || '-'} + {validationLabel(row)} +
+
+
+ )} + + {step === 'results' && results && ( +
+
{t('members.batchResultTitle')}
+

+ {t('members.batchResultSummary', { + total: results.totalCount, + success: results.successCount, + failure: results.failureCount, + })} +

+
+ + + + + + + + + + {results.results.map((result, index) => ( + + + + + + ))} + +
{t('members.batchColUserId')}{t('members.batchColRole')}{t('members.batchColStatus')}
{result.userId}{result.role} + {result.success ? t('members.batchResultSuccess') : mapResultError(result.error, t)} +
+
+
+ )} + + + {step === 'upload' && ( + + )} + {step === 'preview' && ( + <> + + + + )} + {step === 'results' && ( + + )} + +
+
+ ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5d51b7e3..3a6fb643 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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...", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 42eed50a..06c831e9 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "放开以上传文件...", diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 89ff5d70..056942c8 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -52,6 +52,7 @@ export function DashboardPage() {
{user?.displayName}
{user?.email}
+
{t('dashboard.userId')}: {user?.userId}
{t('dashboard.loginVia', { provider: user?.oauthProvider })} diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index 88adb53d..28b10c7d 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -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() { ) : null} -
+
{canManageMembers ? ( - - - + <> + + + + + + + ) : ( )} diff --git a/web/src/pages/settings/profile.tsx b/web/src/pages/settings/profile.tsx index dfb4a09c..97f418ad 100644 --- a/web/src/pages/settings/profile.tsx +++ b/web/src/pages/settings/profile.tsx @@ -207,6 +207,11 @@ export function ProfileSettingsPage() {
) : null} +
+ +

{user?.userId || '-'}

+
+
{/* Dynamic fields */} {Object.entries(fieldPolicies).map(([field, policy]) => { diff --git a/web/src/shared/hooks/use-namespace-queries.ts b/web/src/shared/hooks/use-namespace-queries.ts index be4aca2e..aa5d3efd 100644 --- a/web/src/shared/hooks/use-namespace-queries.ts +++ b/web/src/shared/hooks/use-namespace-queries.ts @@ -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 { + return namespaceApi.batchAddMembers(params.slug, params.members) +} + function invalidateNamespaceQueries(queryClient: ReturnType, 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()