diff --git a/electron/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py b/electron/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py index c6576888..da28db6d 100644 --- a/electron/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py +++ b/electron/servers/fastapi/api/v1/ppt/endpoints/codex_auth.py @@ -16,25 +16,32 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from utils.oauth.openai_codex import ( + CodexAccountProfile, OAuthCallbackServer, TokenSuccess, create_authorization_flow, exchange_authorization_code, - get_account_id, + get_account_profile, parse_authorization_input, refresh_access_token, ) from utils.get_env import ( get_codex_access_token_env, + get_codex_email_env, + get_codex_is_pro_env, get_codex_refresh_token_env, get_codex_token_expires_env, + get_codex_username_env, ) from utils.set_env import ( set_codex_access_token_env, set_codex_account_id_env, + set_codex_email_env, + set_codex_is_pro_env, set_codex_refresh_token_env, set_codex_token_expires_env, set_codex_model_env, + set_codex_username_env, ) from utils.user_config import save_codex_tokens_to_user_config @@ -60,6 +67,9 @@ class InitiateResponse(BaseModel): class StatusResponse(BaseModel): status: str # "pending" | "success" | "failed" account_id: Optional[str] = None + username: Optional[str] = None + email: Optional[str] = None + is_pro: Optional[bool] = None detail: Optional[str] = None @@ -69,11 +79,17 @@ class ExchangeRequest(BaseModel): class ExchangeResponse(BaseModel): - account_id: str + account_id: Optional[str] = None + username: Optional[str] = None + email: Optional[str] = None + is_pro: Optional[bool] = None class RefreshResponse(BaseModel): account_id: Optional[str] + username: Optional[str] = None + email: Optional[str] = None + is_pro: Optional[bool] = None detail: str @@ -81,16 +97,31 @@ class RefreshResponse(BaseModel): # Helper # --------------------------------------------------------------------------- -def _store_token(result: TokenSuccess) -> Optional[str]: - """Persist token fields in env vars and userConfig.json. Returns account_id or None.""" +def _parse_optional_bool(value: Optional[str]) -> Optional[bool]: + if value is None: + return None + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "y"}: + return True + if normalized in {"false", "0", "no", "n"}: + return False + return None + + +def _store_token(result: TokenSuccess) -> CodexAccountProfile: + """Persist token fields in env vars and userConfig.json. Returns parsed profile.""" set_codex_access_token_env(result.access) set_codex_refresh_token_env(result.refresh) set_codex_token_expires_env(str(result.expires)) - account_id = get_account_id(result.access) - if account_id: - set_codex_account_id_env(account_id) + + profile = get_account_profile(result.access, result.id_token) + set_codex_account_id_env(profile.account_id or "") + set_codex_username_env(profile.username or "") + set_codex_email_env(profile.email or "") + set_codex_is_pro_env("" if profile.is_pro is None else str(profile.is_pro)) + save_codex_tokens_to_user_config() - return account_id + return profile # --------------------------------------------------------------------------- @@ -166,8 +197,14 @@ async def poll_codex_auth_status(session_id: str): if not isinstance(result, TokenSuccess): return StatusResponse(status="failed", detail=result.reason) - account_id = _store_token(result) - return StatusResponse(status="success", account_id=account_id) + profile = _store_token(result) + return StatusResponse( + status="success", + account_id=profile.account_id, + username=profile.username, + email=profile.email, + is_pro=profile.is_pro, + ) @CODEX_AUTH_ROUTER.post("/exchange", response_model=ExchangeResponse) @@ -207,11 +244,16 @@ async def exchange_codex_code(body: ExchangeRequest): if not isinstance(result, TokenSuccess): raise HTTPException(status_code=502, detail=f"Token exchange failed: {result.reason}") - account_id = _store_token(result) - if not account_id: + profile = _store_token(result) + if not profile.account_id: raise HTTPException(status_code=502, detail="Token exchanged but could not extract account ID") - return ExchangeResponse(account_id=account_id) + return ExchangeResponse( + account_id=profile.account_id, + username=profile.username, + email=profile.email, + is_pro=profile.is_pro, + ) @CODEX_AUTH_ROUTER.post("/refresh", response_model=RefreshResponse) @@ -232,9 +274,12 @@ async def refresh_codex_token(): if not isinstance(result, TokenSuccess): raise HTTPException(status_code=502, detail=f"Token refresh failed: {result.reason}") - account_id = _store_token(result) + profile = _store_token(result) return RefreshResponse( - account_id=account_id, + account_id=profile.account_id, + username=profile.username, + email=profile.email, + is_pro=profile.is_pro, detail="Token refreshed successfully", ) @@ -260,8 +305,18 @@ async def get_codex_auth_status(): except (ValueError, TypeError): pass - account_id = get_account_id(access_token) - return StatusResponse(status="authenticated", account_id=account_id) + profile = get_account_profile(access_token) + return StatusResponse( + status="authenticated", + account_id=profile.account_id, + username=profile.username or get_codex_username_env(), + email=profile.email or get_codex_email_env(), + is_pro=( + profile.is_pro + if profile.is_pro is not None + else _parse_optional_bool(get_codex_is_pro_env()) + ), + ) @CODEX_AUTH_ROUTER.post("/logout") @@ -273,6 +328,9 @@ async def logout_codex(): set_codex_refresh_token_env("") set_codex_token_expires_env("") set_codex_account_id_env("") + set_codex_username_env("") + set_codex_email_env("") + set_codex_is_pro_env("") set_codex_model_env("") save_codex_tokens_to_user_config() return {"detail": "Logged out successfully"} diff --git a/electron/servers/fastapi/models/user_config.py b/electron/servers/fastapi/models/user_config.py index c26a6cb0..05b050d7 100644 --- a/electron/servers/fastapi/models/user_config.py +++ b/electron/servers/fastapi/models/user_config.py @@ -55,3 +55,6 @@ class UserConfig(BaseModel): CODEX_REFRESH_TOKEN: Optional[str] = None CODEX_TOKEN_EXPIRES: Optional[str] = None CODEX_ACCOUNT_ID: Optional[str] = None + CODEX_USERNAME: Optional[str] = None + CODEX_EMAIL: Optional[str] = None + CODEX_IS_PRO: Optional[bool] = None diff --git a/electron/servers/fastapi/utils/get_env.py b/electron/servers/fastapi/utils/get_env.py index 53680acd..bce31306 100644 --- a/electron/servers/fastapi/utils/get_env.py +++ b/electron/servers/fastapi/utils/get_env.py @@ -136,6 +136,18 @@ def get_codex_account_id_env(): return os.getenv("CODEX_ACCOUNT_ID") +def get_codex_username_env(): + return os.getenv("CODEX_USERNAME") + + +def get_codex_email_env(): + return os.getenv("CODEX_EMAIL") + + +def get_codex_is_pro_env(): + return os.getenv("CODEX_IS_PRO") + + def get_codex_model_env(): return os.getenv("CODEX_MODEL") diff --git a/electron/servers/fastapi/utils/oauth/openai_codex.py b/electron/servers/fastapi/utils/oauth/openai_codex.py index f1e2f1ae..c94b75eb 100644 --- a/electron/servers/fastapi/utils/oauth/openai_codex.py +++ b/electron/servers/fastapi/utils/oauth/openai_codex.py @@ -244,6 +244,7 @@ class TokenSuccess: access: str refresh: str expires: int # Unix ms timestamp when the token expires + id_token: Optional[str] = None @dataclass @@ -261,6 +262,14 @@ class AuthorizationFlow: url: str +@dataclass +class CodexAccountProfile: + account_id: Optional[str] = None + username: Optional[str] = None + email: Optional[str] = None + is_pro: Optional[bool] = None + + # --------------------------------------------------------------------------- # JWT helpers # --------------------------------------------------------------------------- @@ -296,6 +305,51 @@ def get_account_id(access_token: str) -> Optional[str]: return None +def _as_non_empty_str(value) -> Optional[str]: + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return None + + +def get_account_profile(access_token: str, id_token: Optional[str] = None) -> CodexAccountProfile: + """Extract profile from exact observed JWT paths in access/id tokens.""" + access_payload = _decode_jwt_payload(access_token) or {} + access_auth = access_payload.get(JWT_CLAIM_PATH) + access_auth = access_auth if isinstance(access_auth, dict) else {} + + access_profile = access_payload.get("https://api.openai.com/profile") + access_profile = access_profile if isinstance(access_profile, dict) else {} + + id_payload = _decode_jwt_payload(id_token) if id_token else None + id_payload = id_payload if isinstance(id_payload, dict) else {} + id_auth = id_payload.get(JWT_CLAIM_PATH) + id_auth = id_auth if isinstance(id_auth, dict) else {} + + account_id = _as_non_empty_str(access_auth.get("chatgpt_account_id")) or _as_non_empty_str( + id_auth.get("chatgpt_account_id") + ) + username = _as_non_empty_str(id_payload.get("name")) + email = _as_non_empty_str(access_profile.get("email")) or _as_non_empty_str( + id_payload.get("email") + ) + + plan_type = _as_non_empty_str(access_auth.get("chatgpt_plan_type")) or _as_non_empty_str( + id_auth.get("chatgpt_plan_type") + ) + if plan_type: + is_pro = plan_type.strip().lower() != "free" + else: + is_pro = None + + return CodexAccountProfile( + account_id=account_id, + username=username, + email=email, + is_pro=is_pro, + ) + + # --------------------------------------------------------------------------- # Authorization URL + PKCE # --------------------------------------------------------------------------- @@ -474,6 +528,7 @@ def exchange_authorization_code( return TokenFailure(reason=f"HTTP {response.status_code}: {response.text[:200]}") body = response.json() + access = body.get("access_token") refresh = body.get("refresh_token") expires_in = body.get("expires_in") @@ -482,7 +537,9 @@ def exchange_authorization_code( return TokenFailure(reason=f"Token response missing fields: {list(body.keys())}") expires_ms = int(time.time() * 1000) + int(expires_in) * 1000 - return TokenSuccess(access=access, refresh=refresh, expires=expires_ms) + id_token = body.get("id_token") + id_token = id_token if isinstance(id_token, str) else None + return TokenSuccess(access=access, refresh=refresh, expires=expires_ms, id_token=id_token) except Exception as exc: return TokenFailure(reason=str(exc)) diff --git a/electron/servers/fastapi/utils/set_env.py b/electron/servers/fastapi/utils/set_env.py index 6f26b34f..f626f4df 100644 --- a/electron/servers/fastapi/utils/set_env.py +++ b/electron/servers/fastapi/utils/set_env.py @@ -122,5 +122,17 @@ def set_codex_account_id_env(value: str): os.environ["CODEX_ACCOUNT_ID"] = value +def set_codex_username_env(value: str): + os.environ["CODEX_USERNAME"] = value + + +def set_codex_email_env(value: str): + os.environ["CODEX_EMAIL"] = value + + +def set_codex_is_pro_env(value: str): + os.environ["CODEX_IS_PRO"] = value + + def set_codex_model_env(value: str): os.environ["CODEX_MODEL"] = value diff --git a/electron/servers/fastapi/utils/user_config.py b/electron/servers/fastapi/utils/user_config.py index f83d3047..ab1d91da 100644 --- a/electron/servers/fastapi/utils/user_config.py +++ b/electron/servers/fastapi/utils/user_config.py @@ -32,6 +32,9 @@ from utils.get_env import ( get_codex_refresh_token_env, get_codex_token_expires_env, get_codex_account_id_env, + get_codex_username_env, + get_codex_email_env, + get_codex_is_pro_env, get_codex_model_env, ) from utils.parsers import parse_bool_or_none @@ -64,6 +67,9 @@ from utils.set_env import ( set_codex_refresh_token_env, set_codex_token_expires_env, set_codex_account_id_env, + set_codex_username_env, + set_codex_email_env, + set_codex_is_pro_env, set_codex_model_env, ) @@ -133,6 +139,13 @@ def get_user_config(): CODEX_REFRESH_TOKEN=existing_config.CODEX_REFRESH_TOKEN or get_codex_refresh_token_env(), CODEX_TOKEN_EXPIRES=existing_config.CODEX_TOKEN_EXPIRES or get_codex_token_expires_env(), CODEX_ACCOUNT_ID=existing_config.CODEX_ACCOUNT_ID or get_codex_account_id_env(), + CODEX_USERNAME=existing_config.CODEX_USERNAME or get_codex_username_env(), + CODEX_EMAIL=existing_config.CODEX_EMAIL or get_codex_email_env(), + CODEX_IS_PRO=( + existing_config.CODEX_IS_PRO + if existing_config.CODEX_IS_PRO is not None + else parse_bool_or_none(get_codex_is_pro_env()) + ), ) @@ -196,6 +209,12 @@ def update_env_with_user_config(): set_codex_token_expires_env(user_config.CODEX_TOKEN_EXPIRES) if user_config.CODEX_ACCOUNT_ID: set_codex_account_id_env(user_config.CODEX_ACCOUNT_ID) + if user_config.CODEX_USERNAME: + set_codex_username_env(user_config.CODEX_USERNAME) + if user_config.CODEX_EMAIL: + set_codex_email_env(user_config.CODEX_EMAIL) + if user_config.CODEX_IS_PRO is not None: + set_codex_is_pro_env(str(user_config.CODEX_IS_PRO)) def save_codex_tokens_to_user_config() -> None: @@ -220,6 +239,9 @@ def save_codex_tokens_to_user_config() -> None: existing["CODEX_REFRESH_TOKEN"] = get_codex_refresh_token_env() existing["CODEX_TOKEN_EXPIRES"] = get_codex_token_expires_env() existing["CODEX_ACCOUNT_ID"] = get_codex_account_id_env() + existing["CODEX_USERNAME"] = get_codex_username_env() + existing["CODEX_EMAIL"] = get_codex_email_env() + existing["CODEX_IS_PRO"] = parse_bool_or_none(get_codex_is_pro_env()) try: with open(user_config_path, "w") as f: diff --git a/electron/servers/nextjs/app/api/user-config/route.ts b/electron/servers/nextjs/app/api/user-config/route.ts index 80d306bf..680f6a44 100644 --- a/electron/servers/nextjs/app/api/user-config/route.ts +++ b/electron/servers/nextjs/app/api/user-config/route.ts @@ -50,6 +50,9 @@ export async function POST(request: Request) { CODEX_REFRESH_TOKEN: existingConfig.CODEX_REFRESH_TOKEN, CODEX_TOKEN_EXPIRES: existingConfig.CODEX_TOKEN_EXPIRES, CODEX_ACCOUNT_ID: existingConfig.CODEX_ACCOUNT_ID, + CODEX_USERNAME: existingConfig.CODEX_USERNAME, + CODEX_EMAIL: existingConfig.CODEX_EMAIL, + CODEX_IS_PRO: existingConfig.CODEX_IS_PRO, USE_CUSTOM_URL: userConfig.USE_CUSTOM_URL === undefined ? existingConfig.USE_CUSTOM_URL diff --git a/electron/servers/nextjs/components/CodexConfig.tsx b/electron/servers/nextjs/components/CodexConfig.tsx index c85d270e..2ecc0dfb 100644 --- a/electron/servers/nextjs/components/CodexConfig.tsx +++ b/electron/servers/nextjs/components/CodexConfig.tsx @@ -6,6 +6,7 @@ import { Loader2, RefreshCw, Trash2, + Crown, User, UserCheck, ArrowRight, @@ -34,6 +35,9 @@ type AuthStatus = "checking" | "unauthenticated" | "polling" | "authenticated"; interface StatusResponse { status: string; account_id?: string; + username?: string; + email?: string; + is_pro?: boolean; detail?: string; } @@ -60,6 +64,9 @@ export default function CodexConfig({ }: CodexConfigProps) { const [authStatus, setAuthStatus] = useState("checking"); const [accountId, setAccountId] = useState(null); + const [username, setUsername] = useState(null); + const [email, setEmail] = useState(null); + const [isPro, setIsPro] = useState(null); const [sessionId, setSessionId] = useState(null); const [manualCode, setManualCode] = useState(""); const [isExchanging, setIsExchanging] = useState(false); @@ -80,22 +87,32 @@ export default function CodexConfig({ return () => stopPolling(); }, []); + const applyProfile = (data: Partial) => { + setAccountId(data.account_id ?? null); + setUsername(data.username ?? null); + setEmail(data.email ?? null); + setIsPro(typeof data.is_pro === "boolean" ? data.is_pro : 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") { setAuthStatus("authenticated"); - setAccountId(data.account_id ?? null); + applyProfile(data); } else { setAuthStatus("unauthenticated"); + applyProfile({}); } } catch { setAuthStatus("unauthenticated"); + applyProfile({}); } }; @@ -125,7 +142,7 @@ export default function CodexConfig({ if (pollData.status === "success") { stopPolling(); setAuthStatus("authenticated"); - setAccountId(pollData.account_id ?? null); + applyProfile(pollData); setSessionId(null); if (!codexModel) { onInputChange(DEFAULT_CODEX_MODEL, "codex_model"); @@ -134,6 +151,7 @@ export default function CodexConfig({ } else if (pollData.status === "failed") { stopPolling(); setAuthStatus("unauthenticated"); + applyProfile({}); toast.error("Authentication failed. Please try again."); } } catch { @@ -143,6 +161,7 @@ export default function CodexConfig({ } catch (err) { toast.error("Failed to start sign-in flow"); setAuthStatus("unauthenticated"); + applyProfile({}); } }; @@ -162,7 +181,7 @@ export default function CodexConfig({ const data = await res.json(); stopPolling(); setAuthStatus("authenticated"); - setAccountId(data.account_id); + applyProfile(data); setSessionId(null); setManualCode(""); if (!codexModel) { @@ -189,6 +208,9 @@ export default function CodexConfig({ await fetch(getApiUrl("/api/v1/ppt/codex/auth/logout"), { method: "POST" }); setAuthStatus("unauthenticated"); setAccountId(null); + setUsername(null); + setEmail(null); + setIsPro(null); onInputChange("", "codex_model"); toast.success("Signed out from ChatGPT"); } catch { @@ -206,11 +228,12 @@ export default function CodexConfig({ }); if (!res.ok) throw new Error("Refresh failed"); const data = await res.json(); - if (data.account_id) setAccountId(data.account_id); + applyProfile(data); toast.success("Token refreshed successfully"); } catch { toast.error("Token refresh failed. Please sign in again."); setAuthStatus("unauthenticated"); + applyProfile({}); } finally { setIsRefreshing(false); } @@ -287,17 +310,39 @@ export default function CodexConfig({ } if (authStatus === "authenticated") { + const planLabel = isPro === true ? "Pro" : isPro === false ? "Free" : "Unknown"; + return (
-
- {accountId && ( +
+

- Acc: {accountId} + {username || email || (accountId ? `Account ${accountId}` : "ChatGPT Account")}

+ + {isPro === true ? ( + + ) : ( + + )} + {planLabel} + +
+ {email && username && ( +

{email}

+ )} + {!email && accountId && ( +

ID: {accountId}

)}

Signed in to ChatGPT

diff --git a/electron/servers/nextjs/components/SettingCodex.tsx b/electron/servers/nextjs/components/SettingCodex.tsx index dc0c31fd..5533fe85 100644 --- a/electron/servers/nextjs/components/SettingCodex.tsx +++ b/electron/servers/nextjs/components/SettingCodex.tsx @@ -6,6 +6,7 @@ import { Loader2, RefreshCw, Trash2, + Crown, User, UserCheck, } from "lucide-react"; @@ -33,6 +34,9 @@ type AuthStatus = "checking" | "unauthenticated" | "polling" | "authenticated"; interface StatusResponse { status: string; account_id?: string; + username?: string; + email?: string; + is_pro?: boolean; detail?: string; } @@ -59,6 +63,9 @@ export default function CodexConfig({ }: CodexConfigProps) { const [authStatus, setAuthStatus] = useState("checking"); const [accountId, setAccountId] = useState(null); + const [username, setUsername] = useState(null); + const [email, setEmail] = useState(null); + const [isPro, setIsPro] = useState(null); const [sessionId, setSessionId] = useState(null); const [manualCode, setManualCode] = useState(""); const [isExchanging, setIsExchanging] = useState(false); @@ -79,22 +86,32 @@ export default function CodexConfig({ return () => stopPolling(); }, []); + const applyProfile = (data: Partial) => { + setAccountId(data.account_id ?? null); + setUsername(data.username ?? null); + setEmail(data.email ?? null); + setIsPro(typeof data.is_pro === "boolean" ? data.is_pro : 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") { setAuthStatus("authenticated"); - setAccountId(data.account_id ?? null); + applyProfile(data); } else { setAuthStatus("unauthenticated"); + applyProfile({}); } } catch { setAuthStatus("unauthenticated"); + applyProfile({}); } }; @@ -122,7 +139,7 @@ export default function CodexConfig({ if (pollData.status === "success") { stopPolling(); setAuthStatus("authenticated"); - setAccountId(pollData.account_id ?? null); + applyProfile(pollData); setSessionId(null); if (!codexModel) { onInputChange(DEFAULT_CODEX_MODEL, "codex_model"); @@ -131,6 +148,7 @@ export default function CodexConfig({ } else if (pollData.status === "failed") { stopPolling(); setAuthStatus("unauthenticated"); + applyProfile({}); toast.error("Authentication failed. Please try again."); } } catch { @@ -140,6 +158,7 @@ export default function CodexConfig({ } catch (err) { toast.error("Failed to start sign-in flow"); setAuthStatus("unauthenticated"); + applyProfile({}); } }; @@ -159,7 +178,7 @@ export default function CodexConfig({ const data = await res.json(); stopPolling(); setAuthStatus("authenticated"); - setAccountId(data.account_id); + applyProfile(data); setSessionId(null); setManualCode(""); if (!codexModel) { @@ -185,7 +204,7 @@ export default function CodexConfig({ try { await fetch(getApiUrl("/api/v1/ppt/codex/auth/logout"), { method: "POST" }); setAuthStatus("unauthenticated"); - setAccountId(null); + applyProfile({}); onInputChange("", "codex_model"); toast.success("Signed out from ChatGPT"); } catch { @@ -203,11 +222,12 @@ export default function CodexConfig({ }); if (!res.ok) throw new Error("Refresh failed"); const data = await res.json(); - if (data.account_id) setAccountId(data.account_id); + applyProfile(data); toast.success("Token refreshed successfully"); } catch { toast.error("Token refresh failed. Please sign in again."); setAuthStatus("unauthenticated"); + applyProfile({}); } finally { setIsRefreshing(false); } @@ -266,15 +286,37 @@ export default function CodexConfig({ } if (authStatus === "authenticated") { + const planLabel = isPro === true ? "Pro" : isPro === false ? "Free" : "Unknown"; + return (
-
+
- {accountId && ( +

- Acc: {accountId} + {username || email || (accountId ? `Account ${accountId}` : "ChatGPT Account")}

+ + {isPro === true ? ( + + ) : ( + + )} + {planLabel} + +
+ {email && username && ( +

{email}

+ )} + {!email && accountId && ( +

ID: {accountId}

)}

Signed in to ChatGPT

diff --git a/electron/servers/nextjs/types/llm_config.ts b/electron/servers/nextjs/types/llm_config.ts index 7c0cc9de..493cd72c 100644 --- a/electron/servers/nextjs/types/llm_config.ts +++ b/electron/servers/nextjs/types/llm_config.ts @@ -49,6 +49,9 @@ export interface LLMConfig { CODEX_REFRESH_TOKEN?: string; CODEX_TOKEN_EXPIRES?: string; CODEX_ACCOUNT_ID?: string; + CODEX_USERNAME?: string; + CODEX_EMAIL?: string; + CODEX_IS_PRO?: boolean; // Only used in UI settings USE_CUSTOM_URL?: boolean;