fix(desktop): offer remote sign-in on a gated-gateway boot failure (#39402)
When a remote gateway with username/password (or OAuth) auth restarts, its session cookie lapses and Desktop boots into the recovery overlay with a session-expired error. That overlay only exposed local-recovery actions — Retry (resets the local bootstrap latch) and Repair (re-runs the installer) — neither of which can re-establish a remote session, so the user is stuck in a no-op Retry loop with no way to sign in again. The overlay now detects a remote-reauth boot failure from the saved connection config (remote + gated + not currently connected + has a URL) and surfaces a primary 'Sign in to remote gateway' button that opens the gateway login window (the username/password form for a basic gateway, the OAuth redirect otherwise) and reloads on success. Button copy is driven by a best-effort provider probe, matching the gateway-settings page. Detection and copy logic live in a pure helper module with unit coverage.
This commit is contained in:
@ -2,11 +2,23 @@ import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons'
|
||||
import type { DesktopConnectionConfig } from '@/global'
|
||||
import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
|
||||
type BusyAction = 'local' | 'repair' | 'retry' | null
|
||||
import type { RemoteReauth } from './boot-failure-reauth'
|
||||
import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
|
||||
|
||||
type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null
|
||||
|
||||
// A remote gateway whose access cookie has lapsed (e.g. the dashboard
|
||||
// restarted on the remote box) boots into this overlay with a reauth-shaped
|
||||
// error. The local-recovery buttons (Retry resets the local bootstrap latch;
|
||||
// Repair re-runs the installer) are no-ops for that case — the only fix is to
|
||||
// re-establish the remote session. The detection + copy helpers live in
|
||||
// ./boot-failure-reauth so they're unit-testable without a React render.
|
||||
|
||||
// Recovery surface for a hard boot failure (gateway never came up, backend
|
||||
// exited during startup, bootstrap latched, …). Without this the app shell
|
||||
@ -18,6 +30,7 @@ export function BootFailureOverlay() {
|
||||
const [busy, setBusy] = useState<BusyAction>(null)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [remoteReauth, setRemoteReauth] = useState<RemoteReauth | null>(null)
|
||||
|
||||
const visible = Boolean(boot.error) && !boot.running
|
||||
// While first-run onboarding owns the picker/flow we let it surface its own
|
||||
@ -36,6 +49,59 @@ export function BootFailureOverlay() {
|
||||
.catch(() => undefined)
|
||||
}, [visible])
|
||||
|
||||
// Resolve whether this boot failure is a remote-gateway reauth so we can
|
||||
// offer the actionable "Sign in" path instead of the local-only recovery
|
||||
// buttons. Runs whenever the overlay becomes visible.
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setRemoteReauth(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.getConnectionConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
let config: DesktopConnectionConfig
|
||||
|
||||
try {
|
||||
config = await desktop.getConnectionConfig()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (cancelled || !isRemoteReauthFailure(config)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort probe for the provider shape so the button copy matches
|
||||
// what the user will see in the login window (password form vs OAuth
|
||||
// redirect). Probe failure just keeps the generic copy.
|
||||
let shape = deriveProviderShape(null)
|
||||
|
||||
try {
|
||||
const probe = await desktop.probeConnectionConfig(config.remoteUrl)
|
||||
shape = deriveProviderShape(probe?.providers)
|
||||
} catch {
|
||||
// Generic copy is fine.
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setRemoteReauth({ url: config.remoteUrl, ...shape })
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
if (!visible || suppressed) {
|
||||
return null
|
||||
}
|
||||
@ -59,8 +125,44 @@ export function BootFailureOverlay() {
|
||||
setBusy(null)
|
||||
}
|
||||
|
||||
// Open the gateway's login window (renders the username/password form for a
|
||||
// basic gateway, or the OAuth redirect otherwise — the desktop drives both
|
||||
// through the same window). On a successful sign-in the session cookie is
|
||||
// re-established in the persistent partition; reload so boot re-runs and the
|
||||
// reconnect now mints a ticket against a live session.
|
||||
const signInRemote = async () => {
|
||||
if (!remoteReauth) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy('signin')
|
||||
|
||||
try {
|
||||
const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url)
|
||||
|
||||
if (result?.connected) {
|
||||
notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' })
|
||||
window.location.reload()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Sign-in incomplete',
|
||||
message: 'The login window closed before authentication finished.'
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, 'Sign-in failed')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
|
||||
|
||||
const label = signInLabel(remoteReauth)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
@ -69,10 +171,13 @@ export function BootFailureOverlay() {
|
||||
<AlertTriangle className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Hermes couldn't start</h2>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
|
||||
{remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"}
|
||||
</h2>
|
||||
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your
|
||||
chats or settings.
|
||||
{remoteReauth
|
||||
? 'Your remote gateway session has expired (the dashboard likely restarted). Sign in again to reconnect — nothing here deletes your chats or settings.'
|
||||
: "The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your chats or settings."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -84,14 +189,23 @@ export function BootFailureOverlay() {
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
|
||||
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
Retry
|
||||
</Button>
|
||||
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
|
||||
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
|
||||
Repair install
|
||||
</Button>
|
||||
{remoteReauth ? (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void signInRemote()}>
|
||||
{busy === 'signin' ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
||||
{label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
|
||||
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
{!remoteReauth ? (
|
||||
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
|
||||
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
|
||||
Repair install
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
|
||||
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Use local gateway
|
||||
@ -102,7 +216,9 @@ export function BootFailureOverlay() {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Repair re-runs the installer and can take a few minutes on a fresh machine.
|
||||
{remoteReauth
|
||||
? 'Opens the gateway login window. Use “Use local gateway” to switch to the bundled backend instead.'
|
||||
: 'Repair re-runs the installer and can take a few minutes on a fresh machine.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
99
apps/desktop/src/components/boot-failure-reauth.test.ts
Normal file
99
apps/desktop/src/components/boot-failure-reauth.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { DesktopConnectionConfig } from '@/global'
|
||||
|
||||
import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth'
|
||||
|
||||
function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnectionConfig {
|
||||
return {
|
||||
envOverride: false,
|
||||
mode: 'remote',
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: 'https://box:9119',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('isRemoteReauthFailure', () => {
|
||||
it('true for a remote, gated, disconnected gateway with a URL', () => {
|
||||
expect(isRemoteReauthFailure(config())).toBe(true)
|
||||
})
|
||||
|
||||
it('false when the oauth session is still connected', () => {
|
||||
expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('false for a local gateway', () => {
|
||||
expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('false for a token (non-gated) remote gateway', () => {
|
||||
expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('false when there is no remote URL to sign in against', () => {
|
||||
expect(isRemoteReauthFailure(config({ remoteUrl: '' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('false for null/undefined config', () => {
|
||||
expect(isRemoteReauthFailure(null)).toBe(false)
|
||||
expect(isRemoteReauthFailure(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveProviderShape', () => {
|
||||
it('generic copy when there are no providers', () => {
|
||||
expect(deriveProviderShape([])).toEqual({ isPassword: false, providerLabel: 'your identity provider' })
|
||||
expect(deriveProviderShape(null)).toEqual({ isPassword: false, providerLabel: 'your identity provider' })
|
||||
})
|
||||
|
||||
it('password shape when the sole provider supports password', () => {
|
||||
expect(
|
||||
deriveProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }])
|
||||
).toEqual({ isPassword: true, providerLabel: 'Username & Password' })
|
||||
})
|
||||
|
||||
it('OAuth shape when the provider is a redirect IDP', () => {
|
||||
expect(deriveProviderShape([{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }])).toEqual({
|
||||
isPassword: false,
|
||||
providerLabel: 'Nous Research'
|
||||
})
|
||||
})
|
||||
|
||||
it('mixed deployment keeps generic OAuth copy (not every provider is password)', () => {
|
||||
const shape = deriveProviderShape([
|
||||
{ name: 'basic', displayName: 'Username & Password', supportsPassword: true },
|
||||
{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }
|
||||
])
|
||||
|
||||
expect(shape.isPassword).toBe(false)
|
||||
expect(shape.providerLabel).toBe('Username & Password / Nous Research')
|
||||
})
|
||||
|
||||
it('falls back to name when displayName is empty', () => {
|
||||
expect(deriveProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }]).providerLabel).toBe(
|
||||
'basic'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('signInLabel', () => {
|
||||
it('password gateway gets the plain "Sign in to remote gateway" copy', () => {
|
||||
expect(signInLabel({ url: 'x', isPassword: true, providerLabel: 'Username & Password' })).toBe(
|
||||
'Sign in to remote gateway'
|
||||
)
|
||||
})
|
||||
|
||||
it('OAuth gateway names the provider', () => {
|
||||
expect(signInLabel({ url: 'x', isPassword: false, providerLabel: 'Nous Research' })).toBe(
|
||||
'Sign in with Nous Research'
|
||||
)
|
||||
})
|
||||
|
||||
it('null reauth falls back to the generic provider phrase', () => {
|
||||
expect(signInLabel(null)).toBe('Sign in with your identity provider')
|
||||
})
|
||||
})
|
||||
67
apps/desktop/src/components/boot-failure-reauth.ts
Normal file
67
apps/desktop/src/components/boot-failure-reauth.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import type { DesktopAuthProvider, DesktopConnectionConfig } from '@/global'
|
||||
|
||||
// Pure helpers for the boot-failure overlay's remote-reauth branch. Kept out
|
||||
// of the .tsx so they can be unit-tested without a React/jsdom render (the
|
||||
// jsx-dev-runtime resolution in this repo's vitest setup is flaky for
|
||||
// component renders, but these are plain functions).
|
||||
|
||||
export interface RemoteReauth {
|
||||
url: string
|
||||
// True when every advertised provider is username/password — drives the
|
||||
// button copy ("Sign in to remote gateway" vs "Sign in with <provider>"),
|
||||
// mirroring the gateway-settings page. Probe is best-effort.
|
||||
isPassword: boolean
|
||||
providerLabel: string
|
||||
}
|
||||
|
||||
// A remote, gated (oauth-bucket), not-currently-connected gateway is a
|
||||
// remote-reauth boot failure: the access cookie lapsed (e.g. the remote
|
||||
// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
|
||||
// fix it — only re-establishing the remote session can. A connected oauth
|
||||
// session, or a token/local gateway, boots for some other reason the
|
||||
// local-recovery buttons address, so those return false here.
|
||||
export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean {
|
||||
if (!config) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
config.mode === 'remote' &&
|
||||
config.remoteAuthMode === 'oauth' &&
|
||||
!config.remoteOauthConnected &&
|
||||
Boolean(config.remoteUrl)
|
||||
)
|
||||
}
|
||||
|
||||
// Derive the password flag + display label from the probed providers. A
|
||||
// gateway is treated as password-style only when EVERY advertised provider
|
||||
// supports password (a mixed deployment keeps the generic OAuth copy), so the
|
||||
// button copy matches the login window the user is about to see.
|
||||
export function deriveProviderShape(providers: DesktopAuthProvider[] | null | undefined): {
|
||||
isPassword: boolean
|
||||
providerLabel: string
|
||||
} {
|
||||
const list = providers ?? []
|
||||
|
||||
if (list.length === 0) {
|
||||
return { isPassword: false, providerLabel: 'your identity provider' }
|
||||
}
|
||||
|
||||
const isPassword = list.every(p => Boolean(p.supportsPassword))
|
||||
|
||||
const providerLabel =
|
||||
list.length === 1
|
||||
? list[0].displayName || list[0].name
|
||||
: list.map(p => p.displayName || p.name).join(' / ')
|
||||
|
||||
return { isPassword, providerLabel }
|
||||
}
|
||||
|
||||
// Button copy for the remote sign-in action.
|
||||
export function signInLabel(reauth: RemoteReauth | null): string {
|
||||
if (reauth?.isPassword) {
|
||||
return 'Sign in to remote gateway'
|
||||
}
|
||||
|
||||
return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}`
|
||||
}
|
||||
Reference in New Issue
Block a user