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 : } - - ) : null} + {needsSignIn ? : null} {value === 'automatic' && relayCell ? (

Boolean(state.orcaProfileSignInProgress?.link)) + + return ( +

{ + // Why: arrow keys on nested controls must not change the selected path. + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { + event.stopPropagation() + } + }} + className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-accent/40 py-2.5 pl-10 pr-3" + data-testid="anywhere-sign-in-panel" + > + {browserWaiting ? ( + + ) : ( +

+ {translate( + 'auto.components.settings.MobilePairingConnectionOptions.relaySignInDetail', + 'Signs this desktop in to Orca and routes the phone through Orca’s servers. LAN does not need an account.' + )} +

+ )} + + {reconnectRequired + ? translate( + 'auto.components.settings.MobilePairingConnectionOptions.signInAgain', + 'Sign in again for Relay' + ) + : translate( + 'auto.components.settings.MobilePairingConnectionOptions.signIn', + 'Sign in for Relay' + )} + +
+ ) +} diff --git a/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx b/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx index 44387575a25..8721e4e6b17 100644 --- a/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx +++ b/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { OrcaProfileSignOutConfirmDialog } from '../orca-profiles/OrcaProfileSignOutConfirmDialog' +import { OrcaSignInButton, OrcaSignInWaitNotice } from '../OrcaSignInProgress' function accountStatusCopy( state: 'local' | 'unconfigured' | 'connected' | 'reconnect-required' | undefined, @@ -60,7 +61,6 @@ function AccountBenefit({ export function OrcaAccountSettingsPane(): React.JSX.Element { const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) - const connect = useAppStore((state) => state.connectCurrentOrcaProfile) const signOut = useAppStore((state) => state.signOutCurrentOrcaProfile) const [signOutOpen, setSignOutOpen] = useState(false) const [signingOut, setSigningOut] = useState(false) @@ -116,12 +116,13 @@ export function OrcaAccountSettingsPane(): React.JSX.Element { {translate('auto.components.settings.orcaAccount.signOut', 'Sign out')} ) : ( - + )} + {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 ? ( - + ) : 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