- Added Codex authentication endpoints and logic for OAuth flow. - Updated Docker configuration to expose port 1455 for Codex callback. - Enhanced user configuration model to include Codex-related fields. - Integrated Codex selection into the LLM provider UI. - Implemented token management and refresh logic for Codex. - Added utility functions for handling Codex OAuth tokens and state management.
429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
"use client";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Check,
|
|
ChevronsUpDown,
|
|
Loader2,
|
|
LogIn,
|
|
LogOut,
|
|
RefreshCw,
|
|
UserCheck,
|
|
} from "lucide-react";
|
|
import { Button } from "./ui/button";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "./ui/command";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
|
import { cn } from "@/lib/utils";
|
|
import { toast } from "sonner";
|
|
|
|
interface CodexConfigProps {
|
|
codexModel: string;
|
|
onInputChange: (value: string | boolean, field: string) => void;
|
|
}
|
|
|
|
type AuthStatus = "checking" | "unauthenticated" | "polling" | "authenticated";
|
|
|
|
interface StatusResponse {
|
|
status: string;
|
|
account_id?: string;
|
|
detail?: string;
|
|
}
|
|
|
|
interface CodexModel {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
const CHATGPT_MODELS: CodexModel[] = [
|
|
{ id: "gpt-5.1", name: "GPT-5.1" },
|
|
{ id: "gpt-5.1-codex-max", name: "GPT-5.1 Codex Max" },
|
|
{ id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
|
|
{ id: "gpt-5.2", name: "GPT-5.2" },
|
|
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
|
|
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
|
{ id: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark (Free)" },
|
|
];
|
|
|
|
const DEFAULT_CODEX_MODEL = "gpt-5.3-codex-spark";
|
|
|
|
export default function CodexConfig({
|
|
codexModel,
|
|
onInputChange,
|
|
}: CodexConfigProps) {
|
|
const [authStatus, setAuthStatus] = useState<AuthStatus>("checking");
|
|
const [accountId, setAccountId] = useState<string | null>(null);
|
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
const [manualCode, setManualCode] = useState("");
|
|
const [isExchanging, setIsExchanging] = useState(false);
|
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [openModelSelect, setOpenModelSelect] = useState(false);
|
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const stopPolling = () => {
|
|
if (pollIntervalRef.current) {
|
|
clearInterval(pollIntervalRef.current);
|
|
pollIntervalRef.current = null;
|
|
}
|
|
};
|
|
|
|
// Check current auth state on mount
|
|
useEffect(() => {
|
|
checkCurrentAuthStatus();
|
|
return () => stopPolling();
|
|
}, []);
|
|
|
|
const checkCurrentAuthStatus = async () => {
|
|
try {
|
|
const res = await fetch("/api/v1/ppt/codex/auth/status");
|
|
if (!res.ok) {
|
|
setAuthStatus("unauthenticated");
|
|
return;
|
|
}
|
|
const data: StatusResponse = await res.json();
|
|
if (data.status === "authenticated") {
|
|
setAuthStatus("authenticated");
|
|
setAccountId(data.account_id ?? null);
|
|
} else {
|
|
setAuthStatus("unauthenticated");
|
|
}
|
|
} catch {
|
|
setAuthStatus("unauthenticated");
|
|
}
|
|
};
|
|
|
|
const handleSignIn = async () => {
|
|
try {
|
|
const res = await fetch("/api/v1/ppt/codex/auth/initiate", {
|
|
method: "POST",
|
|
});
|
|
if (!res.ok) throw new Error("Failed to initiate auth");
|
|
const data = await res.json();
|
|
const { session_id, url } = data;
|
|
|
|
setSessionId(session_id);
|
|
setAuthStatus("polling");
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
|
|
// Start polling the status endpoint every 2s
|
|
pollIntervalRef.current = setInterval(async () => {
|
|
try {
|
|
const pollRes = await fetch(
|
|
`/api/v1/ppt/codex/auth/status/${session_id}`
|
|
);
|
|
if (!pollRes.ok) return;
|
|
const pollData: StatusResponse = await pollRes.json();
|
|
|
|
if (pollData.status === "success") {
|
|
stopPolling();
|
|
setAuthStatus("authenticated");
|
|
setAccountId(pollData.account_id ?? null);
|
|
setSessionId(null);
|
|
// Set a sensible default model if none chosen
|
|
if (!codexModel) {
|
|
onInputChange(DEFAULT_CODEX_MODEL, "codex_model");
|
|
}
|
|
toast.success("Signed in to ChatGPT successfully");
|
|
} else if (pollData.status === "failed") {
|
|
stopPolling();
|
|
setAuthStatus("unauthenticated");
|
|
toast.error("Authentication failed. Please try again.");
|
|
}
|
|
} catch {
|
|
// keep polling on transient errors
|
|
}
|
|
}, 2000);
|
|
} catch (err) {
|
|
toast.error("Failed to start sign-in flow");
|
|
setAuthStatus("unauthenticated");
|
|
}
|
|
};
|
|
|
|
const handleManualExchange = async () => {
|
|
if (!sessionId || !manualCode.trim()) return;
|
|
setIsExchanging(true);
|
|
try {
|
|
const res = await fetch("/api/v1/ppt/codex/auth/exchange", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ session_id: sessionId, code: manualCode.trim() }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.detail || "Exchange failed");
|
|
}
|
|
const data = await res.json();
|
|
stopPolling();
|
|
setAuthStatus("authenticated");
|
|
setAccountId(data.account_id);
|
|
setSessionId(null);
|
|
setManualCode("");
|
|
if (!codexModel) {
|
|
onInputChange(DEFAULT_CODEX_MODEL, "codex_model");
|
|
}
|
|
toast.success("Signed in to ChatGPT successfully");
|
|
} catch (err: any) {
|
|
toast.error(err.message || "Code exchange failed");
|
|
} finally {
|
|
setIsExchanging(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelPolling = () => {
|
|
stopPolling();
|
|
setSessionId(null);
|
|
setManualCode("");
|
|
setAuthStatus("unauthenticated");
|
|
};
|
|
|
|
const handleSignOut = async () => {
|
|
setIsLoggingOut(true);
|
|
try {
|
|
await fetch("/api/v1/ppt/codex/auth/logout", { method: "POST" });
|
|
setAuthStatus("unauthenticated");
|
|
setAccountId(null);
|
|
onInputChange("", "codex_model");
|
|
toast.success("Signed out from ChatGPT");
|
|
} catch {
|
|
toast.error("Sign out failed");
|
|
} finally {
|
|
setIsLoggingOut(false);
|
|
}
|
|
};
|
|
|
|
const handleRefreshToken = async () => {
|
|
setIsRefreshing(true);
|
|
try {
|
|
const res = await fetch("/api/v1/ppt/codex/auth/refresh", {
|
|
method: "POST",
|
|
});
|
|
if (!res.ok) throw new Error("Refresh failed");
|
|
const data = await res.json();
|
|
if (data.account_id) setAccountId(data.account_id);
|
|
toast.success("Token refreshed successfully");
|
|
} catch {
|
|
toast.error("Token refresh failed. Please sign in again.");
|
|
setAuthStatus("unauthenticated");
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
// ─── Checking ────────────────────────────────────────────────────────────
|
|
if (authStatus === "checking") {
|
|
return (
|
|
<div className="flex items-center justify-center py-12 gap-3 text-gray-500">
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
<span className="text-sm">Checking authentication status…</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Polling / waiting ───────────────────────────────────────────────────
|
|
if (authStatus === "polling") {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col items-center gap-4 py-8 px-4 bg-blue-50 rounded-xl border border-blue-100">
|
|
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium text-blue-900">
|
|
Waiting for authentication…
|
|
</p>
|
|
<p className="text-xs text-blue-600 mt-1">
|
|
Complete the sign-in in the browser tab that just opened.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleCancelPolling}
|
|
className="text-gray-600"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Manual fallback */}
|
|
<div className="space-y-3">
|
|
<p className="text-sm font-medium text-gray-700">
|
|
Didn't get redirected automatically?
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
After completing the sign-in, paste the full redirect URL or
|
|
authorization code below.
|
|
</p>
|
|
<input
|
|
type="text"
|
|
placeholder="Paste redirect URL or authorization code…"
|
|
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors text-sm"
|
|
value={manualCode}
|
|
onChange={(e) => setManualCode(e.target.value)}
|
|
/>
|
|
<Button
|
|
onClick={handleManualExchange}
|
|
disabled={isExchanging || !manualCode.trim()}
|
|
className="w-full"
|
|
>
|
|
{isExchanging ? (
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Exchanging…
|
|
</div>
|
|
) : (
|
|
"Submit Code"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Authenticated ───────────────────────────────────────────────────────
|
|
if (authStatus === "authenticated") {
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Account info */}
|
|
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-xl border border-green-100">
|
|
<UserCheck className="w-6 h-6 text-green-600 shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-green-900">
|
|
Signed in to ChatGPT
|
|
</p>
|
|
{accountId && (
|
|
<p className="text-xs text-green-700 truncate mt-0.5">
|
|
Account: {accountId}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2 shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleRefreshToken}
|
|
disabled={isRefreshing}
|
|
title="Refresh access token"
|
|
className="text-gray-600 border-gray-300"
|
|
>
|
|
{isRefreshing ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleSignOut}
|
|
disabled={isLoggingOut}
|
|
className="text-red-600 border-red-200 hover:bg-red-50"
|
|
>
|
|
{isLoggingOut ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<LogOut className="w-3.5 h-3.5" />
|
|
)}
|
|
<span className="ml-1.5">Sign out</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Model selection */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
Select ChatGPT Model
|
|
</label>
|
|
<Popover open={openModelSelect} onOpenChange={setOpenModelSelect}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={openModelSelect}
|
|
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
|
>
|
|
<span className="text-sm font-medium text-gray-900">
|
|
{codexModel
|
|
? (CHATGPT_MODELS.find((m) => m.id === codexModel)?.name ?? codexModel)
|
|
: "Select a model"}
|
|
</span>
|
|
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
align="start"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder="Search models…" />
|
|
<CommandList>
|
|
<CommandEmpty>No model found.</CommandEmpty>
|
|
<CommandGroup>
|
|
{CHATGPT_MODELS.map((model) => (
|
|
<CommandItem
|
|
key={model.id}
|
|
value={model.id}
|
|
onSelect={(value) => {
|
|
onInputChange(value, "codex_model");
|
|
setOpenModelSelect(false);
|
|
}}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
codexModel === model.id ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<span className="text-sm font-medium text-gray-900">
|
|
{model.name}
|
|
</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<p className="mt-2 text-xs text-gray-500 flex items-center gap-2">
|
|
<span className="block w-1 h-1 rounded-full bg-gray-400" />
|
|
Model availability depends on your ChatGPT subscription tier.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Unauthenticated ─────────────────────────────────────────────────────
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200">
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">
|
|
ChatGPT Plus / Pro
|
|
</h3>
|
|
<p className="text-sm text-gray-600">
|
|
Sign in with your OpenAI account to use ChatGPT models directly via
|
|
OAuth — no API key required.
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleSignIn}
|
|
className="w-full h-12 gap-2 bg-[#10a37f] hover:bg-[#0e8f6f] text-white"
|
|
>
|
|
<LogIn className="w-4 h-4" />
|
|
Sign in with ChatGPT
|
|
</Button>
|
|
|
|
<p className="text-xs text-gray-500 flex items-start gap-2">
|
|
<span className="block w-1 h-1 rounded-full bg-gray-400 mt-1.5 shrink-0" />
|
|
A browser window will open for you to authenticate with your OpenAI
|
|
account. Your credentials are stored locally and never shared.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|