picoclaw/web/frontend/src/components/credentials/openai-credential-card.tsx
wenjie 6f8c91f8c3 feat(credentials): add multi-provider OAuth credential management
- add backend `/api/oauth/*` endpoints for provider status, browser/device-code/token login, flow query/polling, and logout
- extend API handler with OAuth flow/state tracking and route registration, plus OAuth unit tests
- implement frontend credentials page/components for OpenAI, Anthropic, and Google Antigravity login/logout
- add OAuth API client and `useCredentialsPage` hook, with new EN/ZH i18n strings
2026-03-09 19:24:44 +08:00

161 lines
5.1 KiB
TypeScript

import {
IconBrandOpenai,
IconClockHour4,
IconKey,
IconLoader2,
IconPlayerStopFilled,
} from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { OAuthProviderStatus } from "@/api/oauth"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { CredentialCard } from "./credential-card"
interface OpenAICredentialCardProps {
status?: OAuthProviderStatus
activeAction: string
token: string
onTokenChange: (value: string) => void
onStartBrowserOAuth: () => void
onStartDeviceCode: () => void
onStopLoading: () => void
onSaveToken: () => void
onAskLogout: () => void
}
export function OpenAICredentialCard({
status,
activeAction,
token,
onTokenChange,
onStartBrowserOAuth,
onStartDeviceCode,
onStopLoading,
onSaveToken,
onAskLogout,
}: OpenAICredentialCardProps) {
const { t } = useTranslation()
const actionBusy = activeAction !== ""
const browserLoading = activeAction === "openai:browser"
const deviceLoading = activeAction === "openai:device"
const oauthLoading = browserLoading || deviceLoading
const tokenLoading = activeAction === "openai:token"
return (
<CredentialCard
title="OpenAI"
description={t(
"credentials.providers.openai.description",
"Supports browser OAuth, device code, and token login.",
)}
status={status?.status ?? "not_logged_in"}
authMethod={status?.auth_method}
details={
status?.account_id ? (
<p>
{t("credentials.labels.account", "Account")}: {status.account_id}
</p>
) : null
}
actions={
<div className="border-muted flex h-[120px] flex-col rounded-lg border p-3">
<div className="flex h-full flex-col gap-3">
<div className="min-h-8">
<div className="flex flex-nowrap items-center gap-2 overflow-x-auto">
<Button
size="sm"
variant="outline"
disabled={actionBusy}
onClick={onStartBrowserOAuth}
>
{browserLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconBrandOpenai className="size-4" />
{t("credentials.actions.browser", "Browser OAuth")}
</Button>
{oauthLoading && !deviceLoading && (
<Button
size="icon-xs"
variant="secondary"
onClick={onStopLoading}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<IconPlayerStopFilled className="size-4" />
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={actionBusy}
onClick={onStartDeviceCode}
>
{deviceLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconClockHour4 className="size-4" />
{t("credentials.actions.deviceCode", "Device Code")}
</Button>
</div>
</div>
<div className="min-h-9 flex-1">
<div className="flex h-full items-center gap-2">
<Input
value={token}
onChange={(e) => onTokenChange(e.target.value)}
type="password"
placeholder={t(
"credentials.fields.openaiToken",
"OpenAI token",
)}
/>
<Button
size="sm"
disabled={actionBusy || !token.trim()}
onClick={onSaveToken}
>
{tokenLoading && (
<IconLoader2 className="size-4 animate-spin" />
)}
<IconKey className="size-4" />
{t("credentials.actions.saveToken", "Save")}
</Button>
{tokenLoading && (
<Button
size="icon-sm"
variant="ghost"
onClick={onStopLoading}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<IconPlayerStopFilled className="size-4" />
</Button>
)}
</div>
</div>
</div>
</div>
}
footer={
status?.logged_in ? (
<Button
variant="ghost"
size="sm"
disabled={actionBusy}
onClick={onAskLogout}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{activeAction === "openai:logout" && (
<IconLoader2 className="size-4 animate-spin" />
)}
{t("credentials.actions.logout", "Logout")}
</Button>
) : null
}
/>
)
}