From ee17c1eb437594a81914272cea2abff8345e476a Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Mon, 12 Oct 2026 12:52:04 +0800
Subject: [PATCH] fix(invitation): enter namespace after member preview
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
.../specs/namespace-invitations/spec.md | 2 +-
web/src/pages/join.test.tsx | 84 +++++++++++++++++++
web/src/pages/join.tsx | 19 +++--
3 files changed, 96 insertions(+), 9 deletions(-)
create mode 100644 web/src/pages/join.test.tsx
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 f1f2769c..9f7277f5 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
@@ -85,7 +85,7 @@ Token 原始随机字节不得少于 32 字节,并采用 Base64URL 编码;
前端 SHALL 完成链接分享、登录回跳和邀请状态闭环。
-空间卡片提供单击创建并复制邀请的入口,有仍可兑换的旧链接时先确认替换;成员管理页也提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token,随后进入空间时替换浏览器历史中的邀请页。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。
+空间卡片提供单击创建并复制邀请的入口,有仍可兑换的旧链接时先确认替换;成员管理页也提供生成、替换确认、复制失败后的手动复制以及单独作废;接收页提供加载、确认、已加入和失效状态。成功、已加入或终态失效时清除 sessionStorage 中的 Token;已加入预览一旦成功即清除 Token 并自动使用 replace 导航进入空间,避免重复导航。加载状态使用可访问的 status 提示,终态失效使用 alert 提示。网络错误保留 Token 以重试。生成公开链接必须复用现有公开基地址解析方式,清理 fragment 时保留当前 path 和 query。中文、英文和俄文文案均覆盖。
#### Scenario: 剪贴板写入失败
- **WHEN** 创建成功但浏览器拒绝写剪贴板
diff --git a/web/src/pages/join.test.tsx b/web/src/pages/join.test.tsx
new file mode 100644
index 00000000..c2452441
--- /dev/null
+++ b/web/src/pages/join.test.tsx
@@ -0,0 +1,84 @@
+/** @vitest-environment jsdom */
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ navigate: vi.fn(),
+ preview: vi.fn(),
+ accept: vi.fn(),
+}))
+
+vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate }))
+vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
+vi.mock('@/api/client', () => ({
+ namespaceApi: { previewInvitation: mocks.preview, acceptInvitation: mocks.accept },
+}))
+vi.mock('@/features/auth/use-auth', () => ({
+ useAuth: () => ({ user: { userId: 'guest', displayName: 'Guest' } }),
+}))
+vi.mock('@/shared/lib/api-error', () => ({
+ ApiError: class ApiError extends Error {
+ constructor(message: string, public status: number) { super(message) }
+ },
+}))
+
+import { ApiError } from '@/shared/lib/api-error'
+import { JoinPage } from './join'
+
+const token = 'A'.repeat(43)
+
+function renderJoin() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return { client, ...render(
{t('invitation.invalid')}
: null} - {!invalid && preview.isPending ?{t('invitation.loading')}
: null} + {invalid ?{t('invitation.invalid')}
: null} + {!invalid && preview.isPending ?{t('invitation.loading')}
: null} {!invalid && preview.error ? ({t('invitation.networkError')}
@@ -54,10 +60,7 @@ export function JoinPage() {{t('invitation.namespace', { name: preview.data.namespaceDisplayName })}
{t('invitation.account', { name: user?.displayName ?? user?.userId ?? '' })}
{preview.data.outcome === 'ALREADY_MEMBER' ? ( - <> -{t('invitation.alreadyMember')}
- - > +{t('invitation.alreadyMember')}
) : ( <>{t('invitation.permissions')}