fix(cloud-auth): show that a sign-in is in progress, with copy-link and cancel, on every Orca sign-in button (#30410)

* 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
This commit is contained in:
Jinwoo Hong
2026-10-18 19:28:04 -04:00
committed by GitHub
parent 39314834be
commit c4730d692f
35 changed files with 901 additions and 127 deletions
@@ -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)
}
+17 -1
View File
@@ -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 (
@@ -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()
})
})
+9 -2
View File
@@ -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<OrcaCloudAuthorizationCode> {
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')
@@ -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)
}
}
@@ -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' })
})
})
@@ -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<ProgressListener>()
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()
}
+7
View File
@@ -1,4 +1,5 @@
import type {
OrcaCloudSignInProgress,
ConnectCurrentOrcaProfileResult,
CreateCloudLinkedOrcaProfileArgs,
CreateCloudLinkedOrcaProfileResult,
@@ -42,6 +43,12 @@ export type OrcaProfileApi = {
args: FindOrcaProfileProjectsByPathArgs
) => Promise<FindOrcaProfileProjectsByPathResult>
connectCurrent: () => Promise<ConnectCurrentOrcaProfileResult>
signInProgress: () => Promise<OrcaCloudSignInProgress | null>
onSignInProgressChanged: (
callback: (progress: OrcaCloudSignInProgress | null) => void
) => () => void
/** Stops waiting on the browser; the sign-in resolves as cancelled. */
cancelSignIn: () => Promise<void>
refreshAuth: () => Promise<RefreshCurrentOrcaProfileAuthResult>
signOutCurrent: () => Promise<SignOutCurrentOrcaProfileResult>
selectOrg: (args: SelectOrcaProfileOrgArgs) => Promise<SelectOrcaProfileOrgResult>
+12
View File
@@ -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),
@@ -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(<OrcaSignInButton>Sign in to Orca</OrcaSignInButton>)
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(<OrcaSignInButton>Sign in to Orca</OrcaSignInButton>)
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(<OrcaSignInWaitNotice />)
expect(container).toBeEmptyDOMElement()
})
it('copies the waiting link', async () => {
mocks.state.orcaProfileSignInProgress = { link: LINK }
render(<OrcaSignInWaitNotice />)
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(<OrcaSignInWaitNotice />)
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(<OrcaSignInWaitNotice />)
await userEvent.setup().click(screen.getByRole('button', { name: 'Cancel' }))
expect(cancelSignIn).toHaveBeenCalledOnce()
})
})
@@ -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<void> {
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<React.ComponentProps<typeof Button>, 'onClick' | 'type'>): React.JSX.Element {
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const signingIn = useAppStore((state) => Boolean(state.orcaProfileSignInProgress))
return (
<Button
{...props}
type="button"
disabled={disabled || signingIn}
onClick={() => void connect()}
>
{signingIn ? (
<>
<Loader2 className="animate-spin" />
{translate('auto.components.OrcaSignInProgress.signingIn', 'Signing in…')}
</>
) : (
children
)}
</Button>
)
}
export function OrcaSignInWaitNotice({
className
}: {
className?: string
}): React.JSX.Element | null {
const link = useAppStore((state) => state.orcaProfileSignInProgress?.link)
if (!link) {
return null
}
return (
<div
role="status"
className={cn(
'flex flex-wrap items-center gap-x-0.5 text-xs text-muted-foreground',
className
)}
>
<span className="pr-2">
{translate(
'auto.components.OrcaSignInProgress.waiting',
'Waiting for sign-in in your browser'
)}
</span>
<span aria-hidden>·</span>
<Button type="button" variant="link" size="xs" onClick={() => void copySignInLink(link)}>
{translate('auto.components.OrcaSignInProgress.copyLink', 'Copy sign-in link')}
</Button>
<span aria-hidden>·</span>
<Button
type="button"
variant="link"
size="xs"
onClick={() => void window.api.orcaProfiles.cancelSignIn().catch(() => {})}
>
{translate('auto.components.OrcaSignInProgress.cancel', 'Cancel')}
</Button>
</div>
)
}
@@ -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<string | null>(null)
const [authRefreshReady, setAuthRefreshReady] = useState(false)
const [expanded, setExpanded] = useState(false)
@@ -238,16 +238,11 @@ export function UnexpectedSignoutCard(): React.JSX.Element | null {
</Collapsible>
<div className="mt-0.5 flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1"
disabled={!canConnect}
onClick={() => void connect()}
>
<OrcaSignInButton variant="default" size="sm" className="flex-1" disabled={!canConnect}>
{translate('auto.components.UnexpectedSignoutCard.c5b3e8a17d', 'Sign in to Orca')}
</Button>
</OrcaSignInButton>
</div>
<OrcaSignInWaitNotice />
</div>
</Card>
</div>
@@ -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<HTMLDivElement>(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({
)}
</p>
</div>
<Button
type="button"
<OrcaSignInButton
variant="outline"
size="xs"
disabled={authStatus?.configured !== true}
onClick={() => void connect()}
>
{authStatus?.state === 'reconnect-required'
? translate(
@@ -223,9 +221,10 @@ export function ArtifactPublishButton({
'Sign in again'
)
: translate('auto.components.artifacts.ArtifactPublishButton.signIn', 'Sign in')}
</Button>
</OrcaSignInButton>
</div>
) : null}
{!signedIn ? <OrcaSignInWaitNotice /> : null}
{!sharingEnabled ? (
<div className={cn('space-y-2', !signedIn && 'border-t border-border/60 pt-3')}>
@@ -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 {
<ArtifactsPageAuthState
needsReconnect={needsReconnect}
configured={authStatus?.configured === true}
onConnect={() => void connect()}
onOpenAccountSettings={openAccountSettings}
/>
) : loading && artifacts.length === 0 ? (
@@ -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({
</p>
</div>
{configured ? (
<Button size="sm" onClick={onConnect}>
{needsReconnect
? translate(
'auto.components.artifacts.ArtifactsPage.signInAgainAction',
'Sign in again'
)
: translate('auto.components.artifacts.ArtifactsPage.signIn', 'Sign in to Orca')}
</Button>
<>
<OrcaSignInButton size="sm">
{needsReconnect
? translate(
'auto.components.artifacts.ArtifactsPage.signInAgainAction',
'Sign in again'
)
: translate('auto.components.artifacts.ArtifactsPage.signIn', 'Sign in to Orca')}
</OrcaSignInButton>
<OrcaSignInWaitNotice className="justify-center" />
</>
) : (
<div className="flex flex-col items-center gap-2">
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
@@ -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({
)}
</p>
</div>
<Button
type="button"
size="sm"
disabled={authStatus?.configured !== true}
onClick={() => void connect()}
>
<OrcaSignInButton size="sm" disabled={authStatus?.configured !== true}>
{authStatus?.state === 'reconnect-required'
? translate('auto.components.settings.artifacts.signInAgain', 'Sign in again')
: translate('auto.components.settings.artifacts.signIn', 'Sign in to Orca')}
</Button>
</OrcaSignInButton>
<OrcaSignInWaitNotice className="basis-full" />
</section>
) : null}
<section className="space-y-4 py-5">
@@ -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')}
</Button>
) : (
<Button type="button" variant="outline" size="sm" onClick={() => void connect()}>
<OrcaSignInButton variant="outline" size="sm">
{translate(
'auto.components.settings.DevToolsPane.orcaCloudConnect',
'Connect profile'
)}
</Button>
</OrcaSignInButton>
)}
{connected ? null : <OrcaSignInWaitNotice className="basis-full" />}
<Button type="button" variant="ghost" size="sm" onClick={() => void refresh()}>
{translate(
'auto.components.settings.DevToolsPane.orcaCloudRefresh',
@@ -7,11 +7,15 @@ import { cleanup, render, screen, waitFor, within } from '@testing-library/react
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobileRelayStatusDetail } from '../../../../shared/mobile-relay-status'
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
import type {
OrcaCloudSignInProgress,
OrcaProfileAuthStatus
} from '../../../../shared/orca-profiles'
import { MobilePairingConnectionOptions } from './MobilePairingConnectionOptions'
type MobileRelayStoreState = {
orcaProfileAuthStatus: OrcaProfileAuthStatus | null
orcaProfileSignInProgress?: OrcaCloudSignInProgress | null
connectCurrentOrcaProfile: () => Promise<null>
fetchOrcaProfileAuthStatus: () => Promise<OrcaProfileAuthStatus | null>
}
@@ -108,10 +112,29 @@ describe('MobilePairingConnectionOptions', () => {
expect(screen.queryByText(/not configured for this build/i)).toBeNull()
await user.click(signIn)
expect(onChange).toHaveBeenCalledWith('automatic')
expect(connect).toHaveBeenCalledOnce()
})
it('greys out Sign in and offers Copy and Cancel while the browser wait is open', async () => {
const user = userEvent.setup()
const link = 'https://login.example/authorize?state=abc'
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
const cancelSignIn = vi.fn().mockResolvedValue(undefined)
Object.assign(window.api, { ui: { writeClipboardText }, orcaProfiles: { cancelSignIn } })
render(<MobilePairingConnectionOptions value="automatic" onChange={vi.fn()} />)
publishStoreState({ ...mocks.state, orcaProfileSignInProgress: { link } })
expect(await screen.findByRole('status')).toHaveTextContent(
'Waiting for sign-in in your browser'
)
expect(screen.getByRole('button', { name: 'Signing in…' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Copy sign-in link' }))
expect(writeClipboardText).toHaveBeenCalledWith(link)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(cancelSignIn).toHaveBeenCalledOnce()
})
it('hides Sign in when LAN is selected', () => {
render(<MobilePairingConnectionOptions value="local-only" onChange={vi.fn()} />)
expect(screen.queryByTestId('anywhere-sign-in-panel')).toBeNull()
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { translate } from '../../i18n/i18n'
import { useAppStore } from '../../store'
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
@@ -11,6 +10,7 @@ import type {
} from '../../../../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
import { MobilePairingPathOption } from './MobilePairingPathOption'
import { MobileRelaySignInPanel } from './MobileRelaySignInPanel'
function relayStatusLabel(status: MobileRelayStatus): string {
if (status === 'registered') {
@@ -65,7 +65,6 @@ export function MobilePairingConnectionOptions({
relayMintRetrying?: boolean
}): React.JSX.Element {
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
const [relayStatus, setRelayStatus] = useState<MobileRelayStatus>('offline')
const [relayCellUrl, setRelayCellUrl] = useState<string | undefined>(undefined)
const signedIn = authStatus?.state === 'connected'
@@ -197,51 +196,7 @@ export function MobilePairingConnectionOptions({
) : null
}
/>
{needsSignIn ? (
<div
// Why: indent under the Relay radio so Sign in reads as a Relay
// sub-step, not a requirement for the whole connection section/LAN.
// Deliberately role-less: a `group` here would be an invalid owned
// element of the radiogroup, and its label would double-announce the
// button it wraps. The plain div contributes nothing to the a11y
// tree, leaving the CTA reachable by Tab as an ordinary button.
onKeyDown={(event) => {
// Why: nested controls live inside the radiogroup for layout; do not
// let arrow keys bubble and flip 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"
>
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{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.'
)}
</p>
<Button
type="button"
size="sm"
className="shrink-0"
onClick={() => {
onChange('automatic')
void connect()
}}
>
{reconnectRequired
? translate(
'auto.components.settings.MobilePairingConnectionOptions.signInAgain',
'Sign in again for Relay'
)
: translate(
'auto.components.settings.MobilePairingConnectionOptions.signIn',
'Sign in for Relay'
)}
</Button>
</div>
) : null}
{needsSignIn ? <MobileRelaySignInPanel reconnectRequired={reconnectRequired} /> : null}
{value === 'automatic' && relayCell ? (
<p
className="border-t border-border/60 py-2 pl-10 pr-3 text-xs text-muted-foreground"
@@ -0,0 +1,47 @@
import { translate } from '../../i18n/i18n'
import { useAppStore } from '../../store'
import { OrcaSignInButton, OrcaSignInWaitNotice } from '../OrcaSignInProgress'
export function MobileRelaySignInPanel({
reconnectRequired
}: {
reconnectRequired: boolean
}): React.JSX.Element {
const browserWaiting = useAppStore((state) => Boolean(state.orcaProfileSignInProgress?.link))
return (
<div
// Why role-less: a radiogroup may only own radios; this div is layout under the Relay radio.
onKeyDown={(event) => {
// 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 ? (
<OrcaSignInWaitNotice className="min-w-0 flex-1" />
) : (
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
{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.'
)}
</p>
)}
<OrcaSignInButton size="sm" className="shrink-0">
{reconnectRequired
? translate(
'auto.components.settings.MobilePairingConnectionOptions.signInAgain',
'Sign in again for Relay'
)
: translate(
'auto.components.settings.MobilePairingConnectionOptions.signIn',
'Sign in for Relay'
)}
</OrcaSignInButton>
</div>
)
}
@@ -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')}
</Button>
) : (
<Button type="button" size="sm" disabled={!canConnect} onClick={() => void connect()}>
<OrcaSignInButton size="sm" disabled={!canConnect}>
{authStatus?.state === 'reconnect-required'
? translate('auto.components.settings.orcaAccount.signInAgain', 'Sign in again')
: translate('auto.components.settings.orcaAccount.signIn', 'Sign in to Orca')}
</Button>
</OrcaSignInButton>
)}
{connected ? null : <OrcaSignInWaitNotice className="basis-full" />}
</div>
<div className="space-y-4 border-t border-border/60 pt-5">
@@ -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 {
</p>
</div>
{!isWebClient ? (
<Button
type="button"
size="sm"
disabled={authStatus?.configured !== true}
onClick={() => void connect()}
>
<OrcaSignInButton size="sm" disabled={authStatus?.configured !== true}>
{authStatus?.state === 'reconnect-required'
? translate('auto.components.settings.shareSkills.signInAgain', 'Sign in again')
: translate('auto.components.settings.shareSkills.signIn', 'Sign in to Orca')}
</Button>
</OrcaSignInButton>
) : null}
{!isWebClient ? <OrcaSignInWaitNotice className="basis-full" /> : null}
</section>
) : null}
@@ -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<typeof createTestStore>
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<OrcaCloudSignInProgress | null>((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()
})
})
@@ -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(() => {})
}
}
+10 -1
View File
@@ -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": {
+10 -1
View File
@@ -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": {
+10 -1
View File
@@ -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": {
+10 -1
View File
@@ -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": {
+10 -1
View File
@@ -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": {
+10 -1
View File
@@ -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": {
@@ -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<ConnectCurrentOrcaProfileResult>((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()
})
})
@@ -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 &&
@@ -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<AppState, [], [], OrcaProfile
orcaProfiles: [],
activeOrcaProfileId: null,
orcaProfileAuthStatus: null,
orcaProfileSignInProgress: null,
orcaProfilesMultiProfileUi: false,
orcaProfilesLoading: false,
orcaProfileSwitching: false,
@@ -24,6 +24,7 @@ export function createWebOrcaProfilesApi(): Partial<PreloadApi> {
}),
authStatus: webOrcaProfileAuthStatus,
onAuthStatusChanged: () => noopUnsubscribe,
onSignInProgressChanged: () => noopUnsubscribe,
createLocal: () =>
Promise.resolve({
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
@@ -48,6 +49,8 @@ export function createWebOrcaProfilesApi(): Partial<PreloadApi> {
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
}),
signInProgress: async () => null,
cancelSignIn: async () => {},
refreshAuth: async () => ({
status: 'unconfigured',
auth: await webOrcaProfileAuthStatus()
+8
View File
@@ -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