mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
fix(invitation): enter namespace after member preview
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
44a1623620
commit
ee17c1eb43
3 changed files with 96 additions and 9 deletions
|
|
@ -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** 创建成功但浏览器拒绝写剪贴板
|
||||
|
|
|
|||
84
web/src/pages/join.test.tsx
Normal file
84
web/src/pages/join.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue