fix: recover login state after network interruptions

This commit is contained in:
2026-10-17 22:44:10 +08:00
parent f77f3d77c0
commit 1c244e135f
3 changed files with 173 additions and 11 deletions
+11 -11
View File
@@ -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 { Boxes, Container, FolderOpen, LogOut, Package, Settings, SquareTerminal, Users } from 'lucide-react';
import { api, ApiError, type User } from './api'; import { api, type User } from './api';
import { Alert, message, Panel, useAction } from './ui'; import { Alert, Panel, useAction } from './ui';
import { watchSession, type LoginState } from './sessionRecovery';
import { Instances } from './Instances'; import { Instances } from './Instances';
import { Images } from './Images'; import { Images } from './Images';
import { RuntimeSelector } from './RuntimeSelector'; import { RuntimeSelector } from './RuntimeSelector';
@@ -26,17 +27,16 @@ const secondaryPages = [
]; ];
export default function App() { export default function App() {
const [user, setUser] = useState<User | null>(null), [loading, setLoading] = useState(true), [error, setError] = useState(''); const [{user, loading, error}, setSession] = useState<LoginState>({user: null, loading: true, error: ''});
const session = useRef<ReturnType<typeof watchSession> | null>(null);
useEffect(() => { useEffect(() => {
let active = true; const watcher = watchSession(() => api.me(), setSession, window, document);
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); }); session.current = watcher;
const expired = (event: Event) => { setUser(null); setError(event instanceof CustomEvent && typeof event.detail === 'string' ? event.detail : '会话已过期,请重新登录。'); }; return () => { watcher.dispose(); session.current = null; };
window.addEventListener('session-expired', expired);
return () => { active = false; window.removeEventListener('session-expired', expired); };
}, []); }, []);
if (loading) return <div className="grid min-h-dvh place-items-center text-muted-foreground" role="status">正在连接工作台…</div>; if (loading) return <div className="grid min-h-dvh place-items-center text-muted-foreground" role="status">正在连接工作台…</div>;
if (!user) return <Login error={error} onLogin={u => { setUser(u); setError(''); }} />; if (!user) return <Login error={error} onLogin={u => session.current?.login(u)} />;
return <Console key={user.id} user={user} onLogout={() => setUser(null)} onPasswordChanged={() => { setUser(null); setError('密码已修改,请使用新密码重新登录。'); }} />; return <>{error && <div role="status" className="fixed inset-x-0 top-0 z-50 p-3"><Alert>{error}</Alert></div>}<Console key={user.id} user={user} onLogout={() => session.current?.stop()} onPasswordChanged={() => session.current?.stop('密码已修改,请使用新密码重新登录。')} /></>;
} }
function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: () => void; onPasswordChanged: () => void}) { function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: () => void; onPasswordChanged: () => void}) {
+88
View File
@@ -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<Session>>().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<Session>>().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<Session>>().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<Session>>().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);
});
+74
View File
@@ -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<Session>,
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<void> | 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);
},
};
}