mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +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.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.',
|
||||
|
||||
@ -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 服务商以访问模型。',
|
||||
|
||||
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