Update Codex login success page UX (#20136)

## Summary

update the local login success page to match the Codex desktop auth UX
use theme-aware colors and an inline 20px Codex mark
keep the actual localhost success page aligned with the browser auth UX
PR

## Tests

<img width="1728" height="1117" alt="Screenshot 2026-04-29 at 12 00
34 PM"
src="https://github.com/user-attachments/assets/76a40c3f-07c3-452c-97da-e7c43717cd2c"
/>
This commit is contained in:
rafael-jac
2026-04-29 19:14:53 -04:00
committed by GitHub
Unverified
parent 74f06dcdfb
commit 98f67b15d3
15 changed files with 550 additions and 188 deletions
@@ -1586,6 +1586,9 @@
},
{
"properties": {
"codexStreamlinedLogin": {
"type": "boolean"
},
"type": {
"enum": [
"chatgpt"
@@ -10075,6 +10075,9 @@
},
{
"properties": {
"codexStreamlinedLogin": {
"type": "boolean"
},
"type": {
"enum": [
"chatgpt"
@@ -6729,6 +6729,9 @@
},
{
"properties": {
"codexStreamlinedLogin": {
"type": "boolean"
},
"type": {
"enum": [
"chatgpt"
@@ -23,6 +23,9 @@
},
{
"properties": {
"codexStreamlinedLogin": {
"type": "boolean"
},
"type": {
"enum": [
"chatgpt"
@@ -2,7 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt" } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
/**
* Access token (JWT) supplied by the client.
* This token is used for backend API requests and email extraction.
@@ -2227,7 +2227,9 @@ mod tests {
fn serialize_account_login_chatgpt() -> Result<()> {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt,
params: v2::LoginAccountParams::Chatgpt {
codex_streamlined_login: false,
},
};
assert_eq!(
json!({
@@ -2242,6 +2244,28 @@ mod tests {
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_streamlined() -> Result<()> {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
codex_streamlined_login: true,
},
};
assert_eq!(
json!({
"method": "account/login/start",
"id": 3,
"params": {
"type": "chatgpt",
"codexStreamlinedLogin": true
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_device_code() -> Result<()> {
let request = ClientRequest::LoginAccount {
@@ -2174,9 +2174,12 @@ pub enum LoginAccountParams {
#[ts(rename = "apiKey")]
api_key: String,
},
#[serde(rename = "chatgpt")]
#[ts(rename = "chatgpt")]
Chatgpt,
#[serde(rename = "chatgpt", rename_all = "camelCase")]
#[ts(rename = "chatgpt", rename_all = "camelCase")]
Chatgpt {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
codex_streamlined_login: bool,
},
#[serde(rename = "chatgptDeviceCode")]
#[ts(rename = "chatgptDeviceCode")]
ChatgptDeviceCode,
+3 -1
View File
@@ -1607,7 +1607,9 @@ impl CodexClient {
let request_id = self.request_id();
let request = ClientRequest::LoginAccount {
request_id: request_id.clone(),
params: codex_app_server_protocol::LoginAccountParams::Chatgpt,
params: codex_app_server_protocol::LoginAccountParams::Chatgpt {
codex_streamlined_login: false,
},
};
self.send_request(request, request_id, "account/login/start")
@@ -1338,8 +1338,11 @@ impl CodexMessageProcessor {
self.login_api_key_v2(request_id, LoginApiKeyParams { api_key })
.await;
}
LoginAccountParams::Chatgpt => {
self.login_chatgpt_v2(request_id).await;
LoginAccountParams::Chatgpt {
codex_streamlined_login,
} => {
self.login_chatgpt_v2(request_id, codex_streamlined_login)
.await;
}
LoginAccountParams::ChatgptDeviceCode => {
self.login_chatgpt_device_code_v2(request_id).await;
@@ -1441,6 +1444,7 @@ impl CodexMessageProcessor {
// Build options for a ChatGPT login attempt; performs validation.
async fn login_chatgpt_common(
&self,
codex_streamlined_login: bool,
) -> std::result::Result<LoginServerOptions, JSONRPCErrorError> {
let config = self.config.as_ref();
@@ -1458,6 +1462,7 @@ impl CodexMessageProcessor {
let opts = LoginServerOptions {
open_browser: false,
codex_streamlined_login,
..LoginServerOptions::new(
config.codex_home.to_path_buf(),
CLIENT_ID.to_string(),
@@ -1496,13 +1501,20 @@ impl CodexMessageProcessor {
}
}
async fn login_chatgpt_v2(&self, request_id: ConnectionRequestId) {
let result = self.login_chatgpt_response().await;
async fn login_chatgpt_v2(
&self,
request_id: ConnectionRequestId,
codex_streamlined_login: bool,
) {
let result = self.login_chatgpt_response(codex_streamlined_login).await;
self.outgoing.send_result(request_id, result).await;
}
async fn login_chatgpt_response(&self) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self.login_chatgpt_common().await?;
async fn login_chatgpt_response(
&self,
codex_streamlined_login: bool,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self.login_chatgpt_common(codex_streamlined_login).await?;
let server = run_login_server(opts)
.map_err(|err| internal_error(format!("failed to start login server: {err}")))?;
let login_id = Uuid::new_v4();
@@ -1573,7 +1585,9 @@ impl CodexMessageProcessor {
async fn login_chatgpt_device_code_response(
&self,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self.login_chatgpt_common().await?;
let opts = self
.login_chatgpt_common(/*codex_streamlined_login*/ false)
.await?;
let device_code = request_device_code(&opts)
.await
.map_err(Self::login_chatgpt_device_code_start_error)?;
+1
View File
@@ -6,5 +6,6 @@ codex_rust_crate(
compile_data = [
"src/assets/error.html",
"src/assets/success.html",
"src/assets/success_legacy.html",
],
)
+209 -170
View File
@@ -2,197 +2,236 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sign into Codex</title>
<link rel="icon" href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E' type="image/svg+xml">
<meta name="color-scheme" content="light dark" />
<title>Signed in to Codex</title>
<link
rel="icon"
href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E'
type="image/svg+xml"
/>
<style>
.container {
margin: auto;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: white;
:root {
--font-use: "SF Pro", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--gray-0: #ffffff;
--gray-50: #f9f9f9;
--gray-100: #ededed;
--gray-150: #dfdfdf;
--gray-300: #afafaf;
--gray-500: #5d5d5d;
--gray-900: #181818;
--gray-1000: #0d0d0d;
--color-background-surface: var(--gray-0);
--color-text-foreground: var(--gray-1000);
--color-border: rgb(13 13 13 / 10%);
--color-button-background: var(--gray-0);
--color-button-background-hover: var(--gray-50);
--color-button-border: var(--gray-100);
--color-button-border-hover: var(--gray-150);
--interactive-label-secondary-default: var(--gray-1000);
--text-secondary: var(--gray-500);
color: var(--color-text-foreground);
background: var(--color-background-surface);
font-family: var(--font-use);
}
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
@media (prefers-color-scheme: dark) {
:root {
--color-background-surface: var(--gray-900);
--color-text-foreground: var(--gray-0);
--color-border: rgb(255 255 255 / 8%);
--color-button-background: rgb(255 255 255 / 5%);
--color-button-background-hover: rgb(255 255 255 / 8%);
--color-button-border: rgb(255 255 255 / 8%);
--color-button-border-hover: rgb(255 255 255 / 16%);
--interactive-label-secondary-default: var(--gray-300);
--text-secondary: rgb(255 255 255 / 65%);
}
}
.inner-container {
width: 400px;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: inline-flex;
}
.content {
align-self: stretch;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: flex;
margin-top: 15vh;
}
.svg-wrapper {
position: relative;
}
.title {
text-align: center;
color: var(--text-primary, #0D0D0D);
font-size: 32px;
font-weight: 400;
line-height: 40px;
word-wrap: break-word;
}
.setup-box {
width: 600px;
padding: 16px 20px;
background: var(--bg-primary, white);
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.05);
border-radius: 16px;
outline: 1px var(--border-default, rgba(13, 13, 13, 0.10)) solid;
outline-offset: -1px;
justify-content: flex-start;
align-items: center;
gap: 16px;
display: inline-flex;
}
.setup-content {
flex: 1 1 0;
justify-content: flex-start;
align-items: center;
gap: 24px;
display: flex;
}
.setup-text {
flex: 1 1 0;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 4px;
display: inline-flex;
}
.setup-title {
align-self: stretch;
color: var(--text-primary, #0D0D0D);
font-size: 14px;
font-weight: 510;
line-height: 20px;
word-wrap: break-word;
}
.setup-description {
align-self: stretch;
color: var(--text-secondary, #5D5D5D);
font-size: 14px;
font-weight: 400;
line-height: 20px;
word-wrap: break-word;
}
.redirect-box {
justify-content: flex-start;
align-items: center;
gap: 8px;
display: flex;
}
.close-button,
.redirect-button {
height: 28px;
padding: 8px 16px;
background: var(--interactive-bg-primary-default, #0D0D0D);
border-radius: 999px;
justify-content: center;
align-items: center;
gap: 4px;
display: flex;
}
.close-button,
.redirect-text {
color: var(--interactive-label-primary-default, white);
font-size: 14px;
font-weight: 510;
line-height: 20px;
word-wrap: break-word;
text-decoration: none;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
border-radius: 16px;
border: .5px solid rgba(0, 0, 0, 0.1);
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 16px 0px;
* {
box-sizing: border-box;
background-color: rgb(255, 255, 255);
}
body {
margin: 0;
min-height: 100vh;
background: var(--color-background-surface);
}
.wordmark {
position: fixed;
top: 18px;
left: 20px;
font-size: 21px;
font-weight: 650;
letter-spacing: -0.45px;
line-height: 24px;
}
main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 56px 24px;
}
.content {
width: min(100%, 560px);
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
text-align: center;
}
.message {
margin: 0;
color: var(--text-secondary, #5d5d5d);
font-feature-settings: "liga" off, "clig" off;
font-size: 14px;
font-style: normal;
font-weight: 400;
letter-spacing: -0.18px;
line-height: 20px;
}
.button {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--color-button-border);
border-radius: 999px;
background: var(--color-button-background);
color: var(--interactive-label-secondary-default, #0d0d0d);
cursor: pointer;
font-family: var(--font-use, "SF Pro");
font-feature-settings: "liga" off, "clig" off;
font-size: 14px;
font-style: normal;
font-weight: 510;
letter-spacing: -0.154px;
line-height: 20px;
padding: 8px 16px 8px 14px;
}
.button:hover {
background: var(--color-button-background-hover);
border-color: var(--color-button-border-hover);
}
.button:focus-visible {
outline: 2px solid var(--color-text-foreground);
outline-offset: 3px;
}
.codex-mark {
flex: 0 0 20px;
width: 20px;
height: 20px;
}
.setup-box {
display: none;
width: min(100vw - 32px, 560px);
padding: 16px 20px;
border: 1px solid var(--color-border);
border-radius: 16px;
text-align: left;
}
.setup-title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 510;
line-height: 20px;
}
.setup-description {
margin: 0;
color: var(--text-secondary, #5d5d5d);
font-size: 14px;
line-height: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="inner-container">
<div class="content">
<div class="logo">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path stroke="#000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"></path></svg>
</div>
<div class="title">Signed in to Codex</div>
</div>
<div class="close-box" style="display: none;">
<div class="setup-description">You may now close this page</div>
</div>
<div class="setup-box" style="display: none;">
<div class="setup-content">
<div class="setup-text">
<div class="setup-title">Finish setting up your API organization</div>
<div class="setup-description">Add a payment method to use your organization.</div>
</div>
<div class="redirect-box">
<div data-hasendicon="false" data-hasstarticon="false" data-ishovered="false" data-isinactive="false" data-ispressed="false" data-size="large" data-type="primary" class="redirect-button">
<div class="redirect-text">Redirecting in 3s...</div>
</div>
</div>
</div>
<div class="wordmark" aria-label="ChatGPT">ChatGPT</div>
<main>
<div class="content">
<p class="message" id="status-message">You&rsquo;re signed in and may close this tab</p>
<button class="button" type="button" id="open-codex">
<svg
class="codex-mark"
aria-hidden="true"
fill="none"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"
stroke="currentColor"
stroke-linecap="round"
stroke-width="2.484"
/>
</svg>
<span>Open Codex</span>
</button>
<div class="setup-box" id="setup-box">
<p class="setup-title">Finish setting up your API organization</p>
<p class="setup-description">Add a payment method to use your organization. Redirecting in <span id="countdown">3</span>s...</p>
</div>
</div>
</div>
</main>
<script>
(function () {
const CODEX_URL = "codex://threads/new";
const params = new URLSearchParams(window.location.search);
const needsSetup = params.get('needs_setup') === 'true';
const platformUrl = params.get('platform_url') || 'https://platform.openai.com';
const orgId = params.get('org_id');
const projectId = params.get('project_id');
const planType = params.get('plan_type');
const idToken = params.get('id_token');
// Show different message and optional redirect when setup is required
const preview = params.get("preview") === "true";
const needsSetup = params.get("needs_setup") === "true";
const platformUrl = params.get("platform_url") || "https://platform.openai.com";
const orgId = params.get("org_id");
const projectId = params.get("project_id");
const planType = params.get("plan_type");
const idToken = params.get("id_token");
window.history.replaceState(null, "", window.location.pathname);
function openCodex() {
window.location.href = CODEX_URL;
}
document.getElementById("open-codex").addEventListener("click", openCodex);
if (!preview) {
setTimeout(openCodex, 250);
}
if (needsSetup) {
const setupBox = document.querySelector('.setup-box');
setupBox.style.display = 'flex';
const redirectUrlObj = new URL('/org-setup', platformUrl);
redirectUrlObj.searchParams.set('p', planType);
redirectUrlObj.searchParams.set('t', idToken);
redirectUrlObj.searchParams.set('with_org', orgId);
redirectUrlObj.searchParams.set('project_id', projectId);
const redirectUrl = redirectUrlObj.toString();
const message = document.querySelector('.redirect-text');
const setupBox = document.getElementById("setup-box");
const countdownNode = document.getElementById("countdown");
setupBox.style.display = "block";
const redirectUrlObj = new URL("/org-setup", platformUrl);
redirectUrlObj.search = new URLSearchParams({
p: planType,
t: idToken,
with_org: orgId,
project_id: projectId,
}).toString();
let countdown = 3;
function tick() {
message.textContent =
'Redirecting in ' + countdown + 's…';
countdownNode.textContent = String(countdown);
if (countdown === 0) {
window.location.replace(redirectUrl);
} else {
countdown -= 1;
setTimeout(tick, 1000);
window.location.replace(redirectUrlObj.toString());
return;
}
countdown -= 1;
setTimeout(tick, 1000);
}
tick();
} else {
const closeBox = document.querySelector('.close-box');
closeBox.style.display = 'flex';
}
})();
</script>
</body>
</html>
</html>
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sign into Codex</title>
<link rel="icon" href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E' type="image/svg+xml">
<style>
.container {
margin: auto;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: white;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.inner-container {
width: 400px;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: inline-flex;
}
.content {
align-self: stretch;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 20px;
display: flex;
margin-top: 15vh;
}
.svg-wrapper {
position: relative;
}
.title {
text-align: center;
color: var(--text-primary, #0D0D0D);
font-size: 32px;
font-weight: 400;
line-height: 40px;
word-wrap: break-word;
}
.setup-box {
width: 600px;
padding: 16px 20px;
background: var(--bg-primary, white);
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.05);
border-radius: 16px;
outline: 1px var(--border-default, rgba(13, 13, 13, 0.10)) solid;
outline-offset: -1px;
justify-content: flex-start;
align-items: center;
gap: 16px;
display: inline-flex;
}
.setup-content {
flex: 1 1 0;
justify-content: flex-start;
align-items: center;
gap: 24px;
display: flex;
}
.setup-text {
flex: 1 1 0;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 4px;
display: inline-flex;
}
.setup-title {
align-self: stretch;
color: var(--text-primary, #0D0D0D);
font-size: 14px;
font-weight: 510;
line-height: 20px;
word-wrap: break-word;
}
.setup-description {
align-self: stretch;
color: var(--text-secondary, #5D5D5D);
font-size: 14px;
font-weight: 400;
line-height: 20px;
word-wrap: break-word;
}
.redirect-box {
justify-content: flex-start;
align-items: center;
gap: 8px;
display: flex;
}
.close-button,
.redirect-button {
height: 28px;
padding: 8px 16px;
background: var(--interactive-bg-primary-default, #0D0D0D);
border-radius: 999px;
justify-content: center;
align-items: center;
gap: 4px;
display: flex;
}
.close-button,
.redirect-text {
color: var(--interactive-label-primary-default, white);
font-size: 14px;
font-weight: 510;
line-height: 20px;
word-wrap: break-word;
text-decoration: none;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
border-radius: 16px;
border: .5px solid rgba(0, 0, 0, 0.1);
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 16px 0px;
box-sizing: border-box;
background-color: rgb(255, 255, 255);
}
</style>
</head>
<body>
<div class="container">
<div class="inner-container">
<div class="content">
<div class="logo">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path stroke="#000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"></path></svg>
</div>
<div class="title">Signed in to Codex</div>
</div>
<div class="close-box" style="display: none;">
<div class="setup-description">You may now close this page</div>
</div>
<div class="setup-box" style="display: none;">
<div class="setup-content">
<div class="setup-text">
<div class="setup-title">Finish setting up your API organization</div>
<div class="setup-description">Add a payment method to use your organization.</div>
</div>
<div class="redirect-box">
<div data-hasendicon="false" data-hasstarticon="false" data-ishovered="false" data-isinactive="false" data-ispressed="false" data-size="large" data-type="primary" class="redirect-button">
<div class="redirect-text">Redirecting in 3s...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const needsSetup = params.get('needs_setup') === 'true';
const platformUrl = params.get('platform_url') || 'https://platform.openai.com';
const orgId = params.get('org_id');
const projectId = params.get('project_id');
const planType = params.get('plan_type');
const idToken = params.get('id_token');
// Show different message and optional redirect when setup is required
if (needsSetup) {
const setupBox = document.querySelector('.setup-box');
setupBox.style.display = 'flex';
const redirectUrlObj = new URL('/org-setup', platformUrl);
redirectUrlObj.searchParams.set('p', planType);
redirectUrlObj.searchParams.set('t', idToken);
redirectUrlObj.searchParams.set('with_org', orgId);
redirectUrlObj.searchParams.set('project_id', projectId);
const redirectUrl = redirectUrlObj.toString();
const message = document.querySelector('.redirect-text');
let countdown = 3;
function tick() {
message.textContent =
'Redirecting in ' + countdown + 's…';
if (countdown === 0) {
window.location.replace(redirectUrl);
} else {
countdown -= 1;
setTimeout(tick, 1000);
}
}
tick();
} else {
const closeBox = document.querySelector('.close-box');
closeBox.style.display = 'flex';
}
})();
</script>
</body>
</html>
+65 -4
View File
@@ -67,6 +67,7 @@ pub struct ServerOptions {
pub open_browser: bool,
pub force_state: Option<String>,
pub forced_chatgpt_workspace_id: Option<String>,
pub codex_streamlined_login: bool,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
}
@@ -86,6 +87,7 @@ impl ServerOptions {
open_browser: true,
force_state: None,
forced_chatgpt_workspace_id,
codex_streamlined_login: false,
cli_auth_credentials_store_mode,
}
}
@@ -374,6 +376,7 @@ async fn process_request(
&opts.issuer,
&tokens.id_token,
&tokens.access_token,
opts.codex_streamlined_login,
);
match tiny_http::Header::from_bytes(&b"Location"[..], success_url.as_bytes()) {
Ok(header) => HandledRequest::RedirectWithHeader(header),
@@ -398,7 +401,14 @@ async fn process_request(
}
}
"/success" => {
let body = include_str!("assets/success.html");
let use_streamlined_success = parsed_url
.query_pairs()
.any(|(key, value)| key == "codex_streamlined_login" && value == "true");
let body = if use_streamlined_success {
include_str!("assets/success.html")
} else {
include_str!("assets/success_legacy.html")
};
HandledRequest::ResponseAndExit {
headers: match Header::from_bytes(
&b"Content-Type"[..],
@@ -713,13 +723,15 @@ pub(crate) async fn exchange_code_for_tokens(
}
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/'));
info!(
issuer = %sanitize_url_for_logging(issuer),
token_endpoint = %sanitize_url_for_logging(&token_endpoint),
redirect_uri = %redirect_uri,
"starting oauth token exchange"
);
let resp = client
.post(format!("{issuer}/oauth/token"))
.post(token_endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(format!(
"grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
@@ -806,7 +818,13 @@ pub(crate) async fn persist_tokens_async(
.map_err(|e| io::Error::other(format!("persist task failed: {e}")))?
}
fn compose_success_url(port: u16, issuer: &str, id_token: &str, access_token: &str) -> String {
fn compose_success_url(
port: u16,
issuer: &str,
id_token: &str,
access_token: &str,
codex_streamlined_login: bool,
) -> String {
let token_claims = jwt_auth_claims(id_token);
let access_claims = jwt_auth_claims(access_token);
@@ -846,6 +864,9 @@ fn compose_success_url(port: u16, issuer: &str, id_token: &str, access_token: &s
("plan_type", plan_type.to_string()),
("platform_url", platform_url.to_string()),
];
if codex_streamlined_login {
params.push(("codex_streamlined_login", "true".to_string()));
}
let qs = params
.drain(..)
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
@@ -1085,8 +1106,9 @@ pub(crate) async fn obtain_api_key(
access_token: String,
}
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/'));
let resp = client
.post(format!("{issuer}/oauth/token"))
.post(token_endpoint)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(format!(
"grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
@@ -1112,7 +1134,9 @@ pub(crate) async fn obtain_api_key(
mod tests {
use pretty_assertions::assert_eq;
use super::DEFAULT_ISSUER;
use super::TokenEndpointErrorDetail;
use super::compose_success_url;
use super::html_escape;
use super::is_missing_codex_entitlement_error;
use super::parse_token_endpoint_error;
@@ -1219,6 +1243,43 @@ mod tests {
);
}
#[test]
fn compose_success_url_omits_streamlined_success_by_default() {
let url = url::Url::parse(&compose_success_url(
/*port*/ 1455,
DEFAULT_ISSUER,
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
/*codex_streamlined_login*/ false,
))
.expect("success url should parse");
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "codex_streamlined_login"),
None
);
}
#[test]
fn compose_success_url_includes_streamlined_success_when_requested() {
let url = url::Url::parse(&compose_success_url(
/*port*/ 1455,
DEFAULT_ISSUER,
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
"e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig",
/*codex_streamlined_login*/ true,
))
.expect("success url should parse");
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "codex_streamlined_login")
.map(|(_, value)| value.into_owned()),
Some("true".to_string())
);
}
#[test]
fn render_login_error_page_escapes_dynamic_fields() {
let body = String::from_utf8(render_login_error_page(
@@ -122,6 +122,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> {
open_browser: false,
force_state: Some(state),
forced_chatgpt_workspace_id: Some(chatgpt_account_id.to_string()),
codex_streamlined_login: false,
};
let server = run_login_server(opts)?;
assert!(
@@ -183,6 +184,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> {
open_browser: false,
force_state: Some(state),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
};
let server = run_login_server(opts)?;
let login_port = server.actual_port;
@@ -222,6 +224,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> {
open_browser: false,
force_state: Some(state.clone()),
forced_chatgpt_workspace_id: Some("org-required".to_string()),
codex_streamlined_login: false,
};
let server = run_login_server(opts)?;
assert!(
@@ -279,6 +282,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error()
open_browser: false,
force_state: Some(state.clone()),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
};
let server = run_login_server(opts)?;
let login_port = server.actual_port;
@@ -346,6 +350,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<
open_browser: false,
force_state: Some(state.clone()),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
};
let server = run_login_server(opts)?;
let login_port = server.actual_port;
@@ -490,6 +495,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
open_browser: false,
force_state: Some("cancel_state".to_string()),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
};
let first_server = run_login_server(first_opts)?;
@@ -510,6 +516,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> {
open_browser: false,
force_state: Some("cancel_state_2".to_string()),
forced_chatgpt_workspace_id: None,
codex_streamlined_login: false,
};
let second_server = run_login_server(second_opts)?;
+3 -1
View File
@@ -876,7 +876,9 @@ impl AuthModeWidget {
match request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::Chatgpt,
params: LoginAccountParams::Chatgpt {
codex_streamlined_login: false,
},
})
.await
{