Add desktop ChatGPT OAuth controls

The desktop app now has a dedicated OpenAI OAuth API client and store mirroring the existing Claude Official flow. The component uses the existing browser-open plus polling pattern and never exposes token material to the UI.

Constraint: OAuth status responses must not return token bodies

Rejected: Reuse Claude OAuth store | the status shape and endpoint differ

Confidence: high

Scope-risk: narrow

Tested: cd desktop && bun run test -- src/stores/hahaOpenAIOAuthStore.test.ts

Tested: git diff --check
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 23:53:11 +08:00
parent 7713c95621
commit 83ee4c09ec
6 changed files with 362 additions and 0 deletions

View File

@ -0,0 +1,38 @@
// desktop/src/api/hahaOpenAIOAuth.ts
import { api, getBaseUrl } from './client'
export type HahaOpenAIOAuthStatus =
| { loggedIn: false }
| {
loggedIn: true
expiresAt: number | null
email: string | null
accountId: string | null
}
function currentServerPort(): number {
const port = new URL(getBaseUrl()).port
const parsed = Number.parseInt(port, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Cannot determine server port from baseUrl: ${getBaseUrl()}`)
}
return parsed
}
export const hahaOpenAIOAuthApi = {
start() {
return api.post<{ authorizeUrl: string; state: string }>(
'/api/haha-openai-oauth/start',
{ serverPort: currentServerPort() },
)
},
status() {
return api.get<HahaOpenAIOAuthStatus>('/api/haha-openai-oauth')
},
logout() {
return api.delete<{ ok: true }>('/api/haha-openai-oauth')
},
}

View File

@ -0,0 +1,104 @@
// desktop/src/components/settings/ChatGPTOfficialLogin.tsx
import { useEffect } from 'react'
import { open as shellOpen } from '@tauri-apps/plugin-shell'
import { LogIn, LogOut } from 'lucide-react'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { useTranslation } from '../../i18n'
export function ChatGPTOfficialLogin() {
const t = useTranslation()
const {
status,
isLoading,
error,
fetchStatus,
login,
logout,
startPolling,
stopPolling,
} = useHahaOpenAIOAuthStore()
useEffect(() => {
void fetchStatus()
return () => stopPolling()
}, [fetchStatus, stopPolling])
const handleLogin = async () => {
try {
const { authorizeUrl } = await login()
try {
await shellOpen(authorizeUrl)
startPolling()
} catch (err) {
console.error('[ChatGPTOfficialLogin] shellOpen failed:', err)
useHahaOpenAIOAuthStore.setState({
error: t('settings.chatgptOfficialLogin.openBrowserFailed'),
})
}
} catch {
// store.login() errors are already captured into store.error
}
}
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>
)
}
return (
<div data-testid="chatgpt-official-login" className="text-xs text-[var(--color-text-tertiary)]">
{t('common.loading')}
</div>
)
}
if (status.loggedIn) {
const accountLabel = status.email || status.accountId || t('settings.chatgptOfficialLogin.accountUnknown')
return (
<div data-testid="chatgpt-official-login" className="flex items-center gap-3 text-sm">
<span className="text-[var(--color-success)]">
{t('settings.chatgptOfficialLogin.loggedInPrefix')} {accountLabel}
</span>
<button
type="button"
onClick={logout}
disabled={isLoading}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] px-3 py-1 text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
>
<LogOut className="h-3.5 w-3.5" aria-hidden="true" />
{isLoading
? t('settings.chatgptOfficialLogin.logoutProcessing')
: t('settings.chatgptOfficialLogin.logoutButton')}
</button>
</div>
)
}
return (
<div data-testid="chatgpt-official-login" className="flex flex-col gap-2">
<div className="text-sm text-[var(--color-text-secondary)]">
{t('settings.chatgptOfficialLogin.intro')}
</div>
<button
type="button"
onClick={handleLogin}
disabled={isLoading}
className="inline-flex items-center gap-2 self-start rounded-md bg-[image:var(--gradient-btn-primary)] px-4 py-2 text-sm text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-opacity hover:brightness-105 disabled:opacity-50"
>
<LogIn className="h-4 w-4" aria-hidden="true" />
{isLoading
? t('settings.chatgptOfficialLogin.loginStarting')
: t('settings.chatgptOfficialLogin.loginButton')}
</button>
{error && (
<div className="text-xs text-[var(--color-error)]">
{t('settings.chatgptOfficialLogin.errorPrefix')}{error}
</div>
)}
</div>
)
}

View File

@ -271,6 +271,17 @@ export const en = {
'settings.claudeOfficialLogin.errorPrefix': 'Error: ',
'settings.claudeOfficialLogin.openBrowserFailed': 'Failed to open browser; please visit the authorization URL manually.',
// Settings > ChatGPT Official Login
'settings.chatgptOfficialLogin.intro': 'Sign in with ChatGPT to use GPT models from desktop sessions.',
'settings.chatgptOfficialLogin.loginButton': 'Sign in with ChatGPT',
'settings.chatgptOfficialLogin.loginStarting': 'Starting sign-in...',
'settings.chatgptOfficialLogin.logoutButton': 'Sign out',
'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.errorPrefix': 'ChatGPT OAuth error: ',
// Settings > Providers
'settings.providers.title': 'Providers',
'settings.providers.description': 'Manage API providers for model access.',

View File

@ -273,6 +273,17 @@ export const zh: Record<TranslationKey, string> = {
'settings.claudeOfficialLogin.errorPrefix': '错误:',
'settings.claudeOfficialLogin.openBrowserFailed': '无法打开浏览器,请手动访问授权链接。',
// Settings > ChatGPT Official Login
'settings.chatgptOfficialLogin.intro': '登录 ChatGPT 后,就可以在桌面端会话里使用 GPT 模型。',
'settings.chatgptOfficialLogin.loginButton': '登录 ChatGPT',
'settings.chatgptOfficialLogin.loginStarting': '正在启动登录...',
'settings.chatgptOfficialLogin.logoutButton': '退出登录',
'settings.chatgptOfficialLogin.logoutProcessing': '正在退出...',
'settings.chatgptOfficialLogin.loggedInPrefix': '已登录',
'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT 账号',
'settings.chatgptOfficialLogin.openBrowserFailed': '无法打开浏览器。请从服务端日志复制授权链接并手动打开。',
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 错误:',
// Settings > Providers
'settings.providers.title': '服务商',
'settings.providers.description': '管理 API 服务商以访问模型。',

View File

@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { startMock, statusMock, logoutMock } = vi.hoisted(() => ({
startMock: vi.fn(),
statusMock: vi.fn(),
logoutMock: vi.fn(),
}))
vi.mock('../api/hahaOpenAIOAuth', () => ({
hahaOpenAIOAuthApi: {
start: startMock,
status: statusMock,
logout: logoutMock,
},
}))
import { useHahaOpenAIOAuthStore } from './hahaOpenAIOAuthStore'
const initialState = useHahaOpenAIOAuthStore.getState()
describe('hahaOpenAIOAuthStore', () => {
beforeEach(() => {
vi.useFakeTimers()
startMock.mockReset()
statusMock.mockReset()
logoutMock.mockReset()
useHahaOpenAIOAuthStore.setState({
...initialState,
status: null,
isPolling: false,
isLoading: false,
error: null,
})
})
afterEach(() => {
useHahaOpenAIOAuthStore.getState().stopPolling()
useHahaOpenAIOAuthStore.setState(initialState)
vi.useRealTimers()
})
it('login returns authorizeUrl without starting polling', async () => {
startMock.mockResolvedValue({
authorizeUrl: 'http://localhost:3456/callback/openai?state=openai-state',
state: 'openai-state',
})
const result = await useHahaOpenAIOAuthStore.getState().login()
expect(result.authorizeUrl).toContain('/callback/openai')
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false)
})
it('startPolling stops after OpenAI OAuth status becomes logged in', async () => {
statusMock
.mockResolvedValueOnce({ loggedIn: false })
.mockResolvedValueOnce({
loggedIn: true,
expiresAt: Date.now() + 60_000,
email: 'user@example.com',
accountId: 'acct_123',
})
useHahaOpenAIOAuthStore.getState().startPolling()
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true)
await vi.advanceTimersByTimeAsync(2_000)
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true)
await vi.advanceTimersByTimeAsync(2_000)
expect(useHahaOpenAIOAuthStore.getState().status).toMatchObject({
loggedIn: true,
email: 'user@example.com',
accountId: 'acct_123',
})
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false)
})
it('logout clears status and stops polling', async () => {
logoutMock.mockResolvedValue({ ok: true })
useHahaOpenAIOAuthStore.setState({
status: {
loggedIn: true,
expiresAt: Date.now() + 60_000,
email: 'user@example.com',
accountId: 'acct_123',
},
})
useHahaOpenAIOAuthStore.getState().startPolling()
await useHahaOpenAIOAuthStore.getState().logout()
expect(logoutMock).toHaveBeenCalledTimes(1)
expect(useHahaOpenAIOAuthStore.getState().status).toEqual({ loggedIn: false })
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false)
})
})

View File

@ -0,0 +1,101 @@
// desktop/src/stores/hahaOpenAIOAuthStore.ts
import { create } from 'zustand'
import {
hahaOpenAIOAuthApi,
type HahaOpenAIOAuthStatus,
} from '../api/hahaOpenAIOAuth'
const POLL_INTERVAL_MS = 2_000
type HahaOpenAIOAuthState = {
status: HahaOpenAIOAuthStatus | null
isPolling: boolean
isLoading: boolean
error: string | null
fetchStatus: () => Promise<void>
login: () => Promise<{ authorizeUrl: string }>
logout: () => Promise<void>
startPolling: () => void
stopPolling: () => void
}
export const useHahaOpenAIOAuthStore = create<HahaOpenAIOAuthState>((set, get) => {
let pollTimer: ReturnType<typeof setTimeout> | null = null
return {
status: null,
isPolling: false,
isLoading: false,
error: null,
fetchStatus: async () => {
try {
const status = await hahaOpenAIOAuthApi.status()
set({ status, error: null })
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) })
}
},
login: async () => {
set({ isLoading: true, error: null })
try {
const res = await hahaOpenAIOAuthApi.start()
set({ isLoading: false })
return { authorizeUrl: res.authorizeUrl }
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : String(err),
})
throw err
}
},
logout: async () => {
get().stopPolling()
set({ isLoading: true, error: null })
try {
await hahaOpenAIOAuthApi.logout()
set({ status: { loggedIn: false }, isLoading: false })
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : String(err),
})
throw err
}
},
startPolling: () => {
if (pollTimer) return
set({ isPolling: true })
const scheduleNext = () => {
pollTimer = setTimeout(async () => {
pollTimer = null
await get().fetchStatus()
const cur = get().status
if (cur && cur.loggedIn) {
get().stopPolling()
return
}
if (get().isPolling) {
scheduleNext()
}
}, POLL_INTERVAL_MS)
}
scheduleNext()
},
stopPolling: () => {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
set({ isPolling: false })
},
}
})