mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
parent
7713c95621
commit
83ee4c09ec
38
desktop/src/api/hahaOpenAIOAuth.ts
Normal file
38
desktop/src/api/hahaOpenAIOAuth.ts
Normal 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')
|
||||||
|
},
|
||||||
|
}
|
||||||
104
desktop/src/components/settings/ChatGPTOfficialLogin.tsx
Normal file
104
desktop/src/components/settings/ChatGPTOfficialLogin.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -271,6 +271,17 @@ export const en = {
|
|||||||
'settings.claudeOfficialLogin.errorPrefix': 'Error: ',
|
'settings.claudeOfficialLogin.errorPrefix': 'Error: ',
|
||||||
'settings.claudeOfficialLogin.openBrowserFailed': 'Failed to open browser; please visit the authorization URL manually.',
|
'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
|
||||||
'settings.providers.title': 'Providers',
|
'settings.providers.title': 'Providers',
|
||||||
'settings.providers.description': 'Manage API providers for model access.',
|
'settings.providers.description': 'Manage API providers for model access.',
|
||||||
|
|||||||
@ -273,6 +273,17 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.claudeOfficialLogin.errorPrefix': '错误:',
|
'settings.claudeOfficialLogin.errorPrefix': '错误:',
|
||||||
'settings.claudeOfficialLogin.openBrowserFailed': '无法打开浏览器,请手动访问授权链接。',
|
'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
|
||||||
'settings.providers.title': '服务商',
|
'settings.providers.title': '服务商',
|
||||||
'settings.providers.description': '管理 API 服务商以访问模型。',
|
'settings.providers.description': '管理 API 服务商以访问模型。',
|
||||||
|
|||||||
97
desktop/src/stores/hahaOpenAIOAuthStore.test.ts
Normal file
97
desktop/src/stores/hahaOpenAIOAuthStore.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
101
desktop/src/stores/hahaOpenAIOAuthStore.ts
Normal file
101
desktop/src/stores/hahaOpenAIOAuthStore.ts
Normal 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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user