mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
fix: prompt unexpectedly signed-out Cloud users once per version
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ActiveOrcaProfileState } from './profile-index-store'
|
||||
import type { OrcaCloudSessionReadResult } from './profile-cloud-session-store'
|
||||
import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status'
|
||||
|
||||
const { readSession, configuration } = vi.hoisted(() => ({
|
||||
readSession: vi.fn<() => OrcaCloudSessionReadResult>(),
|
||||
configuration: { configured: true }
|
||||
}))
|
||||
|
||||
vi.mock('./profile-cloud-session-store', () => ({ readOrcaCloudSession: readSession }))
|
||||
vi.mock('./profile-cloud-auth-config', () => ({
|
||||
getOrcaCloudAuthConfig: () => configuration,
|
||||
isOrcaCloudDevAuthEnabled: () => false
|
||||
}))
|
||||
|
||||
function activeProfile(linked: boolean): ActiveOrcaProfileState {
|
||||
const profile: ActiveOrcaProfileState['profile'] = {
|
||||
id: 'profile-1',
|
||||
name: 'Personal',
|
||||
avatar: { kind: 'initials', initials: 'P', color: 'neutral' },
|
||||
kind: linked ? 'cloud-linked' : 'local',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
lastOpenedAt: 0,
|
||||
...(linked
|
||||
? {
|
||||
cloud: {
|
||||
cloudProfileId: 'cloud-1',
|
||||
userId: 'user-1',
|
||||
email: 'a@example.com',
|
||||
linkedAt: 0
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
return {
|
||||
profile,
|
||||
index: { schemaVersion: 1, activeProfileId: profile.id, profiles: [profile] },
|
||||
dataFile: '',
|
||||
profileDirectory: ''
|
||||
}
|
||||
}
|
||||
|
||||
const absentSessions: OrcaCloudSessionReadResult[] = [
|
||||
{ status: 'missing', persistence: 'none' },
|
||||
{ status: 'decrypt-failed', persistence: 'none', error: 'Cannot decrypt' },
|
||||
{ status: 'unreadable', persistence: 'none', error: 'Permission denied' }
|
||||
]
|
||||
|
||||
describe('unexpected sign-out auth evidence', () => {
|
||||
beforeEach(() => {
|
||||
readSession.mockReset()
|
||||
configuration.configured = true
|
||||
})
|
||||
|
||||
it.each(absentSessions)('requires a preserved cloud link for $status credentials', (session) => {
|
||||
readSession.mockReturnValue(session)
|
||||
const linked = activeProfile(true)
|
||||
expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({
|
||||
state: 'reconnect-required',
|
||||
cloud: linked.profile.cloud,
|
||||
persistence: 'none',
|
||||
credentialError: 'error' in session ? session.error : undefined
|
||||
})
|
||||
readSession.mockClear()
|
||||
const signedOut = getOrcaProfileAuthStatusFromProfile(activeProfile(false), '')
|
||||
expect(signedOut.state).toBe('local')
|
||||
expect(signedOut.cloud).toBeUndefined()
|
||||
expect(readSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(absentSessions)(
|
||||
'keeps unconfigured linked profiles out of reconnect for $status',
|
||||
(session) => {
|
||||
configuration.configured = false
|
||||
readSession.mockReturnValue(session)
|
||||
expect(getOrcaProfileAuthStatusFromProfile(activeProfile(true), '').state).toBe(
|
||||
'unconfigured'
|
||||
)
|
||||
expect(getOrcaProfileAuthStatusFromProfile(activeProfile(false), '').state).toBe(
|
||||
'unconfigured'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('treats a live memory-only session as connected, then reconnects after its loss', () => {
|
||||
readSession.mockReturnValue({
|
||||
status: 'found',
|
||||
persistence: 'memory-only',
|
||||
session: {
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
capabilities: { flags: {}, refreshedAt: 0 }
|
||||
}
|
||||
})
|
||||
const linked = activeProfile(true)
|
||||
expect(getOrcaProfileAuthStatusFromProfile(linked, '')).toMatchObject({
|
||||
state: 'connected',
|
||||
persistence: 'memory-only'
|
||||
})
|
||||
readSession.mockReturnValue({ status: 'missing', persistence: 'none' })
|
||||
expect(getOrcaProfileAuthStatusFromProfile(linked, '').state).toBe('reconnect-required')
|
||||
})
|
||||
})
|
||||
@@ -171,6 +171,7 @@ const UiUpdateFields = z
|
||||
usagePercentageDisplay: z.enum(['used', 'remaining']).optional(),
|
||||
statusBarUsageMode: z.enum(['verbose', 'compact']).optional(),
|
||||
dismissedUpdateVersion: NullableString.optional(),
|
||||
dismissedUnexpectedSignoutVersion: NullableString.optional(),
|
||||
lastUpdateCheckAt: z.number().finite().nullable().optional(),
|
||||
pendingUpdateNudgeId: NullableString.optional(),
|
||||
dismissedUpdateNudgeId: NullableString.optional(),
|
||||
|
||||
@@ -607,6 +607,8 @@ describe('client UI RPC methods', () => {
|
||||
],
|
||||
['taskResumeState.jiraPreset', { taskResumeState: { jiraPreset: 'assigned' } }],
|
||||
['taskResumeState.jiraQuery', { taskResumeState: { jiraQuery: 'ENG' } }],
|
||||
['dismissedUnexpectedSignoutVersion', { dismissedUnexpectedSignoutVersion: '1.2.3' }],
|
||||
['dismissedUnexpectedSignoutVersion null', { dismissedUnexpectedSignoutVersion: null }],
|
||||
['activeView', { activeView: 'tasks' }],
|
||||
['showDotfilesByWorktree', { showDotfilesByWorktree: { 'repo::/worktree': true } }],
|
||||
['setupGuideSidebarDismissed', { setupGuideSidebarDismissed: true }],
|
||||
|
||||
@@ -58,6 +58,11 @@ const SshPassphraseDialog = lazy(() =>
|
||||
const UpdateCard = lazy(() =>
|
||||
import('../components/UpdateCard').then((module) => ({ default: module.UpdateCard }))
|
||||
)
|
||||
const UnexpectedSignoutCard = lazy(() =>
|
||||
import('../components/UnexpectedSignoutCard').then((module) => ({
|
||||
default: module.UnexpectedSignoutCard
|
||||
}))
|
||||
)
|
||||
const RemoteServerUpdateDialog = lazy(
|
||||
() => import('../components/settings/RemoteServerUpdateDialog')
|
||||
)
|
||||
@@ -280,6 +285,11 @@ export function AppRootSurfaces(props: {
|
||||
</OverlayBoundary>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
<OverlayBoundary boundaryId="overlay.unexpected-signout" resetKey={activeView}>
|
||||
<UnexpectedSignoutCard />
|
||||
</OverlayBoundary>
|
||||
</Suspense>
|
||||
<OverlayBoundary boundaryId="overlay.star-nag" resetKey={activeView}>
|
||||
<StarNagCard />
|
||||
</OverlayBoundary>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { BookOpen, ChevronDown, CircleUserRound, Files, Smartphone, X } from 'lucide-react'
|
||||
import { useAppStore } from '../store'
|
||||
import { useOrcaProfileAuthStatusRefresh } from '@/hooks/use-orca-profile-auth-status-refresh'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
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'
|
||||
|
||||
function readPreviewFlag(): boolean {
|
||||
if (!import.meta.env.DEV) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
if (new URLSearchParams(window.location.search).get('showSignoutCard') === '1') {
|
||||
return true
|
||||
}
|
||||
return window.localStorage.getItem('orca-debug-show-signout-card') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function FeatureRow({
|
||||
icon: Icon,
|
||||
title,
|
||||
description
|
||||
}: {
|
||||
icon: typeof Files
|
||||
title: string
|
||||
description: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium">{title}</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UnexpectedSignoutCard(): React.JSX.Element | null {
|
||||
const authStatus = useAppStore((s) => s.orcaProfileAuthStatus)
|
||||
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
|
||||
const persistedDismissedVersion = useAppStore((s) => s.dismissedUnexpectedSignoutVersion)
|
||||
const dismissedVersions = useAppStore((s) => s.unexpectedSignoutDismissedVersions)
|
||||
const dismissForVersion = useAppStore((s) => s.dismissUnexpectedSignoutCard)
|
||||
const connecting = useAppStore((s) => s.orcaProfileConnecting)
|
||||
const connect = useAppStore((s) => s.connectCurrentOrcaProfile)
|
||||
const updateStatus = useAppStore((s) => s.updateStatus)
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [preview] = useState(readPreviewFlag)
|
||||
const [previewDismissed, setPreviewDismissed] = useState(false)
|
||||
const reconnectingProfile = useRef<string | null>(null)
|
||||
|
||||
useOrcaProfileAuthStatusRefresh()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void window.api.updater
|
||||
.getVersion()
|
||||
.then((version) => {
|
||||
if (!cancelled) {
|
||||
setAppVersion(version)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAppVersion(null)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dismissedVersion =
|
||||
appVersion && dismissedVersions.includes(appVersion) ? appVersion : persistedDismissedVersion
|
||||
const eligible = shouldShowUnexpectedSignoutCard({
|
||||
authStatus,
|
||||
persistedUIReady,
|
||||
appVersion,
|
||||
dismissedVersion
|
||||
})
|
||||
|
||||
const visible = preview ? persistedUIReady && !previewDismissed : eligible
|
||||
|
||||
// Observe recovery independently of visibility and asynchronous version/hydration reads.
|
||||
useEffect(() => {
|
||||
if (preview) {
|
||||
return
|
||||
}
|
||||
if (authStatus?.state === 'reconnect-required' && authStatus.configured && authStatus.cloud) {
|
||||
reconnectingProfile.current = authStatus.activeProfileId
|
||||
} else if (authStatus?.state === 'connected') {
|
||||
if (
|
||||
reconnectingProfile.current === authStatus.activeProfileId &&
|
||||
persistedUIReady &&
|
||||
appVersion
|
||||
) {
|
||||
reconnectingProfile.current = null
|
||||
if (dismissedVersion !== appVersion) {
|
||||
dismissForVersion(appVersion)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
reconnectingProfile.current = null
|
||||
}
|
||||
}, [preview, authStatus, persistedUIReady, appVersion, dismissedVersion, dismissForVersion])
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
const email = authStatus?.cloud?.email?.trim() || null
|
||||
const canConnect = authStatus?.configured === true
|
||||
const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available'
|
||||
|
||||
const handleDismiss = (): void => {
|
||||
if (preview) {
|
||||
setPreviewDismissed(true)
|
||||
} else if (appVersion) {
|
||||
dismissForVersion(appVersion)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed right-4 z-40 w-[360px] max-w-[calc(100vw-32px)] max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto ${
|
||||
updateCardVisible ? 'bottom-[220px]' : 'bottom-10'
|
||||
}`}
|
||||
>
|
||||
<Card
|
||||
className="py-0 gap-0 shadow-floating"
|
||||
role="complementary"
|
||||
aria-live="polite"
|
||||
aria-labelledby="unexpected-signout-heading"
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 p-3.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleUserRound className="size-4 text-muted-foreground" />
|
||||
<h3 id="unexpected-signout-heading" className="text-sm font-semibold">
|
||||
{translate(
|
||||
'auto.components.UnexpectedSignoutCard.9f2c1a4b7d',
|
||||
"You've been signed out"
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={handleDismiss}
|
||||
aria-label={translate('auto.components.UnexpectedSignoutCard.3e8f5c2a91', 'Dismiss')}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{email
|
||||
? translate(
|
||||
'auto.components.UnexpectedSignoutCard.7b4d9e1f2a',
|
||||
'Sign in again as {{value0}} to restore Artifact sharing, Orca Relay, and skill sharing.',
|
||||
{ value0: email }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.UnexpectedSignoutCard.5a1c8d3e6f',
|
||||
'Sign in again to restore Artifact sharing, Orca Relay, and skill sharing.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit gap-1 px-1 text-xs text-muted-foreground"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{translate('auto.components.UnexpectedSignoutCard.1f6b2c9d4e', 'What you get back')}
|
||||
<ChevronDown
|
||||
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-2.5">
|
||||
<FeatureRow
|
||||
icon={Files}
|
||||
title={translate(
|
||||
'auto.components.UnexpectedSignoutCard.8d2e4f7a1b',
|
||||
'Artifact sharing'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.UnexpectedSignoutCard.2c9a5b6e8d',
|
||||
'Publish HTML and Markdown files and manage every shared link from Orca.'
|
||||
)}
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={Smartphone}
|
||||
title={translate('auto.components.UnexpectedSignoutCard.6e3f1a9c5b', 'Orca Relay')}
|
||||
description={translate(
|
||||
'auto.components.UnexpectedSignoutCard.4b7d2e8f1a',
|
||||
'Connect Orca Mobile to this desktop across cellular or any Wi-Fi.'
|
||||
)}
|
||||
/>
|
||||
<FeatureRow
|
||||
icon={BookOpen}
|
||||
title={translate(
|
||||
'auto.components.UnexpectedSignoutCard.9a4c6b2d7e',
|
||||
'Skill sharing'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.UnexpectedSignoutCard.3d8e5f1b9c',
|
||||
'Share skills behind an unlisted link and install them on any machine you use.'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="mt-0.5 flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={!canConnect || connecting}
|
||||
onClick={() => void connect()}
|
||||
>
|
||||
{connecting
|
||||
? translate('auto.components.UnexpectedSignoutCard.7e1a9c4d2f', 'Signing in…')
|
||||
: translate('auto.components.UnexpectedSignoutCard.c5b3e8a17d', 'Sign in to Orca')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '../../store'
|
||||
import { getDefaultUIState } from '../../../../shared/constants'
|
||||
import { UnexpectedSignoutCard } from '../UnexpectedSignoutCard'
|
||||
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
|
||||
|
||||
const status: OrcaProfileAuthStatus = {
|
||||
activeProfileId: 'profile-1',
|
||||
configured: true,
|
||||
state: 'reconnect-required',
|
||||
persistence: 'none',
|
||||
cloud: {
|
||||
cloudProfileId: 'cloud-1',
|
||||
userId: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
linkedAt: 0
|
||||
}
|
||||
}
|
||||
const persist = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('DEV', false)
|
||||
persist.mockClear()
|
||||
window.localStorage.clear()
|
||||
window.history.replaceState({}, '', '/')
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
ui: { set: persist },
|
||||
updater: { getVersion: vi.fn().mockResolvedValue('1.4.197') }
|
||||
}
|
||||
})
|
||||
useAppStore.setState(useAppStore.getInitialState(), true)
|
||||
useAppStore.setState({
|
||||
orcaProfileAuthStatus: status,
|
||||
persistedUIReady: true,
|
||||
fetchOrcaProfileAuthStatus: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
async function showCard(): Promise<void> {
|
||||
render(<UnexpectedSignoutCard />)
|
||||
await screen.findByRole('complementary')
|
||||
}
|
||||
|
||||
describe('unexpected signout lifecycle', () => {
|
||||
it('stamps successful sign-in and never re-arms in the same version', async () => {
|
||||
await showCard()
|
||||
act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }))
|
||||
await waitFor(() =>
|
||||
expect(persist).toHaveBeenCalledWith({ dismissedUnexpectedSignoutVersion: '1.4.197' })
|
||||
)
|
||||
act(() => useAppStore.setState({ orcaProfileAuthStatus: status }))
|
||||
expect(screen.queryByRole('complementary')).toBeNull()
|
||||
cleanup()
|
||||
render(<UnexpectedSignoutCard />)
|
||||
await act(async () => {})
|
||||
expect(screen.queryByRole('complementary')).toBeNull()
|
||||
expect(persist).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not treat a cached connected status as a successful re-sign-in', async () => {
|
||||
useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } })
|
||||
render(<UnexpectedSignoutCard />)
|
||||
await act(async () => {})
|
||||
act(() => useAppStore.setState({ orcaProfileAuthStatus: status }))
|
||||
expect(screen.queryByRole('complementary')).not.toBeNull()
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for hydration before stamping an already recovered session', async () => {
|
||||
useAppStore.setState({ persistedUIReady: false })
|
||||
render(<UnexpectedSignoutCard />)
|
||||
await act(async () => {})
|
||||
act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }))
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
act(() => useAppStore.setState({ persistedUIReady: true }))
|
||||
await waitFor(() => expect(persist).toHaveBeenCalledTimes(1))
|
||||
act(() => useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'connected' } }))
|
||||
expect(persist).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps a failed sign-in available without stamping dismissal', async () => {
|
||||
useAppStore.setState({ connectCurrentOrcaProfile: vi.fn().mockResolvedValue(undefined) })
|
||||
await showCard()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Orca' }))
|
||||
await act(async () => {})
|
||||
expect(screen.queryByRole('complementary')).not.toBeNull()
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains a reopened dismissal through stale UI sync and a renderer remount', async () => {
|
||||
useAppStore.getState().hydratePersistedUI(
|
||||
{
|
||||
...getDefaultUIState(),
|
||||
dismissedUnexpectedSignoutVersion: '1.4.197'
|
||||
},
|
||||
'startup'
|
||||
)
|
||||
render(<UnexpectedSignoutCard />)
|
||||
await act(async () => {})
|
||||
act(() => useAppStore.getState().hydratePersistedUI(getDefaultUIState()))
|
||||
expect(screen.queryByRole('complementary')).toBeNull()
|
||||
cleanup()
|
||||
render(<UnexpectedSignoutCard />)
|
||||
await act(async () => {})
|
||||
expect(screen.queryByRole('complementary')).toBeNull()
|
||||
expect(persist).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dismissedUnexpectedSignoutVersion: expect.anything() })
|
||||
)
|
||||
})
|
||||
|
||||
it('persists X dismissal and stays hidden after remount', async () => {
|
||||
await showCard()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||
expect(persist).toHaveBeenCalledWith({ dismissedUnexpectedSignoutVersion: '1.4.197' })
|
||||
cleanup()
|
||||
render(<UnexpectedSignoutCard />)
|
||||
await act(async () => {})
|
||||
expect(screen.queryByRole('complementary')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['storage', 'query'])('ignores the %s preview flag in production', async (source) => {
|
||||
if (source === 'storage') {
|
||||
window.localStorage.setItem('orca-debug-show-signout-card', '1')
|
||||
} else {
|
||||
window.history.replaceState({}, '', '/?showSignoutCard=1')
|
||||
}
|
||||
useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'local', cloud: undefined } })
|
||||
render(<UnexpectedSignoutCard />)
|
||||
await act(async () => {})
|
||||
expect(screen.queryByRole('complementary')).toBeNull()
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses a development preview without writing real dismissal state', async () => {
|
||||
vi.stubEnv('DEV', true)
|
||||
window.localStorage.setItem('orca-debug-show-signout-card', '1')
|
||||
useAppStore.setState({ orcaProfileAuthStatus: { ...status, state: 'local', cloud: undefined } })
|
||||
await showCard()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||
expect(screen.queryByRole('complementary')).toBeNull()
|
||||
expect(persist).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
|
||||
import { shouldShowUnexpectedSignoutCard } from './unexpected-signout-visibility'
|
||||
|
||||
function reconnectRequired(): OrcaProfileAuthStatus {
|
||||
return {
|
||||
activeProfileId: 'profile-1',
|
||||
configured: true,
|
||||
state: 'reconnect-required',
|
||||
persistence: 'none',
|
||||
cloud: {
|
||||
cloudProfileId: 'cloud-1',
|
||||
userId: 'user-1',
|
||||
email: 'user@example.com',
|
||||
displayName: 'User',
|
||||
linkedAt: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('shouldShowUnexpectedSignoutCard', () => {
|
||||
it.each([
|
||||
{ name: 'unknown auth', auth: null, visible: false },
|
||||
{
|
||||
name: 'missing cloud link',
|
||||
auth: { ...reconnectRequired(), cloud: undefined },
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
name: 'unconfigured linked profile',
|
||||
auth: { ...reconnectRequired(), configured: false, state: 'unconfigured' },
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
name: 'unconfigured reconnect status',
|
||||
auth: { ...reconnectRequired(), configured: false },
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
name: 'local with stale cloud metadata',
|
||||
auth: { ...reconnectRequired(), state: 'local' },
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
name: 'live memory-only session',
|
||||
auth: { ...reconnectRequired(), state: 'connected', persistence: 'memory-only' },
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
name: 'decrypt failure with retained link',
|
||||
auth: { ...reconnectRequired(), credentialError: 'Cannot decrypt' },
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
name: 'unreadable session with retained link',
|
||||
auth: { ...reconnectRequired(), credentialError: 'Permission denied' },
|
||||
visible: true
|
||||
}
|
||||
] satisfies { name: string; auth: OrcaProfileAuthStatus | null; visible: boolean }[])(
|
||||
'$name',
|
||||
({ auth, visible }) => {
|
||||
expect(
|
||||
shouldShowUnexpectedSignoutCard({
|
||||
authStatus: auth,
|
||||
persistedUIReady: true,
|
||||
appVersion: '1.4.197',
|
||||
dismissedVersion: null
|
||||
})
|
||||
).toBe(visible)
|
||||
}
|
||||
)
|
||||
|
||||
it('shows when linked but the session is gone', () => {
|
||||
expect(
|
||||
shouldShowUnexpectedSignoutCard({
|
||||
authStatus: reconnectRequired(),
|
||||
persistedUIReady: true,
|
||||
appVersion: '1.4.197',
|
||||
dismissedVersion: null
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides after an explicit sign-out (link removed)', () => {
|
||||
expect(
|
||||
shouldShowUnexpectedSignoutCard({
|
||||
authStatus: {
|
||||
activeProfileId: 'profile-1',
|
||||
configured: true,
|
||||
state: 'local',
|
||||
persistence: 'none'
|
||||
},
|
||||
persistedUIReady: true,
|
||||
appVersion: '1.4.197',
|
||||
dismissedVersion: null
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when connected', () => {
|
||||
const status = reconnectRequired()
|
||||
status.state = 'connected'
|
||||
expect(
|
||||
shouldShowUnexpectedSignoutCard({
|
||||
authStatus: status,
|
||||
persistedUIReady: true,
|
||||
appVersion: '1.4.197',
|
||||
dismissedVersion: null
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('shows only once per app version', () => {
|
||||
expect(
|
||||
shouldShowUnexpectedSignoutCard({
|
||||
authStatus: reconnectRequired(),
|
||||
persistedUIReady: true,
|
||||
appVersion: '1.4.197',
|
||||
dismissedVersion: '1.4.197'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldShowUnexpectedSignoutCard({
|
||||
authStatus: reconnectRequired(),
|
||||
persistedUIReady: true,
|
||||
appVersion: '1.4.198',
|
||||
dismissedVersion: '1.4.197'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('waits for hydration and version', () => {
|
||||
expect(
|
||||
shouldShowUnexpectedSignoutCard({
|
||||
authStatus: reconnectRequired(),
|
||||
persistedUIReady: false,
|
||||
appVersion: '1.4.197',
|
||||
dismissedVersion: null
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldShowUnexpectedSignoutCard({
|
||||
authStatus: reconnectRequired(),
|
||||
persistedUIReady: true,
|
||||
appVersion: null,
|
||||
dismissedVersion: null
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
|
||||
|
||||
export type UnexpectedSignoutGate = {
|
||||
authStatus: OrcaProfileAuthStatus | null
|
||||
persistedUIReady: boolean
|
||||
appVersion: string | null
|
||||
dismissedVersion: string | null
|
||||
}
|
||||
|
||||
export function shouldShowUnexpectedSignoutCard(gate: UnexpectedSignoutGate): boolean {
|
||||
if (!gate.persistedUIReady || gate.appVersion === null) {
|
||||
return false
|
||||
}
|
||||
if (gate.dismissedVersion === gate.appVersion) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
gate.authStatus?.configured === true &&
|
||||
gate.authStatus.state === 'reconnect-required' &&
|
||||
gate.authStatus.cloud != null
|
||||
)
|
||||
}
|
||||
@@ -16885,6 +16885,21 @@
|
||||
"HostedReviewUnlinkMenuItem": {
|
||||
"label": "Unlink {{value0}} from workspace",
|
||||
"description": "Orca will hide {{value0}} {{value1}} details for this workspace. The {{value0}} and branch on {{value2}} won’t be changed."
|
||||
},
|
||||
"UnexpectedSignoutCard": {
|
||||
"9f2c1a4b7d": "You've been signed out",
|
||||
"3e8f5c2a91": "Dismiss",
|
||||
"7b4d9e1f2a": "Your Orca Cloud session for {{value0}} ended. Sign in again to restore cloud features.",
|
||||
"5a1c8d3e6f": "Your Orca Cloud session ended. Sign in again to restore cloud features.",
|
||||
"1f6b2c9d4e": "What you get back",
|
||||
"8d2e4f7a1b": "Artifact sharing",
|
||||
"2c9a5b6e8d": "Publish HTML and Markdown files and manage every shared link from Orca.",
|
||||
"6e3f1a9c5b": "Orca Relay",
|
||||
"4b7d2e8f1a": "Connect Orca Mobile to this desktop across cellular or any Wi-Fi.",
|
||||
"9a4c6b2d7e": "Skill sharing",
|
||||
"3d8e5f1b9c": "Share skills behind an unlisted link and install them on any machine you use.",
|
||||
"7e1a9c4d2f": "Signing in…",
|
||||
"c5b3e8a17d": "Sign in to Orca"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
|
||||
@@ -280,3 +280,81 @@ describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => {
|
||||
expect(setUI).toHaveBeenCalledWith({ osc52ClipboardDefaultOnNoticePending: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('unexpected sign-out dismissal persistence', () => {
|
||||
it('persists a dismissal once even when effects repeat', () => {
|
||||
const setUI = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
|
||||
const store = createUIStore()
|
||||
|
||||
store.getState().dismissUnexpectedSignoutCard('1.2.3')
|
||||
store.getState().dismissUnexpectedSignoutCard('1.2.3')
|
||||
|
||||
expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3')
|
||||
expect(setUI).toHaveBeenCalledExactlyOnceWith({ dismissedUnexpectedSignoutVersion: '1.2.3' })
|
||||
})
|
||||
|
||||
it.each([undefined, null, '1.2.2'])(
|
||||
'does not re-arm a local dismissal from stale hydration (%s)',
|
||||
(dismissedUnexpectedSignoutVersion) => {
|
||||
vi.stubGlobal('window', { api: { ui: { set: vi.fn(() => Promise.resolve()) } } })
|
||||
const store = createUIStore()
|
||||
store.getState().dismissUnexpectedSignoutCard('1.2.3')
|
||||
|
||||
store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion }))
|
||||
|
||||
expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3')
|
||||
}
|
||||
)
|
||||
|
||||
it('defaults legacy profiles and restores dismissal on reopen', () => {
|
||||
const store = createUIStore()
|
||||
expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull()
|
||||
store
|
||||
.getState()
|
||||
.hydratePersistedUI(
|
||||
makePersistedUI({ dismissedUnexpectedSignoutVersion: undefined }),
|
||||
'startup'
|
||||
)
|
||||
expect(store.getState().dismissedUnexpectedSignoutVersion).toBeNull()
|
||||
const reopened = createUIStore()
|
||||
reopened
|
||||
.getState()
|
||||
.hydratePersistedUI(
|
||||
makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }),
|
||||
'startup'
|
||||
)
|
||||
expect(reopened.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3')
|
||||
})
|
||||
|
||||
it('accepts another window dismissal after hydrating an older version', () => {
|
||||
const store = createUIStore()
|
||||
store
|
||||
.getState()
|
||||
.hydratePersistedUI(
|
||||
makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.2' }),
|
||||
'startup'
|
||||
)
|
||||
store
|
||||
.getState()
|
||||
.hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }))
|
||||
expect(store.getState().dismissedUnexpectedSignoutVersion).toBe('1.2.3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unexpected sign-out hydrated dismissal history', () => {
|
||||
it.each([undefined, null, '1.2.2', '1.2.4'])(
|
||||
'retains an observed dismissal after a different version sync (%s)',
|
||||
(dismissedUnexpectedSignoutVersion) => {
|
||||
const store = createUIStore()
|
||||
store
|
||||
.getState()
|
||||
.hydratePersistedUI(
|
||||
makePersistedUI({ dismissedUnexpectedSignoutVersion: '1.2.3' }),
|
||||
'startup'
|
||||
)
|
||||
store.getState().hydratePersistedUI(makePersistedUI({ dismissedUnexpectedSignoutVersion }))
|
||||
expect(store.getState().unexpectedSignoutDismissedVersions).toContain('1.2.3')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -175,6 +175,10 @@ export type UISlicePersistence = {
|
||||
dismissedUpdateVersion: string | null
|
||||
dismissUpdate: (versionOverride?: string) => void
|
||||
clearDismissedUpdateVersion: () => void
|
||||
/** App version that dismissed the unexpected-sign-out card; null = never dismissed. */
|
||||
dismissedUnexpectedSignoutVersion: string | null
|
||||
unexpectedSignoutDismissedVersions: string[]
|
||||
dismissUnexpectedSignoutCard: (version: string) => void
|
||||
/** Dev-only channel override; null follows the running build's own channel. */
|
||||
releaseChannelOverride: ReleaseChannel | null
|
||||
setReleaseChannelOverride: (channel: ReleaseChannel | null) => void
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from '../persisted-ui-write-baseline'
|
||||
import {
|
||||
hydrateTrustedOrcaHooks,
|
||||
hydrateUnexpectedSignoutDismissal,
|
||||
normalizeHydratedVisibleWorkspaceHostIds,
|
||||
preserveStringArrayIdentity,
|
||||
sanitizeHydratedActiveView,
|
||||
@@ -226,6 +227,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par
|
||||
return DEFAULT_PET_ID
|
||||
})(),
|
||||
dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null,
|
||||
...hydrateUnexpectedSignoutDismissal(s, ui.dismissedUnexpectedSignoutVersion),
|
||||
// Why: a persisted value from a build that knew a different channel set
|
||||
// would otherwise survive as-is; activeChannel only falls back on null,
|
||||
// so an unknown string reaches listBuilds and the segmented control.
|
||||
|
||||
@@ -238,3 +238,16 @@ export function migrateStatusBarItems(items: readonly string[] | undefined): Sta
|
||||
}
|
||||
return out as StatusBarItem[]
|
||||
}
|
||||
|
||||
export function hydrateUnexpectedSignoutDismissal(
|
||||
state: Pick<UISlice, 'unexpectedSignoutDismissedVersions'>,
|
||||
version: string | null | undefined
|
||||
): Pick<UISlice, 'dismissedUnexpectedSignoutVersion' | 'unexpectedSignoutDismissedVersions'> {
|
||||
const observed = state.unexpectedSignoutDismissedVersions
|
||||
return {
|
||||
dismissedUnexpectedSignoutVersion: version ?? null,
|
||||
// A later sync must never undo any dismissal observed in this session.
|
||||
unexpectedSignoutDismissedVersions:
|
||||
typeof version === 'string' && !observed.includes(version) ? [...observed, version] : observed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,18 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial
|
||||
updateChangelog: null,
|
||||
updateUserInitiatedCycle: false,
|
||||
dismissedUpdateVersion: null,
|
||||
dismissedUnexpectedSignoutVersion: null,
|
||||
unexpectedSignoutDismissedVersions: [],
|
||||
dismissUnexpectedSignoutCard: (version) => {
|
||||
if (get().unexpectedSignoutDismissedVersions.includes(version)) {
|
||||
return
|
||||
}
|
||||
set({
|
||||
dismissedUnexpectedSignoutVersion: version,
|
||||
unexpectedSignoutDismissedVersions: [...get().unexpectedSignoutDismissedVersions, version]
|
||||
})
|
||||
void window.api.ui.set({ dismissedUnexpectedSignoutVersion: version }).catch(console.error)
|
||||
},
|
||||
clearDismissedUpdateVersion: () => {
|
||||
set({ dismissedUpdateVersion: null })
|
||||
},
|
||||
|
||||
@@ -296,6 +296,7 @@ export function getDefaultUIState(): PersistedUIState {
|
||||
usagePercentageDisplay: DEFAULT_USAGE_PERCENTAGE_DISPLAY,
|
||||
statusBarUsageMode: DEFAULT_STATUS_BAR_USAGE_MODE,
|
||||
dismissedUpdateVersion: null,
|
||||
dismissedUnexpectedSignoutVersion: null,
|
||||
lastUpdateCheckAt: null,
|
||||
trustedOrcaHooks: {},
|
||||
setupScriptPromptDismissedRepoIds: [],
|
||||
|
||||
@@ -125,6 +125,8 @@ export type PersistedUIState = {
|
||||
/** Client-side footer presentation; verbose preserves the pre-roster all-window default. */
|
||||
statusBarUsageMode?: StatusBarUsageMode
|
||||
dismissedUpdateVersion: string | null
|
||||
/** App version that last dismissed the unexpected-sign-out card; null = never. Re-arms on each new version while still signed out. */
|
||||
dismissedUnexpectedSignoutVersion?: string | null
|
||||
lastUpdateCheckAt: number | null
|
||||
/** Dev-only update channel override; absent means the build's own channel. */
|
||||
releaseChannelOverride?: ReleaseChannel | null
|
||||
|
||||
Reference in New Issue
Block a user