Preserve ChatGPT OAuth fallback authorization links

When the desktop shell cannot open a browser, the ChatGPT OAuth component keeps the transient authorization URL available for copy and starts polling after that explicit fallback action.

Constraint: OAuth authorization URLs are transient UI state and must not be persisted.

Rejected: Tell users to find the URL in server logs | the desktop flow does not expose those logs as the recovery surface.

Confidence: high

Scope-risk: narrow

Tested: cd desktop && bun run test -- src/components/settings/ChatGPTOfficialLogin.test.tsx src/stores/hahaOpenAIOAuthStore.test.ts

Tested: cd desktop && bun run test -- src/components/settings/ChatGPTOfficialLogin.test.tsx src/stores/hahaOpenAIOAuthStore.test.ts src/__tests__/generalSettings.test.tsx --testNamePattern "ChatGPT|OpenAI OAuth|Providers tab|ChatGPTOfficialLogin|hahaOpenAIOAuthStore"

Tested: cd desktop && bun run lint

Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 00:04:11 +08:00
parent 7a818ac728
commit d2ff9d0c00
4 changed files with 138 additions and 6 deletions

View File

@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
const {
copyTextToClipboardMock,
logoutMock,
shellOpenMock,
startMock,
statusMock,
} = vi.hoisted(() => ({
copyTextToClipboardMock: vi.fn(),
logoutMock: vi.fn(),
shellOpenMock: vi.fn(),
startMock: vi.fn(),
statusMock: vi.fn(),
}))
vi.mock('@tauri-apps/plugin-shell', () => ({
open: shellOpenMock,
}))
vi.mock('../../api/hahaOpenAIOAuth', () => ({
hahaOpenAIOAuthApi: {
start: startMock,
status: statusMock,
logout: logoutMock,
},
}))
vi.mock('../chat/clipboard', () => ({
copyTextToClipboard: copyTextToClipboardMock,
}))
import { ChatGPTOfficialLogin } from './ChatGPTOfficialLogin'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { useSettingsStore } from '../../stores/settingsStore'
const initialOAuthState = useHahaOpenAIOAuthStore.getState()
describe('ChatGPTOfficialLogin', () => {
beforeEach(() => {
vi.useRealTimers()
startMock.mockReset()
statusMock.mockReset()
logoutMock.mockReset()
shellOpenMock.mockReset()
copyTextToClipboardMock.mockReset()
useSettingsStore.setState({ locale: 'en' })
useHahaOpenAIOAuthStore.setState({
...initialOAuthState,
status: null,
isPolling: false,
isLoading: false,
error: null,
})
})
afterEach(() => {
act(() => {
useHahaOpenAIOAuthStore.getState().stopPolling()
useHahaOpenAIOAuthStore.setState(initialOAuthState)
})
vi.useRealTimers()
cleanup()
vi.restoreAllMocks()
})
it('keeps an actionable authorization link when shell open fails', async () => {
const authorizeUrl = 'https://chatgpt.com/oauth/authorize?state=openai-state'
statusMock.mockResolvedValue({ loggedIn: false })
startMock.mockResolvedValue({ authorizeUrl, state: 'openai-state' })
shellOpenMock.mockRejectedValue(new Error('shell unavailable'))
copyTextToClipboardMock.mockResolvedValue(true)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
render(<ChatGPTOfficialLogin />)
await screen.findByRole('button', { name: 'Sign in with ChatGPT' })
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' }))
})
expect(shellOpenMock).toHaveBeenCalledWith(authorizeUrl)
expect(consoleErrorSpy).toHaveBeenCalledWith('[ChatGPTOfficialLogin] shellOpen failed:', expect.any(Error))
expect(screen.getByText(/Unable to open browser/)).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Copy authorization link' }))
})
expect(copyTextToClipboardMock).toHaveBeenCalledWith(authorizeUrl)
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true)
consoleErrorSpy.mockRestore()
})
})

View File

@ -1,13 +1,15 @@
// desktop/src/components/settings/ChatGPTOfficialLogin.tsx
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { open as shellOpen } from '@tauri-apps/plugin-shell'
import { LogIn, LogOut } from 'lucide-react'
import { Copy, LogIn, LogOut } from 'lucide-react'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { useTranslation } from '../../i18n'
import { copyTextToClipboard } from '../chat/clipboard'
export function ChatGPTOfficialLogin() {
const t = useTranslation()
const [manualAuthorizeUrl, setManualAuthorizeUrl] = useState<string | null>(null)
const {
status,
isLoading,
@ -27,8 +29,10 @@ export function ChatGPTOfficialLogin() {
const handleLogin = async () => {
try {
const { authorizeUrl } = await login()
setManualAuthorizeUrl(authorizeUrl)
try {
await shellOpen(authorizeUrl)
setManualAuthorizeUrl(null)
startPolling()
} catch (err) {
console.error('[ChatGPTOfficialLogin] shellOpen failed:', err)
@ -41,11 +45,38 @@ export function ChatGPTOfficialLogin() {
}
}
const handleCopyAuthorizeUrl = async () => {
if (!manualAuthorizeUrl) return
const copied = await copyTextToClipboard(manualAuthorizeUrl)
if (copied) {
useHahaOpenAIOAuthStore.setState({ error: null })
startPolling()
return
}
useHahaOpenAIOAuthStore.setState({
error: t('settings.chatgptOfficialLogin.copyLinkFailed'),
})
}
const manualAuthorizeButton = manualAuthorizeUrl ? (
<button
type="button"
onClick={handleCopyAuthorizeUrl}
className="inline-flex items-center gap-1.5 self-start rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] px-3 py-1.5 text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
>
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
{t('settings.chatgptOfficialLogin.copyAuthorizeUrl')}
</button>
) : null
if (status === null) {
if (error) {
return (
<div data-testid="chatgpt-official-login" className="text-xs text-[var(--color-error)]">
{t('settings.chatgptOfficialLogin.errorPrefix')}{error}
<div data-testid="chatgpt-official-login" className="flex flex-col gap-2">
<div className="text-xs text-[var(--color-error)]">
{t('settings.chatgptOfficialLogin.errorPrefix')}{error}
</div>
{manualAuthorizeButton}
</div>
)
}
@ -99,6 +130,7 @@ export function ChatGPTOfficialLogin() {
{t('settings.chatgptOfficialLogin.errorPrefix')}{error}
</div>
)}
{manualAuthorizeButton}
</div>
)
}

View File

@ -279,7 +279,9 @@ export const en = {
'settings.chatgptOfficialLogin.logoutProcessing': 'Signing out...',
'settings.chatgptOfficialLogin.loggedInPrefix': 'Signed in as',
'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT account',
'settings.chatgptOfficialLogin.openBrowserFailed': 'Unable to open browser. Copy the authorization URL from server logs and open it manually.',
'settings.chatgptOfficialLogin.openBrowserFailed': 'Unable to open browser. Copy the authorization link and open it manually.',
'settings.chatgptOfficialLogin.copyAuthorizeUrl': 'Copy authorization link',
'settings.chatgptOfficialLogin.copyLinkFailed': 'Unable to copy authorization link.',
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth error: ',
// Settings > Providers

View File

@ -281,7 +281,9 @@ export const zh: Record<TranslationKey, string> = {
'settings.chatgptOfficialLogin.logoutProcessing': '正在退出...',
'settings.chatgptOfficialLogin.loggedInPrefix': '已登录',
'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT 账号',
'settings.chatgptOfficialLogin.openBrowserFailed': '无法打开浏览器。请从服务端日志复制授权链接并手动打开。',
'settings.chatgptOfficialLogin.openBrowserFailed': '无法打开浏览器。请复制授权链接并手动打开。',
'settings.chatgptOfficialLogin.copyAuthorizeUrl': '复制授权链接',
'settings.chatgptOfficialLogin.copyLinkFailed': '无法复制授权链接。',
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 错误:',
// Settings > Providers