Share remote setup state and controls across desktop hosts

This commit is contained in:
ethernet
2026-09-12 19:06:51 -04:00
parent 1662c78702
commit eea968ea20
11 changed files with 1099 additions and 944 deletions

View File

@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopConnectionsRegistry } from '@/global'
@@ -69,6 +69,93 @@ afterEach(() => {
})
describe('ConnectionsRegistrySection', () => {
it('preserves, replaces and deletes stored headers through plaintext consent without selecting a source', async () => {
const active = $connection.get()
const withHeaders: DesktopConnectionsRegistry = {
...registry,
secureTokenStorage: false,
connections: [registry.connections[0], { ...registry.connections[1], headerNames: ['Keep', 'Replace', 'Delete'] }]
}
list.mockResolvedValueOnce(withHeaders)
save.mockRejectedValueOnce(new Error('plaintext consent required'))
const applyConnectionConfig = vi.fn()
const select = vi.fn()
Object.assign(window.hermesDesktop, { applyConnectionConfig })
Object.assign(window.hermesDesktop.connections, { select })
render(<ConnectionsRegistrySection />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
const values = screen.getAllByPlaceholderText('Saved — leave blank to keep')
fireEvent.change(values[1], { target: { value: 'new-header-secret' } })
fireEvent.click(within(screen.getByDisplayValue('Delete').parentElement!).getByRole('button', { name: 'Remove' }))
fireEvent.change(screen.getByPlaceholderText('Existing token ...abc123'), { target: { value: 'new-token' } })
fireEvent.click(screen.getByText('Save connection'))
await screen.findByText('Store the gateway token in plain text?')
const expected = {
id: 'homelab',
kind: 'remote',
label: 'Homelab',
url: 'http://homelab.lan:9119',
authMode: 'token',
token: 'new-token',
headers: { Keep: null, Replace: 'new-header-secret' }
}
expect(save).toHaveBeenCalledExactlyOnceWith(expected)
fireEvent.click(screen.getByRole('button', { name: 'Save as plain text' }))
await waitFor(() => expect(save).toHaveBeenLastCalledWith({ ...expected, allowPlainTextToken: true }))
expect(applyConnectionConfig).not.toHaveBeenCalled()
expect(select).not.toHaveBeenCalled()
expect($connection.get()).toBe(active)
})
it('keeps stale browser sign-in out of a storage-only edit for a different URL', async () => {
const active = $connection.get()
const applyConnectionConfig = vi.fn()
const saveConnectionConfig = vi.fn()
const probeConnectionConfig = vi.fn().mockResolvedValue({ reachable: true, authMode: 'oauth', providers: [] })
let finishLogin!: (value: { connected: boolean }) => void
const oauthLoginConnectionConfig = vi.fn().mockReturnValue(
new Promise<{ connected: boolean }>(resolve => {
finishLogin = resolve
})
)
Object.assign(window.hermesDesktop, {
applyConnectionConfig,
saveConnectionConfig,
probeConnectionConfig,
oauthLoginConnectionConfig
})
render(<ConnectionsRegistrySection />)
fireEvent.click(await screen.findByText('Add connection'))
fireEvent.change(screen.getByPlaceholderText('Homelab'), { target: { value: 'New gateway' } })
const url = screen.getByPlaceholderText('http://homelab.lan:9119')
fireEvent.change(url, { target: { value: 'https://a.example' } })
fireEvent.click(screen.getByRole('button', { name: /^(OAuth|Sign in)$/ }))
fireEvent.click(await screen.findByRole('button', { name: /Sign in with/ }))
await waitFor(() => expect(oauthLoginConnectionConfig).toHaveBeenCalledWith('https://a.example'))
fireEvent.change(url, { target: { value: 'https://b.example' } })
await act(async () => finishLogin({ connected: true }))
expect(screen.queryByText('Signed in')).toBeNull()
fireEvent.click(screen.getByText('Save connection'))
await waitFor(() =>
expect(save).toHaveBeenCalledWith({
kind: 'remote',
label: 'New gateway',
url: 'https://b.example',
authMode: 'oauth',
headers: {}
})
)
expect(applyConnectionConfig).not.toHaveBeenCalled()
expect(saveConnectionConfig).not.toHaveBeenCalled()
expect($connection.get()).toBe(active)
})
it('refreshes a cached roster immediately after a successful connection test', async () => {
_resetFleetRosterForTests()
const getAgentRoster = vi.fn().mockResolvedValue({ agents: [], sources: [] })

View File

@@ -1,12 +1,13 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { RemoteSetupFields } from '@/components/remote-setup/fields'
import { useRemoteSetup } from '@/components/remote-setup/use-remote-setup'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Input } from '@/components/ui/input'
import type {
DesktopConnectionKind,
DesktopConnectionProbeResult,
DesktopConnectionsRegistry,
DesktopRegistryConnection,
DesktopRegistryConnectionInput
@@ -17,23 +18,8 @@ import {
connectionMatchesQuery,
sortConnectionsForDisplay
} from '@/lib/connection-display'
import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
import { triggerHaptic } from '@/lib/haptics'
import {
Check,
Cloud,
Globe,
Loader2,
LogIn,
Monitor,
Pencil,
Plus,
RefreshCw,
SearchIcon,
Terminal,
Trash2
} from '@/lib/icons'
import { coerceRemoteUrlScheme } from '@/lib/remote-url'
import { Cloud, Globe, Loader2, Monitor, Pencil, Plus, RefreshCw, SearchIcon, Terminal, Trash2 } from '@/lib/icons'
import { $activeConnectionId, setConnectionsRegistry } from '@/store/connections'
import { refreshFleetRoster } from '@/store/fleet-roster'
import { notify, notifyError } from '@/store/notifications'
@@ -52,9 +38,6 @@ interface EditorState {
id: null | string
kind: DesktopConnectionKind
label: string
url: string
authMode: 'oauth' | 'token'
token: string
host: string
keyPath: string
// ssh remote profile, hydrated on edit so the duplicate key matches the
@@ -73,9 +56,6 @@ function editorFromConnection(conn: DesktopRegistryConnection): EditorState {
id: conn.id,
kind: conn.kind,
label: conn.label,
url: conn.url || '',
authMode: conn.authMode || 'token',
token: '',
// Reconstruct the composite the single ssh host field displays. The save
// payload sends ONLY this string (never separate user/port), because
// normalizeSshConfig gives explicit user/port fields precedence over the
@@ -93,9 +73,6 @@ function emptyEditor(kind: DesktopConnectionKind): EditorState {
id: null,
kind,
label: '',
url: '',
authMode: 'token',
token: '',
host: '',
keyPath: '',
remoteProfile: '',
@@ -143,7 +120,7 @@ export function sshCompositeKey(composite: string): string {
* Returns the existing entry the candidate collides with, or null.
*/
export function findDuplicateConnection(
editor: Pick<EditorState, 'host' | 'id' | 'kind' | 'remoteProfile' | 'url'>,
editor: Pick<EditorState, 'host' | 'id' | 'kind' | 'remoteProfile'> & { url: string },
connections: DesktopRegistryConnection[]
): DesktopRegistryConnection | null {
if (editor.kind === 'local') {
@@ -255,14 +232,12 @@ export function ConnectionsRegistrySection() {
// Inline duplicate rejection from the save path (dedupe is also enforced in
// the main process, so a crafted payload can't slip past the UI check).
const [dupeError, setDupeError] = useState<null | string>(null)
// A gated remote gateway (OAuth, or username/password) never accepts a
// session token: it authenticates with a browser sign-in and keeps the
// session itself. Probe the edited URL so this row can name the provider,
// and remember whether the login round-trip actually completed.
const [authProbe, setAuthProbe] = useState<DesktopConnectionProbeResult | null>(null)
const [signingIn, setSigningIn] = useState(false)
const [oauthConnected, setOauthConnected] = useState(false)
const probeSeq = useRef(0)
const remote = useRemoteSetup({
host: 'registry',
enabled: editor?.kind === 'remote',
onNotice: notify
})
const bridge = window.hermesDesktop?.connections
@@ -273,94 +248,6 @@ export function ConnectionsRegistrySection() {
setConnectionsRegistry(next)
}, [])
const editorUrl = editor?.kind === 'remote' ? coerceRemoteUrlScheme(editor.url) : ''
const editorWantsOauth = editor?.kind === 'remote' && editor.authMode === 'oauth'
const authProviderShape = deriveRemoteAuthProviderShape(authProbe?.providers, t.boot.failure.identityProvider)
// Probe only while the sign-in row is on screen, and debounce it so typing a
// URL doesn't fire a request per keystroke. Best-effort: a failed probe just
// leaves the generic provider label, it never blocks signing in.
useEffect(() => {
if (!editorWantsOauth || !editorUrl || !window.hermesDesktop?.probeConnectionConfig) {
setAuthProbe(null)
return
}
const seq = ++probeSeq.current
// Staleness is covered by probeSeq, but not unmount: a probe resolving
// after the editor closes would still call setAuthProbe on an unmounted
// component. Harmless in React 18, still worth not doing.
let cancelled = false
const timer = setTimeout(() => {
window.hermesDesktop
.probeConnectionConfig(editorUrl)
.then(result => {
if (!cancelled && seq === probeSeq.current) {
setAuthProbe(result)
}
})
.catch(() => {
if (!cancelled && seq === probeSeq.current) {
setAuthProbe(null)
}
})
}, 400)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [editorUrl, editorWantsOauth])
// The session is scoped to an origin, so pointing the editor at a different
// URL invalidates the "signed in" state this row is reporting. Flipping the
// auth mode invalidates it too: a saved row edited token -> oauth must not
// present a stale "Signed in" pill from an earlier oauth stint.
useEffect(() => {
setOauthConnected(false)
}, [editorUrl, editorWantsOauth])
// Open the gateway's own login window and let the main process keep whatever
// it mints (native PKCE bearer tokens, or the legacy session cookies). This
// is the same IPC the first-run form and the gateway panel use — the
// registry editor simply had no affordance to reach it.
const signInOauth = useCallback(async () => {
if (!editorUrl) {
notify({ kind: 'warning', title: t.settings.gateway.authTitle, message: t.settings.gateway.enterUrlFirst })
return
}
setSigningIn(true)
try {
const result = await window.hermesDesktop.oauthLoginConnectionConfig(editorUrl)
setOauthConnected(Boolean(result.connected))
if (result.connected) {
notify({
title: t.settings.gateway.signedIn,
message: t.settings.gateway.connectedTo(authProviderShape.providerLabel)
})
} else {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: result?.error
? `${t.boot.failure.signInIncompleteMessage}: ${result.error}`
: t.boot.failure.signInIncompleteMessage
})
}
} catch (err) {
notifyError(err, t.settings.gateway.signInFailed)
} finally {
setSigningIn(false)
}
}, [authProviderShape.providerLabel, editorUrl, t])
const load = useCallback(async () => {
if (!bridge) {
setLoading(false)
@@ -383,13 +270,19 @@ export function ConnectionsRegistrySection() {
void load()
}, [load])
const openEditor = (next: EditorState | null) => {
const openEditor = (next: EditorState | null, saved?: DesktopRegistryConnection): void => {
setDupeError(null)
remote.reset({
url: saved?.url || '',
authMode: saved?.authMode || 'token',
tokenSet: saved?.tokenSet ?? false,
tokenPreview: saved?.tokenPreview ?? null
})
setEditor(next)
}
const save = useCallback(
async (allowPlainTextToken = false) => {
async (allowPlainTextToken: boolean = false): Promise<void> => {
if (!bridge || !editor) {
return
}
@@ -397,7 +290,7 @@ export function ConnectionsRegistrySection() {
// Duplicate prevention lives in the save path (not just a disabled
// button): reject a candidate that collides with an existing entry with
// an inline error before anything crosses the IPC boundary.
const dupe = findDuplicateConnection(editor, registry?.connections ?? [])
const dupe = findDuplicateConnection({ ...editor, url: remote.credentials.url }, registry?.connections ?? [])
if (dupe) {
setDupeError(
@@ -425,11 +318,11 @@ export function ConnectionsRegistrySection() {
}
if (editor.kind === 'remote' || editor.kind === 'cloud') {
payload.url = editor.url
payload.authMode = editor.authMode
payload.url = remote.payload.remoteUrl
payload.authMode = remote.credentials.authMode
if (editor.token.trim()) {
payload.token = editor.token.trim()
if (remote.payload.remoteToken) {
payload.token = remote.payload.remoteToken
}
if (allowPlainTextToken) {
@@ -467,8 +360,8 @@ export function ConnectionsRegistrySection() {
!allowPlainTextToken &&
registry?.secureTokenStorage === false &&
editor.kind === 'remote' &&
editor.authMode === 'token' &&
editor.token.trim()
remote.credentials.authMode === 'token' &&
remote.credentials.token.trim()
) {
setPlainTextConfirm(true)
@@ -480,7 +373,16 @@ export function ConnectionsRegistrySection() {
setSaving(false)
}
},
[bridge, editor, publishRegistry, registry?.connections, registry?.secureTokenStorage, s]
[
bridge,
editor,
remote.credentials,
remote.payload,
publishRegistry,
registry?.connections,
registry?.secureTokenStorage,
s
]
)
const remove = useCallback(async () => {
@@ -722,7 +624,7 @@ export function ConnectionsRegistrySection() {
<>
<Button
aria-label={s.editConnection}
onClick={() => openEditor(editorFromConnection(conn))}
onClick={() => openEditor(editorFromConnection(conn), conn)}
size="icon-sm"
variant="ghost"
>
@@ -799,85 +701,14 @@ export function ConnectionsRegistrySection() {
/>
{(editor.kind === 'remote' || editor.kind === 'cloud') && (
<ListRow
action={
<Input
onChange={e => {
setDupeError(null)
setEditor({ ...editor, url: e.target.value })
}}
placeholder="http://homelab.lan:9119"
value={editor.url}
/>
}
title={s.urlTitle}
<RemoteSetupFields
disabled={saving}
onUrlChange={() => setDupeError(null)}
setup={remote}
urlOnly={editor.kind === 'cloud'}
/>
)}
{editor.kind === 'remote' && (
<>
<ListRow
action={
<div className="flex gap-2">
{(['token', 'oauth'] as const).map(mode => (
<Button
key={mode}
onClick={() => setEditor({ ...editor, authMode: mode })}
size="sm"
variant={editor.authMode === mode ? 'default' : 'outline'}
>
{mode === 'token' ? t.settings.gateway.tokenTitle : 'OAuth'}
</Button>
))}
</div>
}
title={t.settings.gateway.authTitle}
/>
{editor.authMode === 'token' && (
<ListRow
action={
<Input
onChange={e => setEditor({ ...editor, token: e.target.value })}
placeholder={t.settings.gateway.pasteSessionToken}
type="password"
value={editor.token}
/>
}
description={t.settings.gateway.tokenDesc}
title={t.settings.gateway.tokenTitle}
/>
)}
{editor.authMode === 'oauth' && (
<ListRow
action={
oauthConnected ? (
<Pill tone="primary">
<Check className="size-3" /> {t.settings.gateway.signedIn}
</Pill>
) : (
<Button disabled={signingIn || !editorUrl} onClick={() => void signInOauth()} size="sm">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{authProviderShape.isPassword
? t.settings.gateway.signIn
: t.settings.gateway.signInWith(authProviderShape.providerLabel)}
</Button>
)
}
description={
oauthConnected
? authProviderShape.isPassword
? t.settings.gateway.authSignedInPassword
: t.settings.gateway.authSignedInOauth
: authProviderShape.isPassword
? t.settings.gateway.authNeedsPassword
: t.settings.gateway.authNeedsOauth(authProviderShape.providerLabel)
}
title={t.settings.gateway.authTitle}
/>
)}
</>
)}
{(editor.kind === 'remote' || editor.kind === 'cloud') && (
<div className="grid gap-2">
<div>

View File

@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Collect the component graph before the behavioral test deadline starts.
@@ -57,6 +57,178 @@ afterEach(() => {
})
describe('GatewaySettings', () => {
it('releases a pending save after a late probe invalidates its response', async () => {
const saved = { ...localConnection, mode: 'remote', remoteUrl: 'https://a.example', remoteTokenSet: true }
getConnectionConfig.mockResolvedValue(saved)
let finishSave!: (value: typeof saved) => void
let finishProbe!: (value: { reachable: boolean; authMode: string; providers: never[] }) => void
saveConnectionConfig.mockReturnValueOnce(
new Promise<typeof saved>(resolve => {
finishSave = resolve
})
)
const probeConnectionConfig = vi.fn(
() =>
new Promise<{ reachable: boolean; authMode: string; providers: never[] }>(resolve => {
finishProbe = resolve
})
)
Object.assign(window.hermesDesktop, { probeConnectionConfig })
render(<GatewaySettings />)
const saveButton = (await screen.findByRole('button', { name: 'Save for next restart' })) as HTMLButtonElement
await waitFor(() => expect(probeConnectionConfig).toHaveBeenCalledWith('https://a.example'))
fireEvent.click(saveButton)
expect(saveConnectionConfig).toHaveBeenCalledExactlyOnceWith({
mode: 'remote',
remoteUrl: 'https://a.example',
remoteAuthMode: 'token',
remoteToken: undefined
})
expect(saveButton.disabled).toBe(true)
await act(async () => finishProbe({ reachable: true, authMode: 'oauth', providers: [] }))
await act(async () => finishSave(saved))
expect(saveButton.disabled).toBe(false)
expect(screen.getByRole('button', { name: /Sign in with/ })).toBeTruthy()
expect(screen.queryByPlaceholderText('Existing token saved')).toBeNull()
})
it('pre-saves OAuth before login and applies the resolved auth mode without requiring a test', async () => {
getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'remote', remoteUrl: 'https://login.example' })
let finishSave!: () => void
saveConnectionConfig.mockReturnValueOnce(
new Promise<void>(resolve => {
finishSave = resolve
})
)
const oauthLoginConnectionConfig = vi.fn().mockResolvedValue({ connected: true })
const applyConnectionConfig = vi.fn().mockResolvedValue(localConnection)
const testConnectionConfig = vi.fn()
Object.assign(window.hermesDesktop, {
oauthLoginConnectionConfig,
applyConnectionConfig,
testConnectionConfig,
probeConnectionConfig: vi.fn().mockResolvedValue({
reachable: true,
authMode: 'oauth',
providers: [{ name: 'password', displayName: 'Username & Password', supportsPassword: true }]
})
})
render(<GatewaySettings />)
fireEvent.click(await screen.findByRole('button', { name: 'Sign in' }))
expect(saveConnectionConfig).toHaveBeenCalledExactlyOnceWith({
mode: 'remote',
remoteAuthMode: 'oauth',
remoteUrl: 'https://login.example'
})
expect(oauthLoginConnectionConfig).not.toHaveBeenCalled()
await act(async () => finishSave())
await screen.findByText('Signed in')
expect(oauthLoginConnectionConfig).toHaveBeenCalledExactlyOnceWith('https://login.example')
fireEvent.click(screen.getByRole('button', { name: 'Save and reconnect' }))
await waitFor(() =>
expect(applyConnectionConfig).toHaveBeenCalledExactlyOnceWith({
mode: 'remote',
remoteAuthMode: 'oauth',
remoteUrl: 'https://login.example',
remoteToken: undefined
})
)
expect(testConnectionConfig).not.toHaveBeenCalled()
})
it('keeps a saved token when blank and requires consent before replacing it in plaintext', async () => {
const saved = {
...localConnection,
mode: 'remote',
remoteUrl: 'https://a.example',
remoteTokenSet: true,
remoteTokenPreview: 'saved-preview',
secureTokenStorage: false,
remoteTokenPlainText: true
}
getConnectionConfig.mockResolvedValue(saved)
saveConnectionConfig.mockResolvedValue(saved)
let finishSave!: (value: typeof saved) => void
saveConnectionConfig.mockReturnValueOnce(
new Promise<typeof saved>(resolve => {
finishSave = resolve
})
)
Object.assign(window.hermesDesktop, {
probeConnectionConfig: vi.fn().mockResolvedValue({ reachable: true, authMode: 'token', providers: [] })
})
render(<GatewaySettings />)
await screen.findByPlaceholderText('Existing token saved-preview')
fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' }))
await waitFor(() =>
expect(saveConnectionConfig).toHaveBeenCalledExactlyOnceWith({
mode: 'remote',
remoteUrl: 'https://a.example',
remoteAuthMode: 'token',
remoteToken: undefined
})
)
// Flush the save's reset and probe effects before acquiring the replacement field.
await act(async () => finishSave(saved))
const tokenInput = await screen.findByPlaceholderText('Existing token saved-preview')
expect(tokenInput.isConnected, 'saved credential control must survive the refresh probe').toBe(true)
fireEvent.change(tokenInput, { target: { value: 'replacement' } })
fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' }))
await screen.findByText('Store the gateway token in plain text?')
expect(saveConnectionConfig).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'Save as plain text' }))
await waitFor(() =>
expect(saveConnectionConfig).toHaveBeenLastCalledWith({
mode: 'remote',
remoteUrl: 'https://a.example',
remoteAuthMode: 'token',
remoteToken: 'replacement',
allowPlainTextToken: true
})
)
})
it('discards an old token test while saving the current credential-ready payload', async () => {
getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'remote', remoteUrl: 'https://a.example' })
const probeConnectionConfig = vi.fn().mockResolvedValue({ reachable: true, authMode: 'token', providers: [] })
let finishTest!: (value: { ok: boolean; baseUrl: string }) => void
const testConnectionConfig = vi.fn().mockReturnValue(
new Promise<{ ok: boolean; baseUrl: string }>(resolve => {
finishTest = resolve
})
)
Object.assign(window.hermesDesktop, { probeConnectionConfig, testConnectionConfig })
render(<GatewaySettings />)
const token = await screen.findByPlaceholderText('Paste session token')
fireEvent.change(token, { target: { value: 'old-token' } })
fireEvent.click(screen.getByRole('button', { name: 'Test remote' }))
expect(testConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteUrl: 'https://a.example',
remoteAuthMode: 'token',
remoteToken: 'old-token'
})
fireEvent.change(token, { target: { value: 'new-token' } })
await act(async () => finishTest({ ok: true, baseUrl: 'https://a.example' }))
expect(screen.queryByText('Connected to https://a.example')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' }))
await waitFor(() =>
expect(saveConnectionConfig).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'remote',
remoteUrl: 'https://a.example',
remoteAuthMode: 'token',
remoteToken: 'new-token'
})
)
)
})
it('keeps saved Cloud instances usable without discovery and marks the live source, not the default', async () => {
getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'cloud', remoteUrl: 'https://a.example' })
registry.value = {

View File

@@ -1,12 +1,14 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { RemoteSetupFields } from '@/components/remote-setup/fields'
import { useRemoteSetup } from '@/components/remote-setup/use-remote-setup'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tip } from '@/components/ui/tooltip'
import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global'
import type { DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionConfigInput } from '@/global'
import { useI18n } from '@/i18n'
import { ExternalLink } from '@/lib/external-link'
import {
@@ -41,7 +43,6 @@ import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'
type Mode = 'local' | 'remote' | 'cloud' | 'ssh'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
// Hermes Cloud discovery lifecycle for the cloud-mode panel.
type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error'
@@ -164,15 +165,24 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [signingIn, setSigningIn] = useState(false)
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
const [remoteToken, setRemoteToken] = useState('')
const remote = useRemoteSetup({
host: 'settings',
enabled: !loading && state.mode === 'remote',
beforeOAuthLogin: async (payload: DesktopConnectionConfigInput): Promise<void> => {
await window.hermesDesktop.saveConnectionConfig(payload)
},
onNotice: notify
})
const [lastTest, setLastTest] = useState<null | string>(null)
const [sshHostSuggestions, setSshHostSuggestions] = useState<string[]>([])
const [sshCustomHost, setSshCustomHost] = useState(false)
const sshResolveSeq = useRef(0)
const sshTestSeq = useRef(0)
const saveSeq = useRef(0)
const saveOwner = useRef<number | null>(null)
const signingSeq = useRef(0)
const cloudConnectSeq = useRef(0)
const contextSeq = useRef(0)
@@ -224,10 +234,17 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
}
const acceptSavedConfig = (config: GatewaySettingsState) => {
const acceptSavedConfig = (config: GatewaySettingsState): void => {
const normalized = normalizeGatewaySettingsState(config)
setState(normalized)
remote.reset({
url: normalized.remoteUrl,
authMode: normalized.remoteAuthMode,
oauthConnected: normalized.remoteOauthConnected,
tokenSet: normalized.remoteTokenSet,
tokenPreview: normalized.remoteTokenPreview
})
}
// When set, the plain-text opt-in dialog is open; `apply` remembers whether
@@ -261,13 +278,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
setCloudOrgState(value)
}
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
// its public /api/status) whether it gates with OAuth or a static session
// token, so we can show the right control (login button vs token box).
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
const probeSeq = useRef(0)
useEffect(() => {
let cancelled = false
const desktop = window.hermesDesktop
@@ -300,12 +310,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
}, [])
// Debounced probe of the entered remote URL. Only runs in remote mode with a
// syntactically plausible URL. The probe result drives whether we render the
// OAuth login button or the session-token entry box. The effective auth mode
// prefers a fresh probe result over the saved value.
const trimmedUrl = coerceRemoteUrlScheme(state.remoteUrl)
const savedAgent = (agent: DesktopCloudAgent) =>
registry?.connections.find(
connection =>
@@ -330,105 +334,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
}
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
setProbe(null)
return
}
const desktop = window.hermesDesktop
if (!desktop?.probeConnectionConfig) {
return
}
const seq = ++probeSeq.current
setProbeStatus('probing')
const timer = setTimeout(() => {
desktop
.probeConnectionConfig(trimmedUrl)
.then(result => {
if (seq !== probeSeq.current) {
return
}
setProbe(result)
setProbeStatus(result.reachable ? 'done' : 'error')
})
.catch(() => {
if (seq !== probeSeq.current) {
return
}
setProbe(null)
setProbeStatus('error')
})
}, 500)
return () => clearTimeout(timer)
}, [state.mode, trimmedUrl])
// Effective auth mode: a reachable probe wins; otherwise fall back to the
// saved config's mode so a re-open of settings doesn't flicker.
const authMode: AuthMode = useMemo(() => {
if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') {
return probe.authMode
}
return state.remoteAuthMode
}, [probe, probeStatus, state.remoteAuthMode])
// Whether we actually KNOW how this gateway authenticates yet. Until we do,
// neither the OAuth button nor the session-token box should render —
// `authMode` defaults to 'token', so without this gate the token box flashes
// for every gateway (including OAuth ones) during the idle/probing window
// before the first probe lands. The scheme is known when either:
// * the live probe finished (probeStatus 'done'), or
// * we're idle but showing a previously-saved remote config (re-opening
// settings for a gateway already signed-in or with a saved token), so
// its control appears immediately with no flicker.
// While probing (or after a probe error), the scheme is unknown and we show
// the probe status row instead of a control.
const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected
const authResolved = useMemo(() => {
if (probeStatus === 'done') {
return true
}
return probeStatus === 'idle' && hasSavedRemote
}, [probeStatus, hasSavedRemote])
const providerLabel = useMemo(() => {
const providers: DesktopAuthProvider[] = probe?.providers ?? []
if (providers.length === 1) {
return providers[0].displayName || providers[0].name
}
if (providers.length > 1) {
return providers.map(p => p.displayName || p.name).join(' / ')
}
return t.boot.failure.identityProvider
}, [probe, t.boot.failure.identityProvider])
// A username/password gateway authenticates through a credential form on the
// gateway's /login page (POST /auth/password-login) rather than an OAuth
// redirect. Everything downstream — the session cookie, the ws-ticket mint,
// the persistent partition — is identical, so the desktop drives it through
// the same sign-in window; only the button copy changes. We treat the
// gateway as password-style only when EVERY advertised provider supports
// password, so a mixed deployment keeps the generic OAuth copy.
const isPasswordProvider = useMemo(() => {
const providers: DesktopAuthProvider[] = probe?.providers ?? []
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
useEffect(() => {
// One-directional: a saved host that isn't in the suggestions must render
// the free-text input (rehydration). Never force custom OFF here — that
@@ -472,6 +377,9 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
setLastTest(null)
}, [
state.mode,
remote.credentials.url,
remote.credentials.token,
remote.credentials.authMode,
state.sshHost,
state.sshUser,
state.sshPort,
@@ -480,33 +388,21 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
state.sshRemoteProfile
])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
if (!trimmedUrl) {
return false
}
if (authMode === 'oauth') {
return oauthConnected
}
return Boolean(remoteToken.trim()) || state.remoteTokenSet
}, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl])
const payload = (allowPlainTextToken?: boolean) => ({
mode: state.mode,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl,
sshHost: state.sshHost.trim(),
sshUser: state.sshUser.trim() || undefined,
sshPort: state.sshPort,
sshKeyPath: state.sshKeyPath.trim() || undefined,
sshRemoteHermesPath: state.sshRemoteHermesPath.trim(),
// Preserve an intentional blank so an existing remote-profile mapping can
// be cleared instead of being mistaken for an omitted field.
sshRemoteProfile: state.sshRemoteProfile.trim(),
const payload = (allowPlainTextToken?: boolean): DesktopConnectionConfigInput => ({
...(state.mode === 'remote'
? remote.payload
: {
mode: state.mode,
remoteAuthMode: state.remoteAuthMode,
remoteUrl: coerceRemoteUrlScheme(state.remoteUrl),
sshHost: state.sshHost.trim(),
sshUser: state.sshUser.trim() || undefined,
sshPort: state.sshPort,
sshKeyPath: state.sshKeyPath.trim() || undefined,
sshRemoteHermesPath: state.sshRemoteHermesPath.trim(),
// A blank clears an existing remote-profile mapping.
sshRemoteProfile: state.sshRemoteProfile.trim()
}),
...(allowPlainTextToken ? { allowPlainTextToken: true } : {})
})
@@ -515,13 +411,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
// and this machine has no OS keyring (safeStorage unavailable). In that case
// we must get an explicit opt-in before persisting.
const wouldPersistPlainTextToken =
(state.mode === 'remote' || state.mode === 'cloud') &&
authMode !== 'oauth' &&
Boolean(remoteToken.trim()) &&
state.mode === 'remote' &&
remote.credentials.authMode === 'token' &&
Boolean(remote.credentials.token.trim()) &&
state.secureTokenStorage === false
const performSave = async (apply: boolean, allowPlainTextToken: boolean) => {
const performSave = async (apply: boolean, allowPlainTextToken: boolean): Promise<void> => {
const seq = ++saveSeq.current
saveOwner.current = seq
setSaving(true)
try {
@@ -534,7 +431,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
acceptSavedConfig(next)
setRemoteToken('')
notify({
kind: 'success',
title: apply ? g.restartingTitle : g.savedTitle,
@@ -574,18 +470,20 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
notifyError(err, apply ? g.applyFailed : g.saveFailed)
}
} finally {
if (seq === saveSeq.current) {
// A stale response cannot replace the draft, but its request must release busy state.
if (seq === saveOwner.current) {
saveOwner.current = null
setSaving(false)
}
}
}
const save = async (apply: boolean) => {
if (state.mode === 'remote' && !canUseRemote) {
const save = async (apply: boolean): Promise<void> => {
if (state.mode === 'remote' && !remote.canCommit) {
notify({
kind: 'warning',
title: g.incompleteTitle,
message: authMode === 'oauth' ? g.incompleteSignIn : g.incompleteToken
message: remote.credentials.authMode === 'oauth' ? g.incompleteSignIn : g.incompleteToken
})
return
@@ -601,94 +499,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
await performSave(apply, false)
}
// OAuth sign-in: persist the URL + oauth mode first (so the saved config has
// the URL the login window needs), then open the gateway login window and
// refresh the connection status from the saved config once it completes.
const signIn = async () => {
const seq = ++signingSeq.current
if (!trimmedUrl) {
notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst })
return
}
setSigningIn(true)
try {
// Save (don't apply/restart) so the login window has a URL to use and the
// oauth mode is persisted, without yet flipping the live connection.
const saved = await window.hermesDesktop.saveConnectionConfig({
mode: state.mode,
remoteAuthMode: 'oauth',
remoteUrl: trimmedUrl
})
if (seq !== signingSeq.current) {
return
}
acceptSavedConfig(saved)
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
if (seq !== signingSeq.current) {
return
}
if (result.connected) {
const refreshed = await window.hermesDesktop.getConnectionConfig(null)
acceptSavedConfig(refreshed)
notify({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) })
} else {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: result?.error
? `${t.boot.failure.signInIncompleteMessage}: ${result.error}`
: t.boot.failure.signInIncompleteMessage
})
}
} catch (err) {
if (seq === signingSeq.current) {
notifyError(err, g.signInFailed)
}
} finally {
if (seq === signingSeq.current) {
setSigningIn(false)
}
}
}
const signOut = async () => {
if (!trimmedUrl) {
return
}
const seq = ++signingSeq.current
setSigningIn(true)
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl)
const refreshed = await window.hermesDesktop.getConnectionConfig(null)
if (seq !== signingSeq.current) {
return
}
acceptSavedConfig(refreshed)
notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage })
} catch (err) {
if (seq === signingSeq.current) {
notifyError(err, g.signOutFailed)
}
} finally {
if (seq === signingSeq.current) {
setSigningIn(false)
}
}
}
// --- Hermes Cloud handlers ---
// Pull the discovered agent list over the shared portal session. Tolerant of
@@ -1054,48 +864,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
}
const testRemote = async () => {
const seq = ++sshTestSeq.current
if (!canUseRemote) {
notify({
kind: 'warning',
title: g.incompleteTitle,
message: authMode === 'oauth' ? g.incompleteSignInTest : g.incompleteTokenTest
})
return
}
setTesting(true)
setLastTest(null)
try {
const result = await window.hermesDesktop.testConnectionConfig({
mode: 'remote',
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
})
if (seq !== sshTestSeq.current) {
return
}
const message = g.connectedTo(result.baseUrl || trimmedUrl, result.version ?? undefined)
setLastTest(message)
notify({ kind: 'success', title: g.reachableTitle, message })
} catch (err) {
if (seq === sshTestSeq.current) {
notifyError(err, g.testFailed)
}
} finally {
if (seq === sshTestSeq.current) {
setTesting(false)
}
}
}
if (loading) {
return (
<SettingsSkeleton
@@ -1360,105 +1128,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
) : null}
{state.mode === 'remote' && !state.envOverride ? (
<div className="mt-5 grid gap-1">
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
placeholder="https://gateway.example.com/hermes"
value={state.remoteUrl}
/>
}
description={g.remoteUrlDesc}
title={g.remoteUrlTitle}
/>
{state.mode === 'remote' && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
{g.probing}
<div className="mt-5">
<RemoteSetupFields disabled={saving} setup={remote} />
{remote.credentials.authMode === 'token' && state.remoteTokenPlainText ? (
<div className="mt-2 text-sm text-destructive">
<div className="font-medium">{g.plainTextStoredTitle}</div>
<div>{g.plainTextStoredDesc}</div>
</div>
) : null}
{state.mode === 'remote' && probeStatus === 'error' ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
{g.probeError}
</div>
) : null}
{/* OAuth / password gateways: present a sign-in button + connection status. */}
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
<ListRow
action={
oauthConnected ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> {g.signedIn}
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
</Button>
)
}
description={
oauthConnected
? isPasswordProvider
? g.authSignedInPassword
: g.authSignedInOauth
: isPasswordProvider
? g.authNeedsPassword
: g.authNeedsOauth(providerLabel)
}
title={g.authTitle}
/>
) : null}
{/* Session-token gateways: keep the existing token entry box. */}
{state.mode === 'remote' && authResolved && authMode === 'token' ? (
<>
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => setRemoteToken(event.target.value)}
placeholder={
state.remoteTokenSet
? g.existingToken(state.remoteTokenPreview ?? g.savedToken)
: g.pasteSessionToken
}
type="password"
value={remoteToken}
/>
}
description={g.tokenDesc}
title={g.tokenTitle}
/>
{/* The saved token is on disk in plain text (no OS keyring). Same
banner idiom as envOverride so it reads as a real warning. */}
{state.remoteTokenPlainText ? (
<div className="mt-2 flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2.5 text-[length:var(--conversation-caption-font-size)] text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<div>
<div className="font-medium">{g.plainTextStoredTitle}</div>
<div className="mt-1 leading-5">{g.plainTextStoredDesc}</div>
</div>
</div>
) : null}
</>
) : null}
</div>
) : null}
@@ -1575,12 +1252,12 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
{state.mode === 'remote' ? (
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
disabled={state.envOverride || saving || remote.testing || !remote.canTest}
onClick={() => void remote.test()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{remote.testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
) : state.mode === 'ssh' ? (

View File

@@ -518,6 +518,39 @@ describe('DesktopInstallOverlay first-run setup', () => {
})
})
it('does not authorize a new URL with an old login result or save before Apply', async () => {
const desktop = installDesktopMock(bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/tmp/hermes', local: 'none', bundled: false }
}))
const saveConnectionConfig = vi.fn()
Object.assign(desktop, { saveConnectionConfig })
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'oauth', baseUrl: 'https://a.example', reachable: true, providers: [], error: null, version: null
})
let finishLogin!: (value: { connected: boolean }) => void
desktop.oauthLoginConnectionConfig.mockReturnValueOnce(new Promise<{ connected: boolean }>(resolve => {
finishLogin = resolve
}))
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
const url = screen.getByPlaceholderText('https://gateway.example.com/hermes')
fireEvent.change(url, { target: { value: 'https://a.example' } })
fireEvent.click(await screen.findByRole('button', { name: /Sign in with/ }))
await waitFor(() => expect(desktop.oauthLoginConnectionConfig).toHaveBeenCalledWith('https://a.example'))
fireEvent.change(url, { target: { value: 'https://b.example' } })
await waitFor(() => expect(desktop.probeConnectionConfig).toHaveBeenCalledWith('https://b.example'))
await act(async () => finishLogin({ connected: true }))
fireEvent.click(screen.getByText('Test connection'))
expect(desktop.testConnectionConfig).not.toHaveBeenCalled()
expect(saveConnectionConfig).not.toHaveBeenCalled()
expect(desktop.applyConnectionConfig).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Back'))
fireEvent.click(await screen.findByText('Install Hermes locally'))
expect(desktop.continueBootstrapLocal).toHaveBeenCalledTimes(1)
expect(saveConnectionConfig).not.toHaveBeenCalled()
})
it('offers remote connection from the unsupported packaged install screen', async () => {
const desktop = installDesktopMock(
bootstrapState({

View File

@@ -20,7 +20,7 @@ import { capitalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { localCardPresentation } from './desktop-install-local-card'
import { FirstRunRemoteForm } from './first-run-remote-form'
import { FirstRunRemoteSetup } from './remote-setup/first-run'
/**
* DesktopInstallOverlay
@@ -398,7 +398,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
}
if (remoteOpen) {
return <FirstRunRemoteForm onBack={() => setRemoteOpen(false)} />
return <FirstRunRemoteSetup onBack={() => setRemoteOpen(false)} />
}
if (state.setupChoice) {

View File

@@ -1,346 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopConnectionProbeResult } from '@/global'
import { useI18n } from '@/i18n'
import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
import { AlertCircle, Check, Loader2, LogIn } from '@/lib/icons'
import { coerceRemoteUrlScheme } from '@/lib/remote-url'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
interface FirstRunRemoteFormProps {
onBack: () => void
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err || 'Unknown error')
}
export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) {
const { t } = useI18n()
const copy = t.install
const [remoteUrl, setRemoteUrl] = useState('')
const [remoteToken, setRemoteToken] = useState('')
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
const [oauthConnected, setOauthConnected] = useState(false)
const [signingIn, setSigningIn] = useState(false)
const [testing, setTesting] = useState(false)
const [applying, setApplying] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [lastTestedPayloadKey, setLastTestedPayloadKey] = useState<string | null>(null)
const probeSeq = useRef(0)
const testSeq = useRef(0)
const trimmedUrl = coerceRemoteUrlScheme(remoteUrl)
const invalidateTest = useCallback(() => {
testSeq.current += 1
setTesting(false)
setError(null)
setSuccess(null)
setLastTestedPayloadKey(null)
}, [])
useEffect(() => {
const seq = ++probeSeq.current
if (!trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
setProbe(null)
setOauthConnected(false)
return
}
const desktop = window.hermesDesktop
if (!desktop?.probeConnectionConfig) {
return
}
setProbeStatus('probing')
const timer = window.setTimeout(() => {
desktop
.probeConnectionConfig(trimmedUrl)
.then(result => {
if (seq !== probeSeq.current) {
return
}
invalidateTest()
setProbe(result)
setProbeStatus(result.reachable ? 'done' : 'error')
if (result.reachable && result.authMode !== 'oauth') {
setOauthConnected(false)
}
})
.catch(err => {
if (seq !== probeSeq.current) {
return
}
setProbe(null)
setProbeStatus('error')
setError(errorMessage(err))
})
}, 500)
return () => window.clearTimeout(timer)
}, [invalidateTest, trimmedUrl])
const authMode: AuthMode = probeStatus === 'done' && probe?.authMode === 'oauth' ? 'oauth' : 'token'
const authResolved = probeStatus === 'done' && probe?.authMode !== 'unknown'
const authProviderShape = deriveRemoteAuthProviderShape(probe?.providers, copy.identityProvider)
const { isPassword: isPasswordProvider, providerLabel } = authProviderShape
const canRetryProbe = Boolean(trimmedUrl && probeStatus === 'error')
const canTest = Boolean(
trimmedUrl && (canRetryProbe || (authResolved && (authMode === 'oauth' ? oauthConnected : remoteToken.trim())))
)
const payload = () => ({
mode: 'remote' as const,
remoteAuthMode: authMode,
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
remoteUrl: trimmedUrl
})
const currentPayloadKey = JSON.stringify(payload())
const payloadKeyRef = useRef(currentPayloadKey)
payloadKeyRef.current = currentPayloadKey
const canApply = lastTestedPayloadKey === currentPayloadKey
const signIn = async () => {
if (!trimmedUrl) {
setError(copy.enterUrlFirst)
return
}
setSigningIn(true)
setError(null)
try {
// Unlike Settings, first-run intentionally does not pre-save remote mode:
// backing out must still allow local install without leaving a remote
// connection selected. The login IPC accepts the raw URL and stores only
// its OAuth cookies; config is persisted once the user applies.
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
invalidateTest()
setOauthConnected(Boolean(result.connected))
if (!result.connected) {
setError(result.error ? `${copy.signInIncomplete}: ${result.error}` : copy.signInIncomplete)
}
} catch (err) {
setError(errorMessage(err))
} finally {
setSigningIn(false)
}
}
const testRemote = async () => {
if (!canTest) {
setError(authMode === 'oauth' ? copy.incompleteSignInTest : copy.incompleteTokenTest)
return
}
const seq = ++testSeq.current
const testedPayload = payload()
const testedPayloadKey = JSON.stringify(testedPayload)
setTesting(true)
setError(null)
setSuccess(null)
setLastTestedPayloadKey(null)
try {
if (!authResolved) {
const result = await window.hermesDesktop.probeConnectionConfig(trimmedUrl)
if (seq !== testSeq.current || testedPayloadKey !== payloadKeyRef.current) {
return
}
setProbe(result)
setProbeStatus(result.reachable ? 'done' : 'error')
setError(result.reachable && result.authMode !== 'unknown' ? null : result.error || copy.probeError)
return
}
const result = await window.hermesDesktop.testConnectionConfig(testedPayload)
if (seq !== testSeq.current || testedPayloadKey !== payloadKeyRef.current) {
return
}
setSuccess(copy.testSucceeded(result.baseUrl || trimmedUrl, result.version ?? undefined))
setLastTestedPayloadKey(testedPayloadKey)
} catch (err) {
if (seq === testSeq.current && testedPayloadKey === payloadKeyRef.current) {
setError(errorMessage(err))
}
} finally {
if (seq === testSeq.current) {
setTesting(false)
}
}
}
const applyRemote = async () => {
if (!canApply) {
return
}
const testedPayload = payload()
setApplying(true)
setError(null)
let applied = false
try {
await window.hermesDesktop.applyConnectionConfig(testedPayload)
applied = true
} catch (err) {
setError(errorMessage(err))
} finally {
setApplying(false)
}
if (applied) {
onBack()
}
}
return (
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="flex w-full max-w-xl flex-col rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />
<div className="min-w-0">
<h2 className="text-xl font-semibold tracking-tight">{copy.remoteSetupTitle}</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{copy.remoteSetupDesc}</p>
</div>
</div>
<div className="mt-6 grid gap-4">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{copy.remoteUrlTitle}</span>
<Input
autoComplete="url"
disabled={applying}
onChange={event => {
invalidateTest()
setRemoteUrl(event.target.value)
}}
placeholder={copy.remoteUrlPlaceholder}
value={remoteUrl}
/>
<span className="text-xs text-muted-foreground">{copy.remoteUrlDesc}</span>
</label>
{probeStatus === 'probing' ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{copy.probing}
</div>
) : null}
{probeStatus === 'error' ? (
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{probe?.error || copy.probeError}</span>
</div>
) : null}
{authResolved && authMode === 'oauth' ? (
<div className="rounded-md border border-(--ui-stroke-tertiary) p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{copy.authTitle}</div>
<p className="mt-1 text-xs text-muted-foreground">
{oauthConnected ? copy.authSignedIn : copy.authNeedsOauth(providerLabel)}
</p>
</div>
{oauthConnected ? (
<div className="flex items-center gap-1.5 text-sm text-primary">
<Check className="size-4" />
{copy.connected}
</div>
) : (
<Button disabled={signingIn || applying} onClick={() => void signIn()} size="sm">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{isPasswordProvider ? copy.signIn : copy.signInWith(providerLabel)}
</Button>
)}
</div>
</div>
) : null}
{authResolved && authMode === 'token' ? (
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{copy.tokenTitle}</span>
<Input
autoComplete="off"
disabled={applying}
onChange={event => {
invalidateTest()
setRemoteToken(event.target.value)
}}
placeholder={copy.pasteSessionToken}
type="password"
value={remoteToken}
/>
<span className="text-xs text-muted-foreground">{copy.tokenDesc}</span>
</label>
) : null}
{error ? (
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
{success ? (
<div className="flex items-center gap-2 text-sm text-primary">
<Check className="size-4" />
<span>{success}</span>
</div>
) : null}
</div>
<div className="mt-7 flex flex-wrap items-center justify-between gap-3">
<Button disabled={applying} onClick={onBack} size="sm" variant="ghost">
{copy.backToSetup}
</Button>
<div className="flex items-center gap-2">
<Button
disabled={testing || applying || !canTest}
onClick={() => void testRemote()}
size="sm"
variant="secondary"
>
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
{copy.testConnection}
</Button>
<Button disabled={applying || !canApply} onClick={() => void applyRemote()} size="sm">
{applying ? <Loader2 className="size-4 animate-spin" /> : null}
{copy.applyRemote}
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,152 @@
import type { ReactElement, ReactNode } from 'react'
import { ListRow, Pill } from '@/app/settings/primitives'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { AlertCircle, Check, Loader2, LogIn } from '@/lib/icons'
import type { RemoteSetup } from './use-remote-setup'
interface FieldProps {
stacked: boolean
title: string
description?: string
children: ReactNode
}
function Field({ stacked, title, description, children }: FieldProps): ReactElement {
return stacked ? (
<div className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{title}</span>
{children}
{description ? <span className="text-xs text-muted-foreground">{description}</span> : null}
</div>
) : (
<ListRow action={children} description={description} title={title} />
)
}
interface RemoteSetupFieldsProps {
setup: RemoteSetup
disabled?: boolean
urlOnly?: boolean
onUrlChange?: () => void
}
export function RemoteSetupFields({
setup,
disabled = false,
urlOnly = false,
onUrlChange
}: RemoteSetupFieldsProps): ReactElement {
const { t } = useI18n()
const g = t.settings.gateway
const firstRun = setup.host === 'first-run'
const registry = setup.host === 'registry'
const copy = firstRun ? t.install : g
const { credentials, isPassword, providerLabel } = setup
const urlTitle = registry ? t.settings.connections.urlTitle : copy.remoteUrlTitle
const authDescription = firstRun
? credentials.oauthConnected
? t.install.authSignedIn
: t.install.authNeedsOauth(providerLabel)
: credentials.oauthConnected
? isPassword
? g.authSignedInPassword
: g.authSignedInOauth
: isPassword
? g.authNeedsPassword
: g.authNeedsOauth(providerLabel)
return (
<div className="grid gap-3">
<Field description={registry ? undefined : copy.remoteUrlDesc} stacked={firstRun} title={urlTitle}>
<Input
aria-label={urlTitle}
autoComplete="url"
disabled={disabled}
onChange={event => {
setup.setUrl(event.target.value)
onUrlChange?.()
}}
placeholder={registry ? 'http://homelab.lan:9119' : t.install.remoteUrlPlaceholder}
value={credentials.url}
/>
</Field>
{!registry && setup.probeStatus === 'probing' ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{copy.probing}
</div>
) : null}
{!registry && setup.probeStatus === 'error' ? (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="size-4" />
{copy.probeError}
</div>
) : null}
{registry && !urlOnly ? (
<Field stacked={false} title={g.authTitle}>
<div className="flex gap-2">
{(['token', 'oauth'] as const).map(mode => (
<Button
disabled={disabled}
key={mode}
onClick={() => setup.setAuthMode(mode)}
size="sm"
variant={credentials.authMode === mode ? 'default' : 'outline'}
>
{mode === 'token' ? g.tokenTitle : g.signIn}
</Button>
))}
</div>
</Field>
) : null}
{!urlOnly && setup.authResolved && credentials.authMode === 'oauth' ? (
<Field description={authDescription} stacked={firstRun} title={copy.authTitle}>
{credentials.oauthConnected ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" />
{firstRun ? t.install.connected : g.signedIn}
</Pill>
{setup.host === 'settings' ? (
<Button disabled={disabled || setup.signingIn} onClick={() => void setup.signOut()} variant="outline">
{g.signOut}
</Button>
) : null}
</div>
) : (
<Button
disabled={disabled || setup.signingIn || !setup.payload.remoteUrl}
onClick={() => void setup.signIn()}
size="sm"
>
{setup.signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPassword ? copy.signIn : copy.signInWith(providerLabel)}
</Button>
)}
</Field>
) : null}
{!urlOnly && setup.authResolved && credentials.authMode === 'token' ? (
<Field description={copy.tokenDesc} stacked={firstRun} title={copy.tokenTitle}>
<Input
aria-label={copy.tokenTitle}
autoComplete="off"
disabled={disabled}
onChange={event => setup.setToken(event.target.value)}
placeholder={
credentials.tokenSet ? g.existingToken(credentials.tokenPreview ?? g.savedToken) : copy.pasteSessionToken
}
type="password"
value={credentials.token}
/>
</Field>
) : null}
{setup.error ? <div className="text-sm text-destructive">{setup.error}</div> : null}
{setup.success ? <div className="text-sm text-primary">{setup.success}</div> : null}
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { useState } from 'react'
import type { ReactElement } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n'
import { Loader2 } from '@/lib/icons'
import { RemoteSetupFields } from './fields'
import { useRemoteSetup } from './use-remote-setup'
interface FirstRunRemoteSetupProps {
onBack: () => void
}
export function FirstRunRemoteSetup({ onBack }: FirstRunRemoteSetupProps): ReactElement {
const { t } = useI18n()
const copy = t.install
const setup = useRemoteSetup({ host: 'first-run' })
const [applying, setApplying] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const apply = async (): Promise<void> => {
if (!setup.canCommit || applying) {
return
}
setApplying(true)
setError(null)
try {
await window.hermesDesktop.applyConnectionConfig(setup.payload)
onBack()
} catch (err) {
setError(err instanceof Error ? err.message : String(err || t.settings.gateway.applyFailed))
} finally {
setApplying(false)
}
}
return (
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="flex w-full max-w-xl flex-col rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />
<div className="min-w-0">
<h2 className="text-xl font-semibold tracking-tight">{copy.remoteSetupTitle}</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{copy.remoteSetupDesc}</p>
</div>
</div>
<div className="mt-6">
<RemoteSetupFields disabled={applying} setup={setup} />
{error ? <div className="mt-3 text-sm text-destructive">{error}</div> : null}
</div>
<div className="mt-7 flex flex-wrap items-center justify-between gap-3">
<Button disabled={applying} onClick={onBack} size="sm" variant="ghost">
{copy.backToSetup}
</Button>
<div className="flex items-center gap-2">
<Button
disabled={setup.testing || applying || !setup.canTest}
onClick={() => void setup.test()}
size="sm"
variant="secondary"
>
{setup.testing ? <Loader2 className="animate-spin" /> : null}
{copy.testConnection}
</Button>
<Button disabled={applying || !setup.canCommit} onClick={() => void apply()} size="sm">
{applying ? <Loader2 className="animate-spin" /> : null}
{copy.applyRemote}
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopConnectionProbeResult } from '@/global'
import { useRemoteSetup } from './use-remote-setup'
import type { RemoteSetupHost } from './use-remote-setup'
function probeResult(authMode: 'oauth' | 'token', label: string): DesktopConnectionProbeResult {
return {
authMode,
baseUrl: `https://${label}.example`,
reachable: true,
error: null,
version: null,
providers: [{ name: label, displayName: label }]
}
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
Reflect.deleteProperty(window, 'hermesDesktop')
})
describe('remote setup owner', () => {
it.each<RemoteSetupHost>(['first-run', 'settings', 'registry'])(
'rejects stale probe results in %s despite fresh host callbacks',
async host => {
const replies: ((value: DesktopConnectionProbeResult) => void)[] = []
const probeConnectionConfig = vi.fn(
() =>
new Promise<DesktopConnectionProbeResult>(resolve => {
replies.push(resolve)
})
)
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { probeConnectionConfig } })
const { result, rerender } = renderHook(() => useRemoteSetup({ host, onNotice: () => {} }))
act(() => {
result.current.setAuthMode('oauth')
result.current.setUrl('https://a.example')
})
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
act(() => result.current.setUrl('https://b.example'))
rerender()
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(probeConnectionConfig.mock.calls).toEqual([['https://a.example'], ['https://b.example']])
await act(async () => {
replies[1](probeResult('oauth', 'new'))
replies[0](probeResult('token', 'old'))
})
expect(result.current.payload).toEqual({
mode: 'remote',
remoteUrl: 'https://b.example',
remoteAuthMode: 'oauth',
remoteToken: undefined
})
expect(result.current.providerLabel).toBe('new')
}
)
it('does not publish login completion after the editor unmounts without a probe bridge', async () => {
const onNotice = vi.fn()
let finish!: (value: { connected: boolean }) => void
const oauthLoginConnectionConfig = vi.fn(
() =>
new Promise<{ connected: boolean }>(resolve => {
finish = resolve
})
)
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { oauthLoginConnectionConfig } })
const { result, unmount } = renderHook(() => useRemoteSetup({ host: 'registry', onNotice }))
act(() => {
result.current.setAuthMode('oauth')
result.current.setUrl('https://a.example')
})
let login!: Promise<void>
await act(async () => {
login = result.current.signIn()
})
expect(oauthLoginConnectionConfig).toHaveBeenCalledExactlyOnceWith('https://a.example')
unmount()
await act(async () => {
finish({ connected: true })
await login
})
expect(onNotice).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,371 @@
import { useEffect, useRef, useState } from 'react'
import type { DesktopConnectionConfigInput, DesktopConnectionProbeResult } from '@/global'
import { useI18n } from '@/i18n'
import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
import { coerceRemoteUrlScheme } from '@/lib/remote-url'
import type { NotificationInput } from '@/store/notifications'
export type RemoteSetupHost = 'first-run' | 'settings' | 'registry'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
export interface RemoteCredentials {
url: string
authMode: AuthMode
token: string
tokenSet: boolean
tokenPreview: string | null
oauthConnected: boolean
}
export interface RemoteSetupOptions {
host: RemoteSetupHost
enabled?: boolean
beforeOAuthLogin?: (payload: DesktopConnectionConfigInput) => Promise<void>
onNotice?: (notice: NotificationInput) => void
}
export interface RemoteSetup {
host: RemoteSetupHost
credentials: RemoteCredentials
payload: DesktopConnectionConfigInput
probeStatus: ProbeStatus
authResolved: boolean
providerLabel: string
isPassword: boolean
signingIn: boolean
testing: boolean
error: string | null
success: string | null
canTest: boolean
canCommit: boolean
setUrl: (url: string) => void
setToken: (token: string) => void
setAuthMode: (mode: AuthMode) => void
reset: (saved?: Partial<RemoteCredentials>) => void
signIn: () => Promise<void>
signOut: () => Promise<void>
test: () => Promise<void>
}
function credentialsFrom(saved: Partial<RemoteCredentials> = {}): RemoteCredentials {
return { url: '', authMode: 'token', token: '', tokenSet: false, tokenPreview: null, oauthConnected: false, ...saved }
}
/**
* Host contracts:
* first-run: no pre-save; Apply requires a test of this exact payload.
* settings: pre-save through beforeOAuthLogin; credentials permit Save/Apply.
* registry: explicit auth selection; storage-only Save may precede credentials.
* Persistence and live source changes belong to the host, never this editor.
*/
export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup {
const { t } = useI18n()
const g = t.settings.gateway
const { host, enabled = true } = options
const callbacks = useRef<RemoteSetupOptions>(options)
callbacks.current = options
const [credentials, setCredentials] = useState<RemoteCredentials>(credentialsFrom)
const [revision, setRevision] = useState<number>(0)
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
const [signingIn, setSigningIn] = useState<boolean>(false)
const [testing, setTesting] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [testedKey, setTestedKey] = useState<string | null>(null)
const targetSeq = useRef<number>(0)
const testSeq = useRef<number>(0)
const loginSeq = useRef<number>(0)
const url = coerceRemoteUrlScheme(credentials.url)
const manualAuth = host === 'registry'
const probeEnabled = enabled && (!manualAuth || credentials.authMode === 'oauth')
const payload: DesktopConnectionConfigInput = {
mode: 'remote',
remoteAuthMode: credentials.authMode,
remoteToken: credentials.authMode === 'token' ? credentials.token.trim() || undefined : undefined,
remoteUrl: url
}
const payloadKey = JSON.stringify(payload)
const currentKey = useRef<string>(payloadKey)
currentKey.current = payloadKey
const { isPassword, providerLabel } = deriveRemoteAuthProviderShape(probe?.providers, t.boot.failure.identityProvider)
const authResolved =
manualAuth ||
(probeStatus === 'done' && probe?.authMode !== 'unknown') ||
(host === 'settings' && probeStatus === 'idle' && (credentials.tokenSet || credentials.oauthConnected))
const credentialReady = Boolean(
url &&
(credentials.authMode === 'oauth' ? credentials.oauthConnected : credentials.token.trim() || credentials.tokenSet)
)
const canTest = enabled && Boolean(url) && ((authResolved && credentialReady) || probeStatus === 'error')
const invalidateTest = (): void => {
testSeq.current += 1
setTesting(false)
setTestedKey(null)
setError(null)
setSuccess(null)
}
const invalidateTarget = (): void => {
targetSeq.current += 1
loginSeq.current += 1
setSigningIn(false)
setProbe(null)
setProbeStatus('idle')
invalidateTest()
}
const reset = (saved?: Partial<RemoteCredentials>): void => {
invalidateTarget()
setCredentials(credentialsFrom(saved))
setRevision(value => value + 1)
}
const setUrl = (value: string): void => {
invalidateTarget()
setCredentials(current => ({ ...current, url: value, oauthConnected: false, tokenSet: false, tokenPreview: null }))
setRevision(value => value + 1)
}
const setToken = (token: string): void => {
invalidateTest()
setCredentials(current => ({ ...current, token }))
}
const setAuthMode = (authMode: AuthMode): void => {
invalidateTarget()
setCredentials(current => ({ ...current, authMode, oauthConnected: false }))
setRevision(value => value + 1)
}
const reportError = (err: unknown, title: string = g.testFailed, kind: 'error' | 'warning' = 'error'): void => {
const message = err instanceof Error ? err.message : String(err || g.testFailed)
setError(message)
callbacks.current.onNotice?.({ kind, title, message })
}
const reportSuccess = (message: string): void => {
setSuccess(message)
callbacks.current.onNotice?.({ kind: 'success', title: g.reachableTitle, message })
}
const acceptProbe = (result: DesktopConnectionProbeResult): void => {
invalidateTest()
setProbe(result)
setProbeStatus(result.reachable ? 'done' : 'error')
if (!manualAuth && result.reachable && result.authMode !== 'unknown') {
const authMode = result.authMode
setCredentials(current => ({
...current,
authMode,
oauthConnected: authMode === 'oauth' && current.oauthConnected
}))
}
}
// The effect reads current callbacks without restarting its debounce on each host render.
const acceptProbeRef = useRef<(result: DesktopConnectionProbeResult) => void>(acceptProbe)
acceptProbeRef.current = acceptProbe
// eslint-disable-next-line no-restricted-syntax -- request generations, not a reactive value mirror
useEffect(() => {
const seq = ++targetSeq.current
let timer: number | undefined
const cancel = (): void => {
targetSeq.current += 1
window.clearTimeout(timer)
}
setProbe(null)
setProbeStatus('idle')
setSigningIn(false)
setTesting(false)
setTestedKey(null)
setSuccess(null)
if (!probeEnabled || !/^https?:\/\//i.test(url) || !window.hermesDesktop?.probeConnectionConfig) {
return cancel
}
setProbeStatus('probing')
timer = window.setTimeout(() => {
void window.hermesDesktop
.probeConnectionConfig(url)
.then(result => {
if (seq === targetSeq.current) {
acceptProbeRef.current(result)
}
})
.catch(() => {
if (seq === targetSeq.current) {
setProbeStatus('error')
}
})
}, 500)
return cancel
}, [probeEnabled, revision, url])
const signIn = async (): Promise<void> => {
if (!url || signingIn) {
return
}
const target = targetSeq.current
const seq = ++loginSeq.current
const current = (): boolean => target === targetSeq.current && seq === loginSeq.current
invalidateTest()
setSigningIn(true)
try {
await callbacks.current.beforeOAuthLogin?.({ mode: 'remote', remoteAuthMode: 'oauth', remoteUrl: url })
if (!current()) {
return
}
const result = await window.hermesDesktop.oauthLoginConnectionConfig(url)
if (!current()) {
return
}
setCredentials(value => ({ ...value, oauthConnected: Boolean(result.connected) }))
if (result.connected) {
callbacks.current.onNotice?.({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) })
} else {
const message = host === 'first-run' ? t.install.signInIncomplete : t.boot.failure.signInIncompleteMessage
reportError(
result.error ? `${message}: ${result.error}` : message,
t.boot.failure.signInIncompleteTitle,
'warning'
)
}
} catch (err) {
if (current()) {
reportError(err, g.signInFailed)
}
} finally {
if (current()) {
setSigningIn(false)
}
}
}
const signOut = async (): Promise<void> => {
const target = targetSeq.current
const seq = ++loginSeq.current
const current = (): boolean => target === targetSeq.current && seq === loginSeq.current
invalidateTest()
setSigningIn(true)
try {
await window.hermesDesktop.oauthLogoutConnectionConfig(url)
if (current()) {
setCredentials(value => ({ ...value, oauthConnected: false }))
callbacks.current.onNotice?.({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage })
}
} catch (err) {
if (current()) {
reportError(err, g.signOutFailed)
}
} finally {
if (current()) {
setSigningIn(false)
}
}
}
const test = async (): Promise<void> => {
if (!canTest) {
return
}
const target = targetSeq.current
const seq = ++testSeq.current
const current = (): boolean =>
target === targetSeq.current && seq === testSeq.current && payloadKey === currentKey.current
setTesting(true)
setError(null)
setSuccess(null)
setTestedKey(null)
try {
if (!authResolved) {
const result = await window.hermesDesktop.probeConnectionConfig(url)
if (current()) {
acceptProbeRef.current(result)
if (!result.reachable || result.authMode === 'unknown') {
reportError(result.error || g.probeError)
}
}
return
}
const result = await window.hermesDesktop.testConnectionConfig(payload)
if (!current()) {
return
}
if (result.ok === false || result.reachable === false) {
throw new Error(result.error || g.testFailed)
}
reportSuccess(
(host === 'first-run' ? t.install.testSucceeded : g.connectedTo)(
result.baseUrl || url,
result.version ?? undefined
)
)
setTestedKey(payloadKey)
} catch (err) {
if (current()) {
reportError(err)
}
} finally {
if (current()) {
setTesting(false)
}
}
}
return {
host,
credentials,
payload,
probeStatus,
authResolved,
providerLabel,
isPassword,
signingIn,
testing,
error,
success,
canTest,
canCommit: enabled && (host === 'first-run' ? testedKey === payloadKey : host === 'registry' || credentialReady),
setUrl,
setToken,
setAuthMode,
reset,
signIn,
signOut,
test
}
}