diff --git a/src/main/ipc/orca-profile-auth-status-broadcast.ts b/src/main/ipc/orca-profile-auth-status-broadcast.ts
index b5ac8483943..9417782b21d 100644
--- a/src/main/ipc/orca-profile-auth-status-broadcast.ts
+++ b/src/main/ipc/orca-profile-auth-status-broadcast.ts
@@ -1,15 +1,29 @@
import { BrowserWindow } from 'electron'
-import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles'
+import {
+ ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL,
+ ORCA_PROFILE_SIGN_IN_PROGRESS_CHANGED_CHANNEL,
+ type OrcaCloudSignInProgress
+} from '../../shared/orca-profiles'
-export function broadcastOrcaProfileAuthStatusChanged(): void {
+function broadcast(channel: string, ...args: unknown[]): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed()) {
continue
}
try {
- window.webContents.send(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL)
+ window.webContents.send(channel, ...args)
} catch {
// A renderer can disappear between isDestroyed() and send().
}
}
}
+
+export function broadcastOrcaProfileAuthStatusChanged(): void {
+ broadcast(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL)
+}
+
+export function broadcastOrcaProfileSignInProgressChanged(
+ progress: OrcaCloudSignInProgress | null
+): void {
+ broadcast(ORCA_PROFILE_SIGN_IN_PROGRESS_CHANGED_CHANNEL, progress)
+}
diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts
index cea9ea4824c..389def8d61a 100644
--- a/src/main/ipc/orca-profiles.ts
+++ b/src/main/ipc/orca-profiles.ts
@@ -17,6 +17,7 @@ import type {
TransferOrcaProfileProjectResult,
ConnectCurrentOrcaProfileResult,
OrcaProfileAuthStatus,
+ OrcaCloudSignInProgress,
SelectOrcaProfileOrgArgs,
SelectOrcaProfileOrgResult,
SignOutCurrentOrcaProfileResult
@@ -47,7 +48,15 @@ import {
} 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'
+import {
+ broadcastOrcaProfileAuthStatusChanged,
+ broadcastOrcaProfileSignInProgressChanged
+} from './orca-profile-auth-status-broadcast'
+import {
+ cancelOrcaCloudSignIn,
+ getOrcaCloudSignInProgress,
+ onOrcaCloudSignInProgressChanged
+} from '../orca-profiles/profile-cloud-sign-in-progress'
import { transferProjectArgsFromUnknown } from './orca-profile-project-transfer-args'
type RegisterOrcaProfileHandlersOptions = {
@@ -168,6 +177,7 @@ export function registerOrcaProfileHandlers(
// 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)
+ onOrcaCloudSignInProgressChanged(broadcastOrcaProfileSignInProgressChanged)
ipcMain.handle(
'orcaProfiles:createLocal',
@@ -268,6 +278,12 @@ export function registerOrcaProfileHandlers(
}
)
+ ipcMain.handle('orcaProfiles:signInProgress', (): OrcaCloudSignInProgress | null =>
+ getOrcaCloudSignInProgress()
+ )
+
+ ipcMain.handle('orcaProfiles:cancelSignIn', (): void => cancelOrcaCloudSignIn())
+
ipcMain.handle(
'orcaProfiles:createCloudLinked',
async (
diff --git a/src/main/orca-profiles/profile-cloud-pkce.test.ts b/src/main/orca-profiles/profile-cloud-pkce.test.ts
index f5c0a802da1..3f7e8f29fa9 100644
--- a/src/main/orca-profiles/profile-cloud-pkce.test.ts
+++ b/src/main/orca-profiles/profile-cloud-pkce.test.ts
@@ -181,4 +181,32 @@ describe('Orca cloud PKCE flow', () => {
expect(secondResponse.statusCode).toBe(200)
await expect(second).resolves.toMatchObject({ code: 'second-code', state: secondState })
})
+ it('hands the opened authorize URL to the caller so it can be copied', async () => {
+ const onAuthorizeUrl = vi.fn()
+ const flow = beginOrcaCloudPkceFlow(config, 'local-default', { onAuthorizeUrl })
+ await vi.waitFor(() => expect(openExternalMock).toHaveBeenCalledTimes(1))
+ const opened = String(openExternalMock.mock.calls[0]?.[0])
+ expect(onAuthorizeUrl).toHaveBeenCalledWith(opened)
+
+ const authUrl = new URL(opened)
+ const redirectUri = authUrl.searchParams.get('redirect_uri') ?? ''
+ const state = authUrl.searchParams.get('state') ?? ''
+ await readHttp(callbackUrl(redirectUri, { code: 'real-code', state }))
+ await expect(flow).resolves.toMatchObject({ code: 'real-code' })
+ })
+
+ it('ends the wait as cancelled when its signal aborts', async () => {
+ const browserWait = new AbortController()
+ const flow = beginOrcaCloudPkceFlow(config, 'local-default', { signal: browserWait.signal })
+ const observedFlow = flow.catch((error: unknown) => error)
+ await vi.waitFor(() => expect(openExternalMock).toHaveBeenCalledTimes(1))
+ const redirectUri = new URL(String(openExternalMock.mock.calls[0]?.[0])).searchParams.get(
+ 'redirect_uri'
+ )
+
+ browserWait.abort()
+
+ await expect(observedFlow).resolves.toMatchObject({ message: 'orca_cloud_auth_cancelled' })
+ await expect(readHttp(`${redirectUri}?code=late`)).rejects.toThrow()
+ })
})
diff --git a/src/main/orca-profiles/profile-cloud-pkce.ts b/src/main/orca-profiles/profile-cloud-pkce.ts
index 5cb908e1702..7f63dd9053f 100644
--- a/src/main/orca-profiles/profile-cloud-pkce.ts
+++ b/src/main/orca-profiles/profile-cloud-pkce.ts
@@ -42,7 +42,8 @@ function closeServer(server: Server): void {
export function beginOrcaCloudPkceFlow(
config: OrcaCloudAuthConfig,
- localProfileId: string
+ localProfileId: string,
+ options: { onAuthorizeUrl?: (url: string) => void; signal?: AbortSignal } = {}
): Promise {
const codeVerifier = createCodeVerifier()
const nonce = base64Url(randomBytes(32))
@@ -124,7 +125,12 @@ export function beginOrcaCloudPkceFlow(
const timeout = setTimeout(() => {
rejectFlow(new Error('orca_cloud_auth_timeout'))
}, AUTH_TIMEOUT_MS)
- server.once('close', () => clearTimeout(timeout))
+ const cancel = (): void => rejectFlow(new Error('orca_cloud_auth_cancelled'))
+ options.signal?.addEventListener('abort', cancel, { once: true })
+ server.once('close', () => {
+ clearTimeout(timeout)
+ options.signal?.removeEventListener('abort', cancel)
+ })
server.once('error', rejectFlow)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
@@ -143,6 +149,7 @@ export function beginOrcaCloudPkceFlow(
authorizeUrl.searchParams.set('code_challenge', createCodeChallenge(codeVerifier))
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
authorizeUrl.searchParams.set('local_profile_id', localProfileId)
+ options.onAuthorizeUrl?.(authorizeUrl.toString())
void shell.openExternal(authorizeUrl.toString()).catch((error) => {
rejectFlow(
error instanceof Error ? error : new Error('orca_cloud_auth_browser_open_failed')
diff --git a/src/main/orca-profiles/profile-cloud-service.ts b/src/main/orca-profiles/profile-cloud-service.ts
index f22ea786f02..f76018951ac 100644
--- a/src/main/orca-profiles/profile-cloud-service.ts
+++ b/src/main/orca-profiles/profile-cloud-service.ts
@@ -33,6 +33,11 @@ import {
} from './profile-cloud-dev-service'
import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status'
import { selectCloudOrg } from './profile-cloud-org-selection'
+import {
+ endOrcaCloudSignInProgress,
+ setOrcaCloudSignInLink,
+ startOrcaCloudSignInProgress
+} from './profile-cloud-sign-in-progress'
import { clearRelayRegionPreferenceCache } from '../runtime/mobile-cloud-relay/relay-region-preference'
export { refreshCurrentOrcaProfileAuth } from './profile-cloud-capability-refresh'
@@ -43,10 +48,7 @@ let linkedCloudConnectAttempt = 0
function invalidateOutstandingCloudConnectAttempts(): void {
nextCloudConnectAttempt += 1
linkedCloudConnectAttempt = nextCloudConnectAttempt
-}
-
-function isUserCancelledAuthError(message: string): boolean {
- return message === 'orca_cloud_auth_timeout' || message === 'orca_cloud_auth_denied'
+ endOrcaCloudSignInProgress()
}
function activeAuth(
@@ -83,8 +85,15 @@ export async function connectCurrentOrcaProfile(
}
const attempt = ++nextCloudConnectAttempt
+ const browserWait = new AbortController()
+ startOrcaCloudSignInProgress(attempt, () => browserWait.abort())
try {
- const code = await beginOrcaCloudPkceFlow(configState.config, active.profile.id)
+ const code = await beginOrcaCloudPkceFlow(configState.config, active.profile.id, {
+ signal: browserWait.signal,
+ onAuthorizeUrl: (link) => setOrcaCloudSignInLink(attempt, link)
+ })
+ // Why: the callback listener closes once the browser answers, so the link is dead.
+ setOrcaCloudSignInLink(attempt, null)
if (attempt < linkedCloudConnectAttempt) {
return {
status: 'cancelled',
@@ -112,7 +121,17 @@ export async function connectCurrentOrcaProfile(
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
- if (isUserCancelledAuthError(message)) {
+ if (message === 'orca_cloud_auth_timeout' && attempt === nextCloudConnectAttempt) {
+ return {
+ status: 'timed-out',
+ auth: getCurrentOrcaProfileAuthStatus(userDataPath)
+ }
+ }
+ if (
+ message === 'orca_cloud_auth_timeout' ||
+ message === 'orca_cloud_auth_denied' ||
+ message === 'orca_cloud_auth_cancelled'
+ ) {
return {
status: 'cancelled',
auth: getCurrentOrcaProfileAuthStatus(userDataPath)
@@ -123,6 +142,8 @@ export async function connectCurrentOrcaProfile(
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
error: message
}
+ } finally {
+ endOrcaCloudSignInProgress(attempt)
}
}
diff --git a/src/main/orca-profiles/profile-cloud-sign-in-progress.test.ts b/src/main/orca-profiles/profile-cloud-sign-in-progress.test.ts
new file mode 100644
index 00000000000..840bfe4330c
--- /dev/null
+++ b/src/main/orca-profiles/profile-cloud-sign-in-progress.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+const { beginOrcaCloudPkceFlowMock, exchangeOrcaCloudAuthCodeMock, safeStorageMock } = vi.hoisted(
+ () => ({
+ beginOrcaCloudPkceFlowMock: vi.fn(),
+ exchangeOrcaCloudAuthCodeMock: vi.fn(),
+ safeStorageMock: {
+ decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
+ encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
+ isEncryptionAvailable: vi.fn(() => true)
+ }
+ })
+)
+
+let userDataPath = ''
+
+vi.mock('electron', () => ({
+ app: {
+ getPath: () => userDataPath
+ },
+ safeStorage: safeStorageMock
+}))
+
+vi.mock('./profile-cloud-pkce', () => ({
+ beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
+}))
+
+vi.mock('./profile-cloud-client', () => ({
+ createOrcaCloudProfile: vi.fn(),
+ exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock,
+ revokeOrcaCloudSession: vi.fn().mockResolvedValue(undefined),
+ selectOrcaCloudOrg: vi.fn()
+}))
+
+import { connectCurrentOrcaProfile, signOutCurrentOrcaProfile } from './profile-cloud-service'
+import {
+ cancelOrcaCloudSignIn,
+ getOrcaCloudSignInProgress,
+ onOrcaCloudSignInProgressChanged
+} from './profile-cloud-sign-in-progress'
+
+type BrowserWait = {
+ link: string
+ publish: () => void
+ answer: (code: string) => void
+ fail: (error: Error) => void
+}
+
+function queueBrowserWaits(): BrowserWait[] {
+ const waits: BrowserWait[] = []
+ beginOrcaCloudPkceFlowMock.mockImplementation(
+ (
+ _config,
+ _profileId,
+ options: { onAuthorizeUrl: (link: string) => void; signal: AbortSignal }
+ ) =>
+ new Promise((resolve, reject) => {
+ const link = `https://orca-cloud.example/authorize?attempt=${waits.length + 1}`
+ options.signal.addEventListener('abort', () =>
+ reject(new Error('orca_cloud_auth_cancelled'))
+ )
+ waits.push({
+ link,
+ publish: () => options.onAuthorizeUrl(link),
+ answer: (code) =>
+ resolve({
+ code,
+ codeVerifier: 'verifier',
+ nonce: 'nonce',
+ redirectUri: 'http://127.0.0.1:4100/auth/callback',
+ state: 'state'
+ }),
+ fail: reject
+ })
+ })
+ )
+ return waits
+}
+
+describe('Orca cloud sign-in progress', () => {
+ beforeEach(() => {
+ userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-sign-in-progress-'))
+ beginOrcaCloudPkceFlowMock.mockReset()
+ exchangeOrcaCloudAuthCodeMock.mockReset()
+ vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example')
+ vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client')
+ })
+
+ afterEach(async () => {
+ // Why: progress lives in module state that outlives a test.
+ await signOutCurrentOrcaProfile(userDataPath)
+ rmSync(userDataPath, { recursive: true, force: true })
+ vi.unstubAllEnvs()
+ })
+
+ it('announces the newest wait and ends it when the wait times out', async () => {
+ const changes: unknown[] = []
+ const unsubscribe = onOrcaCloudSignInProgressChanged((progress) => changes.push(progress))
+ const waits = queueBrowserWaits()
+ const first = connectCurrentOrcaProfile(userDataPath)
+ const second = connectCurrentOrcaProfile(userDataPath)
+ await vi.waitFor(() => expect(waits).toHaveLength(2))
+ waits[1].publish()
+
+ waits[0].fail(new Error('orca_cloud_auth_timeout'))
+ await expect(first).resolves.toMatchObject({ status: 'cancelled' })
+ expect(getOrcaCloudSignInProgress()).toEqual({ link: waits[1].link })
+
+ waits[1].fail(new Error('orca_cloud_auth_timeout'))
+ await expect(second).resolves.toMatchObject({ status: 'timed-out' })
+ expect(changes).toEqual([{ link: null }, { link: null }, { link: waits[1].link }, null])
+ unsubscribe()
+ })
+
+ it('keeps the newest link when an older attempt reports its link late and then ends', async () => {
+ const waits = queueBrowserWaits()
+ const older = connectCurrentOrcaProfile(userDataPath)
+ void connectCurrentOrcaProfile(userDataPath)
+ await vi.waitFor(() => expect(waits).toHaveLength(2))
+
+ waits[1].publish()
+ waits[0].publish()
+ expect(getOrcaCloudSignInProgress()).toEqual({ link: waits[1].link })
+
+ waits[0].fail(new Error('orca_cloud_auth_denied'))
+ await expect(older).resolves.toMatchObject({ status: 'cancelled' })
+ expect(getOrcaCloudSignInProgress()).toEqual({ link: waits[1].link })
+ })
+
+ it('drops the link once the browser answers but stays in progress until sign-in ends', async () => {
+ const waits = queueBrowserWaits()
+ let failExchange!: (error: Error) => void
+ exchangeOrcaCloudAuthCodeMock.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ failExchange = reject
+ })
+ )
+ const attempt = connectCurrentOrcaProfile(userDataPath)
+ await vi.waitFor(() => expect(waits).toHaveLength(1))
+ waits[0].publish()
+
+ waits[0].answer('code')
+ await vi.waitFor(() => expect(exchangeOrcaCloudAuthCodeMock).toHaveBeenCalledOnce())
+ expect(getOrcaCloudSignInProgress()).toEqual({ link: null })
+
+ failExchange(new Error('exchange_failed'))
+ await expect(attempt).resolves.toMatchObject({ status: 'failed' })
+ expect(getOrcaCloudSignInProgress()).toBeNull()
+ })
+
+ it('cancels the browser wait silently', async () => {
+ const waits = queueBrowserWaits()
+ const attempt = connectCurrentOrcaProfile(userDataPath)
+ await vi.waitFor(() => expect(waits).toHaveLength(1))
+ waits[0].publish()
+
+ cancelOrcaCloudSignIn()
+
+ await expect(attempt).resolves.toMatchObject({ status: 'cancelled' })
+ expect(getOrcaCloudSignInProgress()).toBeNull()
+ })
+
+ it('ends the progress on sign-out', async () => {
+ const waits = queueBrowserWaits()
+ const attempt = connectCurrentOrcaProfile(userDataPath)
+ await vi.waitFor(() => expect(waits).toHaveLength(1))
+ waits[0].publish()
+
+ await signOutCurrentOrcaProfile(userDataPath)
+ expect(getOrcaCloudSignInProgress()).toBeNull()
+ waits[0].fail(new Error('orca_cloud_auth_timeout'))
+ await expect(attempt).resolves.toMatchObject({ status: 'cancelled' })
+ })
+})
diff --git a/src/main/orca-profiles/profile-cloud-sign-in-progress.ts b/src/main/orca-profiles/profile-cloud-sign-in-progress.ts
new file mode 100644
index 00000000000..235fab7be93
--- /dev/null
+++ b/src/main/orca-profiles/profile-cloud-sign-in-progress.ts
@@ -0,0 +1,54 @@
+import type { OrcaCloudSignInProgress } from '../../shared/orca-profiles'
+
+type ProgressListener = (progress: OrcaCloudSignInProgress | null) => void
+
+// Why one record: the newest attempt owns the progress, so an older attempt's late calls are no-ops.
+let current: { attempt: number; link: string | null; cancel: () => void } | null = null
+const listeners = new Set()
+
+function snapshot(): OrcaCloudSignInProgress | null {
+ return current ? { link: current.link } : null
+}
+
+function update(next: typeof current): void {
+ current = next
+ const progress = snapshot()
+ for (const listener of listeners) {
+ try {
+ listener(progress)
+ } catch (error) {
+ console.warn('[orca-profiles] Sign-in progress listener failed:', error)
+ }
+ }
+}
+
+export function getOrcaCloudSignInProgress(): OrcaCloudSignInProgress | null {
+ return snapshot()
+}
+
+export function onOrcaCloudSignInProgressChanged(listener: ProgressListener): () => void {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+}
+
+export function startOrcaCloudSignInProgress(attempt: number, cancel: () => void): void {
+ update({ attempt, link: null, cancel })
+}
+
+export function setOrcaCloudSignInLink(attempt: number, link: string | null): void {
+ if (current?.attempt === attempt && current.link !== link) {
+ update({ ...current, link })
+ }
+}
+
+export function endOrcaCloudSignInProgress(attempt?: number): void {
+ if (current && (attempt === undefined || current.attempt === attempt)) {
+ update(null)
+ }
+}
+
+export function cancelOrcaCloudSignIn(): void {
+ current?.cancel()
+}
diff --git a/src/preload/api/orca-profile-api.ts b/src/preload/api/orca-profile-api.ts
index 80e9f08fc8d..33b93f952d4 100644
--- a/src/preload/api/orca-profile-api.ts
+++ b/src/preload/api/orca-profile-api.ts
@@ -1,4 +1,5 @@
import type {
+ OrcaCloudSignInProgress,
ConnectCurrentOrcaProfileResult,
CreateCloudLinkedOrcaProfileArgs,
CreateCloudLinkedOrcaProfileResult,
@@ -42,6 +43,12 @@ export type OrcaProfileApi = {
args: FindOrcaProfileProjectsByPathArgs
) => Promise
connectCurrent: () => Promise
+ signInProgress: () => Promise
+ onSignInProgressChanged: (
+ callback: (progress: OrcaCloudSignInProgress | null) => void
+ ) => () => void
+ /** Stops waiting on the browser; the sign-in resolves as cancelled. */
+ cancelSignIn: () => Promise
refreshAuth: () => Promise
signOutCurrent: () => Promise
selectOrg: (args: SelectOrcaProfileOrgArgs) => Promise
diff --git a/src/preload/api/orca-profiles-bridge.ts b/src/preload/api/orca-profiles-bridge.ts
index 201b6be9799..f144c9d7697 100644
--- a/src/preload/api/orca-profiles-bridge.ts
+++ b/src/preload/api/orca-profiles-bridge.ts
@@ -2,6 +2,8 @@ import { ipcRenderer } from 'electron'
import type { PreloadApi } from '../api-types'
import {
ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL,
+ ORCA_PROFILE_SIGN_IN_PROGRESS_CHANGED_CHANNEL,
+ type OrcaCloudSignInProgress,
type OrcaProfileListResult,
type SwitchOrcaProfileResult,
type TransferOrcaProfileProjectResult
@@ -17,6 +19,14 @@ export const orcaProfilesApi = {
ipcRenderer.on(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL, listener)
return () => ipcRenderer.removeListener(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL, listener)
},
+ onSignInProgressChanged: (
+ callback: (progress: OrcaCloudSignInProgress | null) => void
+ ): (() => void) => {
+ const listener = (_event: unknown, progress: OrcaCloudSignInProgress | null): void =>
+ callback(progress)
+ ipcRenderer.on(ORCA_PROFILE_SIGN_IN_PROGRESS_CHANGED_CHANNEL, listener)
+ return () => ipcRenderer.removeListener(ORCA_PROFILE_SIGN_IN_PROGRESS_CHANGED_CHANNEL, listener)
+ },
createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args),
createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args),
switchProfile: (args) =>
@@ -45,6 +55,8 @@ export const orcaProfilesApi = {
},
findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args),
connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'),
+ signInProgress: () => ipcRenderer.invoke('orcaProfiles:signInProgress'),
+ cancelSignIn: () => ipcRenderer.invoke('orcaProfiles:cancelSignIn'),
refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'),
signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'),
selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args),
diff --git a/src/renderer/src/components/OrcaSignInProgress.test.tsx b/src/renderer/src/components/OrcaSignInProgress.test.tsx
new file mode 100644
index 00000000000..0eb52fd1c31
--- /dev/null
+++ b/src/renderer/src/components/OrcaSignInProgress.test.tsx
@@ -0,0 +1,106 @@
+// @vitest-environment happy-dom
+
+import '@testing-library/jest-dom/vitest'
+
+import { cleanup, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+import type { OrcaCloudSignInProgress } from '../../../shared/orca-profiles'
+import { OrcaSignInButton, OrcaSignInWaitNotice } from './OrcaSignInProgress'
+
+type SignInState = {
+ orcaProfileSignInProgress: OrcaCloudSignInProgress | null
+ connectCurrentOrcaProfile: Mock
+}
+
+const mocks = vi.hoisted((): { state: SignInState; toastError: Mock; toastSuccess: Mock } => ({
+ state: { orcaProfileSignInProgress: null, connectCurrentOrcaProfile: vi.fn() },
+ toastError: vi.fn(),
+ toastSuccess: vi.fn()
+}))
+
+vi.mock('../store', () => ({
+ useAppStore: (selector: (state: SignInState) => unknown) => selector(mocks.state)
+}))
+
+vi.mock('sonner', () => ({
+ toast: { error: mocks.toastError, success: mocks.toastSuccess }
+}))
+
+vi.mock('@/i18n/i18n', () => ({
+ translate: (_key: string, fallback: string) => fallback
+}))
+
+const LINK = 'https://login.example/authorize?state=abc'
+
+describe('Orca sign-in progress', () => {
+ const writeClipboardText = vi.fn()
+ const cancelSignIn = vi.fn()
+
+ beforeEach(() => {
+ mocks.state.orcaProfileSignInProgress = null
+ mocks.state.connectCurrentOrcaProfile.mockReset()
+ mocks.toastError.mockReset()
+ mocks.toastSuccess.mockReset()
+ writeClipboardText.mockReset().mockResolvedValue(undefined)
+ cancelSignIn.mockReset().mockResolvedValue(undefined)
+ Object.defineProperty(window, 'api', {
+ configurable: true,
+ value: { ui: { writeClipboardText }, orcaProfiles: { cancelSignIn } }
+ })
+ })
+
+ afterEach(() => cleanup())
+
+ it('starts a sign-in from the button', async () => {
+ render(Sign in to Orca)
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Sign in to Orca' }))
+
+ expect(mocks.state.connectCurrentOrcaProfile).toHaveBeenCalledOnce()
+ })
+
+ it('greys the button out as Signing in… while a sign-in is in flight', () => {
+ mocks.state.orcaProfileSignInProgress = { link: null }
+ render(Sign in to Orca)
+
+ expect(screen.getByRole('button', { name: 'Signing in…' })).toBeDisabled()
+ })
+
+ it('shows the waiting line only while the browser has not answered', () => {
+ mocks.state.orcaProfileSignInProgress = { link: null }
+ const { container } = render()
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('copies the waiting link', async () => {
+ mocks.state.orcaProfileSignInProgress = { link: LINK }
+ render()
+ expect(screen.getByRole('status')).toHaveTextContent('Waiting for sign-in in your browser')
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Copy sign-in link' }))
+
+ expect(writeClipboardText).toHaveBeenCalledWith(LINK)
+ expect(mocks.toastSuccess).toHaveBeenCalledWith('Sign-in link copied')
+ })
+
+ it('reports a failed clipboard write', async () => {
+ mocks.state.orcaProfileSignInProgress = { link: LINK }
+ writeClipboardText.mockRejectedValue(new Error('denied'))
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ render()
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Copy sign-in link' }))
+
+ expect(mocks.toastError).toHaveBeenCalledWith('Could not copy the link.')
+ })
+
+ it('cancels the browser wait', async () => {
+ mocks.state.orcaProfileSignInProgress = { link: LINK }
+ render()
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(cancelSignIn).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/renderer/src/components/OrcaSignInProgress.tsx b/src/renderer/src/components/OrcaSignInProgress.tsx
new file mode 100644
index 00000000000..9ab14add41f
--- /dev/null
+++ b/src/renderer/src/components/OrcaSignInProgress.tsx
@@ -0,0 +1,85 @@
+import { Loader2 } from 'lucide-react'
+import { toast } from 'sonner'
+import { Button } from './ui/button'
+import { translate } from '@/i18n/i18n'
+import { cn } from '@/lib/utils'
+import { useAppStore } from '../store'
+
+async function copySignInLink(link: string): Promise {
+ try {
+ await window.api.ui.writeClipboardText(link)
+ toast.success(translate('auto.components.OrcaSignInProgress.copied', 'Sign-in link copied'))
+ } catch (error) {
+ console.warn('Could not copy the Orca sign-in link:', error)
+ toast.error(
+ translate('auto.components.OrcaSignInProgress.copyFailed', 'Could not copy the link.')
+ )
+ }
+}
+
+/** Starts an Orca sign-in, and shows that one is in flight until it ends. */
+export function OrcaSignInButton({
+ children,
+ disabled,
+ ...props
+}: Omit, 'onClick' | 'type'>): React.JSX.Element {
+ const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
+ const signingIn = useAppStore((state) => Boolean(state.orcaProfileSignInProgress))
+ return (
+
+ )
+}
+
+export function OrcaSignInWaitNotice({
+ className
+}: {
+ className?: string
+}): React.JSX.Element | null {
+ const link = useAppStore((state) => state.orcaProfileSignInProgress?.link)
+ if (!link) {
+ return null
+ }
+ return (
+
+
+ {translate(
+ 'auto.components.OrcaSignInProgress.waiting',
+ 'Waiting for sign-in in your browser'
+ )}
+
+ ·
+
+ ·
+
+
+ )
+}
diff --git a/src/renderer/src/components/UnexpectedSignoutCard.tsx b/src/renderer/src/components/UnexpectedSignoutCard.tsx
index 40035a8da92..317059b0c4c 100644
--- a/src/renderer/src/components/UnexpectedSignoutCard.tsx
+++ b/src/renderer/src/components/UnexpectedSignoutCard.tsx
@@ -7,6 +7,7 @@ import { Button } from './ui/button'
import { Card } from './ui/card'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'
import { shouldShowUnexpectedSignoutCard } from './unexpected-signout/unexpected-signout-visibility'
+import { OrcaSignInButton, OrcaSignInWaitNotice } from './OrcaSignInProgress'
function readPreviewFlag(): boolean {
if (!import.meta.env.DEV) {
@@ -48,7 +49,6 @@ export function UnexpectedSignoutCard(): React.JSX.Element | null {
const persistedDismissedVersion = useAppStore((s) => s.dismissedUnexpectedSignoutVersion)
const dismissedVersions = useAppStore((s) => s.unexpectedSignoutDismissedVersions)
const dismissForVersion = useAppStore((s) => s.dismissUnexpectedSignoutCard)
- const connect = useAppStore((s) => s.connectCurrentOrcaProfile)
const [appVersion, setAppVersion] = useState(null)
const [authRefreshReady, setAuthRefreshReady] = useState(false)
const [expanded, setExpanded] = useState(false)
@@ -238,16 +238,11 @@ export function UnexpectedSignoutCard(): React.JSX.Element | null {
-
+
+
diff --git a/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx
index 8bb2a49218a..094344fdb16 100644
--- a/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactPublishButton.tsx
@@ -10,6 +10,7 @@ import { useAppStore } from '@/store'
import { ArtifactPublishedLinkPanel } from './ArtifactPublishedLinkPanel'
import { getPublishedArtifactLink } from './artifact-published-link-client'
import { publishArtifactFromSurface } from './artifact-publish-flow'
+import { OrcaSignInButton, OrcaSignInWaitNotice } from '../OrcaSignInProgress'
type PublishedLinkLookup = {
key: string
@@ -52,7 +53,6 @@ export function ArtifactPublishButton({
const lookupSequence = useRef(0)
const popoverContentRef = useRef(null)
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
- const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const settings = useAppStore((state) => state.settings)
@@ -210,12 +210,10 @@ export function ArtifactPublishButton({
)}
-
+
) : null}
+ {!signedIn ? : null}
{!sharingEnabled ? (
diff --git a/src/renderer/src/components/artifacts/ArtifactsPage.tsx b/src/renderer/src/components/artifacts/ArtifactsPage.tsx
index 3c12e06fe4f..4b1f26b21cb 100644
--- a/src/renderer/src/components/artifacts/ArtifactsPage.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactsPage.tsx
@@ -20,7 +20,6 @@ const LOCAL_RUNTIME = { kind: 'local' } as const
export default function ArtifactsPage(): React.JSX.Element {
const closePage = useAppStore((state) => state.closeArtifactsPage)
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
- const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const refreshAuth = useAppStore((state) => state.refreshCurrentOrcaProfileAuth)
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
@@ -193,7 +192,6 @@ export default function ArtifactsPage(): React.JSX.Element {
void connect()}
onOpenAccountSettings={openAccountSettings}
/>
) : loading && artifacts.length === 0 ? (
diff --git a/src/renderer/src/components/artifacts/ArtifactsPageStates.tsx b/src/renderer/src/components/artifacts/ArtifactsPageStates.tsx
index 5974005b2ac..ab34d7f7eef 100644
--- a/src/renderer/src/components/artifacts/ArtifactsPageStates.tsx
+++ b/src/renderer/src/components/artifacts/ArtifactsPageStates.tsx
@@ -1,6 +1,7 @@
import { ArrowRight, Files, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
+import { OrcaSignInButton, OrcaSignInWaitNotice } from '../OrcaSignInProgress'
export function ArtifactsPageErrorBanner({
error,
@@ -24,12 +25,10 @@ export function ArtifactsPageErrorBanner({
export function ArtifactsPageAuthState({
needsReconnect,
configured,
- onConnect,
onOpenAccountSettings
}: {
needsReconnect: boolean
configured: boolean
- onConnect: () => void
onOpenAccountSettings: () => void
}): React.JSX.Element {
return (
@@ -60,14 +59,17 @@ export function ArtifactsPageAuthState({
{configured ? (
-
+ <>
+
+ {needsReconnect
+ ? translate(
+ 'auto.components.artifacts.ArtifactsPage.signInAgainAction',
+ 'Sign in again'
+ )
+ : translate('auto.components.artifacts.ArtifactsPage.signIn', 'Sign in to Orca')}
+
+
+ >
) : (
diff --git a/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx b/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx
index a90906a0759..0b218d3355e 100644
--- a/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx
+++ b/src/renderer/src/components/settings/ArtifactsSettingsPane.tsx
@@ -6,6 +6,7 @@ import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-s
import { useAppStore } from '@/store'
import { isWebClientLocation } from '@/lib/web-client-location'
import { translate } from '@/i18n/i18n'
+import { OrcaSignInButton, OrcaSignInWaitNotice } from '../OrcaSignInProgress'
type HowToStep = { key: string; title: string; description: string }
@@ -18,7 +19,6 @@ export function ArtifactsSettingsPane({
}): React.JSX.Element {
const openArtifactsPage = useAppStore((state) => state.openArtifactsPage)
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
- const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
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.
@@ -124,16 +124,12 @@ export function ArtifactsSettingsPane({
)}
-
+
+
) : null}
diff --git a/src/renderer/src/components/settings/DevToolsPane.tsx b/src/renderer/src/components/settings/DevToolsPane.tsx
index c2631c5423f..176c67cc1f1 100644
--- a/src/renderer/src/components/settings/DevToolsPane.tsx
+++ b/src/renderer/src/components/settings/DevToolsPane.tsx
@@ -9,6 +9,7 @@ import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-s
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
+import { OrcaSignInButton, OrcaSignInWaitNotice } from '../OrcaSignInProgress'
const LONG_WORKSPACE_NAME = 'feature/dev-toast-layout-with-a-long-workspace-name'
const DEV_TOAST_DESCRIPTION =
@@ -139,7 +140,6 @@ function showDeleteFailureToast(): void {
// progress; this surfaces it (and its status) in dev when the env vars are set.
function OrcaCloudDevSubsection(): React.JSX.Element {
const authStatus = useAppStore((s) => s.orcaProfileAuthStatus)
- const connect = useAppStore((s) => s.connectCurrentOrcaProfile)
const signOut = useAppStore((s) => s.signOutCurrentOrcaProfile)
const refresh = useAppStore((s) => s.fetchOrcaProfileAuthStatus)
const configured = authStatus?.configured === true
@@ -173,13 +173,14 @@ function OrcaCloudDevSubsection(): React.JSX.Element {
{translate('auto.components.settings.DevToolsPane.orcaCloudSignOut', 'Sign out')}
) : (
-
+
)}
+ {connected ? null : }
) : (
- void connect()}>
+
{authStatus?.state === 'reconnect-required'
? translate('auto.components.settings.orcaAccount.signInAgain', 'Sign in again')
: translate('auto.components.settings.orcaAccount.signIn', 'Sign in to Orca')}
-
+
)}
+ {connected ? null : }
diff --git a/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx b/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx
index 54ce5edd14a..0ecd181c396 100644
--- a/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx
+++ b/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx
@@ -5,6 +5,7 @@ import { translate } from '@/i18n/i18n'
import { isWebClientLocation } from '@/lib/web-client-location'
import { useAppStore } from '@/store'
import { SettingsSwitchRow } from './SettingsFormControls'
+import { OrcaSignInButton, OrcaSignInWaitNotice } from '../OrcaSignInProgress'
type HowToStep = { key: string; title: string; description: string }
@@ -14,7 +15,6 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
const settings = useAppStore((state) => state.settings)
const updateSettings = useAppStore((state) => state.updateSettings)
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
- const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const signedIn = authStatus?.state === 'connected'
const isWebClient = isWebClientLocation()
const agentSharingEnabled = settings?.agentSkillSharingEnabled === true
@@ -131,17 +131,13 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
{!isWebClient ? (
- void connect()}
- >
+
{authStatus?.state === 'reconnect-required'
? translate('auto.components.settings.shareSkills.signInAgain', 'Sign in again')
: translate('auto.components.settings.shareSkills.signIn', 'Sign in to Orca')}
-
+
) : null}
+ {!isWebClient ? : null}
) : null}
diff --git a/src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts b/src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts
index 593ace5336d..a236c41dcec 100644
--- a/src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts
+++ b/src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.test.ts
@@ -1,13 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
+import type {
+ OrcaCloudSignInProgress,
+ OrcaProfileAuthStatus
+} from '../../../../shared/orca-profiles'
import { createTestStore } from '../../store/slices/store-test-helpers'
-const { storeHolder } = vi.hoisted(() => ({
- storeHolder: { current: null as { getState: () => unknown } | null }
+type TestStore = ReturnType
+
+const { storeHolder } = vi.hoisted((): { storeHolder: { current: TestStore | null } } => ({
+ storeHolder: { current: null }
}))
vi.mock('../../store', () => ({
- useAppStore: { getState: () => storeHolder.current?.getState() }
+ useAppStore: {
+ getState: () => storeHolder.current?.getState(),
+ setState: (partial: { orcaProfileSignInProgress: OrcaCloudSignInProgress | null }) =>
+ storeHolder.current?.setState(partial)
+ }
}))
vi.mock('sonner', () => ({
@@ -80,3 +89,62 @@ describe('orca profile auth IPC bridge', () => {
expect(unsubs).toHaveLength(0)
})
})
+
+describe('orca profile sign-in progress IPC bridge', () => {
+ let emitProgress: (progress: OrcaCloudSignInProgress | null) => void
+ const snapshots: ((progress: OrcaCloudSignInProgress | null) => void)[] = []
+ const WAITING = { link: 'https://login.example/authorize?state=a' }
+
+ beforeEach(() => {
+ snapshots.length = 0
+ storeHolder.current = createTestStore()
+ vi.stubGlobal('window', {
+ api: {
+ orcaProfiles: {
+ onSignInProgressChanged: (
+ callback: (progress: OrcaCloudSignInProgress | null) => void
+ ) => {
+ emitProgress = callback
+ return () => {}
+ },
+ signInProgress: () =>
+ new Promise((resolve) => {
+ snapshots.push(resolve)
+ })
+ }
+ }
+ })
+ })
+
+ const progress = (): OrcaCloudSignInProgress | null =>
+ storeHolder.current?.getState().orcaProfileSignInProgress ?? null
+
+ it('adopts a sign-in already in flight when the window loads', async () => {
+ registerOrcaProfileAuthIpcBridge([])
+ await vi.waitFor(() => expect(snapshots).toHaveLength(1))
+ snapshots[0](WAITING)
+ await vi.waitFor(() => expect(progress()).toEqual(WAITING))
+ })
+
+ it('lets a newer change win over a late snapshot', async () => {
+ registerOrcaProfileAuthIpcBridge([])
+ await vi.waitFor(() => expect(snapshots).toHaveLength(1))
+ emitProgress(null)
+ snapshots[0](WAITING)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(progress()).toBeNull()
+ })
+
+ it('ignores the snapshot of a registration torn down before it arrived', async () => {
+ const first: (() => void)[] = []
+ registerOrcaProfileAuthIpcBridge(first)
+ await vi.waitFor(() => expect(snapshots).toHaveLength(1))
+ first.forEach((dispose) => dispose())
+ registerOrcaProfileAuthIpcBridge([])
+ emitProgress(null)
+
+ snapshots[0](WAITING)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(progress()).toBeNull()
+ })
+})
diff --git a/src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.ts
index 95397b64a62..d3c135460c3 100644
--- a/src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.ts
+++ b/src/renderer/src/hooks/ipc-events/orca-profile-auth-ipc-bridge.ts
@@ -1,14 +1,33 @@
import { useAppStore } from '../../store'
-/** Re-reads auth status when main clears a revoked cloud session behind the renderer's back. */
+/** Keeps auth status and the in-flight sign-in in step with main. */
export function registerOrcaProfileAuthIpcBridge(unsubs: (() => void)[]): void {
- const subscribe = window.api.orcaProfiles?.onAuthStatusChanged
- if (typeof subscribe !== 'function') {
- return
+ const api = window.api.orcaProfiles
+ if (typeof api?.onAuthStatusChanged === 'function') {
+ unsubs.push(
+ api.onAuthStatusChanged(() => {
+ void useAppStore.getState().fetchOrcaProfileAuthStatus()
+ })
+ )
}
- unsubs.push(
- subscribe(() => {
- void useAppStore.getState().fetchOrcaProfileAuthStatus()
+ if (typeof api?.onSignInProgressChanged === 'function') {
+ // Why: a sign-in can already be in flight when this window loads; a later event or teardown wins.
+ let ignoreSnapshot = false
+ const unsubscribe = api.onSignInProgressChanged((progress) => {
+ ignoreSnapshot = true
+ useAppStore.setState({ orcaProfileSignInProgress: progress })
})
- )
+ unsubs.push(() => {
+ ignoreSnapshot = true
+ unsubscribe()
+ })
+ void Promise.resolve()
+ .then(() => api.signInProgress())
+ .then((progress) => {
+ if (!ignoreSnapshot) {
+ useAppStore.setState({ orcaProfileSignInProgress: progress ?? null })
+ }
+ })
+ .catch(() => {})
+ }
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index db19a13c562..793aebd2288 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -762,7 +762,8 @@
"76deec8f58": "Failed to switch organization",
"7d4bc516ee": "Failed to switch profile",
"f518e89aa5": "Project already exists in that profile",
- "f03ae7f27b": "Failed to transfer project"
+ "f03ae7f27b": "Failed to transfer project",
+ "signInTimedOut": "Sign-in timed out"
}
},
"runtime": {
@@ -18165,6 +18166,14 @@
"a62f9db53d": "Cancel",
"c77189bc0c": "Update filter",
"19af906d12": "Save filter"
+ },
+ "OrcaSignInProgress": {
+ "waiting": "Waiting for sign-in in your browser",
+ "copyLink": "Copy sign-in link",
+ "copied": "Sign-in link copied",
+ "copyFailed": "Could not copy the link.",
+ "signingIn": "Signing in…",
+ "cancel": "Cancel"
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 88f8b780700..3f41bf6e74e 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -581,7 +581,8 @@
"76deec8f58": "Error al cambiar organización",
"7d4bc516ee": "Error al cambiar perfil",
"f518e89aa5": "El proyecto ya existe en ese perfil",
- "f03ae7f27b": "Error al transferir proyecto"
+ "f03ae7f27b": "Error al transferir proyecto",
+ "signInTimedOut": "Se agotó el tiempo de inicio de sesión"
}
},
"runtime": {
@@ -17943,6 +17944,14 @@
"a62f9db53d": "Cancel",
"c77189bc0c": "Update filter",
"19af906d12": "Save filter"
+ },
+ "OrcaSignInProgress": {
+ "waiting": "Esperando el inicio de sesión en tu navegador",
+ "copyLink": "Copiar enlace de inicio de sesión",
+ "copied": "Enlace de inicio de sesión copiado",
+ "copyFailed": "No se pudo copiar el enlace.",
+ "signingIn": "Iniciando sesión…",
+ "cancel": "Cancelar"
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index a501c973fbb..3d995dad3b2 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -686,7 +686,8 @@
"76deec8f58": "Échec du changement d'organisation",
"7d4bc516ee": "Échec du changement de profil",
"f518e89aa5": "Le projet existe déjà dans ce profil",
- "f03ae7f27b": "Échec du transfert du projet"
+ "f03ae7f27b": "Échec du transfert du projet",
+ "signInTimedOut": "La connexion a expiré"
}
},
"runtime": {
@@ -18052,6 +18053,14 @@
"a62f9db53d": "Annuler",
"c77189bc0c": "Mettre à jour le filtre",
"19af906d12": "Enregistrer le filtre"
+ },
+ "OrcaSignInProgress": {
+ "waiting": "En attente de la connexion dans votre navigateur",
+ "copyLink": "Copier le lien de connexion",
+ "copied": "Lien de connexion copié",
+ "copyFailed": "Impossible de copier le lien.",
+ "signingIn": "Connexion…",
+ "cancel": "Annuler"
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 33a7758d01e..6e745f66aee 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -582,7 +582,8 @@
"76deec8f58": "組織の切り替えに失敗しました",
"7d4bc516ee": "プロファイルの切り替えに失敗しました",
"f518e89aa5": "プロジェクトはそのプロファイルに既に存在します",
- "f03ae7f27b": "プロジェクトの転送に失敗しました"
+ "f03ae7f27b": "プロジェクトの転送に失敗しました",
+ "signInTimedOut": "サインインがタイムアウトしました"
}
},
"runtime": {
@@ -17948,6 +17949,14 @@
"a62f9db53d": "Cancel",
"c77189bc0c": "Update filter",
"19af906d12": "Save filter"
+ },
+ "OrcaSignInProgress": {
+ "waiting": "ブラウザでのサインインを待っています",
+ "copyLink": "サインインリンクをコピー",
+ "copied": "サインインリンクをコピーしました",
+ "copyFailed": "リンクをコピーできませんでした。",
+ "signingIn": "サインイン中…",
+ "cancel": "キャンセル"
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index a573b8eea8b..145ea9af616 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -582,7 +582,8 @@
"76deec8f58": "조직을 전환하지 못했습니다",
"7d4bc516ee": "프로필을 전환하지 못했습니다",
"f518e89aa5": "프로젝트가 해당 프로필에 이미 있습니다",
- "f03ae7f27b": "프로젝트를 전송하지 못했습니다"
+ "f03ae7f27b": "프로젝트를 전송하지 못했습니다",
+ "signInTimedOut": "로그인 시간이 초과되었습니다"
}
},
"runtime": {
@@ -17948,6 +17949,14 @@
"a62f9db53d": "Cancel",
"c77189bc0c": "Update filter",
"19af906d12": "Save filter"
+ },
+ "OrcaSignInProgress": {
+ "waiting": "브라우저에서 로그인을 기다리는 중",
+ "copyLink": "로그인 링크 복사",
+ "copied": "로그인 링크를 복사했습니다",
+ "copyFailed": "링크를 복사할 수 없습니다.",
+ "signingIn": "로그인 중…",
+ "cancel": "취소"
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 55209715352..cb288e0f4c0 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -582,7 +582,8 @@
"76deec8f58": "切换组织失败",
"7d4bc516ee": "切换配置文件失败",
"f518e89aa5": "该项目已存在于那个配置文件中",
- "f03ae7f27b": "转移项目失败"
+ "f03ae7f27b": "转移项目失败",
+ "signInTimedOut": "登录超时"
}
},
"runtime": {
@@ -17948,6 +17949,14 @@
"a62f9db53d": "Cancel",
"c77189bc0c": "Update filter",
"19af906d12": "Save filter"
+ },
+ "OrcaSignInProgress": {
+ "waiting": "正在等待在浏览器中登录",
+ "copyLink": "复制登录链接",
+ "copied": "已复制登录链接",
+ "copyFailed": "无法复制链接。",
+ "signingIn": "正在登录…",
+ "cancel": "取消"
}
},
"i18n": {
diff --git a/src/renderer/src/store/slices/orca-profiles-auth-actions-sign-in-wait.test.ts b/src/renderer/src/store/slices/orca-profiles-auth-actions-sign-in-wait.test.ts
new file mode 100644
index 00000000000..e85c2f1a16d
--- /dev/null
+++ b/src/renderer/src/store/slices/orca-profiles-auth-actions-sign-in-wait.test.ts
@@ -0,0 +1,75 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type {
+ ConnectCurrentOrcaProfileResult,
+ OrcaProfileAuthStatus
+} from '../../../../shared/orca-profiles'
+import { createTestStore } from './store-test-helpers'
+
+const { toastErrorMock } = vi.hoisted(() => ({
+ toastErrorMock: vi.fn()
+}))
+
+vi.mock('sonner', () => ({
+ toast: {
+ error: toastErrorMock,
+ info: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn()
+ }
+}))
+
+const localAuth: OrcaProfileAuthStatus = {
+ activeProfileId: 'local-default',
+ configured: true,
+ state: 'local',
+ persistence: 'none'
+}
+
+const orcaProfilesApi = {
+ connectCurrent: vi.fn()
+}
+
+function deferredConnect(): (result: ConnectCurrentOrcaProfileResult) => void {
+ let finish!: (result: ConnectCurrentOrcaProfileResult) => void
+ orcaProfilesApi.connectCurrent.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finish = resolve
+ })
+ )
+ return (result) => finish(result)
+}
+
+describe('orca profile sign-in timeout', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ vi.stubGlobal('window', {
+ api: { orcaProfiles: orcaProfilesApi }
+ })
+ })
+
+ it('reports a timeout only for the newest wait', async () => {
+ const finishFirst = deferredConnect()
+ const finishSecond = deferredConnect()
+ const store = createTestStore()
+
+ const first = store.getState().connectCurrentOrcaProfile()
+ const second = store.getState().connectCurrentOrcaProfile()
+ finishFirst({ status: 'cancelled', auth: localAuth })
+ await first
+ expect(toastErrorMock).not.toHaveBeenCalled()
+
+ finishSecond({ status: 'timed-out', auth: localAuth })
+ await second
+ expect(toastErrorMock).toHaveBeenCalledOnce()
+ expect(toastErrorMock).toHaveBeenCalledWith('Sign-in timed out')
+ })
+
+ it('stays silent when the user cancels in the browser', async () => {
+ orcaProfilesApi.connectCurrent.mockResolvedValueOnce({ status: 'cancelled', auth: localAuth })
+ const store = createTestStore()
+
+ await store.getState().connectCurrentOrcaProfile()
+
+ expect(toastErrorMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/store/slices/orca-profiles-auth-actions.ts b/src/renderer/src/store/slices/orca-profiles-auth-actions.ts
index 7abd2dfcc61..f66fb4bf71f 100644
--- a/src/renderer/src/store/slices/orca-profiles-auth-actions.ts
+++ b/src/renderer/src/store/slices/orca-profiles-auth-actions.ts
@@ -113,6 +113,10 @@ export const createOrcaProfilesAuthActions: StateCreator<
description: result.auth.setupMessage
}
)
+ } else if (result.status === 'timed-out' && !alreadyConnected) {
+ toast.error(
+ translate('auto.store.slices.orca.profiles.signInTimedOut', 'Sign-in timed out')
+ )
} else if (
result.status === 'failed' &&
!alreadyConnected &&
diff --git a/src/renderer/src/store/slices/orca-profiles.ts b/src/renderer/src/store/slices/orca-profiles.ts
index e406938d0ac..ef4fff66a68 100644
--- a/src/renderer/src/store/slices/orca-profiles.ts
+++ b/src/renderer/src/store/slices/orca-profiles.ts
@@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type {
+ OrcaCloudSignInProgress,
OrcaProfileAuthStatus,
OrcaProfileSummary,
SwitchOrcaProfileResult,
@@ -18,6 +19,7 @@ export type OrcaProfilesSlice = OrcaProfilesAuthActions & {
orcaProfiles: OrcaProfileSummary[]
activeOrcaProfileId: string | null
orcaProfileAuthStatus: OrcaProfileAuthStatus | null
+ orcaProfileSignInProgress: OrcaCloudSignInProgress | null
orcaProfilesMultiProfileUi: boolean
orcaProfilesLoading: boolean
orcaProfileSwitching: boolean
@@ -38,6 +40,7 @@ export const createOrcaProfilesSlice: StateCreator {
}),
authStatus: webOrcaProfileAuthStatus,
onAuthStatusChanged: () => noopUnsubscribe,
+ onSignInProgressChanged: () => noopUnsubscribe,
createLocal: () =>
Promise.resolve({
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
@@ -48,6 +49,8 @@ export function createWebOrcaProfilesApi(): Partial {
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
}),
+ signInProgress: async () => null,
+ cancelSignIn: async () => {},
refreshAuth: async () => ({
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
diff --git a/src/shared/orca-profiles.ts b/src/shared/orca-profiles.ts
index 298cfc28312..b1139a380b9 100644
--- a/src/shared/orca-profiles.ts
+++ b/src/shared/orca-profiles.ts
@@ -6,6 +6,7 @@ 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'
+export const ORCA_PROFILE_SIGN_IN_PROGRESS_CHANGED_CHANNEL = 'orcaProfiles:signInProgressChanged'
const LEGACY_ORCA_BROWSER_SESSION_PARTITION_PREFIX = 'persist:orca-browser-session-'
export type OrcaProfileAvatar = {
@@ -151,6 +152,9 @@ export type TransferOrcaProfileProjectResult =
duplicateRepoId: string
}
+/** A sign-in in flight; `link` is set only while the browser has not answered yet. */
+export type OrcaCloudSignInProgress = { link: string | null }
+
export type ConnectCurrentOrcaProfileResult =
| {
status: 'connected'
@@ -166,6 +170,10 @@ export type ConnectCurrentOrcaProfileResult =
status: 'cancelled'
auth: OrcaProfileAuthStatus
}
+ | {
+ status: 'timed-out'
+ auth: OrcaProfileAuthStatus
+ }
| {
status: 'failed'
auth: OrcaProfileAuthStatus