450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
"use client";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Loader2,
|
|
RefreshCw,
|
|
Trash2,
|
|
UserCheck,
|
|
ArrowRight,
|
|
} from "lucide-react";
|
|
import { notify } from "@/components/ui/sonner";
|
|
import { getApiUrl } from "@/utils/api";
|
|
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { syncStoreAfterCodexSignOut } from "@/utils/storeHelpers";
|
|
import {
|
|
DEFAULT_CODEX_MODEL,
|
|
isSupportedCodexModel,
|
|
} from "@/utils/codexModels";
|
|
import {
|
|
isChatGptAuthRequiredResponse,
|
|
normalizeChatGptAuthMessage,
|
|
requestChatGptReauth,
|
|
} from "@/utils/chatgptAuth";
|
|
|
|
interface CodexConfigProps {
|
|
codexModel: string;
|
|
onInputChange: (value: string | boolean, field: string) => void;
|
|
onAuthStatusChange?: (authenticated: boolean) => void;
|
|
}
|
|
|
|
type AuthStatus = "checking" | "unauthenticated" | "polling" | "authenticated";
|
|
|
|
interface StatusResponse {
|
|
status: string;
|
|
account_id?: string;
|
|
username?: string;
|
|
email?: string;
|
|
is_pro?: boolean;
|
|
detail?: string;
|
|
}
|
|
|
|
export default function CodexConfig({
|
|
codexModel,
|
|
onInputChange,
|
|
onAuthStatusChange,
|
|
}: CodexConfigProps) {
|
|
const [authStatus, setAuthStatus] = useState<AuthStatus>("checking");
|
|
const [accountId, setAccountId] = useState<string | null>(null);
|
|
const [username, setUsername] = useState<string | null>(null);
|
|
const [email, setEmail] = 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 pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
|
|
const stopPolling = () => {
|
|
if (pollIntervalRef.current) {
|
|
clearInterval(pollIntervalRef.current);
|
|
pollIntervalRef.current = null;
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
checkCurrentAuthStatus();
|
|
return () => stopPolling();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
onAuthStatusChange?.(authStatus === "authenticated");
|
|
}, [authStatus, onAuthStatusChange]);
|
|
|
|
useEffect(() => {
|
|
if (codexModel && !isSupportedCodexModel(codexModel)) {
|
|
onInputChange(DEFAULT_CODEX_MODEL, "codex_model");
|
|
}
|
|
}, [codexModel, onInputChange]);
|
|
|
|
const applyProfile = (data: Partial<StatusResponse>) => {
|
|
setAccountId(data.account_id ?? null);
|
|
setUsername(data.username ?? null);
|
|
setEmail(data.email ?? null);
|
|
};
|
|
|
|
const checkCurrentAuthStatus = async () => {
|
|
try {
|
|
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/status"));
|
|
if (!res.ok) {
|
|
setAuthStatus("unauthenticated");
|
|
applyProfile({});
|
|
return;
|
|
}
|
|
const data: StatusResponse = await res.json();
|
|
if (data.status === "authenticated") {
|
|
onInputChange('codex', 'LLM');
|
|
if (!isSupportedCodexModel(codexModel)) {
|
|
onInputChange(DEFAULT_CODEX_MODEL, 'codex_model');
|
|
}
|
|
setAuthStatus("authenticated");
|
|
applyProfile(data);
|
|
} else {
|
|
setAuthStatus("unauthenticated");
|
|
applyProfile({});
|
|
}
|
|
} catch {
|
|
setAuthStatus("unauthenticated");
|
|
applyProfile({});
|
|
}
|
|
};
|
|
|
|
const handleSignIn = async () => {
|
|
try {
|
|
|
|
trackEvent(MixpanelEvent.Codex_SignIn_API_Call);
|
|
onInputChange('codex', 'LLM');
|
|
|
|
const res = await fetch(getApiUrl("/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");
|
|
|
|
pollIntervalRef.current = setInterval(async () => {
|
|
try {
|
|
const pollRes = await fetch(
|
|
getApiUrl(`/api/v1/ppt/codex/auth/status/${session_id}`)
|
|
);
|
|
if (!pollRes.ok) return;
|
|
const pollData: StatusResponse = await pollRes.json();
|
|
|
|
if (pollData.status === "success") {
|
|
trackEvent(MixpanelEvent.Codex_SignIn_Completed, { method: "browser_poll" });
|
|
stopPolling();
|
|
setAuthStatus("authenticated");
|
|
applyProfile(pollData);
|
|
setSessionId(null);
|
|
if (!isSupportedCodexModel(codexModel)) {
|
|
onInputChange(DEFAULT_CODEX_MODEL, "codex_model");
|
|
}
|
|
notify.success(
|
|
"Signed in to ChatGPT",
|
|
"Your ChatGPT account is connected and ready to use."
|
|
);
|
|
} else if (pollData.status === "failed") {
|
|
trackEvent(MixpanelEvent.Codex_SignIn_Failed, { method: "browser_poll" });
|
|
stopPolling();
|
|
setAuthStatus("unauthenticated");
|
|
applyProfile({});
|
|
notify.error(
|
|
"Sign-in failed",
|
|
"Authentication did not complete. Please try signing in again."
|
|
);
|
|
}
|
|
} catch {
|
|
// keep polling on transient errors
|
|
}
|
|
}, 2000);
|
|
} catch (err) {
|
|
trackEvent(MixpanelEvent.Codex_SignIn_Failed, { method: "initiate" });
|
|
notify.error(
|
|
"Sign-in failed",
|
|
"Could not start the sign-in flow. Please try again."
|
|
);
|
|
setAuthStatus("unauthenticated");
|
|
applyProfile({});
|
|
}
|
|
};
|
|
|
|
const handleManualExchange = async () => {
|
|
if (!sessionId || !manualCode.trim()) return;
|
|
setIsExchanging(true);
|
|
try {
|
|
const res = await fetch(getApiUrl("/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();
|
|
trackEvent(MixpanelEvent.Codex_SignIn_Completed, { method: "manual_exchange" });
|
|
stopPolling();
|
|
setAuthStatus("authenticated");
|
|
applyProfile(data);
|
|
setSessionId(null);
|
|
setManualCode("");
|
|
if (!isSupportedCodexModel(codexModel)) {
|
|
onInputChange(DEFAULT_CODEX_MODEL, "codex_model");
|
|
}
|
|
notify.success(
|
|
"Signed in to ChatGPT",
|
|
"Your ChatGPT account is connected and ready to use."
|
|
);
|
|
} catch (err: any) {
|
|
trackEvent(MixpanelEvent.Codex_SignIn_Failed, { method: "manual_exchange" });
|
|
notify.error(
|
|
"Sign-in failed",
|
|
err.message || "The verification code could not be accepted. Please try again."
|
|
);
|
|
} finally {
|
|
setIsExchanging(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelPolling = () => {
|
|
trackEvent(MixpanelEvent.Codex_SignIn_Cancelled);
|
|
stopPolling();
|
|
setSessionId(null);
|
|
setManualCode("");
|
|
setAuthStatus("unauthenticated");
|
|
};
|
|
|
|
const handleSignOut = async () => {
|
|
setIsLoggingOut(true);
|
|
try {
|
|
await fetch(getApiUrl("/api/v1/ppt/codex/auth/logout"), { method: "POST" });
|
|
trackEvent(MixpanelEvent.Codex_Signed_Out);
|
|
setAuthStatus("unauthenticated");
|
|
setAccountId(null);
|
|
setUsername(null);
|
|
setEmail(null);
|
|
onInputChange("", "codex_model");
|
|
onInputChange("", "CODEX_ACCESS_TOKEN");
|
|
onInputChange("", "CODEX_REFRESH_TOKEN");
|
|
onInputChange("", "CODEX_TOKEN_EXPIRES");
|
|
onInputChange("", "CODEX_ACCOUNT_ID");
|
|
onInputChange("", "CODEX_USERNAME");
|
|
onInputChange("", "CODEX_EMAIL");
|
|
onInputChange(false, "CODEX_IS_PRO");
|
|
syncStoreAfterCodexSignOut();
|
|
router.replace(pathname.startsWith("/settings") ? "/settings" : "/");
|
|
notify.success(
|
|
"Signed out",
|
|
"You have been disconnected from ChatGPT."
|
|
);
|
|
} catch {
|
|
notify.error(
|
|
"Sign-out failed",
|
|
"Could not disconnect from ChatGPT. Please try again."
|
|
);
|
|
} finally {
|
|
setIsLoggingOut(false);
|
|
}
|
|
};
|
|
|
|
const handleRefreshToken = async () => {
|
|
setIsRefreshing(true);
|
|
try {
|
|
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/refresh"), {
|
|
method: "POST",
|
|
});
|
|
if (!res.ok) {
|
|
let errorData: { detail?: unknown; message?: string; error?: string } | null = null;
|
|
let message = "Your ChatGPT session could not be renewed. Please sign in again.";
|
|
try {
|
|
const parsedError: { detail?: unknown; message?: string; error?: string } = await res.json();
|
|
errorData = parsedError;
|
|
message =
|
|
(typeof parsedError.detail === "string" && parsedError.detail) ||
|
|
parsedError.message ||
|
|
parsedError.error ||
|
|
message;
|
|
} catch {}
|
|
if (isChatGptAuthRequiredResponse(res, errorData, message)) {
|
|
requestChatGptReauth({
|
|
message: normalizeChatGptAuthMessage(message),
|
|
source: "codex-refresh",
|
|
});
|
|
return;
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
const data = await res.json();
|
|
applyProfile(data);
|
|
notify.success(
|
|
"Session refreshed",
|
|
"Your ChatGPT connection was renewed successfully."
|
|
);
|
|
} catch {
|
|
notify.error(
|
|
"Session refresh failed",
|
|
"Your ChatGPT session could not be renewed. Please sign in again."
|
|
);
|
|
setAuthStatus("unauthenticated");
|
|
applyProfile({});
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
if (authStatus === "checking") {
|
|
return (
|
|
<div className="mb-5 w-full p-3 border border-[#EDEEEF] font-syne rounded-[8px] flex items-center gap-6">
|
|
<div className="w-[74px] h-[74px] bg-[#333333] rounded-full flex items-center justify-center shrink-0">
|
|
<Loader2 className="w-10 h-10 text-[#191919] animate-spin" />
|
|
</div>
|
|
<div className="text-start flex-1 min-w-0">
|
|
<h4 className="text-[#191919] text-lg font-medium">Checking status</h4>
|
|
<p className="text-[#B3B3B3] text-sm font-normal">
|
|
Verifying your ChatGPT connection…
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (authStatus === "polling") {
|
|
return (
|
|
<div className="mb-5 space-y-4 font-syne">
|
|
<div className="w-full p-3 border border-[#EDEEEF] rounded-[8px] flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-6 min-w-0 flex-1">
|
|
<div className="w-[40px] h-[40px] bg-[#EDEEEF] rounded-full flex items-center justify-center shrink-0">
|
|
<Loader2 className="w-5 h-5 text-[#191919] animate-spin" />
|
|
</div>
|
|
<div className="text-start min-w-0">
|
|
<h4 className="text-[#191919] text-lg font-medium">Waiting for sign-in</h4>
|
|
<p className="text-[#B3B3B3] text-sm font-normal">
|
|
Complete sign-in in the browser tab we opened.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleCancelPolling}
|
|
className="shrink-0 text-sm text-[#B3B3B3] hover:text-[#191919] underline underline-offset-2 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-2 rounded-[8px] border border-[#EDEEEF] p-3">
|
|
<p className="text-[#191919] text-xs font-normal">
|
|
Paste redirect URL or code if you were not redirected automatically
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder="Paste URL or code…"
|
|
className="flex-1 min-w-0 px-3 py-2.5 outline-none border border-[#EDEEEF] rounded-[8px] text-sm text-[#191919] placeholder:text-[#666666] focus:border-[#555555] transition-colors"
|
|
value={manualCode}
|
|
onChange={(e) => setManualCode(e.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleManualExchange}
|
|
disabled={isExchanging || !manualCode.trim()}
|
|
className="shrink-0 px-4 py-2.5 bg-[#EDEEEF] hover:bg-[#E4E5E6] disabled:opacity-40 disabled:hover:bg-[#EDEEEF] rounded-[8px] text-sm font-medium text-[#191919] transition-colors flex items-center justify-center min-w-[88px]"
|
|
>
|
|
{isExchanging ? (
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
) : (
|
|
"Submit"
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (authStatus === "authenticated") {
|
|
|
|
return (
|
|
<div className=" mb-5">
|
|
<div className="flex items-center justify-between gap-3 p-5 border border-[#EDEEEF] rounded-[8px]">
|
|
<div className="flex items-center gap-3">
|
|
|
|
<div className="w-[40px] h-[40px] bg-[#333333] rounded-full flex items-center justify-center" >
|
|
|
|
<img src="/providers/OpenAI-white.png" alt="openai Logo" className="w-[27px] h-[27px]" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<p className="text-sm font-medium text-[#191919] truncate">
|
|
{username || email || (accountId ? `Account ${accountId}` : "ChatGPT Account")}
|
|
</p>
|
|
|
|
</div>
|
|
{email && username && (
|
|
<p className="text-xs text-[#B3B3B3] truncate">{email}</p>
|
|
)}
|
|
{!email && accountId && (
|
|
<p className="text-xs text-[#B3B3B3] truncate">ID: {accountId}</p>
|
|
)}
|
|
<p className="text-xs text-[#B3B3B3]">Signed in to ChatGPT</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-1.5 shrink-0">
|
|
<button
|
|
onClick={handleRefreshToken}
|
|
disabled={isRefreshing}
|
|
title="Refresh token"
|
|
className="flex items-center justify-center px-3.5 py-2.5 border border-[#EDEEEF] rounded-[58px] minid:opacity-40 transition-colors"
|
|
>
|
|
{isRefreshing ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#191919]" />
|
|
) : (
|
|
<RefreshCw className="w-3.5 h-3.5 text-[#191919]" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={handleSignOut}
|
|
disabled={isLoggingOut}
|
|
title="Sign out"
|
|
className="flex items-center justify-center px-3.5 py-2.5 border border-[#EDEEEF] rounded-[58px] disabled:opacity-40 transition-colors"
|
|
>
|
|
{isLoggingOut ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#191919]" />
|
|
) : (
|
|
<Trash2 className="w-3.5 h-3.5 text-[#191919]" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
onClick={handleSignIn}
|
|
className=" w-full p-5 border border-[#EDEEEF] font-syne hover:bg-[#F7F6F9] transition-colors duration-300 rounded-[12px] flex items-center justify-between "
|
|
>
|
|
<div className="flex items-center gap-2 flex-1">
|
|
<div className="w-[40px] h-[40px] bg-[#333333] rounded-full flex items-center justify-center" >
|
|
|
|
<img src="/providers/OpenAI-white.png" alt="openai Logo" className="w-[27px] h-[27px]" />
|
|
</div>
|
|
<div className="text-start flex-1">
|
|
<h4 className="text-[#191919] text-sm font-medium">Sign in with ChatGPT</h4>
|
|
<p className="text-[#B3B3B3] text-xs font-normal">Use your ChatGPT account — no API key required</p>
|
|
</div>
|
|
</div>
|
|
<ArrowRight className="w-[22px] h-[22px] text-[#4C4C4C]" />
|
|
</button>
|
|
);
|
|
}
|