Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion frontend/src/app/layout/app-header.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'

Expand Down Expand Up @@ -96,6 +96,7 @@ function renderHeader(
afterEach(() => {
cleanup()
window.localStorage.clear()
window.sessionStorage.clear()
window.history.replaceState({ idx: 0 }, '')
})

Expand Down Expand Up @@ -223,6 +224,52 @@ describe('AppHeader', () => {
expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token')
})

it('登录工作台后显示一次邀请奖励提示,打开账号菜单时收起', async () => {
window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
renderHeader('/workspace')

expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy()
fireEvent.click(await screen.findByRole('button', { name: '打开账号菜单' }))

expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull()
})

it('邀请提示可以直达邀请奖励,并在关闭或十五秒后收起', async () => {
window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
const timeoutSpy = vi.spyOn(window, 'setTimeout')
renderHeader('/workspace')

const hint = await screen.findByRole('status', { name: '邀请奖励提示' })
expect(screen.getByRole('link', { name: '去看看邀请奖励' }).getAttribute('href')).toBe(
'/account?section=invite',
)
const timerCall = timeoutSpy.mock.calls.find(([, delay]) => delay === 15_000)
expect(timerCall).toBeTruthy()
const timerCallback = timerCall?.[0]
expect(typeof timerCallback).toBe('function')
act(() => {
if (typeof timerCallback === 'function') timerCallback()
})
expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull()

window.sessionStorage.clear()
cleanup()
renderHeader('/workspace')
expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '关闭邀请奖励提示' }))
expect(hint.isConnected).toBe(false)
})

it('当前登录会话离开工作台后不重复显示', async () => {
window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
renderHeader('/workspace')
expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy()

fireEvent.click(screen.getByRole('link', { name: '项目资产' }))
fireEvent.click(screen.getByRole('link', { name: '首页' }))
expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull()
})

it('远端退出失败时仍清除本地会话并返回首页', async () => {
window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token')
const apis = createApis()
Expand Down
59 changes: 58 additions & 1 deletion frontend/src/app/layout/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate } from 'react-router'

import { quotaApis as defaultQuotaApis } from '@/entities'
import type { QuotaApis } from '@/entities'
import { useAuthSession } from '@/features/auth-session'
import { AUTH_SESSION_STORAGE_PREFIX, useAuthSession } from '@/features/auth-session'
import { useQuotaBalance } from '@/features/quota'
import { PageBackButton } from './page-back-button'

Expand All @@ -22,6 +22,7 @@ export interface AppHeaderProps {
}

const accountMenuExitDurationMs = 260
const inviteHintStorageKey = `${AUTH_SESSION_STORAGE_PREFIX}invite-hint-seen.v1`

/** 四个入口对应四种去处:回首页、看资产、做新东西、核验已完成的造型。 */
const productNavigation: ProductNavigationItem[] = [
Expand Down Expand Up @@ -79,6 +80,7 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {})
const navigate = useNavigate()
const session = useAuthSession()
const [accountMenuState, setAccountMenuState] = useState<AccountMenuState>('closed')
const [inviteHintVisible, setInviteHintVisible] = useState(false)
const accountMenuOpen = accountMenuState === 'open'
const creditBalance = useQuotaBalance(
accountMenuState !== 'closed' && session.state.status === 'authenticated',
Expand All @@ -99,15 +101,40 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {})
return () => window.clearTimeout(timer)
}, [accountMenuState])

useEffect(() => {
if (pathname !== '/workspace' || session.state.status !== 'authenticated') {
setInviteHintVisible(false)
return
}

if (window.sessionStorage.getItem(inviteHintStorageKey) === '1') return

window.sessionStorage.setItem(inviteHintStorageKey, '1')
setInviteHintVisible(true)

const timer = window.setTimeout(() => {
setInviteHintVisible(false)
}, 15_000)

return () => window.clearTimeout(timer)
Comment thread
huyanxius marked this conversation as resolved.
}, [pathname, session.state.status])

function signOut() {
window.sessionStorage.removeItem(inviteHintStorageKey)
Comment thread
huyanxius marked this conversation as resolved.
const returnHome = () => navigate('/', { replace: true })
void session.logout().then(returnHome, returnHome)
}

function toggleAccountMenu() {
dismissInviteHint()
setAccountMenuState((state) => (state === 'open' ? 'closing' : 'open'))
}

function dismissInviteHint() {
window.sessionStorage.setItem(inviteHintStorageKey, '1')
setInviteHintVisible(false)
}

function finishAccountMenuMotion() {
if (accountMenuState === 'closing') {
setAccountMenuState('closed')
Expand Down Expand Up @@ -210,6 +237,36 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {})
</Link>
) : (
<>
{inviteHintVisible ? (
<div
role="status"
aria-label="邀请奖励提示"
className="absolute top-[calc(100%+0.7rem)] right-0 z-10 w-[min(15rem,calc(100vw-2rem))] rounded-lg border border-app-ink/12 bg-app-surface-raised px-3.5 py-3 text-left shadow-app-menu before:absolute before:-top-1.5 before:right-5 before:h-3 before:w-3 before:rotate-45 before:border-l before:border-t before:border-app-ink/12 before:bg-app-surface-raised"
>
<div className="relative flex items-start gap-3">
<div className="min-w-0">
<p className="text-[13px] font-medium leading-5 text-app-ink-soft">
邀请好友,双方各得 200 积分
</p>
<Link
to="/account?section=invite"
onClick={dismissInviteHint}
className="mt-1 inline-flex text-[12px] text-app-muted underline decoration-app-ink/20 underline-offset-2 transition-colors hover:text-app-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-app-accent"
>
去看看邀请奖励
</Link>
</div>
<button
type="button"
aria-label="关闭邀请奖励提示"
onClick={dismissInviteHint}
className="-mr-1 -mt-1 grid h-6 w-6 shrink-0 place-items-center rounded-md text-sm leading-none text-app-faint transition-colors hover:bg-app-ink/5 hover:text-app-ink-soft focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-app-accent"
>
×
</button>
</div>
</div>
) : null}
<button
type="button"
aria-label="打开账号菜单"
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/features/auth-session/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ async function expectState(value: string) {
afterEach(() => {
cleanup()
clearRefreshToken()
window.sessionStorage.clear()
currentSession = null
vi.useRealTimers()
})
Expand Down Expand Up @@ -163,6 +164,7 @@ describe('AuthSessionProvider', () => {
)

it('clears local state before best-effort logout finishes and never restores it on failure', async () => {
window.sessionStorage.setItem('windup.auth-session.invite-hint-seen.v1', '1')
const logout = deferred<void>()
const apis = createApis()
apis.logout.mockReturnValue(logout.promise)
Expand All @@ -175,6 +177,7 @@ describe('AuthSessionProvider', () => {
logoutPromise = session().logout()
})
await expectState('guest:logged-out:')
expect(window.sessionStorage.getItem('windup.auth-session.invite-hint-seen.v1')).toBeNull()
expect(getApiAccessToken()).toBeNull()
expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull()
expect(apis.logout).toHaveBeenCalledWith('refresh-token')
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/features/auth-session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ import type { AuthTokens, User, UserApis } from '@/entities'
import { registerApiAccessTokenProvider, registerApiUnauthorizedRecovery } from '@/shared/api'
import {
REFRESH_TOKEN_STORAGE_KEY,
clearAuthSessionScopedStorage,
clearRefreshToken,
loadRefreshToken,
saveRefreshToken,
} from './session-storage'

export { AUTH_SESSION_STORAGE_PREFIX } from './session-storage'

export type AuthGuestReason = null | 'logged-out' | 'session-expired' | 'password-changed'

export type AuthSessionState =
Expand Down Expand Up @@ -97,6 +100,7 @@ export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps
accessTokenRef.current = null
refreshTokenRef.current = null
if (persist) clearRefreshToken()
clearAuthSessionScopedStorage()
setAccessTokenVersion((version) => version + 1)
updateState({ status: 'guest', user: null, reason })
},
Expand Down
41 changes: 40 additions & 1 deletion frontend/src/features/auth-session/session-storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,47 @@
import { describe, expect, it, vi } from 'vitest'

import { REFRESH_TOKEN_STORAGE_KEY, createRefreshTokenStorage } from './session-storage'
import {
AUTH_SESSION_STORAGE_PREFIX,
REFRESH_TOKEN_STORAGE_KEY,
clearAuthSessionScopedStorage,
createRefreshTokenStorage,
} from './session-storage'

describe('refresh token storage', () => {
it('clears only values owned by the current auth session namespace', () => {
const values = new Map([
[`${AUTH_SESSION_STORAGE_PREFIX}invite-hint-seen.v1`, '1'],
['unrelated.preference', 'keep'],
])
const storage = {
get length() {
return values.size
},
key: (index: number) => [...values.keys()][index] ?? null,
removeItem: vi.fn((key: string) => values.delete(key)),
}

clearAuthSessionScopedStorage(storage)

expect(storage.removeItem).toHaveBeenCalledWith(
`${AUTH_SESSION_STORAGE_PREFIX}invite-hint-seen.v1`,
)
expect(values.get('unrelated.preference')).toBe('keep')
})

it('does not let unavailable session storage block session teardown', () => {
expect(() => clearAuthSessionScopedStorage(null)).not.toThrow()
expect(() =>
clearAuthSessionScopedStorage({
length: 1,
key: () => {
throw new DOMException('Storage is disabled', 'SecurityError')
},
removeItem: vi.fn(),
}),
).not.toThrow()
})

it('persists only the refresh token under the contracted key', () => {
const values = new Map<string, string>()
const storage = {
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/features/auth-session/session-storage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export const REFRESH_TOKEN_STORAGE_KEY = 'windup.auth.refresh-token'
export const AUTH_SESSION_STORAGE_PREFIX = 'windup.auth-session.'

type RefreshTokenStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
type SessionScopedStorage = Pick<Storage, 'key' | 'length' | 'removeItem'>

export interface RefreshTokenStore {
load(): string | null
Expand All @@ -16,6 +18,29 @@ function getLocalStorage(): RefreshTokenStorage | null {
}
}

function getSessionStorage(): SessionScopedStorage | null {
try {
return globalThis.sessionStorage
} catch {
return null
}
}

/** 清除只属于一次登录会话的 UI 标记;登出、过期和改密都经过这一边界。 */
export function clearAuthSessionScopedStorage(storage = getSessionStorage()): void {
if (!storage) return
try {
const keys: string[] = []
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index)
if (key?.startsWith(AUTH_SESSION_STORAGE_PREFIX)) keys.push(key)
}
for (const key of keys) storage.removeItem(key)
} catch {
// sessionStorage 不可用不应阻断登出或会话失效。
}
}

/**
* localStorage 是跨刷新、跨标签的增强能力,不是维持当前页面登录的前提。
* 浏览器拒绝存储访问时,闭包中的副本继续支撑本标签页会话。
Expand Down
Loading