mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Manual authorization links now behave as transient recovery state: a fresh login clears older URLs, a successful copy clears the exposed URL before polling, and logged-in status defensively drops any stale manual link. Constraint: OAuth authorization URLs should not survive beyond the active recovery action. Rejected: Keep the copy button after a successful copy | that leaves an expired authorization URL visible after later auth state changes. 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
145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
// desktop/src/components/settings/ChatGPTOfficialLogin.tsx
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { open as shellOpen } from '@tauri-apps/plugin-shell'
|
|
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,
|
|
error,
|
|
fetchStatus,
|
|
login,
|
|
logout,
|
|
startPolling,
|
|
stopPolling,
|
|
} = useHahaOpenAIOAuthStore()
|
|
|
|
useEffect(() => {
|
|
void fetchStatus()
|
|
return () => stopPolling()
|
|
}, [fetchStatus, stopPolling])
|
|
|
|
useEffect(() => {
|
|
if (status?.loggedIn) {
|
|
setManualAuthorizeUrl(null)
|
|
}
|
|
}, [status?.loggedIn])
|
|
|
|
const handleLogin = async () => {
|
|
setManualAuthorizeUrl(null)
|
|
try {
|
|
const { authorizeUrl } = await login()
|
|
setManualAuthorizeUrl(authorizeUrl)
|
|
try {
|
|
await shellOpen(authorizeUrl)
|
|
setManualAuthorizeUrl(null)
|
|
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
|
|
}
|
|
}
|
|
|
|
const handleCopyAuthorizeUrl = async () => {
|
|
if (!manualAuthorizeUrl) return
|
|
const copied = await copyTextToClipboard(manualAuthorizeUrl)
|
|
if (copied) {
|
|
setManualAuthorizeUrl(null)
|
|
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="flex flex-col gap-2">
|
|
<div className="text-xs text-[var(--color-error)]">
|
|
{t('settings.chatgptOfficialLogin.errorPrefix')}{error}
|
|
</div>
|
|
{manualAuthorizeButton}
|
|
</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>
|
|
)}
|
|
{manualAuthorizeButton}
|
|
</div>
|
|
)
|
|
}
|