mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
7a818ac728
commit
d2ff9d0c00
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user