From c4730d692fa2d71fd52a751112bc09ed0c261cfc Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Sun, 18 Oct 2026 19:28:04 -0400
Subject: [PATCH] fix(cloud-auth): show that a sign-in is in progress, with
copy-link and cancel, on every Orca sign-in button (#30410)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(cloud-auth): show the browser sign-in wait and offer its link from Relay sign-in
Clicking 'Sign in for Relay' handed the sign-in page to the OS browser and
then showed nothing, so a browser that never came forward looked like a dead
button, and the 5-minute wait ended silently. The Relay sign-in row now shows
the wait with a Copy sign-in link button, and a wait that times out toasts.
STA-10940
* fix(cloud-auth): show the sign-in wait on every Orca sign-in button
The waiting text and Copy sign-in link now come from one shared notice used by
Mobile Relay, Orca Account, Artifacts (settings, page and publish popover),
Share Skills, the unexpected sign-out card and dev tools. The copyable link is
dropped as soon as the browser answers, and a copy with no waiting link
reports a failure instead of doing nothing.
STA-10940
* refactor(cloud-auth): main owns the waiting sign-in link; renderer only mirrors it
The notice now shows exactly while main holds a live link: main announces the
link when the browser wait starts and clears it the moment the browser
answers, so 'Waiting for sign-in in your browser' no longer outlives the wait
during the token exchange. Only the newest attempt may publish its link, so an
older attempt reporting late cannot replace or later wipe it. The renderer's
separate pending flag and its resets are gone.
STA-10940
* fix(cloud-auth): tolerate a sign-in link read that returns no promise
The app-wide IPC tests stub every bridge method as returning nothing, and the
one-time link read called .then on that result.
STA-10940
* fix(cloud-auth): a torn-down link subscription ignores its late snapshot; restore auth bridge tests
A window's one-time sign-in link read could land after its subscription was
disposed and overwrite a newer value from the replacement subscription. The
auth-status refresh tests the earlier rewrite dropped are back, alongside the
link cases.
STA-10940
* feat(cloud-auth): sign-in buttons show Signing in… with copy-link and cancel links
Every Orca sign-in button greys out to 'Signing in…' with a spinner until the
sign-in ends. While the browser has not answered, a line offers 'Copy sign-in
link' and 'Cancel'; Cancel aborts the browser wait so a lost tab never locks
the button. Main owns the in-flight sign-in (waiting on the browser, then
finishing) and every window mirrors it.
STA-10940
---
.../ipc/orca-profile-auth-status-broadcast.ts | 20 +-
src/main/ipc/orca-profiles.ts | 18 +-
.../orca-profiles/profile-cloud-pkce.test.ts | 28 +++
src/main/orca-profiles/profile-cloud-pkce.ts | 11 +-
.../orca-profiles/profile-cloud-service.ts | 33 +++-
.../profile-cloud-sign-in-progress.test.ts | 177 ++++++++++++++++++
.../profile-cloud-sign-in-progress.ts | 54 ++++++
src/preload/api/orca-profile-api.ts | 7 +
src/preload/api/orca-profiles-bridge.ts | 12 ++
.../components/OrcaSignInProgress.test.tsx | 106 +++++++++++
.../src/components/OrcaSignInProgress.tsx | 85 +++++++++
.../src/components/UnexpectedSignoutCard.tsx | 13 +-
.../artifacts/ArtifactPublishButton.tsx | 9 +-
.../components/artifacts/ArtifactsPage.tsx | 2 -
.../artifacts/ArtifactsPageStates.tsx | 22 ++-
.../settings/ArtifactsSettingsPane.tsx | 12 +-
.../src/components/settings/DevToolsPane.tsx | 7 +-
.../MobilePairingConnectionOptions.test.tsx | 27 ++-
.../MobilePairingConnectionOptions.tsx | 49 +----
.../settings/MobileRelaySignInPanel.tsx | 47 +++++
.../settings/OrcaAccountSettingsPane.tsx | 7 +-
.../settings/ShareSkillsSettingsPane.tsx | 12 +-
.../orca-profile-auth-ipc-bridge.test.ts | 76 +++++++-
.../orca-profile-auth-ipc-bridge.ts | 35 +++-
src/renderer/src/i18n/locales/en.json | 11 +-
src/renderer/src/i18n/locales/es.json | 11 +-
src/renderer/src/i18n/locales/fr.json | 11 +-
src/renderer/src/i18n/locales/ja.json | 11 +-
src/renderer/src/i18n/locales/ko.json | 11 +-
src/renderer/src/i18n/locales/zh.json | 11 +-
...profiles-auth-actions-sign-in-wait.test.ts | 75 ++++++++
.../slices/orca-profiles-auth-actions.ts | 4 +
.../src/store/slices/orca-profiles.ts | 3 +
.../web/preload-api/web-orca-profiles-api.ts | 3 +
src/shared/orca-profiles.ts | 8 +
35 files changed, 901 insertions(+), 127 deletions(-)
create mode 100644 src/main/orca-profiles/profile-cloud-sign-in-progress.test.ts
create mode 100644 src/main/orca-profiles/profile-cloud-sign-in-progress.ts
create mode 100644 src/renderer/src/components/OrcaSignInProgress.test.tsx
create mode 100644 src/renderer/src/components/OrcaSignInProgress.tsx
create mode 100644 src/renderer/src/components/settings/MobileRelaySignInPanel.tsx
create mode 100644 src/renderer/src/store/slices/orca-profiles-auth-actions-sign-in-wait.test.ts
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