refactor(desktop): split remote setup into probe, test and OAuth hooks
useRemoteSetup covered the credential form, the connection test and the OAuth login in one hook. The test/feedback leg now lives in useRemoteConnectionTest and the login/logout leg in useRemoteOAuth, both fenced by the probe's target generation as before. useRemoteSetup keeps the form state and composes the three legs; its public shape is unchanged.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { type RefObject, useRef, useState } from 'react'
|
||||
|
||||
import type { DesktopConnectionConfigInput, DesktopConnectionProbeResult } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { NotificationInput } from '@/store/notifications'
|
||||
|
||||
import type { RemoteSetupHost } from './use-remote-setup'
|
||||
|
||||
interface RemoteConnectionTestOptions {
|
||||
host: RemoteSetupHost
|
||||
url: string
|
||||
payload: DesktopConnectionConfigInput
|
||||
canTest: boolean
|
||||
authResolved: boolean
|
||||
targetSeq: RefObject<number>
|
||||
acceptProbe: (result: DesktopConnectionProbeResult) => void
|
||||
notify: (notice: NotificationInput) => void
|
||||
}
|
||||
|
||||
export interface RemoteConnectionTest {
|
||||
testing: boolean
|
||||
error: string | null
|
||||
success: string | null
|
||||
// The last passing test covered exactly the payload on screen.
|
||||
passed: boolean
|
||||
invalidateTest: () => void
|
||||
clearTestOutcome: () => void
|
||||
reportError: (err: unknown, title?: string, kind?: 'error' | 'warning') => void
|
||||
test: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Test/feedback leg of the remote editor: one generation per test, fenced by target and payload. */
|
||||
export function useRemoteConnectionTest(options: RemoteConnectionTestOptions): RemoteConnectionTest {
|
||||
const { t } = useI18n()
|
||||
const g = t.settings.gateway
|
||||
const { host, url, payload, canTest, authResolved, targetSeq, acceptProbe, notify } = options
|
||||
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 testSeq = useRef<number>(0)
|
||||
const payloadKey: string = JSON.stringify(payload)
|
||||
const currentKey = useRef<string>(payloadKey)
|
||||
currentKey.current = payloadKey
|
||||
|
||||
const invalidateTest = (): void => {
|
||||
testSeq.current += 1
|
||||
setTesting(false)
|
||||
setTestedKey(null)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
}
|
||||
|
||||
// A retargeted probe drops the verdict but keeps the last error visible.
|
||||
const clearTestOutcome = (): void => {
|
||||
setTesting(false)
|
||||
setTestedKey(null)
|
||||
setSuccess(null)
|
||||
}
|
||||
|
||||
const reportError = (err: unknown, title: string = g.testFailed, kind: 'error' | 'warning' = 'error'): void => {
|
||||
const message: string = err instanceof Error ? err.message : String(err || g.testFailed)
|
||||
setError(message)
|
||||
notify({ kind, title, message })
|
||||
}
|
||||
|
||||
const reportSuccess = (message: string): void => {
|
||||
setSuccess(message)
|
||||
notify({ kind: 'success', title: g.reachableTitle, message })
|
||||
}
|
||||
|
||||
const test = async (): Promise<void> => {
|
||||
if (!canTest) {
|
||||
return
|
||||
}
|
||||
|
||||
const target: number = targetSeq.current
|
||||
const seq: number = ++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()) {
|
||||
acceptProbe(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 {
|
||||
testing,
|
||||
error,
|
||||
success,
|
||||
passed: testedKey === payloadKey,
|
||||
invalidateTest,
|
||||
clearTestOutcome,
|
||||
reportError,
|
||||
test
|
||||
}
|
||||
}
|
||||
130
apps/desktop/src/components/remote-setup/use-remote-oauth.ts
Normal file
130
apps/desktop/src/components/remote-setup/use-remote-oauth.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { type RefObject, useRef, useState } from 'react'
|
||||
|
||||
import type { DesktopConnectionConfigInput } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { NotificationInput } from '@/store/notifications'
|
||||
|
||||
import type { RemoteSetupHost } from './use-remote-setup'
|
||||
|
||||
interface RemoteOAuthOptions {
|
||||
host: RemoteSetupHost
|
||||
url: string
|
||||
providerLabel: string
|
||||
targetSeq: RefObject<number>
|
||||
beforeOAuthLogin: (payload: DesktopConnectionConfigInput) => Promise<void> | undefined
|
||||
setOAuthConnected: (connected: boolean) => void
|
||||
invalidateTest: () => void
|
||||
reportError: (err: unknown, title?: string, kind?: 'error' | 'warning') => void
|
||||
notify: (notice: NotificationInput) => void
|
||||
}
|
||||
|
||||
export interface RemoteOAuth {
|
||||
signingIn: boolean
|
||||
// Retargeting orphans any in-flight login; its result must not land.
|
||||
invalidateLogin: () => void
|
||||
clearSigningIn: () => void
|
||||
signIn: () => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
/** OAuth leg of the remote editor: login/logout generations fenced by the probe target. */
|
||||
export function useRemoteOAuth(options: RemoteOAuthOptions): RemoteOAuth {
|
||||
const { t } = useI18n()
|
||||
const g = t.settings.gateway
|
||||
const {
|
||||
host,
|
||||
url,
|
||||
providerLabel,
|
||||
targetSeq,
|
||||
beforeOAuthLogin,
|
||||
setOAuthConnected,
|
||||
invalidateTest,
|
||||
reportError,
|
||||
notify
|
||||
} = options
|
||||
const [signingIn, setSigningIn] = useState<boolean>(false)
|
||||
const loginSeq = useRef<number>(0)
|
||||
|
||||
const invalidateLogin = (): void => {
|
||||
loginSeq.current += 1
|
||||
setSigningIn(false)
|
||||
}
|
||||
|
||||
const clearSigningIn = (): void => {
|
||||
setSigningIn(false)
|
||||
}
|
||||
|
||||
const signIn = async (): Promise<void> => {
|
||||
if (!url || signingIn) {
|
||||
return
|
||||
}
|
||||
|
||||
const target: number = targetSeq.current
|
||||
const seq: number = ++loginSeq.current
|
||||
const current = (): boolean => target === targetSeq.current && seq === loginSeq.current
|
||||
invalidateTest()
|
||||
setSigningIn(true)
|
||||
|
||||
try {
|
||||
await beforeOAuthLogin({ mode: 'remote', remoteAuthMode: 'oauth', remoteUrl: url })
|
||||
|
||||
if (!current()) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.hermesDesktop.oauthLoginConnectionConfig(url)
|
||||
|
||||
if (!current()) {
|
||||
return
|
||||
}
|
||||
|
||||
setOAuthConnected(Boolean(result.connected))
|
||||
|
||||
if (result.connected) {
|
||||
notify({ 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: number = targetSeq.current
|
||||
const seq: number = ++loginSeq.current
|
||||
const current = (): boolean => target === targetSeq.current && seq === loginSeq.current
|
||||
invalidateTest()
|
||||
setSigningIn(true)
|
||||
|
||||
try {
|
||||
await window.hermesDesktop.oauthLogoutConnectionConfig(url)
|
||||
|
||||
if (current()) {
|
||||
setOAuthConnected(false)
|
||||
notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage })
|
||||
}
|
||||
} catch (err) {
|
||||
if (current()) {
|
||||
reportError(err, g.signOutFailed)
|
||||
}
|
||||
} finally {
|
||||
if (current()) {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { signingIn, invalidateLogin, clearSigningIn, signIn, signOut }
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
|
||||
import { coerceRemoteUrlScheme } from '@/lib/remote-url'
|
||||
import type { NotificationInput } from '@/store/notifications'
|
||||
|
||||
import { type RemoteConnectionTest, useRemoteConnectionTest } from './use-remote-connection-test'
|
||||
import { type RemoteOAuth, useRemoteOAuth } from './use-remote-oauth'
|
||||
import { useRemoteProbe } from './use-remote-probe'
|
||||
|
||||
export type RemoteSetupHost = 'first-run' | 'settings' | 'registry'
|
||||
@@ -64,37 +66,27 @@ function credentialsFrom(saved: Partial<RemoteCredentials> = {}): RemoteCredenti
|
||||
*/
|
||||
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 [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 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 notify = (notice: NotificationInput): void => callbacks.current.onNotice?.(notice)
|
||||
|
||||
const { probe, probeStatus, targetSeq, invalidateProbe, acceptProbe } = useRemoteProbe({
|
||||
enabled: probeEnabled,
|
||||
url,
|
||||
revision,
|
||||
onReset: (): void => {
|
||||
setSigningIn(false)
|
||||
setTesting(false)
|
||||
setTestedKey(null)
|
||||
setSuccess(null)
|
||||
oauth.clearSigningIn()
|
||||
connectionTest.clearTestOutcome()
|
||||
},
|
||||
onResult: (result: DesktopConnectionProbeResult): void => {
|
||||
invalidateTest()
|
||||
connectionTest.invalidateTest()
|
||||
|
||||
if (!manualAuth && result.reachable && result.authMode !== 'unknown') {
|
||||
const authMode = result.authMode
|
||||
@@ -114,9 +106,6 @@ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup {
|
||||
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 =
|
||||
@@ -131,19 +120,34 @@ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup {
|
||||
|
||||
const canTest = enabled && Boolean(url) && ((authResolved && credentialReady) || probeStatus === 'error')
|
||||
|
||||
const invalidateTest = (): void => {
|
||||
testSeq.current += 1
|
||||
setTesting(false)
|
||||
setTestedKey(null)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
}
|
||||
const connectionTest: RemoteConnectionTest = useRemoteConnectionTest({
|
||||
host,
|
||||
url,
|
||||
payload,
|
||||
canTest,
|
||||
authResolved,
|
||||
targetSeq,
|
||||
acceptProbe,
|
||||
notify
|
||||
})
|
||||
|
||||
const oauth: RemoteOAuth = useRemoteOAuth({
|
||||
host,
|
||||
url,
|
||||
providerLabel,
|
||||
targetSeq,
|
||||
beforeOAuthLogin: (value: DesktopConnectionConfigInput): Promise<void> | undefined =>
|
||||
callbacks.current.beforeOAuthLogin?.(value),
|
||||
setOAuthConnected: (oauthConnected: boolean): void => setCredentials(value => ({ ...value, oauthConnected })),
|
||||
invalidateTest: connectionTest.invalidateTest,
|
||||
reportError: connectionTest.reportError,
|
||||
notify
|
||||
})
|
||||
|
||||
const invalidateTarget = (): void => {
|
||||
invalidateProbe()
|
||||
loginSeq.current += 1
|
||||
setSigningIn(false)
|
||||
invalidateTest()
|
||||
oauth.invalidateLogin()
|
||||
connectionTest.invalidateTest()
|
||||
}
|
||||
|
||||
const reset = (saved?: Partial<RemoteCredentials>): void => {
|
||||
@@ -159,7 +163,7 @@ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup {
|
||||
}
|
||||
|
||||
const setToken = (token: string): void => {
|
||||
invalidateTest()
|
||||
connectionTest.invalidateTest()
|
||||
setCredentials(current => ({ ...current, token }))
|
||||
}
|
||||
|
||||
@@ -169,148 +173,6 @@ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup {
|
||||
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 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()) {
|
||||
acceptProbe(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,
|
||||
@@ -319,18 +181,18 @@ export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup {
|
||||
authResolved,
|
||||
providerLabel,
|
||||
isPassword,
|
||||
signingIn,
|
||||
testing,
|
||||
error,
|
||||
success,
|
||||
signingIn: oauth.signingIn,
|
||||
testing: connectionTest.testing,
|
||||
error: connectionTest.error,
|
||||
success: connectionTest.success,
|
||||
canTest,
|
||||
canCommit: enabled && (host === 'first-run' ? testedKey === payloadKey : host === 'registry' || credentialReady),
|
||||
canCommit: enabled && (host === 'first-run' ? connectionTest.passed : host === 'registry' || credentialReady),
|
||||
setUrl,
|
||||
setToken,
|
||||
setAuthMode,
|
||||
reset,
|
||||
signIn,
|
||||
signOut,
|
||||
test
|
||||
signIn: oauth.signIn,
|
||||
signOut: oauth.signOut,
|
||||
test: connectionTest.test
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user