Merge pull request #342 from iflytek/feature/batch-member-import

feat(namespace): batch member import via CSV upload
This commit is contained in:
dongmucat 2026-04-28 09:44:53 +08:00 • committed by GitHub
commit f707cfee61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1311 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

@ -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: {}

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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