From eea968ea205a1d4f57b581b2bc86694e7d2a5bfa Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 12 Sep 2026 19:06:51 -0400 Subject: [PATCH] Share remote setup state and controls across desktop hosts --- .../settings/connections-registry.test.tsx | 89 +++- .../src/app/settings/connections-registry.tsx | 251 ++-------- .../app/settings/gateway-settings.test.tsx | 174 ++++++- .../src/app/settings/gateway-settings.tsx | 445 +++--------------- .../desktop-install-overlay.test.tsx | 33 ++ .../components/desktop-install-overlay.tsx | 4 +- .../src/components/first-run-remote-form.tsx | 346 -------------- .../src/components/remote-setup/fields.tsx | 152 ++++++ .../src/components/remote-setup/first-run.tsx | 78 +++ .../remote-setup/use-remote-setup.test.tsx | 100 ++++ .../remote-setup/use-remote-setup.ts | 371 +++++++++++++++ 11 files changed, 1099 insertions(+), 944 deletions(-) delete mode 100644 apps/desktop/src/components/first-run-remote-form.tsx create mode 100644 apps/desktop/src/components/remote-setup/fields.tsx create mode 100644 apps/desktop/src/components/remote-setup/first-run.tsx create mode 100644 apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx create mode 100644 apps/desktop/src/components/remote-setup/use-remote-setup.ts diff --git a/apps/desktop/src/app/settings/connections-registry.test.tsx b/apps/desktop/src/app/settings/connections-registry.test.tsx index 6a5b4007f7..96e6774da4 100644 --- a/apps/desktop/src/app/settings/connections-registry.test.tsx +++ b/apps/desktop/src/app/settings/connections-registry.test.tsx @@ -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() + 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() + 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: [] }) diff --git a/apps/desktop/src/app/settings/connections-registry.tsx b/apps/desktop/src/app/settings/connections-registry.tsx index a6af04357c..af2bf5a190 100644 --- a/apps/desktop/src/app/settings/connections-registry.tsx +++ b/apps/desktop/src/app/settings/connections-registry.tsx @@ -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, + editor: Pick & { 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) - // 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(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 => { 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() { <> - ))} - - } - title={t.settings.gateway.authTitle} - /> - {editor.authMode === 'token' && ( - 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' && ( - - {t.settings.gateway.signedIn} - - ) : ( - - ) - } - 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') && (
diff --git a/apps/desktop/src/app/settings/gateway-settings.test.tsx b/apps/desktop/src/app/settings/gateway-settings.test.tsx index a42a6ed77f..31d4fe470f 100644 --- a/apps/desktop/src/app/settings/gateway-settings.test.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.test.tsx @@ -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(resolve => { + finishSave = resolve + }) + ) + + const probeConnectionConfig = vi.fn( + () => + new Promise<{ reachable: boolean; authMode: string; providers: never[] }>(resolve => { + finishProbe = resolve + }) + ) + + Object.assign(window.hermesDesktop, { probeConnectionConfig }) + render() + 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(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() + 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(resolve => { + finishSave = resolve + }) + ) + Object.assign(window.hermesDesktop, { + probeConnectionConfig: vi.fn().mockResolvedValue({ reachable: true, authMode: 'token', providers: [] }) + }) + render() + 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() + 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 = { diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index ac5a2b43d2..5409a87c41 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -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(EMPTY_STATE) - const [remoteToken, setRemoteToken] = useState('') + + const remote = useRemoteSetup({ + host: 'settings', + enabled: !loading && state.mode === 'remote', + beforeOAuthLogin: async (payload: DesktopConnectionConfigInput): Promise => { + await window.hermesDesktop.saveConnectionConfig(payload) + }, + onNotice: notify + }) + const [lastTest, setLastTest] = useState(null) const [sshHostSuggestions, setSshHostSuggestions] = useState([]) const [sshCustomHost, setSshCustomHost] = useState(false) const sshResolveSeq = useRef(0) const sshTestSeq = useRef(0) const saveSeq = useRef(0) + const saveOwner = useRef(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('idle') - const [probe, setProbe] = useState(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 => { 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 => { + 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 ( - 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' ? ( -
- - {g.probing} +
+ + {remote.credentials.authMode === 'token' && state.remoteTokenPlainText ? ( +
+
{g.plainTextStoredTitle}
+
{g.plainTextStoredDesc}
) : null} - - {state.mode === 'remote' && probeStatus === 'error' ? ( -
- - {g.probeError} -
- ) : null} - - {/* OAuth / password gateways: present a sign-in button + connection status. */} - {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( - - - {g.signedIn} - - -
- ) : ( - - ) - } - 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' ? ( - <> - 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 ? ( -
- -
-
{g.plainTextStoredTitle}
-
{g.plainTextStoredDesc}
-
-
- ) : null} - - ) : null}
) : null} @@ -1575,12 +1252,12 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { {state.mode === 'remote' ? ( ) : state.mode === 'ssh' ? ( diff --git a/apps/desktop/src/components/desktop-install-overlay.test.tsx b/apps/desktop/src/components/desktop-install-overlay.test.tsx index 51165480c4..ba98301881 100644 --- a/apps/desktop/src/components/desktop-install-overlay.test.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.test.tsx @@ -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() + 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({ diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 71026701d0..8cfb83d359 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -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 setRemoteOpen(false)} /> + return setRemoteOpen(false)} /> } if (state.setupChoice) { diff --git a/apps/desktop/src/components/first-run-remote-form.tsx b/apps/desktop/src/components/first-run-remote-form.tsx deleted file mode 100644 index 139f0bd2a9..0000000000 --- a/apps/desktop/src/components/first-run-remote-form.tsx +++ /dev/null @@ -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('idle') - const [probe, setProbe] = useState(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(null) - const [success, setSuccess] = useState(null) - const [lastTestedPayloadKey, setLastTestedPayloadKey] = useState(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 ( -
-
-
- -
-

{copy.remoteSetupTitle}

-

{copy.remoteSetupDesc}

-
-
- -
- - - {probeStatus === 'probing' ? ( -
- - {copy.probing} -
- ) : null} - - {probeStatus === 'error' ? ( -
- - {probe?.error || copy.probeError} -
- ) : null} - - {authResolved && authMode === 'oauth' ? ( -
-
-
-
{copy.authTitle}
-

- {oauthConnected ? copy.authSignedIn : copy.authNeedsOauth(providerLabel)} -

-
- {oauthConnected ? ( -
- - {copy.connected} -
- ) : ( - - )} -
-
- ) : null} - - {authResolved && authMode === 'token' ? ( - - ) : null} - - {error ? ( -
- - {error} -
- ) : null} - - {success ? ( -
- - {success} -
- ) : null} -
- -
- -
- - -
-
-
-
- ) -} diff --git a/apps/desktop/src/components/remote-setup/fields.tsx b/apps/desktop/src/components/remote-setup/fields.tsx new file mode 100644 index 0000000000..9ae00f239d --- /dev/null +++ b/apps/desktop/src/components/remote-setup/fields.tsx @@ -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 ? ( +
+ {title} + {children} + {description ? {description} : null} +
+ ) : ( + + ) +} + +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 ( +
+ + { + setup.setUrl(event.target.value) + onUrlChange?.() + }} + placeholder={registry ? 'http://homelab.lan:9119' : t.install.remoteUrlPlaceholder} + value={credentials.url} + /> + + {!registry && setup.probeStatus === 'probing' ? ( +
+ + {copy.probing} +
+ ) : null} + {!registry && setup.probeStatus === 'error' ? ( +
+ + {copy.probeError} +
+ ) : null} + {registry && !urlOnly ? ( + +
+ {(['token', 'oauth'] as const).map(mode => ( + + ))} +
+
+ ) : null} + {!urlOnly && setup.authResolved && credentials.authMode === 'oauth' ? ( + + {credentials.oauthConnected ? ( +
+ + + {firstRun ? t.install.connected : g.signedIn} + + {setup.host === 'settings' ? ( + + ) : null} +
+ ) : ( + + )} +
+ ) : null} + {!urlOnly && setup.authResolved && credentials.authMode === 'token' ? ( + + setup.setToken(event.target.value)} + placeholder={ + credentials.tokenSet ? g.existingToken(credentials.tokenPreview ?? g.savedToken) : copy.pasteSessionToken + } + type="password" + value={credentials.token} + /> + + ) : null} + {setup.error ?
{setup.error}
: null} + {setup.success ?
{setup.success}
: null} +
+ ) +} diff --git a/apps/desktop/src/components/remote-setup/first-run.tsx b/apps/desktop/src/components/remote-setup/first-run.tsx new file mode 100644 index 0000000000..5c7fe3a541 --- /dev/null +++ b/apps/desktop/src/components/remote-setup/first-run.tsx @@ -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(false) + const [error, setError] = useState(null) + + const apply = async (): Promise => { + 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 ( +
+
+
+ +
+

{copy.remoteSetupTitle}

+

{copy.remoteSetupDesc}

+
+
+
+ + {error ?
{error}
: null} +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx b/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx new file mode 100644 index 0000000000..e15cf9443d --- /dev/null +++ b/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx @@ -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(['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(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 + 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() + }) +}) diff --git a/apps/desktop/src/components/remote-setup/use-remote-setup.ts b/apps/desktop/src/components/remote-setup/use-remote-setup.ts new file mode 100644 index 0000000000..f7c2bc6fe9 --- /dev/null +++ b/apps/desktop/src/components/remote-setup/use-remote-setup.ts @@ -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 + 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) => void + signIn: () => Promise + signOut: () => Promise + test: () => Promise +} + +function credentialsFrom(saved: Partial = {}): 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(options) + callbacks.current = options + const [credentials, setCredentials] = useState(credentialsFrom) + const [revision, setRevision] = useState(0) + const [probe, setProbe] = useState(null) + const [probeStatus, setProbeStatus] = useState('idle') + const [signingIn, setSigningIn] = useState(false) + const [testing, setTesting] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [testedKey, setTestedKey] = useState(null) + const targetSeq = useRef(0) + const testSeq = useRef(0) + const loginSeq = useRef(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(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): 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 => { + 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 => { + 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 => { + 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 + } +}