Share remote setup state and controls across desktop hosts
This commit is contained in:
@@ -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: [] })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
152
apps/desktop/src/components/remote-setup/fields.tsx
Normal file
152
apps/desktop/src/components/remote-setup/fields.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
apps/desktop/src/components/remote-setup/first-run.tsx
Normal file
78
apps/desktop/src/components/remote-setup/first-run.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
371
apps/desktop/src/components/remote-setup/use-remote-setup.ts
Normal file
371
apps/desktop/src/components/remote-setup/use-remote-setup.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user