From 1c244e135f61aa8a471da2e44fd71c69af813004 Mon Sep 17 00:00:00 2001 From: m1saka Date: Sat, 17 Oct 2026 22:44:10 +0800 Subject: [PATCH] fix: recover login state after network interruptions --- web/src/App.tsx | 22 ++++----- web/src/sessionRecovery.test.ts | 88 +++++++++++++++++++++++++++++++++ web/src/sessionRecovery.ts | 74 +++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 web/src/sessionRecovery.test.ts create mode 100644 web/src/sessionRecovery.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 9c49851..12173df 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState, type FormEvent } from 'react'; +import { useEffect, useRef, useState, type FormEvent } from 'react'; import { Boxes, Container, FolderOpen, LogOut, Package, Settings, SquareTerminal, Users } from 'lucide-react'; -import { api, ApiError, type User } from './api'; -import { Alert, message, Panel, useAction } from './ui'; +import { api, type User } from './api'; +import { Alert, Panel, useAction } from './ui'; +import { watchSession, type LoginState } from './sessionRecovery'; import { Instances } from './Instances'; import { Images } from './Images'; import { RuntimeSelector } from './RuntimeSelector'; @@ -26,17 +27,16 @@ const secondaryPages = [ ]; export default function App() { - const [user, setUser] = useState(null), [loading, setLoading] = useState(true), [error, setError] = useState(''); + const [{user, loading, error}, setSession] = useState({user: null, loading: true, error: ''}); + const session = useRef | null>(null); useEffect(() => { - let active = true; - api.me().then(s => { if (active) setUser(s.user); }).catch(e => { if (active && (!(e instanceof ApiError) || e.status !== 401)) setError(message(e)); }).finally(() => { if (active) setLoading(false); }); - const expired = (event: Event) => { setUser(null); setError(event instanceof CustomEvent && typeof event.detail === 'string' ? event.detail : '会话已过期,请重新登录。'); }; - window.addEventListener('session-expired', expired); - return () => { active = false; window.removeEventListener('session-expired', expired); }; + const watcher = watchSession(() => api.me(), setSession, window, document); + session.current = watcher; + return () => { watcher.dispose(); session.current = null; }; }, []); if (loading) return
正在连接工作台…
; - if (!user) return { setUser(u); setError(''); }} />; - return setUser(null)} onPasswordChanged={() => { setUser(null); setError('密码已修改,请使用新密码重新登录。'); }} />; + if (!user) return session.current?.login(u)} />; + return <>{error &&
{error}
} session.current?.stop()} onPasswordChanged={() => session.current?.stop('密码已修改,请使用新密码重新登录。')} />; } function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: () => void; onPasswordChanged: () => void}) { diff --git a/web/src/sessionRecovery.test.ts b/web/src/sessionRecovery.test.ts new file mode 100644 index 0000000..fe085e7 --- /dev/null +++ b/web/src/sessionRecovery.test.ts @@ -0,0 +1,88 @@ +import { expect, it, vi } from 'vitest'; +import { ApiError, type Session } from './api'; +import { watchSession } from './sessionRecovery'; + +const session: Session = {user: {id: 'u', username: 'member', admin: false, quota_bytes: 0}, csrf_token: 'csrf'}; +function setup(me = vi.fn<() => Promise>().mockResolvedValue(session)) { + const page = new EventTarget(); + const document = Object.assign(new EventTarget(), {hidden: false}); + const update = vi.fn(); + const watcher = watchSession(me, update, page, document); + return {me, page, document, update, watcher, state: () => update.mock.calls.at(-1)?.[0]}; +} + +it.each(['online', 'pageshow', 'focus', 'visibilitychange'])('restores an expired login on %s without restarting', async event => { + const s = setup(); await s.watcher.check(); + s.me.mockRejectedValueOnce(new ApiError(401, 'expired')); + s.page.dispatchEvent(new Event('session-expired')); await s.watcher.check(); + expect(s.state().user).toBeNull(); + (event === 'visibilitychange' ? s.document : s.page).dispatchEvent(new Event(event)); + await s.watcher.check(); + expect(s.state()).toEqual({user: session.user, loading: false, error: ''}); + s.watcher.dispose(); +}); + +it('rechecks a stale expiration event before discarding the user', async () => { + const s = setup(); await s.watcher.check(); + s.page.dispatchEvent(new Event('session-expired')); await s.watcher.check(); + expect(s.me).toHaveBeenCalledTimes(2); + expect(s.update.mock.calls.every(([state]) => state.user === session.user)).toBe(true); + s.watcher.dispose(); +}); + +it.each([0, 500])('keeps the logged-in user on transient failure %s, then clears the warning', async status => { + const s = setup(); await s.watcher.check(); + s.me.mockRejectedValueOnce(new ApiError(status, 'offline')); + s.page.dispatchEvent(new Event('session-expired')); await s.watcher.check(); + expect(s.state().user).toEqual(session.user); + expect(s.state().error).toContain('暂时无法确认'); + s.page.dispatchEvent(new Event('online')); await s.watcher.check(); + expect(s.state().error).toBe(''); + s.watcher.dispose(); +}); + +it('recovers from an offline cold start and keeps anonymous startup quiet', async () => { + const s = setup(vi.fn<() => Promise>().mockRejectedValueOnce(new ApiError(0, 'offline')).mockResolvedValue(session)); + await s.watcher.check(); + expect(s.state().loading).toBe(false); + s.page.dispatchEvent(new Event('online')); await s.watcher.check(); + expect(s.state().user).toEqual(session.user); s.watcher.dispose(); + const anonymous = setup(vi.fn<() => Promise>().mockRejectedValue(new ApiError(401, 'anonymous'))); + await anonymous.watcher.check(); + anonymous.page.dispatchEvent(new Event('focus')); await anonymous.watcher.check(); + expect(anonymous.state().error).toBe(''); anonymous.watcher.dispose(); +}); + +it.each(['logout', 'password', 'login', 'dispose'])('ignores old responses after %s', async action => { + let resolve!: (value: Session) => void; + const s = setup(vi.fn<() => Promise>().mockReturnValue(new Promise(r => {resolve = r;}))); + const pending = s.watcher.check(); + const nextUser = {...session.user, id: 'new'}; + if (action === 'logout') s.watcher.stop(); + if (action === 'password') s.page.dispatchEvent(Object.assign(new Event('session-expired'), {detail: '密码已重设'})); + if (action === 'login') s.watcher.login(nextUser); + if (action === 'dispose') s.watcher.dispose(); + const calls = s.update.mock.calls.length; + resolve(session); await pending; + expect(s.update).toHaveBeenCalledTimes(calls); + if (action !== 'dispose') expect(s.state().user).toEqual(action === 'login' ? nextUser : null); + if (action !== 'login') { + s.page.dispatchEvent(new Event('online')); + s.page.dispatchEvent(new Event('session-expired')); + expect(s.me).toHaveBeenCalledTimes(1); + } + s.watcher.dispose(); +}); + +it('coalesces simultaneous resume events and skips hidden pages', async () => { + const s = setup(); + s.page.dispatchEvent(new Event('online')); + s.document.dispatchEvent(new Event('visibilitychange')); + await s.watcher.check(); + expect(s.me).toHaveBeenCalledTimes(1); + s.document.hidden = true; s.page.dispatchEvent(new Event('focus')); + expect(s.me).toHaveBeenCalledTimes(1); + s.watcher.dispose(); + s.document.hidden = false; s.document.dispatchEvent(new Event('visibilitychange')); + expect(s.me).toHaveBeenCalledTimes(1); +}); diff --git a/web/src/sessionRecovery.ts b/web/src/sessionRecovery.ts new file mode 100644 index 0000000..f60a3d6 --- /dev/null +++ b/web/src/sessionRecovery.ts @@ -0,0 +1,74 @@ +import { ApiError, type Session, type User } from './api'; + +export interface LoginState { user: User | null; loading: boolean; error: string } + +// Own the lifecycle of session checks so stale responses cannot undo an explicit +// logout, password change, or a newer login. Network failures are not logouts. +export function watchSession( + me: () => Promise, + update: (state: LoginState) => void, + page: EventTarget, + document: EventTarget & { hidden: boolean }, +) { + let state: LoginState = { user: null, loading: true, error: '' }; + let active = true, enabled = true, generation = 0; + let hadSession = false; + let pending: Promise | undefined; + const publish = (next: LoginState) => { state = next; if (active) update(state); }; + const invalidate = () => { generation++; pending = undefined; }; + const check = () => { + if (!active || !enabled) return Promise.resolve(); + if (pending) return pending; + const version = generation; + pending = (async () => { + try { + const session = await me(); + if (active && version === generation) { + hadSession = true; + publish({ user: session.user, loading: false, error: '' }); + } + } catch (error) { + if (!active || version !== generation) return; + const expired = error instanceof ApiError && error.status === 401; + publish({ user: expired ? null : state.user, loading: false, error: expired + ? (hadSession ? '会话已过期,请重新登录。' : '') + : '暂时无法确认登录状态,请检查网络;恢复连接后将自动重试。' }); + } finally { + if (version === generation) pending = undefined; + } + })(); + return pending; + }; + const stop = (error = '') => { + enabled = false; invalidate(); + publish({ user: null, loading: false, error }); + }; + const expired = (event: Event) => { + // Password reset is an explicit invalidation, not a transient request error. + const detail = 'detail' in event ? event.detail : undefined; + if (typeof detail === 'string') stop(detail); + else void check(); + }; + const resume = () => { if (!document.hidden) void check(); }; + page.addEventListener('session-expired', expired); + page.addEventListener('online', resume); + page.addEventListener('pageshow', resume); + page.addEventListener('focus', resume); + document.addEventListener('visibilitychange', resume); + void check(); + return { + check, stop, + login(user: User) { + invalidate(); enabled = true; hadSession = true; + publish({ user, loading: false, error: '' }); + }, + dispose() { + active = false; invalidate(); + page.removeEventListener('session-expired', expired); + page.removeEventListener('online', resume); + page.removeEventListener('pageshow', resume); + page.removeEventListener('focus', resume); + document.removeEventListener('visibilitychange', resume); + }, + }; +}