diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index 943981302..b8cc2205e 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -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(null) const [logs, setLogs] = useState([]) const [showLogs, setShowLogs] = useState(false) + const [remoteReauth, setRemoteReauth] = useState(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 (
@@ -69,10 +171,13 @@ export function BootFailureOverlay() {
-

Hermes couldn't start

+

+ {remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"} +

- 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."}

@@ -84,14 +189,23 @@ export function BootFailureOverlay() {
- - + {remoteReauth ? ( + + ) : ( + + )} + {!remoteReauth ? ( + + ) : null}

- 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.'}

diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts new file mode 100644 index 000000000..21d7f8229 --- /dev/null +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -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 { + 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') + }) +}) diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts new file mode 100644 index 000000000..20ac68618 --- /dev/null +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -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 "), + // 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'}` +}