fix(invitation): enter namespace after member preview

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-10-12 12:52:04 +08:00
parent 44a1623620
commit ee17c1eb43
3 changed files with 96 additions and 9 deletions

View file

@ -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** 创建成功但浏览器拒绝写剪贴板

View file

@ -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(<QueryClientProvider client={client}><JoinPage /></QueryClientProvider>) }
}
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(<QueryClientProvider client={view.client}><JoinPage /></QueryClientProvider>)
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)
})
})

View file

@ -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 (
<div className="mx-auto max-w-xl px-4 py-16">
<Card className="space-y-5 p-8">
<h1 className="text-2xl font-semibold">{t('invitation.title')}</h1>
{invalid ? <p>{t('invitation.invalid')}</p> : null}
{!invalid && preview.isPending ? <p>{t('invitation.loading')}</p> : null}
{invalid ? <p role="alert">{t('invitation.invalid')}</p> : null}
{!invalid && preview.isPending ? <p role="status">{t('invitation.loading')}</p> : null}
{!invalid && preview.error ? (
<div className="space-y-3">
<p role="alert">{t('invitation.networkError')}</p>
@ -54,10 +60,7 @@ export function JoinPage() {
<p>{t('invitation.namespace', { name: preview.data.namespaceDisplayName })}</p>
<p>{t('invitation.account', { name: user?.displayName ?? user?.userId ?? '' })}</p>
{preview.data.outcome === 'ALREADY_MEMBER' ? (
<>
<p>{t('invitation.alreadyMember')}</p>
<Button onClick={() => { clearInvitationToken(); navigate({ to: '/space/$namespace', params: { namespace: preview.data.namespaceSlug }, replace: true }) }}>{t('invitation.enter')}</Button>
</>
<p role="status">{t('invitation.alreadyMember')}</p>
) : (
<>
<p>{t('invitation.permissions')}</p>