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() }
+}
+
+describe('JoinPage', () => {
+ beforeEach(() => {
+ window.sessionStorage.clear()
+ window.history.replaceState(null, '', '/')
+ mocks.navigate.mockReset()
+ mocks.preview.mockReset()
+ mocks.accept.mockReset()
+ })
+
+ afterEach(cleanup)
+
+ it('clears the token and navigates once with replace when preview finds an existing member', async () => {
+ window.history.replaceState(null, '', `/join#${token}`)
+ mocks.preview.mockResolvedValue({
+ namespaceSlug: 'team', namespaceDisplayName: 'Team', namespaceAvatarUrl: null,
+ outcome: 'ALREADY_MEMBER', expiresAt: '2026-10-13T00:00:00Z',
+ })
+
+ const view = renderJoin()
+
+ await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith({
+ to: '/space/$namespace', params: { namespace: 'team' }, replace: true,
+ }))
+ expect(window.sessionStorage.length).toBe(0)
+ expect(window.location.hash).toBe('')
+ view.rerender()
+ expect(mocks.navigate).toHaveBeenCalledTimes(1)
+ expect(screen.queryByRole('button', { name: 'invitation.enter' })).toBeNull()
+ })
+
+ it('announces loading while preview is pending', () => {
+ window.history.replaceState(null, '', `/join#${token}`)
+ mocks.preview.mockImplementation(() => new Promise(() => {}))
+
+ renderJoin()
+
+ expect(screen.getByRole('status').textContent).toBe('invitation.loading')
+ })
+
+ it('announces a terminal invalid link', async () => {
+ window.history.replaceState(null, '', `/join#${token}`)
+ mocks.preview.mockRejectedValue(new ApiError('invalid', 404))
+
+ renderJoin()
+
+ expect((await screen.findByRole('alert')).textContent).toBe('invitation.invalid')
+ expect(window.sessionStorage.length).toBe(0)
+ })
+})
diff --git a/web/src/pages/join.tsx b/web/src/pages/join.tsx
index 74b672b1..60dc8d6a 100644
--- a/web/src/pages/join.tsx
+++ b/web/src/pages/join.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
@@ -14,6 +14,7 @@ export function JoinPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { user } = useAuth()
+ const enteredNamespace = useRef(false)
const [token] = useState(() => {
captureInvitationFragment()
return storedInvitationToken()
@@ -35,14 +36,19 @@ export function JoinPage() {
const invalid = !token || (preview.error instanceof ApiError && preview.error.status === 404)
|| (accept.error instanceof ApiError && accept.error.status === 404)
useEffect(() => { if (invalid) clearInvitationToken() }, [invalid])
- useEffect(() => { if (preview.data?.outcome === 'ALREADY_MEMBER') clearInvitationToken() }, [preview.data?.outcome])
+ useEffect(() => {
+ if (preview.data?.outcome !== 'ALREADY_MEMBER' || enteredNamespace.current) return
+ enteredNamespace.current = true
+ clearInvitationToken()
+ void navigate({ to: '/space/$namespace', params: { namespace: preview.data.namespaceSlug }, replace: true })
+ }, [navigate, preview.data])
return (
{t('invitation.title')}
- {invalid ? {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')}