From d2ff9d0c000e538e8a4a3c06e65374f3650d892e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 00:04:11 +0800 Subject: [PATCH] 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 --- .../settings/ChatGPTOfficialLogin.test.tsx | 96 +++++++++++++++++++ .../settings/ChatGPTOfficialLogin.tsx | 40 +++++++- desktop/src/i18n/locales/en.ts | 4 +- desktop/src/i18n/locales/zh.ts | 4 +- 4 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx new file mode 100644 index 00000000..48f7b93b --- /dev/null +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx @@ -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() + + 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() + }) +}) diff --git a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx index 4bbe0082..c973254f 100644 --- a/desktop/src/components/settings/ChatGPTOfficialLogin.tsx +++ b/desktop/src/components/settings/ChatGPTOfficialLogin.tsx @@ -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(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 ? ( + + ) : null + if (status === null) { if (error) { return ( -
- {t('settings.chatgptOfficialLogin.errorPrefix')}{error} +
+
+ {t('settings.chatgptOfficialLogin.errorPrefix')}{error} +
+ {manualAuthorizeButton}
) } @@ -99,6 +130,7 @@ export function ChatGPTOfficialLogin() { {t('settings.chatgptOfficialLogin.errorPrefix')}{error}
)} + {manualAuthorizeButton} ) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 0d57aa4a..7d955eb1 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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 diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 392d090b..12fa0df1 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -281,7 +281,9 @@ export const zh: Record = { '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