diff --git a/docs/skillhub/en/guide/namespace.md b/docs/skillhub/en/guide/namespace.md index 8c2f391a..f9e1cfa4 100644 --- a/docs/skillhub/en/guide/namespace.md +++ b/docs/skillhub/en/guide/namespace.md @@ -176,6 +176,16 @@ Content-Type: application/json POST /api/v1/namespaces/{slug}/unfreeze ``` +## Invite Members and View History + +Team Owners and Admins can create a link from **Members → Invite members**. It expires after 24 hours and permits one new member to join. The recipient must sign in, meet existing admission rules, and confirm joining. Replacing an active link or revoking it requires confirmation and does not remove existing members. + +The dialog includes a read-only, paginated history showing the creator, creation and expiration times, status, and the account that successfully joined. Each generated invitation has its own record. Replacement, revocation, expiration, or a member leaving does not erase history. History cannot restore expired links and does not indicate current membership. Only the space's Owner/Admin can view it; platform roles do not bypass this restriction. Frozen or archived spaces retain a history-only entry. + +An upgrade imports only the provable current invitation, marked as a legacy record; earlier links are not reconstructed from incomplete audit data. History remains while the space exists and is deleted when the space is permanently deleted. Existing audit records follow their original retention rules. + +The history endpoint requires a browser session: `GET /api/v1/namespaces/{slug}/invitations/history?page=0&size=10`. Page sizes are bounded to 1–50; tokens and hashes are never returned. + ## Notes > **Slug Uniqueness**: Namespace slugs must be globally unique and cannot be changed after creation. Use a short identifier for your team or project. diff --git a/docs/skillhub/guide/namespace.md b/docs/skillhub/guide/namespace.md index e26d7890..6fa2ff99 100644 --- a/docs/skillhub/guide/namespace.md +++ b/docs/skillhub/guide/namespace.md @@ -176,6 +176,16 @@ Content-Type: application/json POST /api/v1/namespaces/{slug}/unfreeze ``` +## 邀请成员与邀请记录 + +团队空间的 Owner/Admin 可在“成员 → 邀请成员”生成24小时有效、仅一名新成员可使用的链接。对方须先登录并通过平台准入,再确认加入;仅打开链接不会自动成为成员。替换有效链接和作废须确认,已加入成员不受影响。 + +弹窗下方直接展示分页邀请记录:创建人及时间、到期时间、状态,以及实际加入账号与时间。每次生成独立记录,替换、作废、过期和成员离开不会抹掉原历史。历史不能恢复失效链接,也不代表当前成员资格。普通成员和仅有平台角色的账号不能查看,冻结/归档空间的 Owner/Admin 可查看只读历史。 + +升级只迁入可以准确读取的当前邀请并标记“旧记录”,不推测更早链接的使用情况。空间存在期间保留历史,彻底删除空间时删除邀请业务记录,既有操作审计按原规则保留。 + +历史查询使用浏览器 Session:`GET /api/v1/namespaces/{slug}/invitations/history?page=0&size=10`,每页1–50条,不返回邀请码或哈希。 + ## 注意事项 > **Slug 唯一性**:命名空间 slug 在全局范围内必须唯一,且创建后不可修改。建议使用团队或项目的简短标识符。 diff --git a/openspec/changes/add-namespace-invite-links/design.md b/openspec/changes/add-namespace-invite-links/design.md index 29c663a8..79c5a404 100644 --- a/openspec/changes/add-namespace-invite-links/design.md +++ b/openspec/changes/add-namespace-invite-links/design.md @@ -32,3 +32,17 @@ 邀请生成原文仍不存服务器;创建者以 userId+slug 隔离保存在当前标签页 sessionStorage。重开及刷新时先检查 active 再预览当前 Token,成功才显示;失效/替换时清除,网络失败不展示旧缓存但可重试。跨浏览器或标签页无法恢复时明确说明,替换须确认。存储不可用时仅保留当前弹窗的内存副本并提示立即复制。 邀请弹窗使用640px桌面宽度(手机为视口减32px),按邀请状态与链接、加入规则、管理操作分区。只在成功验证原邀请时展示实际到期时间;未知或失败不猜测到期时间,提供状态重试。复制为主要操作,替换和作废保留确认。 + +### 2026-10-14 邀请历史设计 + +已确认:邀请记录指一次成功生成链接及其生命周期结果;加入记录指账号通过该链接成功确认加入,非页面访问记录。每次生成独立记录;只有空间OWNER/ADMIN可以查看,不给平台角色增加旁路权限。REQ-INV-08/09/10已实现独立历史表及分页接口;前述当前邀请覆盖/作废删除只适用于当前链接凭证,业务历史不再被覆盖或删除。 + +旧版限制:升级前审计仅记录操作者、动作和空间ID,未携带邀请标识,因此无法可靠还原全部旧链接关联;新增审计关联独立邀请标识。已确认:空间存在期间保留,彻底删除空间时删除邀请业务历史而保留既有审计;仅迁入能确认的当前邀请并标注旧记录;保留曾经加入事实,账号变更不改写原邀请归属。已确认整体方案:保留当前邀请操作,并在弹窗下方直接展示只读分页历史,不加嵌套标签页。 + +#### 历史与当前邀请的技术边界 + +保留当前邀请哈希验证路径,新增独立业务历史及当前邀请关联标识。历史不存Token或哈希,每次生成写入独立记录;有效未消费的旧邀请被替换时标为已替换,已消费和已到期的旧记录保留原结果。成功加入、成员新增、邀请消费及相关审计必须同事务,不产生无成员的成功加入历史。自然过期由服务端时钟投影,业务流转校验在代码中,不新增SQL业务CHECK。 + +新迁移基于已实施V70–V73,不能为历史展示重写已应用迁移。只迁入当前邀请可证实字段并标记legacy,未知的过往姓名和关联不伪造;新记录以稳定账号ID关联,姓名以记录时快照辅助展示。空间彻底删除联动删除历史,冻结/归档保留历史。 + +新增历史查询沿用Session认证、禁止缓存及空间OWNER/ADMIN权限;每页数量有上限,按创建时间倒序和独立标识稳定排序。创建/预览/接受现有响应合同保持兼容。审计新增独立邀请标识关联,不包含任何凭证。 diff --git a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md index 98e1cc5a..2527237f 100644 --- a/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md +++ b/openspec/changes/add-namespace-invite-links/specs/namespace-invitations/spec.md @@ -134,3 +134,67 @@ API SHALL 使用 Issue #911 的路径与响应合同。 - **THEN** 显示目标空间及邀请有效/无有效邀请/加载/读取失败状态;可恢复原链接时显示实际到期时间及复制入口 - **AND** 说明24小时内单次使用、MEMBER权限、先登录及现有准入规则;替换和作废操作独立于复制,并继续要求确认 - **AND** 读取失败可重试;桌面弹窗适当增宽,手机仍可完整操作 + +### Requirement: REQ-INV-08 系统 SHALL 保留独立邀请记录 + +每次成功生成邀请 SHALL 创建一条具有独立标识的邀请记录,保存创建人、创建时间、到期时间及生命周期结果。再次生成、替换或作废 SHALL NOT 覆盖或删除之前的业务历史记录。记录可展示有效、已使用、已替换、已作废、已过期状态;过期状态按真实到期时间判定,不要求依赖定时任务才失效。 + +新成员通过邀请成功确认加入时 SHALL 将实际加入账号及加入时间准确关联至该次邀请记录。只打开或预览链接、不成功兑换或已有成员访问 SHALL NOT 被记录为新的加入成功。邀请记录 SHALL NOT 展示原始 Token 或 Token 哈希,历史记录不构成恢复已失效链接的入口。 + +本条为2026-10-14确认并实现的新增需求,执行验证以本次验收记录为准。空间存在期间持续保留邀请业务记录,不新增保留期配置;彻底删除空间时删除其邀请业务记录,操作审计按既有规则保留。升级仅迁入仍能准确读取的当前邀请并标注旧记录;不可可靠关联的更早审计不得拼造为邀请历史。用户退出或被移除后仍保留当时的成功加入事实;账号改名或合并不改写原邀请账号归属,记录不代表当前成员资格。 + +#### Scenario: 邀请已使用后再次生成 +- **WHEN** 邀请A被张三成功使用,管理员随后生成邀请B +- **THEN** A保留已使用状态及张三的加入时间,B为新的独立记录 + +#### Scenario: 替换或作废邀请 +- **WHEN** 管理员替换仍有效的邀请A,或作废邀请B +- **THEN** A或B保留各自已替换或已作废记录,不覆盖先前的加入记录 + +#### Scenario: 预览不记为加入 +- **WHEN** 用户仅打开邀请页或预览链接,未成功确认加入 +- **THEN** 不产生邀请加入成功记录 + +### Requirement: REQ-INV-09 邀请历史 SHALL 按空间管理权限限制访问 + +邀请历史页面和查询接口 SHALL 仅允许该空间OWNER或ADMIN访问。普通成员不得查看;平台角色不自动绕过空间成员角色规则。权限判定采用当前身份及当前空间角色,不能仅依赖前端隐藏入口。 + +#### Scenario: 普通成员或仅有平台角色的账号查看历史 +- **WHEN** 当前账号不是该空间OWNER或ADMIN +- **THEN** 页面不提供历史查看入口,直接调用查询接口也不能获得记录 + +#### Scenario: 空间管理员查看历史 +- **WHEN** 当前账号具有该空间OWNER或ADMIN角色 +- **THEN** 可查看该空间邀请记录,不可据此查看其他空间记录 + +#### Scenario: 删除空间与历史保留 +- **WHEN** 空间被彻底删除 +- **THEN** 删除该空间的邀请业务记录,现有操作审计按原规则保留;归档或冻结不等同于彻底删除 + +#### Scenario: 升级迁入旧邀请 +- **WHEN** 升级前存在可准确读取的当前邀请 +- **THEN** 将其已知创建及消费信息迁入并标注为旧记录,不伪造更早记录的链接关联 + +#### Scenario: 加入后退出或被移除 +- **WHEN** 通过邀请加入的用户后来退出或被管理员移除 +- **THEN** 历史仍显示当时的加入事实,不据此授予或恢复成员权限 + +### Requirement: REQ-INV-10 邀请弹窗 SHALL 提供只读分页历史 + +现有邀请弹窗 SHALL 在上方保留当前链接及生成/替换/作废操作,下方直接显示邀请记录,不增加嵌套标签页。历史最新在前并分页,展示创建人及时间、到期时间、有效/已使用/已替换/已作废/已过期状态、实际加入人及加入时间;旧迁入记录明确标注。历史不显示原始链接、Token或哈希,不提供复制或恢复失效链接操作。列表支持加载、空列表、失败及重试状态;不同空间记录不得串用,成功变更邀请后刷新列表。 + +#### Scenario: 管理员在同一弹窗查看历史 +- **WHEN** 空间OWNER/ADMIN打开邀请弹窗 +- **THEN** 当前邀请操作与只读历史同时可访问;列表按最新在前分页,手机端可阅读和翻页 + +#### Scenario: 成功变更邀请刷新历史 +- **WHEN** 管理员成功生成、替换或作废邀请 +- **THEN** 列表刷新并展示对应记录和状态;取消确认不新增记录 + +#### Scenario: 历史读取失败 +- **WHEN** 历史查询请求失败 +- **THEN** 显示失败及重试入口,不能将其他空间缓存记录展示为当前空间历史 + +#### Scenario: 冻结或归档空间查看历史 +- **WHEN** TEAM空间被冻结或归档,当前账号仍为该空间OWNER或ADMIN +- **THEN** 可打开只读历史入口,不开放当前邀请生成/替换/作废操作,历史读取不依赖变更权限 diff --git a/openspec/changes/add-namespace-invite-links/tasks.md b/openspec/changes/add-namespace-invite-links/tasks.md index cf8efd06..616f0658 100644 --- a/openspec/changes/add-namespace-invite-links/tasks.md +++ b/openspec/changes/add-namespace-invite-links/tasks.md @@ -23,3 +23,11 @@ - [ ] 4.1 生成具名邀请片段并兼容旧链接,覆盖无效片段、暂存恢复和BASE_PATH。 - [ ] 4.2 收拢空间卡片,提供管理详情导航,将邀请集中成员、治理集中设置。 - [ ] 4.3 验证普通成员/团队管理/平台审核权限及邀请完整浏览器流程,更新唯一预览。 + + +## 5. 邀请历史(2026-10-14,方案已确认,实施中) + +- [x] 5.1 确认每次生成独立记录、真实加入关联和空间OWNER/ADMIN查看权限,写入REQ-INV-08/09。 +- [x] 5.2 确认保存期限、旧数据、成员/账号/空间变更边界及交互,补齐设计和REQ-INV-10验收标准。 +- [x] 5.3 实现历史持久化、生命周期事务关联、权限接口及弹窗历史展示(含冻结/归档只读入口)。 +- [ ] 5.4 验证旧数据、邀请状态、并发加入、访问权限、分页及本地浏览器流程。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java index 02879c68..5b396929 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceInvitationController.java @@ -20,6 +20,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; +import java.time.Instant; +import java.util.List; @RestController @RequestMapping({"/api/v1", "/api/web"}) @@ -39,6 +42,28 @@ public class NamespaceInvitationController extends BaseApiController { return ok("response.success.read", new InvitationStatus(invitations.hasActiveInvitation(slug, principal.userId()))); } + @GetMapping("/namespaces/{slug}/invitations/history") + public ApiResponse history(@PathVariable String slug, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @AuthenticationPrincipal PlatformPrincipal principal, HttpServletRequest request, HttpServletResponse response) { + noStore(response); + requireSession(principal, request); + var rows = invitations.history(slug, principal.userId(), page, size); + var now = invitations.currentTime(); + return ok("response.success.read", new InvitationHistoryPage(rows.getContent().stream() + .map(row -> new InvitationHistoryItem(row.getId(), row.getIssuedBy(), row.getIssuedByName(), + row.getIssuedAt(), row.getExpiresAt(), row.status(now), row.getConsumedBy(), row.getConsumedByName(), + row.getConsumedAt(), row.getEndedAt(), row.isLegacy())).toList(), + rows.getNumber(), rows.getSize(), rows.getTotalElements(), rows.getTotalPages())); + } + + public record InvitationHistoryItem(String invitationId, String issuedBy, String issuedByName, + Instant issuedAt, Instant expiresAt, String status, String consumedBy, + String consumedByName, Instant consumedAt, Instant endedAt, boolean legacy) {} + public record InvitationHistoryPage(List items, int page, int size, + long total, int totalPages) {} + @PostMapping("/namespaces/{slug}/invitations") public ApiResponse create(@PathVariable String slug, @RequestBody(required = false) NamespaceInvitationCreateRequest body, diff --git a/server/skillhub-app/src/main/resources/db/migration/V74__namespace_invitation_history.sql b/server/skillhub-app/src/main/resources/db/migration/V74__namespace_invitation_history.sql new file mode 100644 index 00000000..433b1ada --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V74__namespace_invitation_history.sql @@ -0,0 +1,25 @@ +CREATE TABLE namespace_invitation_history ( + id VARCHAR(36) PRIMARY KEY, + namespace_id BIGINT NOT NULL REFERENCES namespace(id) ON DELETE CASCADE, + issued_by VARCHAR(128) NOT NULL, + issued_by_name VARCHAR(255), + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_by VARCHAR(128), + consumed_by_name VARCHAR(255), + consumed_at TIMESTAMPTZ, + ended_reason VARCHAR(16), + ended_at TIMESTAMPTZ, + legacy BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX idx_namespace_invitation_history_page + ON namespace_invitation_history(namespace_id, issued_at DESC, id DESC); +ALTER TABLE namespace_invitation ADD COLUMN history_id VARCHAR(36); +UPDATE namespace_invitation SET history_id = gen_random_uuid()::text; +INSERT INTO namespace_invitation_history + (id, namespace_id, issued_by, issued_at, expires_at, consumed_by, consumed_at, legacy) +SELECT history_id, namespace_id, issued_by, issued_at, expires_at, consumed_by, consumed_at, TRUE +FROM namespace_invitation; +ALTER TABLE namespace_invitation ALTER COLUMN history_id SET NOT NULL; +ALTER TABLE namespace_invitation ADD CONSTRAINT fk_namespace_invitation_history + FOREIGN KEY (history_id) REFERENCES namespace_invitation_history(id); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java index 8da40754..f1d28d9a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceInvitationControllerTest.java @@ -105,6 +105,28 @@ class NamespaceInvitationControllerTest { .getAnnotation(PostMapping.class).value()).containsExactly("/namespace-invitations/accept"); } + @Test + void historyRequiresSessionAndNeverReturnsTokensOrHashes() throws Exception { + PlatformPrincipal owner = new PlatformPrincipal("owner", "Owner", null, null, "local", Set.of()); + assertThatThrownBy(() -> controller.history("team", 0, 10, owner, request, response)) + .isInstanceOf(UnauthorizedException.class); + when(request.getSession(false)).thenReturn(mock(HttpSession.class)); + PlatformPrincipal token = new PlatformPrincipal("owner", "Owner", null, null, "api_token", Set.of("SUPER_ADMIN")); + assertThatThrownBy(() -> controller.history("team", 0, 10, token, request, response)) + .isInstanceOf(UnauthorizedException.class); + Instant now = Instant.parse("2026-10-13T00:00:00Z"); + var row = new com.iflytek.skillhub.domain.namespace.NamespaceInvitationHistory(7L, "owner", "Original name", now, now.plusSeconds(86400)); + when(invitations.history("team", "owner", 0, 10)).thenReturn(new org.springframework.data.domain.PageImpl<>(java.util.List.of(row), org.springframework.data.domain.PageRequest.of(0, 10), 1)); + when(invitations.currentTime()).thenReturn(now); + when(responses.ok(anyString(), any())).thenAnswer(call -> new ApiResponse<>(0, "ok", call.getArgument(1), now, null)); + var page = controller.history("team", 0, 10, owner, request, response).data(); + assertThat(page.items().getFirst().status()).isEqualTo("ACTIVE"); + assertThat(page.total()).isEqualTo(1); + String json = new ObjectMapper().registerModule(new JavaTimeModule()).writeValueAsString(page); + assertThat(json).contains("invitationId", "Original name").doesNotContain("token", "hash"); + verify(response, times(3)).setHeader("Cache-Control", "no-store, private"); + } + @Test void firstCreateNeedsNoBodyAndSuccessDtosMatchIssueContract() { when(request.getSession(false)).thenReturn(mock(HttpSession.class)); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java index 74d5a554..7fdd5932 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/NamespaceInvitationPostgresTest.java @@ -86,10 +86,12 @@ class NamespaceInvitationPostgresTest { @Autowired private AccountMergeService accountMerge; @Autowired private AccountMergeRequestRepository mergeRequests; @Autowired private NamespaceInvitationJpaRepository invitationRows; + @Autowired private com.iflytek.skillhub.infra.jpa.NamespaceInvitationHistoryJpaRepository historyRows; @Autowired private NamespaceJpaRepository namespaces; @Autowired private NamespaceMemberJpaRepository members; @Autowired private UserAccountJpaRepository users; @Autowired private PlatformTransactionManager transactionManager; + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbc; @MockBean private SkillRepository skills; @MockBean private ReviewTaskRepository reviewTasks; @MockBean private PromotionRequestRepository promotions; @@ -119,6 +121,10 @@ class NamespaceInvitationPostgresTest { assertThat(members.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged()) .getContent().stream().filter(member -> member.getRole() == NamespaceRole.MEMBER)).hasSize(1); assertThat(invitationRows.findByNamespaceId(namespaceId).orElseThrow().getConsumedBy()).isIn(first, second); + var history = invitations.history("inv-" + suffix, owner, 0, 10).getContent(); + assertThat(history).hasSize(1); + assertThat(history.getFirst().getConsumedBy()).isIn(first, second); + assertThat(history.getFirst().status(Instant.now())).isEqualTo("USED"); } @Test @@ -248,6 +254,9 @@ class NamespaceInvitationPostgresTest { } assertThat(namespaces.findBySlug(slug)).isEmpty(); assertThat(invitationRows.findByNamespaceId(namespaceId)).isEmpty(); + assertThat(historyRows.findByNamespaceId(namespaceId, org.springframework.data.domain.Pageable.unpaged())).isEmpty(); + assertThat(jdbc.queryForObject("SELECT count(*) FROM audit_log WHERE target_type='NAMESPACE' AND target_id=? AND action='NAMESPACE_INVITE_CREATED'", Long.class, namespaceId)).isEqualTo(1); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, guest)).isEmpty(); } @@ -301,6 +310,98 @@ class NamespaceInvitationPostgresTest { .isEqualTo(!alreadyMember); } + @Test + void migrationBackfillsOnlyProvableCurrentRowsAndNeverTokensOrInventedNames() throws Exception { + String schema = "invite_legacy_" + UUID.randomUUID().toString().replace("-", ""); + var flyway = org.flywaydb.core.Flyway.configure() + .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .schemas(schema).defaultSchema(schema).target("73").load(); + flyway.migrate(); + try (var connection = java.sql.DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()); + var statement = connection.createStatement()) { + statement.execute("SET search_path TO " + schema); + statement.execute("INSERT INTO user_account(id,display_name,status) VALUES ('legacy-owner','Owner','ACTIVE')"); + statement.execute("INSERT INTO namespace(slug,display_name,type,status,created_by) VALUES ('legacy-one','Legacy one','TEAM','ACTIVE','legacy-owner'),('legacy-two','Legacy two','TEAM','ACTIVE','legacy-owner')"); + statement.execute("INSERT INTO namespace_invitation(namespace_id,token_hash,issued_by,issued_at,expires_at,consumed_by,consumed_at) " + + "SELECT id, CASE slug WHEN 'legacy-one' THEN repeat('a',64) ELSE repeat('b',64) END,'legacy-owner',now()-interval '1 hour',now()+interval '23 hours'," + + "CASE slug WHEN 'legacy-two' THEN 'legacy-guest' END,CASE slug WHEN 'legacy-two' THEN now() END FROM namespace WHERE slug IN ('legacy-one','legacy-two')"); + org.flywaydb.core.Flyway.configure().dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()) + .schemas(schema).defaultSchema(schema).load().migrate(); + try (var rows = statement.executeQuery("SELECT count(*), count(*) FILTER (WHERE legacy), count(*) FILTER (WHERE consumed_by='legacy-guest'), count(issued_by_name) FROM namespace_invitation_history")) { + rows.next(); + assertThat(rows.getInt(1)).isEqualTo(2); + assertThat(rows.getInt(2)).isEqualTo(2); + assertThat(rows.getInt(3)).isEqualTo(1); + assertThat(rows.getInt(4)).isZero(); + } + try (var rows = statement.executeQuery("SELECT count(*) FROM namespace_invitation i JOIN namespace_invitation_history h ON h.id=i.history_id AND h.namespace_id=i.namespace_id")) { + rows.next(); assertThat(rows.getInt(1)).isEqualTo(2); + } + statement.execute("DROP SCHEMA " + schema + " CASCADE"); + } + } + + @Test + void historyKeepsUsedAndRemovedMemberFactsAndIndependentReplacementRevocation() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "history-owner-" + suffix; + String guest = "history-guest-" + suffix; + String slug = "history-" + suffix; + Long namespaceId = createTeam(slug, owner, guest); + String firstToken = invitations.create(slug, owner).token(); + String firstId = invitationRows.findByNamespaceId(namespaceId).orElseThrow().getHistoryId(); + invitations.create(slug, owner, true); + assertThat(historyRows.findById(firstId).orElseThrow().status(Instant.now())).isEqualTo("REPLACED"); + String secondId = invitationRows.findByNamespaceId(namespaceId).orElseThrow().getHistoryId(); + invitations.revoke(slug, owner); + assertThat(historyRows.findById(secondId).orElseThrow().status(Instant.now())).isEqualTo("REVOKED"); + String thirdToken = invitations.create(slug, owner).token(); + String thirdId = invitationRows.findByNamespaceId(namespaceId).orElseThrow().getHistoryId(); + invitations.accept(thirdToken, guest); + new TransactionTemplate(transactionManager).executeWithoutResult(status -> { + members.delete(members.findByNamespaceIdAndUserId(namespaceId, guest).orElseThrow()); + users.findAllById(java.util.List.of(guest)).iterator().next().setDisplayName("Renamed later"); + }); + invitations.create(slug, owner); + var history = invitations.history(slug, owner, 0, 2); + assertThat(history.getTotalElements()).isEqualTo(4); + assertThat(jdbc.queryForObject("SELECT detail_json::jsonb->>'invitationId' FROM audit_log WHERE target_type='NAMESPACE' AND target_id=? AND action='NAMESPACE_INVITE_ACCEPTED'", String.class, namespaceId)).isEqualTo(thirdId); + assertThat(history.getTotalPages()).isEqualTo(2); + var used = historyRows.findById(thirdId).orElseThrow(); + assertThat(used.status(Instant.now())).isEqualTo("USED"); + assertThat(used.getConsumedBy()).isEqualTo(guest); + assertThat(used.getConsumedByName()).isNotEqualTo("Renamed later"); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> invitations.preview(firstToken, guest))) + .isInstanceOf(DomainNotFoundException.class); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> invitations.history(slug, guest, 0, 10))) + .isInstanceOf(DomainNotFoundException.class); + assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> invitations.history(slug, owner, 0, 51))) + .isInstanceOf(DomainBadRequestException.class); + } + + @Test + void accountMergeDoesNotRewriteHistoricalRecipientIdentity() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String owner = "history-owner-" + suffix; + String secondary = "history-secondary-" + suffix; + String primary = "history-primary-" + suffix; + String slug = "history-merge-" + suffix; + Long namespaceId = createTeam(slug, owner, secondary); + String token = invitations.create(slug, owner).token(); + invitations.accept(token, secondary); + Long requestId = new TransactionTemplate(transactionManager).execute(status -> { + users.saveAndFlush(new UserAccount(primary, primary, null, null)); + AccountMergeRequest request = new AccountMergeRequest(primary, secondary, null, Instant.now().plusSeconds(3600)); + request.setStatus(AccountMergeRequest.STATUS_VERIFIED); + return mergeRequests.saveAndFlush(request).getId(); + }); + accountMerge.confirm(primary, requestId); + assertThat(members.findByNamespaceIdAndUserId(namespaceId, primary)).isPresent(); + var row = invitations.history(slug, owner, 0, 10).getContent().getFirst(); + assertThat(row.getConsumedBy()).isEqualTo(secondary); + assertThat(row.status(Instant.now())).isEqualTo("USED"); + } + private Long createTeam(String slug, String owner, String guest) { return new TransactionTemplate(transactionManager).execute(status -> { users.saveAndFlush(new UserAccount(owner, owner, null, null)); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java index eb97b7c1..df0d2b38 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitation.java @@ -32,6 +32,12 @@ public class NamespaceInvitation { @Column(name = "consumed_at") private Instant consumedAt; + @Column(name = "history_id", nullable = false) + private String historyId; + + public String getHistoryId() { return historyId; } + public void setHistoryId(String historyId) { this.historyId = historyId; } + protected NamespaceInvitation() {} public NamespaceInvitation(Long namespaceId, String tokenHash, String issuedBy, Instant issuedAt, Instant expiresAt) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistory.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistory.java new file mode 100644 index 00000000..665524d1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistory.java @@ -0,0 +1,80 @@ +package com.iflytek.skillhub.domain.namespace; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "namespace_invitation_history") +public class NamespaceInvitationHistory { + @Id + @Column(name = "id") + private String id; + @Column(name = "namespace_id") + private Long namespaceId; + @Column(name = "issued_by") + private String issuedBy; + @Column(name = "issued_by_name") + private String issuedByName; + @Column(name = "issued_at") + private Instant issuedAt; + @Column(name = "expires_at") + private Instant expiresAt; + @Column(name = "consumed_by") + private String consumedBy; + @Column(name = "consumed_by_name") + private String consumedByName; + @Column(name = "consumed_at") + private Instant consumedAt; + @Column(name = "ended_reason") + private String endedReason; + @Column(name = "ended_at") + private Instant endedAt; + @Column(name = "legacy") + private boolean legacy; + + protected NamespaceInvitationHistory() {} + + public NamespaceInvitationHistory(Long namespaceId, String issuer, String issuerName, Instant issuedAt, Instant expiresAt) { + this.id = UUID.randomUUID().toString(); + this.namespaceId = namespaceId; + this.issuedBy = issuer; + this.issuedByName = issuerName; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + } + + public void end(String reason, Instant now) { + if (consumedAt == null && endedAt == null && expiresAt.isAfter(now)) { + endedReason = reason; + endedAt = now; + } + } + + public void consume(String userId, String name, Instant now) { + consumedBy = userId; + consumedByName = name; + consumedAt = now; + } + + public String status(Instant now) { + if (consumedAt != null) return "USED"; + if (endedReason != null) return endedReason; + return expiresAt.isAfter(now) ? "ACTIVE" : "EXPIRED"; + } + public String getId() { return id; } + public Long getNamespaceId() { return namespaceId; } + public String getIssuedBy() { return issuedBy; } + public String getIssuedByName() { return issuedByName; } + public Instant getIssuedAt() { return issuedAt; } + public Instant getExpiresAt() { return expiresAt; } + public String getConsumedBy() { return consumedBy; } + public String getConsumedByName() { return consumedByName; } + public Instant getConsumedAt() { return consumedAt; } + public String getEndedReason() { return endedReason; } + public Instant getEndedAt() { return endedAt; } + public boolean isLegacy() { return legacy; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistoryRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistoryRepository.java new file mode 100644 index 00000000..8d8637fc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationHistoryRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface NamespaceInvitationHistoryRepository { + NamespaceInvitationHistory save(NamespaceInvitationHistory history); + Optional findById(String id); + Page findByNamespaceId(Long namespaceId, Pageable pageable); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java index fa50f5cb..590e1ff5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationService.java @@ -6,6 +6,10 @@ import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; import org.springframework.stereotype.Service; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import org.springframework.transaction.annotation.Transactional; import java.nio.charset.StandardCharsets; @@ -26,16 +30,18 @@ public class NamespaceInvitationService { private final UserAccountRepository users; private final AuditLogService audit; private final Clock clock; + private final NamespaceInvitationHistoryRepository histories; public NamespaceInvitationService(NamespaceRepository namespaces, NamespaceInvitationRepository invitations, NamespaceMemberRepository members, UserAccountRepository users, - AuditLogService audit, Clock clock) { + AuditLogService audit, Clock clock, NamespaceInvitationHistoryRepository histories) { this.namespaces = namespaces; this.invitations = invitations; this.members = members; this.users = users; this.audit = audit; this.clock = clock; + this.histories = histories; } public record Created(String token, Instant expiresAt, boolean replaced) {} @@ -66,13 +72,21 @@ public class NamespaceInvitationService { if (replaced && !replace) { throw new DomainConflictException("error.namespace.invite.replaceConfirmationRequired"); } + if (invitation != null) { + NamespaceInvitationHistory previous = history(invitation); + previous.end("REPLACED", now); + histories.save(previous); + } + NamespaceInvitationHistory history = histories.save(new NamespaceInvitationHistory(namespace.getId(), actorId, + users.findById(actorId).orElseThrow(this::invalid).getDisplayName(), now, now.plusSeconds(86400))); if (invitation == null) { invitation = new NamespaceInvitation(namespace.getId(), hash(token), actorId, now, now.plusSeconds(86400)); } else { invitation.replace(hash(token), actorId, now); } + invitation.setHistoryId(history.getId()); invitations.save(invitation); - record(actorId, replaced ? "NAMESPACE_INVITE_REPLACED" : "NAMESPACE_INVITE_CREATED", namespace.getId()); + record(actorId, replaced ? "NAMESPACE_INVITE_REPLACED" : "NAMESPACE_INVITE_CREATED", namespace.getId(), history.getId()); return new Created(token, invitation.getExpiresAt(), replaced); } @@ -82,11 +96,12 @@ public class NamespaceInvitationService { Namespace namespace = lock(slug); requireManager(namespace, actorId); requireActiveTeam(namespace); - if (invitations.findByNamespaceId(namespace.getId()).isEmpty()) { - throw invalid(); - } + NamespaceInvitation invitation = invitations.findByNamespaceId(namespace.getId()).orElseThrow(this::invalid); + NamespaceInvitationHistory history = history(invitation); + history.end("REVOKED", clock.instant()); + histories.save(history); invitations.deleteByNamespaceId(namespace.getId()); - record(actorId, "NAMESPACE_INVITE_REVOKED", namespace.getId()); + record(actorId, "NAMESPACE_INVITE_REVOKED", namespace.getId(), history.getId()); } @Transactional(readOnly = true) @@ -130,12 +145,34 @@ public class NamespaceInvitationService { requireActiveUser(userId); requireValid(namespace, invitation); members.save(new NamespaceMember(namespace.getId(), userId, NamespaceRole.MEMBER)); - invitation.consume(userId, clock.instant()); + Instant acceptedAt = clock.instant(); + invitation.consume(userId, acceptedAt); + NamespaceInvitationHistory history = history(invitation); + history.consume(userId, users.findById(userId).orElseThrow(this::invalid).getDisplayName(), acceptedAt); + histories.save(history); invitations.save(invitation); - record(userId, "NAMESPACE_INVITE_ACCEPTED", namespace.getId()); + record(userId, "NAMESPACE_INVITE_ACCEPTED", namespace.getId(), history.getId()); return new Accepted(namespace.getSlug(), AcceptOutcome.JOINED); } + @Transactional(readOnly = true) + public Page history(String slug, String actorId, int page, int size) { + if (page < 0 || size < 1 || size > 50) { + throw new DomainBadRequestException("error.pagination.invalid", 50); + } + Namespace namespace = namespaces.findBySlug(slug).orElseThrow(this::invalid); + requireManager(namespace, actorId); + return histories.findByNamespaceId(namespace.getId(), PageRequest.of(page, size, + Sort.by(Sort.Direction.DESC, "issuedAt", "id"))); + } + + public Instant currentTime() { return clock.instant(); } + + private NamespaceInvitationHistory history(NamespaceInvitation invitation) { + return histories.findById(invitation.getHistoryId()).orElseThrow(() -> + new IllegalStateException("Current invitation history is missing")); + } + private Namespace lock(String slug) { Long namespaceId = namespaces.findIdBySlug(slug).orElseThrow(this::invalid); return namespaces.findLockedById(namespaceId).orElseThrow(this::invalid); @@ -186,7 +223,7 @@ public class NamespaceInvitationService { } } - private void record(String actorId, String action, Long namespaceId) { - audit.record(actorId, action, "NAMESPACE", namespaceId, null, null, null, null); + private void record(String actorId, String action, Long namespaceId, String invitationId) { + audit.record(actorId, action, "NAMESPACE", namespaceId, null, null, null, "{\"invitationId\":\"" + invitationId + "\"}"); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java index 940c67d6..6d97da7d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceInvitationServiceTest.java @@ -25,11 +25,15 @@ class NamespaceInvitationServiceTest { private final NamespaceMemberRepository members = mock(NamespaceMemberRepository.class); private final UserAccountRepository users = mock(UserAccountRepository.class); private final AuditLogService audit = mock(AuditLogService.class); + private final NamespaceInvitationHistoryRepository histories = mock(NamespaceInvitationHistoryRepository.class); private final Clock clock = Clock.fixed(Instant.parse("2026-10-12T00:00:00Z"), ZoneOffset.UTC); - private final NamespaceInvitationService service = new NamespaceInvitationService(namespaces, invitations, members, users, audit, clock); + private final NamespaceInvitationService service = new NamespaceInvitationService(namespaces, invitations, members, users, audit, clock, histories); @BeforeEach void setup() { + lenient().when(histories.save(any())).thenAnswer(call -> call.getArgument(0)); + lenient().when(histories.findById(org.mockito.ArgumentMatchers.nullable(String.class))).thenAnswer(call -> + Optional.of(new NamespaceInvitationHistory(7L, "owner", "Owner", clock.instant(), clock.instant().plusSeconds(86400)))); Namespace namespace = new Namespace("team", "Team", "owner"); // Entity ID is database-generated; mock the locked lookup with the same instance. try { @@ -309,6 +313,38 @@ class NamespaceInvitationServiceTest { assertThat(renewed.token()).isNotEqualTo(first.token()); } + @Test + void historyStatusPreservesTerminalFactsAndUsesExactExpiryBoundary() { + var row = new NamespaceInvitationHistory(7L, "owner", "Owner", clock.instant().minusSeconds(86400), clock.instant()); + row.end("REPLACED", clock.instant()); + assertThat(row.status(clock.instant())).isEqualTo("EXPIRED"); + var used = new NamespaceInvitationHistory(7L, "owner", "Owner", clock.instant(), clock.instant().plusSeconds(86400)); + used.consume("guest", "Guest", clock.instant()); + used.end("REVOKED", clock.instant()); + assertThat(used.status(clock.instant().plusSeconds(100000))).isEqualTo("USED"); + } + + @Test + void historyRejectsMembersAndPlatformAdminsWithoutNamespaceManagementRole() { + assertThatThrownBy(() -> service.history("team", "guest", 0, 10)).isInstanceOf(DomainNotFoundException.class); + assertThatThrownBy(() -> service.history("team", "platform-admin", 0, 10)).isInstanceOf(DomainNotFoundException.class); + verify(histories, never()).findByNamespaceId(any(), any()); + } + + @Test + void inactiveManagerCannotReadHistoryAndFrozenNamespaceDoesNotRemoveReadPermission() { + var namespace = namespaces.findById(7L).orElseThrow(); + when(histories.findByNamespaceId(eq(7L), any())).thenReturn(org.springframework.data.domain.Page.empty()); + namespace.setStatus(NamespaceStatus.FROZEN); + assertThat(service.history("team", "owner", 0, 10)).isEmpty(); + namespace.setStatus(NamespaceStatus.ARCHIVED); + assertThat(service.history("team", "owner", 0, 10)).isEmpty(); + UserAccount inactive = mock(UserAccount.class); + when(inactive.getStatus()).thenReturn(UserStatus.MERGED); + when(users.findById("owner")).thenReturn(Optional.of(inactive)); + assertThatThrownBy(() -> service.history("team", "owner", 0, 10)).isInstanceOf(DomainNotFoundException.class); + } + private NamespaceInvitation invitationExpiringNow() { ArgumentCaptor saved = ArgumentCaptor.forClass(NamespaceInvitation.class); verify(invitations).save(saved.capture()); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationHistoryJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationHistoryJpaRepository.java new file mode 100644 index 00000000..8a317c59 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceInvitationHistoryJpaRepository.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationHistory; +import com.iflytek.skillhub.domain.namespace.NamespaceInvitationHistoryRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface NamespaceInvitationHistoryJpaRepository extends JpaRepository, NamespaceInvitationHistoryRepository { + @Override + java.util.Optional findById(String id); +} diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index d1d25158..c5ef67ab 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -185,6 +185,7 @@ describe('namespace invitation API', () => { }) vi.stubGlobal('fetch', fetchMock) + await namespaceApi.invitationHistory('@team', 2, 10) await namespaceApi.invitationStatus('@team') await namespaceApi.createInvitation('@team', false) await namespaceApi.revokeInvitation('@team') @@ -192,14 +193,15 @@ describe('namespace invitation API', () => { await namespaceApi.acceptInvitation('raw-token') expect(fetchMock.mock.calls.map(([url, init]) => [url, init.method ?? 'GET'])).toEqual([ + ['/api/web/namespaces/team/invitations/history?page=2&size=10', 'GET'], ['/api/web/namespaces/team/invitations', 'GET'], ['/api/web/namespaces/team/invitations', 'POST'], ['/api/web/namespaces/team/invitations', 'DELETE'], ['/api/web/namespace-invitations/preview', 'POST'], ['/api/web/namespace-invitations/accept', 'POST'], ]) - expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ replace: false }) - expect(JSON.parse(fetchMock.mock.calls[3][1].body)).toEqual({ token: 'raw-token' }) + expect(JSON.parse(fetchMock.mock.calls[2][1].body)).toEqual({ replace: false }) + expect(JSON.parse(fetchMock.mock.calls[4][1].body)).toEqual({ token: 'raw-token' }) }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1e62dd8a..2ab2f06f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -48,6 +48,7 @@ import type { Namespace, CreateNamespaceRequest, NamespaceMember, + NamespaceInvitationHistoryPage, NamespaceCandidateUser, NotificationItem, NotificationPreferenceItem, @@ -818,6 +819,11 @@ export const labelApi = { } export const namespaceApi = { + async invitationHistory(slug: string, page = 0, size = 10): Promise { + const params = new URLSearchParams({ page: String(page), size: String(size) }) + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations/history?${params}`) + }, + async invitationStatus(slug: string): Promise<{ active: boolean }> { return fetchJson<{ active: boolean }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/invitations`) }, diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 3141a3b3..762c2973 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -4589,6 +4589,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/namespaces/{slug}/invitations/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["history_2"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/namespaces/{slug}/invitations/history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["history_3"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/me/suites/workspace": { parameters: { query?: never; @@ -7770,6 +7802,43 @@ export interface components { email?: string; status?: string; }; + ApiResponseInvitationHistoryPage: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["InvitationHistoryPage"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + InvitationHistoryItem: { + invitationId?: string; + issuedBy?: string; + issuedByName?: string; + /** Format: date-time */ + issuedAt?: string; + /** Format: date-time */ + expiresAt?: string; + status?: string; + consumedBy?: string; + consumedByName?: string; + /** Format: date-time */ + consumedAt?: string; + /** Format: date-time */ + endedAt?: string; + legacy?: boolean; + }; + InvitationHistoryPage: { + items?: components["schemas"]["InvitationHistoryItem"][]; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + totalPages?: number; + }; ApiResponseInvitationStatus: { /** Format: int32 */ code?: number; @@ -16930,6 +16999,56 @@ export interface operations { }; }; }; + history_2: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInvitationHistoryPage"]; + }; + }; + }; + }; + history_3: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInvitationHistoryPage"]; + }; + }; + }; + }; listMySkillSuiteWorkspace: { parameters: { query?: { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 90d76a87..d4ea200b 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -809,3 +809,9 @@ export interface NotificationPreferenceItem { export interface NotificationUnreadCount { count: number } + +/** Read-only, token-free invitation history from the generated API contract. */ +export type NamespaceInvitationHistory = Required +export type NamespaceInvitationHistoryPage = Omit, 'items'> & { + items: NamespaceInvitationHistory[] +} diff --git a/web/src/features/namespace/invitation-controls.test.tsx b/web/src/features/namespace/invitation-controls.test.tsx index 762ad856..0d3cf895 100644 --- a/web/src/features/namespace/invitation-controls.test.tsx +++ b/web/src/features/namespace/invitation-controls.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const api = vi.hoisted(() => ({ invitationStatus: vi.fn(), + invitationHistory: vi.fn().mockResolvedValue({ items: [], total: 0, totalPages: 0, page: 0, size: 10 }), createInvitation: vi.fn(), revokeInvitation: vi.fn(), previewInvitation: vi.fn(), @@ -27,6 +28,7 @@ describe('InvitationControls', () => { window.sessionStorage.clear() auth.user = { userId: 'owner' } Object.values(api).forEach((mock) => mock.mockReset()) + api.invitationHistory.mockResolvedValue({ items: [], total: 0, totalPages: 0, page: 0, size: 10 }) }) async function openConfirmation(action: 'replace' | 'revoke') { @@ -87,6 +89,30 @@ describe('InvitationControls', () => { expect(screen.queryByRole('dialog')).toBeNull() }) + it.each(['create', 'replace', 'revoke'] as const)('refreshes history only after %s succeeds', async (action) => { + api.invitationStatus.mockResolvedValue({ active: action !== 'create' }) + api.previewInvitation.mockResolvedValue({ namespaceSlug: 'team', expiresAt: '2999-01-01T00:00:00Z' }) + const historyPage = { items: [], page: 0, size: 10, total: 0, totalPages: 0 } + api.invitationHistory.mockResolvedValue(historyPage) + let succeed: () => void = () => {} + if (action === 'revoke') api.revokeInvitation.mockImplementation(() => new Promise((resolve) => { succeed = () => { api.invitationStatus.mockResolvedValue({ active: false }); resolve() } })) + else api.createInvitation.mockImplementation(() => new Promise((resolve) => { succeed = () => { api.invitationStatus.mockResolvedValue({ active: true }); resolve({ token: 'a'.repeat(43), replaced: action === 'replace' }) } })) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + const button = await screen.findByRole('button', { name: action === 'create' ? 'invitation.create' : action === 'replace' ? 'invitation.replaceAction' : 'invitation.revoke' }) + await waitFor(() => expect((button as HTMLButtonElement).disabled).toBe(false)) + await waitFor(() => expect(api.invitationHistory).toHaveBeenCalledTimes(1)) + fireEvent.click(button) + if (action !== 'create') { + const dialog = await screen.findByRole('dialog') + fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.confirm' })) + } + await waitFor(() => expect(action === 'revoke' ? api.revokeInvitation : api.createInvitation).toHaveBeenCalledTimes(1)) + expect(api.invitationHistory).toHaveBeenCalledTimes(1) + succeed() + await waitFor(() => expect(api.invitationHistory).toHaveBeenCalledTimes(2)) + }) + const token = 'a'.repeat(43) const storageKey = 'skillhub.namespace.issued-invitation:owner:team' const validPreview = { namespaceSlug: 'team', expiresAt: '2999-01-01T00:00:00Z', outcome: 'ALREADY_MEMBER' } diff --git a/web/src/features/namespace/invitation-controls.tsx b/web/src/features/namespace/invitation-controls.tsx index c1ceece4..d2323d3f 100644 --- a/web/src/features/namespace/invitation-controls.tsx +++ b/web/src/features/namespace/invitation-controls.tsx @@ -12,6 +12,7 @@ import { resolvePublicRegistryUrl } from '@/shared/lib/registry-url' import { toast } from '@/shared/lib/toast' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' +import { InvitationHistory } from './invitation-history' export function InvitationControls({ slug, compact = false }: { slug: string; compact?: boolean }) { const { t, i18n } = useTranslation() @@ -74,14 +75,20 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co try { window.sessionStorage.setItem(storageKey, token) } catch { setStorageUnavailable(true) } const url = invitationUrl(resolvePublicRegistryUrl(getAppBaseUrl(), window.location.origin, BASE_PATH), token) await copyLink(url, replaced) - await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }), + queryClient.invalidateQueries({ queryKey: ['namespace-invitation-history', slug, user?.userId] }), + ]) }, }) const revoke = useMutation({ mutationFn: () => namespaceApi.revokeInvitation(slug), onSuccess: async () => { clearStoredToken() - await queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['namespace-invitation-status', slug] }), + queryClient.invalidateQueries({ queryKey: ['namespace-invitation-history', slug, user?.userId] }), + ]) toast.success(t('invitation.revoked')) }, }) @@ -142,6 +149,7 @@ export function InvitationControls({ slug, compact = false }: { slug: string; co ) : null} + {!compact && user ? : null} { if (!open) setConfirm(null) }} title={t(confirm === 'revoke' ? 'invitation.revokeTitle' : 'invitation.replaceTitle')} description={t(confirm === 'revoke' ? 'invitation.revokeDescription' : 'invitation.replaceDescription')} diff --git a/web/src/features/namespace/invitation-history.test.tsx b/web/src/features/namespace/invitation-history.test.tsx new file mode 100644 index 00000000..3803e1cd --- /dev/null +++ b/web/src/features/namespace/invitation-history.test.tsx @@ -0,0 +1,75 @@ +/** @vitest-environment jsdom */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const api = vi.hoisted(() => ({ invitationHistory: vi.fn() })) +const auth = vi.hoisted(() => ({ user: { userId: 'owner' } as { userId: string } | null })) +vi.mock('@/api/client', () => ({ namespaceApi: api })) +vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => auth })) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) })) +import { InvitationHistory } from './invitation-history' + +const record = { + invitationId: 'record-1', issuedBy: 'owner', issuedByName: 'Owner then', + issuedAt: '2026-10-13T12:00:00Z', expiresAt: '2026-10-14T12:00:00Z', + status: 'USED', consumedBy: 'joined-user', consumedByName: 'Original joiner', + consumedAt: '2026-10-13T13:00:00Z', endedAt: '2026-10-13T13:00:00Z', legacy: true, +} +const page = (items = [record], pageIndex = 0, totalPages = 1) => ({ items, page: pageIndex, size: 10, total: items.length, totalPages }) +function mount(slug = 'team', client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { + return render() +} +afterEach(() => { cleanup(); api.invitationHistory.mockReset(); auth.user = { userId: 'owner' } }) + +describe('InvitationHistory', () => { + it('shows immutable join facts and legacy status without link or restoration actions', async () => { + api.invitationHistory.mockResolvedValue(page()) + mount() + const table = await screen.findByRole('table') + expect(within(table).getByText('Owner then')).toBeTruthy() + expect(within(table).getByText('joined-user')).toBeTruthy() + expect(within(table).getByText('Original joiner')).toBeTruthy() + expect(within(table).getByText('invitation.historyUsed')).toBeTruthy() + expect(within(table).getByText('invitation.historyLegacy')).toBeTruthy() + expect(screen.queryByRole('textbox')).toBeNull() + expect(screen.queryByRole('link')).toBeNull() + expect(api.invitationHistory).toHaveBeenCalledWith('team', 0, 10) + }) + it('requests real next page, displays loading without previous page rows, and resets for another namespace', async () => { + api.invitationHistory.mockResolvedValueOnce(page([record], 0, 2)) + mount() + await screen.findByRole('table') + let finish: (value: ReturnType) => void = () => {} + api.invitationHistory.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve })) + fireEvent.click(screen.getByRole('button', { name: 'pagination.next' })) + await waitFor(() => expect(api.invitationHistory).toHaveBeenLastCalledWith('team', 1, 10)) + expect(screen.queryByText('Original joiner')).toBeNull() + expect(screen.getByRole('status').textContent).toBe('invitation.historyLoading') + finish(page([{ ...record, invitationId: 'record-2', consumedByName: 'Second joiner' }], 1, 2)) + await screen.findAllByText('Second joiner') + cleanup() + api.invitationHistory.mockResolvedValue(page([], 0)) + mount('another') + await screen.findByText('invitation.historyEmpty') + expect(api.invitationHistory).toHaveBeenLastCalledWith('another', 0, 10) + expect(screen.queryByText('Second joiner')).toBeNull() + }) + it('hides prior records when refresh fails including 403, then allows retry', async () => { + api.invitationHistory.mockResolvedValueOnce(page()).mockRejectedValueOnce(new Error('403 forbidden')).mockResolvedValue(page([])) + mount() + await screen.findByRole('table') + fireEvent.click(screen.getByRole('button', { name: 'invitation.historyRefresh' })) + await screen.findByRole('alert') + expect(screen.queryByRole('table')).toBeNull() + expect(screen.queryByText('Original joiner')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'invitation.historyRefresh' })) + await screen.findByText('invitation.historyEmpty') + expect(screen.queryByRole('alert')).toBeNull() + }) + it('does not request history without a logged in identity', () => { + auth.user = null + mount() + expect(api.invitationHistory).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/features/namespace/invitation-history.tsx b/web/src/features/namespace/invitation-history.tsx new file mode 100644 index 00000000..d34a1831 --- /dev/null +++ b/web/src/features/namespace/invitation-history.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Loader2, RefreshCw } from 'lucide-react' +import { namespaceApi } from '@/api/client' +import type { NamespaceInvitationHistory } from '@/api/types' +import { useAuth } from '@/features/auth/use-auth' +import { Pagination } from '@/shared/components/pagination' +import { Button } from '@/shared/ui/button' + +const statusKeys: Record = { + ACTIVE: 'invitation.historyActive', USED: 'invitation.historyUsed', + REPLACED: 'invitation.historyReplaced', REVOKED: 'invitation.historyRevoked', + EXPIRED: 'invitation.historyExpired', +} + +/** Mounted only within the OWNER/ADMIN invitation dialog; the API checks the same role. */ +export function InvitationHistory({ slug }: { slug: string }) { + const { t, i18n } = useTranslation() + const { user } = useAuth() + const [page, setPage] = useState(0) + const history = useQuery({ + queryKey: ['namespace-invitation-history', slug, user?.userId, page], + enabled: !!user, + staleTime: 0, + retry: false, + queryFn: () => namespaceApi.invitationHistory(slug, page, 10), + }) + const formatDate = (value: string) => new Intl.DateTimeFormat(i18n?.language ?? 'zh', { + dateStyle: 'short', timeStyle: 'short', + }).format(new Date(value)) + const identity = (name: string | null | undefined, id: string | null | undefined) => ( + + {name || id || t('invitation.historyUnknownUser')} + {name && id ? {id} : null} + + ) + const status = (item: NamespaceInvitationHistory) => ( + + {t(statusKeys[item.status])} + {item.legacy ? {t('invitation.historyLegacy')} : null} + + ) + const joined = (item: NamespaceInvitationHistory) => item.consumedAt ? ( + <>{identity(item.consumedByName, item.consumedBy)} + ) : {t('invitation.historyNoJoin')} + + return ( +
+
+

{t('invitation.historyTitle')}

+ +
+

{t('invitation.historyDescription')}

+ {history.isPending ?

: history.isError ? ( +

{t('invitation.historyError')}

+ ) : history.data?.items.length ? ( + <> +
+ + + + + + + + + {history.data.items.map((item) => + + + + + )} +
{t('invitation.historyTitle')}
{t('invitation.historyCreator')}{t('invitation.historyCreatedAt')} / {t('invitation.historyExpiresAt')}{t('invitation.historyStatus')}{t('invitation.historyJoined')}
{identity(item.issuedByName, item.issuedBy)}{status(item)}{joined(item)}
+
    {history.data.items.map((item) =>
  • +
    {t('invitation.historyCreator')}: {identity(item.issuedByName, item.issuedBy)}
    {status(item)}
    +
    +
    {t('invitation.historyCreatedAt')}
    +
    {t('invitation.historyExpiresAt')}
    +
    {t('invitation.historyJoined')}
    {joined(item)}
    +
    +
  • )}
+
+ {history.data.totalPages > 1 ? : null} + + ) :

{t('invitation.historyEmpty')}

} +
+ ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2f91cc42..a8bb5ffa 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1427,7 +1427,26 @@ "managementTitle": "Manage invitation", "retry": "Refresh status", "expiresAtLabel": "Expires at", - "statusError": "Unable to check invitation status" + "statusError": "Unable to check invitation status", + "historyTitle": "Invitation history", + "historyDescription": "Link creation, changes and successful joins. History does not indicate current membership.", + "historyLoading": "Loading invitation history", + "historyEmpty": "No invitation records", + "historyError": "Could not load invitation history. Please retry.", + "historyCreator": "Created by", + "historyCreatedAt": "Created at", + "historyExpiresAt": "Expires at", + "historyStatus": "Status", + "historyJoined": "Joined by / Joined at", + "historyLegacy": "Legacy record", + "historyUnknownUser": "Name not recorded", + "historyNoJoin": "No recorded join", + "historyActive": "Active", + "historyUsed": "Used", + "historyReplaced": "Replaced", + "historyRevoked": "Revoked", + "historyExpired": "Expired", + "historyRefresh": "Refresh history" }, "members": { "title": "Member Management", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 528981dd..7a5063ef 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1492,7 +1492,26 @@ "managementTitle": "Управление приглашением", "retry": "Обновить статус", "expiresAtLabel": "Истекает", - "statusError": "Не удалось проверить статус приглашения" + "statusError": "Не удалось проверить статус приглашения", + "historyTitle": "История приглашений", + "historyDescription": "Создание ссылок, изменения и успешные вступления. История не отражает текущее членство.", + "historyLoading": "Загрузка истории приглашений", + "historyEmpty": "Нет записей о приглашениях", + "historyError": "Не удалось загрузить историю. Повторите попытку.", + "historyCreator": "Создал", + "historyCreatedAt": "Создано", + "historyExpiresAt": "Истекает", + "historyStatus": "Статус", + "historyJoined": "Участник / Время вступления", + "historyLegacy": "Старая запись", + "historyUnknownUser": "Имя не записано", + "historyNoJoin": "Нет записи о вступлении", + "historyActive": "Действует", + "historyUsed": "Использовано", + "historyReplaced": "Заменено", + "historyRevoked": "Отозвано", + "historyExpired": "Истекло", + "historyRefresh": "Обновить историю" }, "members": { "title": "Управление участниками", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index b18d75a5..1bbc03fd 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1427,7 +1427,26 @@ "managementTitle": "管理邀请", "retry": "重新获取状态", "expiresAtLabel": "到期时间", - "statusError": "邀请状态获取失败" + "statusError": "邀请状态获取失败", + "historyTitle": "邀请记录", + "historyDescription": "记录链接的创建、变更及实际加入结果。历史记录不代表当前成员资格。", + "historyLoading": "正在加载邀请记录", + "historyEmpty": "暂无邀请记录", + "historyError": "邀请记录加载失败,请重试。", + "historyCreator": "创建人", + "historyCreatedAt": "创建时间", + "historyExpiresAt": "到期时间", + "historyStatus": "状态", + "historyJoined": "加入人/加入时间", + "historyLegacy": "旧记录", + "historyUnknownUser": "姓名未记录", + "historyNoJoin": "尚无加入记录", + "historyActive": "有效", + "historyUsed": "已使用", + "historyReplaced": "已替换", + "historyRevoked": "已作废", + "historyExpired": "已过期", + "historyRefresh": "刷新记录" }, "members": { "title": "成员管理", diff --git a/web/src/pages/dashboard/namespace-members.test.tsx b/web/src/pages/dashboard/namespace-members.test.tsx index 404bc575..fdd50548 100644 --- a/web/src/pages/dashboard/namespace-members.test.tsx +++ b/web/src/pages/dashboard/namespace-members.test.tsx @@ -8,7 +8,8 @@ vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ slug: 'team' }) } vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) })) vi.mock('@/features/namespace/namespace-header', () => ({ NamespaceHeader: () =>

Team

})) vi.mock('@/features/namespace/namespace-management-nav', () => ({ NamespaceManagementBack: () => Back, NamespaceManagementNav: () => })) -vi.mock('@/features/namespace/invitation-controls', () => ({ InvitationControls: () => null })) +vi.mock('@/features/namespace/invitation-controls', () => ({ InvitationControls: () => Active invitation controls })) +vi.mock('@/features/namespace/invitation-history', () => ({ InvitationHistory: () => Read-only invitation history })) vi.mock('@/features/namespace/add-namespace-member-dialog', () => ({ AddNamespaceMemberDialog: ({ children }: { children: ReactNode }) => children })) vi.mock('@/features/namespace/batch-import-members-dialog', () => ({ BatchImportMembersDialog: ({ children }: { children: ReactNode }) => children })) vi.mock('@/features/namespace/transfer-ownership-dialog', () => ({ TransferOwnershipDialog: ({ children }: { children: ReactNode }) => children })) @@ -40,6 +41,22 @@ afterEach(cleanup) beforeEach(() => { state.role = 'OWNER'; state.status = 'ACTIVE'; state.update.mockReset().mockResolvedValue({}); state.remove.mockReset() }) describe('NamespaceMembersPage permissions and role actions', () => { + it.each(['FROZEN', 'ARCHIVED'])('allows OWNER to view history without creating links in %s space', async (status) => { + state.status = status + render() + fireEvent.click(screen.getByRole('button', { name: 'invitation.historyTitle' })) + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('Read-only invitation history')).toBeTruthy() + expect(within(dialog).queryByText('Active invitation controls')).toBeNull() + expect(screen.queryByRole('button', { name: 'namespaceManagement.invite' })).toBeNull() + expect(screen.queryByRole('button', { name: 'members.addMember' })).toBeNull() + }) + it('does not offer invitation history to an ordinary member', () => { + state.role = 'MEMBER' + render() + expect(screen.queryByRole('button', { name: 'invitation.historyTitle' })).toBeNull() + expect(screen.queryByRole('button', { name: 'namespaceManagement.invite' })).toBeNull() + }) it('places back navigation before the namespace header', () => { const { container } = render() expect(container.firstElementChild?.firstElementChild?.tagName).toBe('A') diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index dfab9b88..9d99740d 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -6,6 +6,7 @@ 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 { InvitationControls } from '@/features/namespace/invitation-controls' +import { InvitationHistory } from '@/features/namespace/invitation-history' import { NamespaceManagementBack, NamespaceManagementNav } from '@/features/namespace/namespace-management-nav' import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from '@/shared/ui/dialog' import { NamespaceHeader } from '@/features/namespace/namespace-header' @@ -77,6 +78,7 @@ export function NamespaceMembersPage() { // Membership changes are only allowed in active team namespaces and only for // elevated roles surfaced through the current user's namespace membership. const canManageMembers = !isReadOnly && (currentUserRole === 'OWNER' || currentUserRole === 'ADMIN') + const canViewInvitations = namespace?.type === 'TEAM' && (currentUserRole === 'OWNER' || currentUserRole === 'ADMIN') const canEditNamespace = canManageMembers const canTransferOwnership = !isReadOnly && currentUserRole === 'OWNER' @@ -176,14 +178,14 @@ export function NamespaceMembersPage() { ) : null} - {canManageMembers ?
- {canManageMembers ? ( + {canViewInvitations ?
+ {canViewInvitations ? ( - + - {t('namespaceManagement.invite')} + {t(canManageMembers ? 'namespaceManagement.invite' : 'invitation.historyTitle')} {namespace.displayName} @{slug} - + {canManageMembers ? : } ) : null} diff --git a/web/src/pages/join.tsx b/web/src/pages/join.tsx index 60dc8d6a..23c816a8 100644 --- a/web/src/pages/join.tsx +++ b/web/src/pages/join.tsx @@ -29,7 +29,10 @@ export function JoinPage() { mutationFn: () => namespaceApi.acceptInvitation(token!), onSuccess: async (result) => { clearInvitationToken() - await queryClient.invalidateQueries({ queryKey: ['namespaces'] }) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['namespaces'] }), + queryClient.invalidateQueries({ queryKey: ['namespace-invitation-history', result.namespaceSlug] }), + ]) await navigate({ to: '/space/$namespace', params: { namespace: result.namespaceSlug }, replace: true }) }, })