mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(orca-profiles): tell the renderer when a cloud session is revoked (#18694)
A revoked refresh-token family makes the main process clear the stored cloud session, but the renderer cached orcaProfileAuthStatus at startup and only re-fetched when it was empty. The account card kept showing "Connected" and Mobile pairing kept showing a generic Relay failure until the user restarted the app. - Push: clearing a session on an auth failure now emits an invalidation event that broadcasts orcaProfiles:authStatusChanged to every window; the renderer re-reads auth status from it. Explicit sign-out is unchanged. - Pull: every pane that renders auth state re-reads it on mount through useOrcaProfileAuthStatusRefresh instead of only when the store is empty. - Copy: a Relay mint failure re-reads auth status, and the failure notice says the session expired and to sign in again instead of offering a retry that cannot succeed. The LAN path is untouched.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles'
|
||||
|
||||
export function broadcastOrcaProfileAuthStatusChanged(): void {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (window.isDestroyed()) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
window.webContents.send(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL)
|
||||
} catch {
|
||||
// A renderer can disappear between isDestroyed() and send().
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
signOutCurrentOrcaProfile
|
||||
} from '../orca-profiles/profile-cloud-service'
|
||||
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
|
||||
import { onOrcaCloudSessionInvalidated } from '../orca-profiles/profile-cloud-session-invalidation'
|
||||
import { broadcastOrcaProfileAuthStatusChanged } from './orca-profile-auth-status-broadcast'
|
||||
|
||||
type RegisterOrcaProfileHandlersOptions = {
|
||||
onBeforeRelaunch?: () => void | Promise<void>
|
||||
@@ -178,6 +180,12 @@ export function registerOrcaProfileHandlers(
|
||||
getCurrentOrcaProfileAuthStatus(getProfileUserDataPath())
|
||||
)
|
||||
|
||||
// Why: a background refresh can revoke the session with no renderer request in
|
||||
// flight, so push the change instead of waiting for the next pane to ask.
|
||||
// Why not options.onAuthMutation: that hook drives the relay coordinator, which
|
||||
// is the caller that just failed the refresh — re-entering it here would be a loop.
|
||||
onOrcaCloudSessionInvalidated(broadcastOrcaProfileAuthStatusChanged)
|
||||
|
||||
ipcMain.handle(
|
||||
'orcaProfiles:createLocal',
|
||||
(_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
type OrcaCloudSessionInvalidationListener = () => void
|
||||
|
||||
const listeners = new Set<OrcaCloudSessionInvalidationListener>()
|
||||
|
||||
/**
|
||||
* Fires when an auth failure (revoked or rotated-away refresh token) clears a
|
||||
* stored cloud session. Never fires for an explicit user sign-out, which already
|
||||
* hands the fresh auth status back to its caller.
|
||||
*/
|
||||
export function onOrcaCloudSessionInvalidated(
|
||||
listener: OrcaCloudSessionInvalidationListener
|
||||
): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function emitOrcaCloudSessionInvalidated(): void {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[orca-profiles] Cloud session invalidation listener failed:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ vi.mock('./profile-cloud-client', async (importOriginal) => {
|
||||
vi.mock('./profile-cloud-index', () => ({ linkOrcaProfileToCloud: linkMock }))
|
||||
|
||||
import { readFreshOrcaCloudSession } from './profile-cloud-session-refresh'
|
||||
import { OrcaCloudRequestError } from './profile-cloud-client'
|
||||
import { onOrcaCloudSessionInvalidated } from './profile-cloud-session-invalidation'
|
||||
|
||||
const config = {} as OrcaCloudAuthConfig
|
||||
const active = {
|
||||
@@ -110,4 +112,47 @@ describe('profile cloud session refresh', () => {
|
||||
expect(saveIfCurrentMock).toHaveBeenCalledTimes(1)
|
||||
expect(linkMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('notifies subscribers when an auth failure clears the stored session', async () => {
|
||||
const invalidated = vi.fn()
|
||||
const unsubscribe = onOrcaCloudSessionInvalidated(invalidated)
|
||||
refreshMock.mockRejectedValue(new OrcaCloudRequestError(401))
|
||||
|
||||
await expect(readFreshOrcaCloudSession(config, active, '/data')).resolves.toEqual({
|
||||
status: 'reconnect-required'
|
||||
})
|
||||
|
||||
expect(clearMock).toHaveBeenCalledTimes(1)
|
||||
expect(invalidated).toHaveBeenCalledTimes(1)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('stays silent when a concurrent rotation already replaced the failed session', async () => {
|
||||
const invalidated = vi.fn()
|
||||
const unsubscribe = onOrcaCloudSessionInvalidated(invalidated)
|
||||
refreshMock.mockRejectedValue(new OrcaCloudRequestError(401))
|
||||
readMock.mockReturnValueOnce({
|
||||
status: 'found',
|
||||
session: staleSession,
|
||||
persistence: 'memory-only'
|
||||
})
|
||||
readMock.mockReturnValueOnce({
|
||||
status: 'found',
|
||||
session: staleSession,
|
||||
persistence: 'memory-only'
|
||||
})
|
||||
readMock.mockReturnValue({
|
||||
status: 'found',
|
||||
session: { ...staleSession, refreshToken: 'rotated-refresh' },
|
||||
persistence: 'memory-only'
|
||||
})
|
||||
|
||||
await expect(readFreshOrcaCloudSession(config, active, '/data')).resolves.toEqual({
|
||||
status: 'reconnect-required'
|
||||
})
|
||||
|
||||
expect(clearMock).not.toHaveBeenCalled()
|
||||
expect(invalidated).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
cloudSessionIdentity,
|
||||
tombstoneCloudSession
|
||||
} from './profile-cloud-session-mutation'
|
||||
import { emitOrcaCloudSessionInvalidated } from './profile-cloud-session-invalidation'
|
||||
|
||||
const CLOUD_SESSION_REFRESH_SKEW_MS = 60_000
|
||||
|
||||
@@ -66,6 +67,9 @@ function clearCloudSessionIfUnchanged(
|
||||
)
|
||||
}
|
||||
clearOrcaCloudSession(profileId, userDataPath)
|
||||
// Why: the renderer cached auth status at startup; without this it keeps
|
||||
// showing "Connected" until the app restarts.
|
||||
emitOrcaCloudSessionInvalidated()
|
||||
}
|
||||
|
||||
async function refreshStoredCloudSession(
|
||||
|
||||
@@ -28,6 +28,8 @@ import type {
|
||||
export type OrcaProfileApi = {
|
||||
list: () => Promise<OrcaProfileListResult>
|
||||
authStatus: () => Promise<OrcaProfileAuthStatus>
|
||||
/** Fires when main changed the stored auth status on its own (e.g. a revoked session). */
|
||||
onAuthStatusChanged: (callback: () => void) => () => void
|
||||
createLocal: (args?: CreateLocalOrcaProfileArgs) => Promise<CreateLocalOrcaProfileResult>
|
||||
createCloudLinked: (
|
||||
args?: CreateCloudLinkedOrcaProfileArgs
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles'
|
||||
|
||||
export const orcaProfilesApi = {
|
||||
list: () => ipcRenderer.invoke('orcaProfiles:list'),
|
||||
authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'),
|
||||
onAuthStatusChanged: (callback: () => void): (() => void) => {
|
||||
const listener = (): void => callback()
|
||||
ipcRenderer.on(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL, listener)
|
||||
return () => ipcRenderer.removeListener(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL, listener)
|
||||
},
|
||||
createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args),
|
||||
createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args),
|
||||
switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args),
|
||||
|
||||
@@ -18,6 +18,7 @@ type StoreState = {
|
||||
mobilePairingCustomAddresses?: string[]
|
||||
}
|
||||
updateSettings: () => Promise<void>
|
||||
fetchOrcaProfileAuthStatus: () => Promise<unknown>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -146,7 +147,8 @@ describe('MobilePage pairing connection mode', () => {
|
||||
closeMobilePage: vi.fn(),
|
||||
orcaProfileAuthStatus: { state: 'connected' },
|
||||
settings: { showMobileButton: true },
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined)
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function MobilePage(): React.JSX.Element {
|
||||
const [relayMintFailure, setRelayMintFailure] = useState<MobileRelayMintFailure | null>(null)
|
||||
const [pairLoading, setPairLoading] = useState(false)
|
||||
const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected')
|
||||
const refreshAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
|
||||
const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode()
|
||||
const [networkInterfaces, setNetworkInterfaces] = useState<MobileNetworkInterface[]>([])
|
||||
const pairingAddressChangeRef = useRef<(change: MobilePairingAddressChange) => void>(() => {})
|
||||
@@ -93,7 +94,8 @@ export default function MobilePage(): React.JSX.Element {
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
setRelayMintFailure
|
||||
setRelayMintFailure,
|
||||
refreshAuthStatus
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
pairingAddressChangeRef.current = ({ address, source }) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CircleAlert, Loader2 } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function MobileRelayMintFailureNotice({
|
||||
@@ -23,6 +24,10 @@ export function MobileRelayMintFailureNotice({
|
||||
busy?: boolean
|
||||
}): React.JSX.Element {
|
||||
const providerMissing = failure.stage === 'provider_missing'
|
||||
// Why: a revoked cloud session fails every mint; "retry or use LAN" hides the one action that works.
|
||||
const reconnectRequired = useAppStore(
|
||||
(state) => state.orcaProfileAuthStatus?.state === 'reconnect-required'
|
||||
)
|
||||
const [showBusyFeedback, setShowBusyFeedback] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!busy) {
|
||||
@@ -43,10 +48,15 @@ export function MobileRelayMintFailureNotice({
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableTitle',
|
||||
'Orca Relay isn’t available on this desktop.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.title',
|
||||
'Couldn’t create a Relay pairing code.'
|
||||
)
|
||||
: reconnectRequired
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.reconnectTitle',
|
||||
'Your Orca account session expired.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.title',
|
||||
'Couldn’t create a Relay pairing code.'
|
||||
)
|
||||
const body = visibleBusy
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.retryingBody',
|
||||
@@ -57,10 +67,15 @@ export function MobileRelayMintFailureNotice({
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody',
|
||||
'Use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.body',
|
||||
'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
: reconnectRequired
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.reconnectBody',
|
||||
'Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.body',
|
||||
'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -90,7 +105,7 @@ export function MobileRelayMintFailureNotice({
|
||||
<Button type="button" size={compact ? 'xs' : 'sm'} onClick={onUseLan}>
|
||||
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')}
|
||||
</Button>
|
||||
{!providerMissing ? (
|
||||
{!providerMissing && !reconnectRequired ? (
|
||||
<Button
|
||||
type="button"
|
||||
size={compact ? 'xs' : 'sm'}
|
||||
|
||||
@@ -28,6 +28,8 @@ export function useMobilePairingGeneration(params: {
|
||||
setPairingQrError: (value: boolean) => void
|
||||
setPairLoading: (value: boolean) => void
|
||||
setRelayMintFailure: (value: MobileRelayMintFailure | null) => void
|
||||
/** Re-read on a Relay mint failure: a revoked session is the likeliest cause. */
|
||||
refreshAuthStatus: () => void
|
||||
}): {
|
||||
generatePairing: (
|
||||
rotate: boolean,
|
||||
@@ -47,7 +49,8 @@ export function useMobilePairingGeneration(params: {
|
||||
setPairingUrl,
|
||||
setPairingQrError,
|
||||
setPairLoading,
|
||||
setRelayMintFailure
|
||||
setRelayMintFailure,
|
||||
refreshAuthStatus
|
||||
} = params
|
||||
|
||||
const generatePairing = useCallback(
|
||||
@@ -92,6 +95,7 @@ export function useMobilePairingGeneration(params: {
|
||||
setPairingQrError(false)
|
||||
if (result.reason === 'relay_mint_failed' && result.relayFailure) {
|
||||
setRelayMintFailure(result.relayFailure)
|
||||
refreshAuthStatus()
|
||||
} else {
|
||||
setRelayMintFailure(null)
|
||||
// Why: IPC now forwards reason/guidance for all unavailability paths;
|
||||
@@ -132,6 +136,7 @@ export function useMobilePairingGeneration(params: {
|
||||
hasGeneratedRef,
|
||||
mountedRef,
|
||||
pairingRequestIdRef,
|
||||
refreshAuthStatus,
|
||||
selectedAddress,
|
||||
setPairLoading,
|
||||
setPairQrDataUrl,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from 'react'
|
||||
import { ArrowRight, Files } from 'lucide-react'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SettingsSwitchRow } from './SettingsFormControls'
|
||||
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -20,18 +20,13 @@ export function ArtifactsSettingsPane({
|
||||
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
|
||||
const connecting = useAppStore((state) => state.orcaProfileConnecting)
|
||||
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
|
||||
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
|
||||
const signedIn = authStatus?.state === 'connected'
|
||||
// Why: the capability lives in the desktop host's store and is deliberately absent from the
|
||||
// settings.update allowlist, so a web client can only mirror it — never grant it.
|
||||
const isWebClient = isWebClientLocation()
|
||||
const sharingEnabled = settings.artifactSharingEnabled === true
|
||||
|
||||
useEffect(() => {
|
||||
if (!authStatus) {
|
||||
void fetchAuthStatus()
|
||||
}
|
||||
}, [authStatus, fetchAuthStatus])
|
||||
useOrcaProfileAuthStatusRefresh()
|
||||
|
||||
const howToSteps: HowToStep[] = [
|
||||
...(sharingEnabled
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Badge } from '../ui/badge'
|
||||
import { SettingsSubsectionHeader } from './SettingsFormControls'
|
||||
import { showDeleteWorktreeFailureToast } from '../sidebar/delete-worktree-failure-toast'
|
||||
import { showLocalBaseRefUpdateSuggestionToast } from '../sidebar/local-base-ref-suggestion-toast'
|
||||
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
@@ -137,6 +138,7 @@ function OrcaCloudDevSubsection(): React.JSX.Element {
|
||||
const refresh = useAppStore((s) => s.fetchOrcaProfileAuthStatus)
|
||||
const configured = authStatus?.configured === true
|
||||
const connected = authStatus?.state === 'connected'
|
||||
useOrcaProfileAuthStatusRefresh()
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { StrictMode, useSyncExternalStore } from 'react'
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -17,13 +18,28 @@ type MobileRelayStoreState = {
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: {} as MobileRelayStoreState
|
||||
state: {} as MobileRelayStoreState,
|
||||
listeners: new Set<() => void>()
|
||||
}))
|
||||
|
||||
// Why subscribable: a mid-mount auth re-read has to reach the rendered tree, which a
|
||||
// plain selector-over-a-mutable-object mock silently swallows.
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: MobileRelayStoreState) => unknown) => selector(mocks.state)
|
||||
useAppStore: (selector: (state: MobileRelayStoreState) => unknown) =>
|
||||
useSyncExternalStore(
|
||||
(onStoreChange) => {
|
||||
mocks.listeners.add(onStoreChange)
|
||||
return () => mocks.listeners.delete(onStoreChange)
|
||||
},
|
||||
() => selector(mocks.state)
|
||||
)
|
||||
}))
|
||||
|
||||
function publishStoreState(next: MobileRelayStoreState): void {
|
||||
mocks.state = next
|
||||
mocks.listeners.forEach((listener) => listener())
|
||||
}
|
||||
|
||||
vi.mock('../../i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
@@ -251,4 +267,39 @@ describe('MobilePairingConnectionOptions', () => {
|
||||
await user.click(lan)
|
||||
expect(onChange).toHaveBeenCalledWith('local-only')
|
||||
})
|
||||
|
||||
it('re-reads a session revoked since startup and offers Sign in again', async () => {
|
||||
// Regression: the store cached "connected" at startup and the pane only
|
||||
// fetched when it was empty, so a revoked session stayed invisible.
|
||||
const connectedState: MobileRelayStoreState = {
|
||||
...mocks.state,
|
||||
orcaProfileAuthStatus: {
|
||||
activeProfileId: 'profile-1',
|
||||
configured: true,
|
||||
state: 'connected',
|
||||
persistence: 'encrypted'
|
||||
}
|
||||
}
|
||||
mocks.state = connectedState
|
||||
fetchAuthStatus.mockImplementation(async () => {
|
||||
const revoked: OrcaProfileAuthStatus = {
|
||||
activeProfileId: 'profile-1',
|
||||
configured: true,
|
||||
state: 'reconnect-required',
|
||||
persistence: 'encrypted'
|
||||
}
|
||||
publishStoreState({ ...connectedState, orcaProfileAuthStatus: revoked })
|
||||
return revoked
|
||||
})
|
||||
|
||||
// StrictMode double-invokes the effect: a fetch keyed on what it writes would loop.
|
||||
render(
|
||||
<StrictMode>
|
||||
<MobilePairingConnectionOptions value="automatic" onChange={vi.fn()} />
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Sign in again for Relay' })).toBeVisible()
|
||||
expect(fetchAuthStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Badge } from '../ui/badge'
|
||||
import { Button } from '../ui/button'
|
||||
import { translate } from '../../i18n/i18n'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MobileRelayStatus } from '../../../../shared/mobile-relay-status'
|
||||
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
|
||||
@@ -54,7 +55,6 @@ export function MobilePairingConnectionOptions({
|
||||
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
|
||||
const connecting = useAppStore((state) => state.orcaProfileConnecting)
|
||||
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
|
||||
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
|
||||
const [relayStatus, setRelayStatus] = useState<MobileRelayStatus>('offline')
|
||||
const signedIn = authStatus?.state === 'connected'
|
||||
const reconnectRequired = authStatus?.state === 'reconnect-required'
|
||||
@@ -93,11 +93,7 @@ export function MobilePairingConnectionOptions({
|
||||
optionRefs.current[next]?.focus()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!authStatus) {
|
||||
void fetchAuthStatus()
|
||||
}
|
||||
}, [authStatus, fetchAuthStatus])
|
||||
useOrcaProfileAuthStatusRefresh()
|
||||
|
||||
useEffect(() => {
|
||||
let receivedEvent = false
|
||||
|
||||
@@ -32,6 +32,7 @@ type StoreState = {
|
||||
}
|
||||
updateSettings: (patch: Record<string, unknown>) => Promise<void>
|
||||
recordFeatureInteraction: (feature: string) => void
|
||||
fetchOrcaProfileAuthStatus: () => Promise<unknown>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
@@ -188,7 +189,8 @@ describe('MobilePane pairing connection mode', () => {
|
||||
settingsSearchQuery: '',
|
||||
settings: { mobileAutoRestoreFitMs: null },
|
||||
updateSettings,
|
||||
recordFeatureInteraction: vi.fn()
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
@@ -797,7 +799,8 @@ describe('MobilePane', () => {
|
||||
settingsSearchQuery: '',
|
||||
settings: { mobileAutoRestoreFitMs: null },
|
||||
updateSettings: mocks.updateSettings,
|
||||
recordFeatureInteraction: vi.fn()
|
||||
recordFeatureInteraction: vi.fn(),
|
||||
fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
|
||||
@@ -218,6 +218,9 @@ export function MobilePane(): React.JSX.Element {
|
||||
setEndpoint(null)
|
||||
if (result.reason === 'relay_mint_failed' && result.relayFailure) {
|
||||
setRelayMintFailure(result.relayFailure)
|
||||
// Why: a revoked session is the likeliest cause; re-read it so the
|
||||
// notice can offer sign-in instead of a retry that cannot succeed.
|
||||
void useAppStore.getState().fetchOrcaProfileAuthStatus()
|
||||
} else {
|
||||
setRelayMintFailure(null)
|
||||
// Why: IPC now forwards reason/guidance for all unavailability paths;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { BookOpen, Check, CircleUserRound, Files, Smartphone } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -61,18 +62,13 @@ export function OrcaAccountSettingsPane(): React.JSX.Element {
|
||||
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
|
||||
const connecting = useAppStore((state) => state.orcaProfileConnecting)
|
||||
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
|
||||
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
|
||||
const signOut = useAppStore((state) => state.signOutCurrentOrcaProfile)
|
||||
const [signOutOpen, setSignOutOpen] = useState(false)
|
||||
const [signingOut, setSigningOut] = useState(false)
|
||||
const connected = authStatus?.state === 'connected'
|
||||
const canConnect = authStatus?.configured === true
|
||||
|
||||
useEffect(() => {
|
||||
if (!authStatus) {
|
||||
void fetchAuthStatus()
|
||||
}
|
||||
}, [authStatus, fetchAuthStatus])
|
||||
useOrcaProfileAuthStatusRefresh()
|
||||
|
||||
const confirmSignOut = async (): Promise<void> => {
|
||||
if (signingOut) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { ArrowRight, BookOpen } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -16,16 +16,11 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
|
||||
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
|
||||
const connecting = useAppStore((state) => state.orcaProfileConnecting)
|
||||
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
|
||||
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
|
||||
const signedIn = authStatus?.state === 'connected'
|
||||
const isWebClient = isWebClientLocation()
|
||||
const agentSharingEnabled = settings?.agentSkillSharingEnabled === true
|
||||
|
||||
useEffect(() => {
|
||||
if (!authStatus) {
|
||||
void fetchAuthStatus()
|
||||
}
|
||||
}, [authStatus, fetchAuthStatus])
|
||||
useOrcaProfileAuthStatusRefresh()
|
||||
|
||||
const steps: HowToStep[] = [
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime'
|
||||
import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge'
|
||||
import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge'
|
||||
import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge'
|
||||
import { registerOrcaProfileAuthIpcBridge } from './orca-profile-auth-ipc-bridge'
|
||||
import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge'
|
||||
import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge'
|
||||
import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge'
|
||||
@@ -78,6 +79,7 @@ export function installAppLifetimeIpcEvents(
|
||||
remountTerminalTabsAwaitingHostHydration
|
||||
)
|
||||
registerSettingsAndSidebarIpcBridge(unsubs)
|
||||
registerOrcaProfileAuthIpcBridge(unsubs)
|
||||
registerWorkspaceShortcutIpcBridge(unsubs)
|
||||
registerOsMarkdownFileOpenBridge(unsubs)
|
||||
unsubs.push(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
|
||||
import { createTestStore } from '../../store/slices/store-test-helpers'
|
||||
|
||||
const { storeHolder } = vi.hoisted(() => ({
|
||||
storeHolder: { current: null as { getState: () => unknown } | null }
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: { getState: () => storeHolder.current?.getState() }
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn(), info: vi.fn(), success: vi.fn(), warning: vi.fn() }
|
||||
}))
|
||||
|
||||
import { registerOrcaProfileAuthIpcBridge } from './orca-profile-auth-ipc-bridge'
|
||||
|
||||
const connectedAuthStatus: OrcaProfileAuthStatus = {
|
||||
activeProfileId: 'local-default',
|
||||
configured: true,
|
||||
state: 'connected',
|
||||
persistence: 'encrypted'
|
||||
}
|
||||
|
||||
const reconnectRequiredAuthStatus: OrcaProfileAuthStatus = {
|
||||
activeProfileId: 'local-default',
|
||||
configured: true,
|
||||
state: 'reconnect-required',
|
||||
persistence: 'encrypted'
|
||||
}
|
||||
|
||||
describe('orca profile auth IPC bridge', () => {
|
||||
let listener: (() => void) | null = null
|
||||
const unsubscribe = vi.fn()
|
||||
const authStatus = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
listener = null
|
||||
unsubscribe.mockClear()
|
||||
authStatus.mockReset()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
orcaProfiles: {
|
||||
authStatus,
|
||||
onAuthStatusChanged: (callback: () => void) => {
|
||||
listener = callback
|
||||
return unsubscribe
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('re-fetches auth status on the push, flipping connected to reconnect-required', async () => {
|
||||
authStatus.mockResolvedValue(connectedAuthStatus)
|
||||
const store = createTestStore()
|
||||
storeHolder.current = store
|
||||
await store.getState().fetchOrcaProfileAuthStatus()
|
||||
expect(store.getState().orcaProfileAuthStatus).toEqual(connectedAuthStatus)
|
||||
|
||||
const unsubs: (() => void)[] = []
|
||||
registerOrcaProfileAuthIpcBridge(unsubs)
|
||||
authStatus.mockResolvedValue(reconnectRequiredAuthStatus)
|
||||
listener?.()
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getState().orcaProfileAuthStatus).toEqual(reconnectRequiredAuthStatus)
|
||||
)
|
||||
|
||||
unsubs.forEach((dispose) => dispose())
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips registration when the preload bridge does not expose the event', () => {
|
||||
vi.stubGlobal('window', { api: { orcaProfiles: { authStatus } } })
|
||||
const unsubs: (() => void)[] = []
|
||||
|
||||
registerOrcaProfileAuthIpcBridge(unsubs)
|
||||
|
||||
expect(unsubs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useAppStore } from '../../store'
|
||||
|
||||
/** Re-reads auth status when main clears a revoked cloud session behind the renderer's back. */
|
||||
export function registerOrcaProfileAuthIpcBridge(unsubs: (() => void)[]): void {
|
||||
const subscribe = window.api.orcaProfiles?.onAuthStatusChanged
|
||||
if (typeof subscribe !== 'function') {
|
||||
return
|
||||
}
|
||||
unsubs.push(
|
||||
subscribe(() => {
|
||||
void useAppStore.getState().fetchOrcaProfileAuthStatus()
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
/**
|
||||
* Re-reads the cloud auth status whenever a surface that renders it mounts. The
|
||||
* store caches the startup value, so without this a session revoked since launch
|
||||
* still reads as connected. The cached value stays rendered while the fetch runs.
|
||||
*/
|
||||
export function useOrcaProfileAuthStatusRefresh(): void {
|
||||
const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus)
|
||||
useEffect(() => {
|
||||
void fetchAuthStatus()
|
||||
}, [fetchAuthStatus])
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [
|
||||
'emulator.onPaneFocus',
|
||||
'gh.onPRRefreshEvent',
|
||||
'keybindings.onChanged',
|
||||
'orcaProfiles.onAuthStatusChanged',
|
||||
'pty.onExit',
|
||||
'rateLimits.onUpdate',
|
||||
'remoteWorkspace.onChanged',
|
||||
@@ -126,6 +127,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
|
||||
'ui.onToggleWorktreePalette',
|
||||
'ui.onToggleFloatingTerminal',
|
||||
'ui.onTerminalShortcutCaptured',
|
||||
'orcaProfiles.onAuthStatusChanged',
|
||||
'ui.onOpenQuickOpen',
|
||||
'ui.onToggleQuickCommandsMenu',
|
||||
'ui.onOpenNewWorkspace',
|
||||
|
||||
@@ -13771,7 +13771,9 @@
|
||||
"useLan": "Use LAN",
|
||||
"retrying": "Retrying…",
|
||||
"retry": "Retry Relay",
|
||||
"copyDiagnostics": "Copy diagnostics"
|
||||
"copyDiagnostics": "Copy diagnostics",
|
||||
"reconnectTitle": "Your Orca account session expired.",
|
||||
"reconnectBody": "Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same Wi‑Fi."
|
||||
}
|
||||
},
|
||||
"gitlab": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
createDefaultLocalOrcaProfile
|
||||
} from '../../../../shared/orca-profiles'
|
||||
import { noopUnsubscribe } from './web-storage'
|
||||
|
||||
export function createWebOrcaProfilesApi(): Partial<PreloadApi> {
|
||||
const webOrcaProfileAuthStatus = () =>
|
||||
@@ -22,6 +23,7 @@ export function createWebOrcaProfilesApi(): Partial<PreloadApi> {
|
||||
multiProfileUi: false
|
||||
}),
|
||||
authStatus: webOrcaProfileAuthStatus,
|
||||
onAuthStatusChanged: () => noopUnsubscribe,
|
||||
createLocal: () =>
|
||||
Promise.resolve({
|
||||
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ExecutionHostId } from './execution-host'
|
||||
export const ORCA_PROFILE_INDEX_SCHEMA_VERSION = 1
|
||||
export const DEFAULT_LOCAL_ORCA_PROFILE_ID = 'local-default'
|
||||
export const DEFAULT_LOCAL_ORCA_PROFILE_NAME = 'Personal'
|
||||
/** Main -> renderer push when the stored auth status changed without the renderer asking. */
|
||||
export const ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL = 'orcaProfiles:authStatusChanged'
|
||||
const LEGACY_ORCA_BROWSER_SESSION_PARTITION_PREFIX = 'persist:orca-browser-session-'
|
||||
|
||||
export type OrcaProfileAvatar = {
|
||||
|
||||
Reference in New Issue
Block a user