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