fix(oauth): surface actionable guidance when sign-in windows lapse

The local expires_in countdown killed OAuth sessions with a bare
"Session expired" before the backend poller's enriched message (Portal
sign-in stalled in the opened tab, retry/API-key fallback) could reach
the UI, and the desktop onboarding poller had no local expiry at all —
a dead session polled forever. Both surfaces now lapse with guidance
naming the common cause, prefer the backend error_message when it has
one, and keep polling when the backend still reports pending (clock
skew).
This commit is contained in:
kokhlo
2026-08-30 19:45:08 +03:00
committed by Teknium
parent 26476a978a
commit 55181a7d79
12 changed files with 394 additions and 10 deletions
+2
View File
@@ -2169,6 +2169,8 @@ export const ar = defineLocale({
connectedProvider: provider => `تم ربط ${provider}`,
connectedPicking: provider => `تم ربط ${provider}. جار اختيار نموذج افتراضي...`,
signInFailed: 'فشل تسجيل الدخول. حاول مرة أخرى.',
signInExpired:
'انتهت مهلة انتظار التفويض. السبب الأكثر شيوعًا هو تعطّل صفحة تسجيل الدخول في تبويب المتصفح (مشكلة من جهة الخادم) — أكمل تسجيل الدخول هناك ثم أعد المحاولة. إذا استمر الفشل، استخدم مفتاح API أو واجهة سطر الأوامر بدلاً من ذلك.',
pickDifferentProvider: 'اختر مزوداً آخر',
signInWith: provider => `تسجيل الدخول عبر ${provider}`,
openedBrowser: provider => `فتحنا ${provider} في المتصفح.`,
+2
View File
@@ -2798,6 +2798,8 @@ export const en: Translations = {
connectedProvider: provider => `${provider} connected`,
connectedPicking: provider => `${provider} connected. Picking a default model...`,
signInFailed: 'Sign-in failed. Try again.',
signInExpired:
'Sign-in expired waiting for authorization. This usually means the sign-in page stalled in the opened tab (server-side issue) — finish signing in there, then try again. If it keeps failing, use an API key or the CLI fallback instead.',
pickDifferentProvider: 'Pick a different provider',
signInWith: provider => `Sign in with ${provider}`,
openedBrowser: provider => `We opened ${provider} in your browser.`,
+2
View File
@@ -2442,6 +2442,8 @@ export const ja = defineLocale({
connectedProvider: provider => `${provider} が接続されました`,
connectedPicking: provider => `${provider} が接続されました。デフォルトモデルを選択中...`,
signInFailed: 'サインインに失敗しました。再試行してください。',
signInExpired:
'承認待ちでタイムアウトしました。多くの場合、開いたタブのサインインページが止まっている(サーバー側の問題)ためです。そのページでサインインを完了してから再試行してください。解決しない場合は API キーまたは CLI を利用してください。',
pickDifferentProvider: '別のプロバイダーを選択',
signInWith: provider => `${provider} でサインイン`,
openedBrowser: provider => `${provider} をブラウザーで開きました。`,
+1
View File
@@ -2366,6 +2366,7 @@ export interface Translations {
connectedProvider: (provider: string) => string
connectedPicking: (provider: string) => string
signInFailed: string
signInExpired: string
pickDifferentProvider: string
signInWith: (provider: string) => string
openedBrowser: (provider: string) => string
+2
View File
@@ -2352,6 +2352,8 @@ export const zhHant = defineLocale({
connectedProvider: provider => `${provider} 已連線`,
connectedPicking: provider => `${provider} 已連線。正在選擇預設模型...`,
signInFailed: '登入失敗,請重試。',
signInExpired:
'等待授權逾時。通常是因為登入頁面在開啟的分頁中卡住(伺服器端問題)——請在該頁面完成登入後重試。若仍失敗,請改用 API 金鑰或 CLI 方式。',
pickDifferentProvider: '選擇其他提供方',
signInWith: provider => `使用 ${provider} 登入`,
openedBrowser: provider => `已在瀏覽器中開啟 ${provider}。`,
+2
View File
@@ -2963,6 +2963,8 @@ export const zh: Translations = {
connectedProvider: provider => `${provider} 已连接`,
connectedPicking: provider => `${provider} 已连接。正在选择默认模型...`,
signInFailed: '登录失败,请重试。',
signInExpired:
'等待授权超时。通常是因为登录页面在打开的标签页中卡住(服务端问题)——请在该页面完成登录后重试。若仍失败,请改用 API 密钥或 CLI 方式。',
pickDifferentProvider: '选择其他提供方',
signInWith: provider => `使用 ${provider} 登录`,
openedBrowser: provider => `已在浏览器中打开 ${provider}。`,
+95
View File
@@ -1,3 +1,4 @@
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as notifications from '@/store/notifications'
@@ -643,3 +644,97 @@ describe('saveOnboardingLocalEndpoint', () => {
expect($desktopOnboarding.get().configured).not.toBe(true)
})
})
describe('device-code poll expiry', () => {
beforeEach(() => {
window.localStorage.clear()
$desktopOnboarding.set(baseState())
})
afterEach(() => {
window.localStorage.clear()
$desktopOnboarding.set(baseState())
vi.restoreAllMocks()
vi.useRealTimers()
})
function deviceCodeProvider() {
// makeOAuthProvider builds a pkce provider; device-code flows need the
// device_code branch instead.
return { ...makeOAuthProvider('nous', 'Nous Portal'), flow: 'device_code' as const }
}
function deviceStart(expiresIn: number) {
return {
expires_in: expiresIn,
flow: 'device_code',
poll_interval: 5,
session_id: 'device-sess-1',
user_code: 'ABCD-EFGH',
verification_url: 'https://portal.example/device'
}
}
it('lapses to an error with actionable guidance when the window expires still pending', async () => {
vi.useFakeTimers()
installApiMock(async ({ path }: { path: string }) => {
if (path === '/api/providers/oauth/nous/start') {
return deviceStart(2)
}
if (path === '/api/providers/oauth/nous/poll/device-sess-1') {
return { status: 'pending' }
}
throw new Error(`unexpected api path: ${path}`)
})
const { startProviderOAuth } = await import('./onboarding')
await startProviderOAuth(deviceCodeProvider(), onboardingContext(emptyOpenRouterGateway()))
expect($desktopOnboarding.get().flow.status).toBe('polling')
// Let both the poll interval and the expiry window lapse.
await act(async () => {
vi.advanceTimersByTime(3000)
})
const flow = $desktopOnboarding.get().flow
expect(flow.status).toBe('error')
if (flow.status === 'error') {
expect(flow.message).toContain('Sign-in expired waiting for authorization')
}
})
it('keeps polling while the window is open and clears the expiry on cancel', async () => {
vi.useFakeTimers()
installApiMock(async ({ path }: { path: string }) => {
if (path === '/api/providers/oauth/nous/start') {
return deviceStart(600)
}
if (path === '/api/providers/oauth/nous/poll/device-sess-1') {
return { status: 'pending' }
}
throw new Error(`unexpected api path: ${path}`)
})
const { startProviderOAuth, cancelOnboardingFlow } = await import('./onboarding')
await startProviderOAuth(deviceCodeProvider(), onboardingContext(emptyOpenRouterGateway()))
await act(async () => {
vi.advanceTimersByTime(10_000)
})
expect($desktopOnboarding.get().flow.status).toBe('polling')
cancelOnboardingFlow()
// Far past the original window: the cancelled flow must not flip to an
// expiry error after the fact.
await act(async () => {
vi.advanceTimersByTime(700_000)
})
expect($desktopOnboarding.get().flow.status).toBe('idle')
})
})
+33
View File
@@ -11,6 +11,7 @@ import {
submitOAuthCode,
validateProviderCredential
} from '@/hermes'
import { translateNow } from '@/i18n'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { setMainModelAssignment } from '@/store/cron-model-impact'
@@ -175,6 +176,30 @@ function clearPoll() {
window.clearInterval(pollTimer)
pollTimer = null
}
clearPollExpiry()
}
let pollExpiryTimer: number | null = null
function clearPollExpiry() {
if (pollExpiryTimer !== null) {
window.clearTimeout(pollExpiryTimer)
pollExpiryTimer = null
}
}
/** Lapse a device-code session locally when its window expires, instead of
* polling a dead session forever. Uses the flow's own `expires_in`; the
* backend poller may still flip the session to error first, and its message
* (surfaced by `pollSession`) is preferred whenever it arrives in time. */
function schedulePollExpiry(start: DeviceStart, onExpire: () => void) {
clearPollExpiry()
const ttlMs = Math.max(1, Number(start.expires_in) || 0) * 1000
pollExpiryTimer = window.setTimeout(() => {
pollExpiryTimer = null
onExpire()
}, ttlMs)
}
async function checkRuntime(ctx: OnboardingContext, requestedProvider?: string): Promise<RuntimeReadinessResult> {
@@ -630,6 +655,14 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
}
setFlow({ status: 'polling', provider, start, copied: false })
schedulePollExpiry(start, () =>
setFlow({
status: 'error',
provider,
start,
message: translateNow('onboarding.signInExpired')
})
)
pollTimer = window.setInterval(() => void pollSession(provider, start, ctx), POLL_MS)
} catch (error) {
setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` })
+210
View File
@@ -0,0 +1,210 @@
// @vitest-environment jsdom
// OAuthLoginModal expiry behaviour: when the sign-in window lapses locally,
// the modal must surface the backend poller's actionable message when it has
// one, keep polling when the backend still considers the session pending
// (clock skew), and fall back to guidance naming the common cause instead of
// the old bare "Session expired".
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/i18n";
const apiMocks = vi.hoisted(() => ({
cancelOAuthSession: vi.fn(async () => ({ ok: true })),
pollOAuthSession: vi.fn(),
startOAuthLogin: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
api: {
cancelOAuthSession: apiMocks.cancelOAuthSession,
pollOAuthSession: apiMocks.pollOAuthSession,
startOAuthLogin: apiMocks.startOAuthLogin,
},
}));
vi.mock("@/lib/clipboard", () => ({
copyTextToClipboard: vi.fn(async () => true),
}));
import { OAuthLoginModal } from "./OAuthLoginModal";
let container: HTMLDivElement;
let root: Root;
const provider = {
cli_command: "hermes login nous",
disconnectable: true,
docs_url: "https://example.com/nous",
flow: "device_code" as const,
id: "nous",
name: "Nous Portal",
status: { logged_in: false },
};
function deviceStart(expiresIn: number) {
return {
expires_in: expiresIn,
flow: "device_code",
poll_interval: 5,
session_id: "sess-1",
user_code: "ABCD-EFGH",
verification_url: "https://portal.example/device",
};
}
async function render(ui: ReactNode) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(<I18nProvider>{ui}</I18nProvider>));
}
/** Fast-forward the countdown (1s ticks) until it lapses. */
async function exhaustCountdown(seconds: number) {
for (let i = 0; i <= seconds; i++) {
await act(async () => {
vi.advanceTimersByTime(1000);
});
}
await act(async () => {});
}
beforeEach(() => {
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
apiMocks.startOAuthLogin.mockReset();
apiMocks.pollOAuthSession.mockReset();
apiMocks.cancelOAuthSession.mockReset().mockResolvedValue({ ok: true });
// The steady state during the countdown: still pending.
apiMocks.pollOAuthSession.mockResolvedValue({
session_id: "sess-1",
status: "pending",
});
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
root?.unmount();
container?.remove();
});
describe("OAuthLoginModal local expiry", () => {
it("surfaces the backend error_message when the session lapsed", async () => {
apiMocks.startOAuthLogin.mockResolvedValue(deviceStart(3));
await render(
<OAuthLoginModal
provider={provider}
onClose={() => {}}
onSuccess={() => {}}
onError={() => {}}
/>,
);
// The final poll flips to the backend's enriched message.
apiMocks.pollOAuthSession.mockResolvedValue({
session_id: "sess-1",
status: "error",
error_message:
"Timed out waiting for device authorization. Portal sign-in is required before the device code can be approved.",
});
await exhaustCountdown(5);
await act(async () => {
vi.advanceTimersByTime(2000);
});
await act(async () => {});
expect(container.textContent).toContain(
"Portal sign-in is required",
);
});
it("keeps polling when the backend still reports pending (clock skew)", async () => {
apiMocks.startOAuthLogin.mockResolvedValue(deviceStart(3));
await render(
<OAuthLoginModal
provider={provider}
onClose={() => {}}
onSuccess={() => {}}
onError={() => {}}
/>,
);
await exhaustCountdown(5);
await act(async () => {});
// No error box: the modal is still in the polling phase.
expect(container.textContent).toContain("ABCD-EFGH");
expect(apiMocks.pollOAuthSession.mock.calls.length).toBeGreaterThan(0);
});
it("falls back to guidance naming the stalled-tab cause (pkce flow, no poll)", async () => {
apiMocks.startOAuthLogin.mockResolvedValue({
auth_url: "https://portal.example/auth",
expires_in: 3,
flow: "pkce",
session_id: "sess-1",
});
await render(
<OAuthLoginModal
provider={provider}
onClose={() => {}}
onSuccess={() => {}}
onError={() => {}}
/>,
);
await exhaustCountdown(6);
await act(async () => {});
expect(container.textContent).toContain("stalled in the opened tab");
// PKCE has no poll endpoint call at expiry.
expect(apiMocks.pollOAuthSession).not.toHaveBeenCalled();
});
it("a Retry after expiry starts fresh instead of insta-lapsing", async () => {
apiMocks.startOAuthLogin.mockResolvedValueOnce(deviceStart(2));
await render(
<OAuthLoginModal
provider={provider}
onClose={() => {}}
onSuccess={() => {}}
onError={() => {}}
/>,
);
apiMocks.pollOAuthSession.mockResolvedValue({
session_id: "sess-1",
status: "error",
error_message: "expired_token: code expired",
});
await exhaustCountdown(4);
await act(async () => {
vi.advanceTimersByTime(2000);
});
await act(async () => {});
expect(container.textContent).toContain("expired_token");
// Retry: new start response, fresh countdown.
apiMocks.startOAuthLogin.mockResolvedValueOnce(deviceStart(60));
apiMocks.pollOAuthSession.mockResolvedValue({
session_id: "sess-1",
status: "pending",
});
const retry = [...container.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Retry"),
);
expect(retry).toBeTruthy();
await act(async () => retry!.click());
await act(async () => {});
// Well within the new window: still showing the code, no error.
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(container.textContent).toContain("ABCD-EFGH");
});
});
+42 -10
View File
@@ -69,23 +69,54 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Tick the countdown
// When the sign-in window lapses locally, the backend poller usually has
// the real story (e.g. "Portal sign-in is required before the device code
// can be approved") — but its next poll tick is up to 2s away. Rather than
// preempt it with a bare "expired", ask the poll endpoint once, then fall
// back to guidance that names the common cause (sign-in stalled in the
// opened tab) instead of a dead-end.
const handleLocalExpiry = async () => {
if (!isMounted.current) return;
let backendMessage: string | null = null;
if (start && start.flow === "device_code") {
try {
const resp = await api.pollOAuthSession(provider.id, start.session_id);
if (resp.error_message) backendMessage = resp.error_message;
else if (resp.status === "pending") {
// Still pending server-side: the local countdown fired early
// (clock skew or a stalled tab). Keep the session alive for the
// poller instead of killing it with a wrong "expired".
if (isMounted.current) setPhase("polling");
return;
}
} catch {
// Poll endpoint unreachable — fall through to generic guidance.
}
}
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(backendMessage || t.oauth.sessionExpiredNoError);
};
// Tick the countdown down to zero — never further. What happens AT zero
// is owned by the lapse effect below, so the updater stays pure.
useEffect(() => {
if (secondsLeft === null) return;
if (secondsLeft <= 0) return;
if (phase === "approved" || phase === "error") return;
const tick = window.setInterval(() => {
if (!isMounted.current) return;
setSecondsLeft((s) => {
if (s !== null && s <= 1) {
setPhase("error");
setErrorMsg(t.oauth.sessionExpired);
return 0;
}
return s !== null && s > 0 ? s - 1 : 0;
});
setSecondsLeft((s) => (s !== null && s > 0 ? s - 1 : 0));
}, 1000);
return () => window.clearInterval(tick);
}, [secondsLeft, phase, t]);
}, [secondsLeft, phase]);
useEffect(() => {
if (secondsLeft !== 0) return;
if (phase === "approved" || phase === "error") return;
void handleLocalExpiry();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [secondsLeft, phase]);
// Device-code: poll backend every 2s
useEffect(() => {
@@ -350,6 +381,7 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
setErrorMsg(null);
setStart(null);
setPkceCode("");
setSecondsLeft(null);
setPhase("starting");
api
.startOAuthLogin(provider.id)
+2
View File
@@ -534,6 +534,8 @@ export const en: Translations = {
copyCliCommand: "Copy CLI command (for external / fallback)",
connect: "Connect",
sessionExpires: "Session expires in {time}",
sessionExpiredNoError:
"Sign-in expired without reaching the provider. This usually means the sign-in page stalled in the opened tab (server-side issue) — finish signing in there, then click Retry. If it keeps failing, use an API key or the CLI fallback instead.",
initiatingLogin: "Initiating login flow…",
exchangingCode: "Exchanging code for tokens…",
connectedClosing: "Connected! Closing…",
+1
View File
@@ -551,6 +551,7 @@ export interface Translations {
copyCliCommand: string;
connect: string;
sessionExpires: string;
sessionExpiredNoError: string;
initiatingLogin: string;
exchangingCode: string;
connectedClosing: string;