import { useEffect, useState, useCallback, useRef } from "react"; import { ShieldCheck, ShieldOff, Copy, ExternalLink, RefreshCw, LogOut, Terminal, LogIn } from "lucide-react"; import { api, type OAuthProvider } from "@/lib/api"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { OAuthLoginModal } from "@/components/OAuthLoginModal"; /** * OAuthProvidersCard โ€” surfaces every OAuth-capable LLM provider with its * current connection status, a truncated token preview when connected, and * action buttons (Copy CLI command for setup, Disconnect for cleanup). * * Phase 1 scope: read-only status + disconnect + copy-to-clipboard CLI * command. Phase 2 will add in-browser PKCE / device-code flows so users * never need to drop to a terminal. */ interface Props { onError?: (msg: string) => void; onSuccess?: (msg: string) => void; } const FLOW_LABELS: Record = { pkce: "Browser login (PKCE)", device_code: "Device code", external: "External CLI", }; function formatExpiresAt(expiresAt: string | null | undefined): string | null { if (!expiresAt) return null; try { const dt = new Date(expiresAt); if (Number.isNaN(dt.getTime())) return null; const now = Date.now(); const diff = dt.getTime() - now; if (diff < 0) return "expired"; const mins = Math.floor(diff / 60_000); if (mins < 60) return `expires in ${mins}m`; const hours = Math.floor(mins / 60); if (hours < 24) return `expires in ${hours}h`; const days = Math.floor(hours / 24); return `expires in ${days}d`; } catch { return null; } } export function OAuthProvidersCard({ onError, onSuccess }: Props) { const [providers, setProviders] = useState(null); const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [copiedId, setCopiedId] = useState(null); // Provider that the login modal is currently open for. null = modal closed. const [loginFor, setLoginFor] = useState(null); // Use refs for callbacks to avoid re-creating refresh() when parent re-renders const onErrorRef = useRef(onError); onErrorRef.current = onError; const refresh = useCallback(() => { setLoading(true); api .getOAuthProviders() .then((resp) => setProviders(resp.providers)) .catch((e) => onErrorRef.current?.(`Failed to load providers: ${e}`)) .finally(() => setLoading(false)); }, []); useEffect(() => { refresh(); }, [refresh]); const handleCopy = async (provider: OAuthProvider) => { try { await navigator.clipboard.writeText(provider.cli_command); setCopiedId(provider.id); onSuccess?.(`Copied: ${provider.cli_command}`); setTimeout(() => setCopiedId((v) => (v === provider.id ? null : v)), 1500); } catch { onError?.("Clipboard write failed โ€” copy the command manually"); } }; const handleDisconnect = async (provider: OAuthProvider) => { if (!confirm(`Disconnect ${provider.name}? You'll need to log in again to use this provider.`)) { return; } setBusyId(provider.id); try { await api.disconnectOAuthProvider(provider.id); onSuccess?.(`${provider.name} disconnected`); refresh(); } catch (e) { onError?.(`Disconnect failed: ${e}`); } finally { setBusyId(null); } }; const connectedCount = providers?.filter((p) => p.status.logged_in).length ?? 0; const totalCount = providers?.length ?? 0; return (
Provider Logins (OAuth)
{connectedCount} of {totalCount} OAuth providers connected. Login flows currently run via the CLI; click Copy command and paste into a terminal to set up.
{loading && providers === null && (
)} {providers && providers.length === 0 && (

No OAuth-capable providers detected.

)}
{providers?.map((p) => { const expiresLabel = formatExpiresAt(p.status.expires_at); const isBusy = busyId === p.id; return (
{/* Left: status icon + name + source */}
{p.status.logged_in ? ( ) : ( )}
{p.name} {FLOW_LABELS[p.flow]} {p.status.logged_in && ( Connected )} {expiresLabel === "expired" && ( Expired )} {expiresLabel && expiresLabel !== "expired" && ( {expiresLabel} )}
{p.status.logged_in && p.status.token_preview && ( token{" "} {p.status.token_preview} {p.status.source_label && ( {" "}ยท {p.status.source_label} )} )} {!p.status.logged_in && ( Not connected. Run{" "} {p.cli_command} {" "} in a terminal. )} {p.status.error && ( {p.status.error} )}
{/* Right: action buttons */}
{p.docs_url && ( )} {!p.status.logged_in && p.flow !== "external" && ( )} {!p.status.logged_in && ( )} {p.status.logged_in && p.flow !== "external" && ( )} {p.status.logged_in && p.flow === "external" && ( Managed externally )}
); })}
{loginFor && ( { setLoginFor(null); refresh(); // always refresh on close so token preview updates after login }} onSuccess={(msg) => onSuccess?.(msg)} onError={(msg) => onError?.(msg)} /> )} ); }