Add device-code onboarding and ChatGPT token refresh to app-server TUI (#14952)

## Summary
- add device-code ChatGPT sign-in to `tui_app_server` onboarding and
reuse the existing `chatgptAuthTokens` login path
- fall back to browser login when device-code auth is unavailable on the
server
- treat `ChatgptAuthTokens` as an existing signed-in ChatGPT state
during onboarding
- add a local ChatGPT auth loader for handing local tokens to the app
server and serving refresh requests
- handle `account/chatgptAuthTokens/refresh` instead of marking it
unsupported, including workspace/account mismatch checks
- add focused coverage for onboarding success, existing auth handling,
local auth loading, and refresh request behavior

## Testing
- `cargo test -p codex-tui-app-server`
- `just fix -p codex-tui-app-server`
This commit is contained in:
Eric Traut 2026-03-17 14:12:12 -06:00 committed by GitHub
parent 95bdea93d2
commit 49e7dda2df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 713 additions and 15 deletions

View file

@ -16,9 +16,12 @@ use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::app_server_rate_limit_snapshot_to_core;
use crate::app_server_session::status_account_display_from_auth_mode;
use crate::local_chatgpt_auth::load_local_chatgpt_auth;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
@ -90,6 +93,7 @@ impl App {
matches!(
notification.auth_mode,
Some(codex_app_server_protocol::AuthMode::Chatgpt)
| Some(codex_app_server_protocol::AuthMode::ChatgptAuthTokens)
),
);
}
@ -150,6 +154,15 @@ impl App {
}
}
AppServerEvent::ServerRequest(request) => {
if let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = request {
self.handle_chatgpt_auth_tokens_refresh_request(
app_server_client,
request_id,
params,
)
.await;
return;
}
if let Some(unsupported) = self
.pending_app_server_requests
.note_server_request(&request)
@ -181,6 +194,70 @@ impl App {
}
}
async fn handle_chatgpt_auth_tokens_refresh_request(
&mut self,
app_server_client: &AppServerSession,
request_id: codex_app_server_protocol::RequestId,
params: ChatgptAuthTokensRefreshParams,
) {
let config = self.config.clone();
let result = tokio::task::spawn_blocking(move || {
resolve_chatgpt_auth_tokens_refresh_response(
&config.codex_home,
config.cli_auth_credentials_store_mode,
config.forced_chatgpt_workspace_id.as_deref(),
&params,
)
})
.await;
match result {
Ok(Ok(response)) => {
let response = serde_json::to_value(response).map_err(|err| {
format!("failed to serialize chatgpt auth refresh response: {err}")
});
match response {
Ok(response) => {
if let Err(err) = app_server_client
.resolve_server_request(request_id, response)
.await
{
tracing::warn!("failed to resolve chatgpt auth refresh request: {err}");
}
}
Err(err) => {
self.chat_widget.add_error_message(err.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, err)
.await
{
tracing::warn!("{reject_err}");
}
}
}
}
Ok(Err(err)) => {
self.chat_widget.add_error_message(err.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, err)
.await
{
tracing::warn!("{reject_err}");
}
}
Err(err) => {
let message = format!("chatgpt auth refresh task failed: {err}");
self.chat_widget.add_error_message(message.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, message)
.await
{
tracing::warn!("{reject_err}");
}
}
}
}
async fn reject_app_server_request(
&self,
app_server_client: &AppServerSession,
@ -201,6 +278,28 @@ impl App {
}
}
fn resolve_chatgpt_auth_tokens_refresh_response(
codex_home: &std::path::Path,
auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<&str>,
params: &ChatgptAuthTokensRefreshParams,
) -> Result<codex_app_server_protocol::ChatgptAuthTokensRefreshResponse, String> {
let auth = load_local_chatgpt_auth(
codex_home,
auth_credentials_store_mode,
forced_chatgpt_workspace_id,
)?;
if let Some(previous_account_id) = params.previous_account_id.as_deref()
&& previous_account_id != auth.chatgpt_account_id
{
return Err(format!(
"local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`",
auth.chatgpt_account_id
));
}
Ok(auth.to_refresh_response())
}
/// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s
/// suitable for replaying into the TUI event store.
///
@ -624,6 +723,113 @@ fn thread_item_to_core(item: &ThreadItem) -> Option<TurnItem> {
}
}
#[cfg(test)]
mod refresh_tests {
use super::*;
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::AuthDotJson;
use codex_core::auth::save_auth;
use codex_core::token_data::TokenData;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use tempfile::TempDir;
fn fake_jwt(account_id: &str, plan_type: &str) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = json!({
"email": "user@example.com",
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
"chatgpt_plan_type": plan_type,
},
});
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn write_chatgpt_auth(codex_home: &std::path::Path) {
let id_token = fake_jwt("workspace-1", "business");
let access_token = fake_jwt("workspace-1", "business");
save_auth(
codex_home,
&AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(TokenData {
id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token)
.expect("id token should parse"),
access_token,
refresh_token: "refresh-token".to_string(),
account_id: Some("workspace-1".to_string()),
}),
last_refresh: Some(Utc::now()),
},
AuthCredentialsStoreMode::File,
)
.expect("chatgpt auth should save");
}
#[test]
fn refresh_request_uses_local_chatgpt_auth() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let response = resolve_chatgpt_auth_tokens_refresh_response(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
&ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-1".to_string()),
},
)
.expect("refresh response should resolve");
assert_eq!(response.chatgpt_account_id, "workspace-1");
assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business"));
assert!(!response.access_token.is_empty());
}
#[test]
fn refresh_request_rejects_account_mismatch() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let err = resolve_chatgpt_auth_tokens_refresh_response(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
&ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-2".to_string()),
},
)
.expect_err("mismatched account should fail");
assert_eq!(
err,
"local ChatGPT auth refresh account mismatch: expected `workspace-2`, got `workspace-1`"
);
}
}
fn app_server_web_search_action_to_core(
action: codex_app_server_protocol::WebSearchAction,
) -> Option<codex_protocol::models::WebSearchAction> {

View file

@ -99,13 +99,7 @@ impl PendingAppServerRequests {
.to_string(),
})
}
ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "ChatGPT auth token refresh is not available in app-server TUI yet."
.to_string(),
})
}
ServerRequest::ChatgptAuthTokensRefresh { .. } => None,
ServerRequest::ApplyPatchApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
@ -608,6 +602,22 @@ mod tests {
);
}
#[test]
fn does_not_mark_chatgpt_auth_refresh_as_unsupported() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::ChatgptAuthTokensRefresh {
request_id: AppServerRequestId::Integer(100),
params: codex_app_server_protocol::ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-1".to_string()),
},
}),
None
);
}
#[test]
fn rejects_invalid_patch_decisions_for_file_change_requests() {
let mut pending = PendingAppServerRequests::default();

View file

@ -100,6 +100,7 @@ pub mod insert_history;
mod key_hint;
mod line_truncation;
pub mod live_wrap;
mod local_chatgpt_auth;
mod markdown;
mod markdown_render;
mod markdown_stream;

View file

@ -0,0 +1,195 @@
use std::path::Path;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::load_auth_dot_json;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LocalChatgptAuth {
pub(crate) access_token: String,
pub(crate) chatgpt_account_id: String,
pub(crate) chatgpt_plan_type: Option<String>,
}
impl LocalChatgptAuth {
pub(crate) fn to_refresh_response(&self) -> ChatgptAuthTokensRefreshResponse {
ChatgptAuthTokensRefreshResponse {
access_token: self.access_token.clone(),
chatgpt_account_id: self.chatgpt_account_id.clone(),
chatgpt_plan_type: self.chatgpt_plan_type.clone(),
}
}
}
pub(crate) fn load_local_chatgpt_auth(
codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<&str>,
) -> Result<LocalChatgptAuth, String> {
let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode)
.map_err(|err| format!("failed to load local auth: {err}"))?
.ok_or_else(|| "no local auth available".to_string())?;
if matches!(auth.auth_mode, Some(AuthMode::ApiKey)) || auth.openai_api_key.is_some() {
return Err("local auth is not a ChatGPT login".to_string());
}
let tokens = auth
.tokens
.ok_or_else(|| "local ChatGPT auth is missing token data".to_string())?;
let access_token = tokens.access_token;
let chatgpt_account_id = tokens
.account_id
.or(tokens.id_token.chatgpt_account_id.clone())
.ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?;
if let Some(expected_workspace) = forced_chatgpt_workspace_id
&& chatgpt_account_id != expected_workspace
{
return Err(format!(
"local ChatGPT auth must use workspace {expected_workspace}, but found {chatgpt_account_id:?}"
));
}
let chatgpt_plan_type = tokens
.id_token
.get_chatgpt_plan_type()
.map(|plan_type| plan_type.to_ascii_lowercase());
Ok(LocalChatgptAuth {
access_token,
chatgpt_account_id,
chatgpt_plan_type,
})
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_core::auth::AuthDotJson;
use codex_core::auth::login_with_chatgpt_auth_tokens;
use codex_core::auth::save_auth;
use codex_core::token_data::TokenData;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use tempfile::TempDir;
fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = json!({
"email": email,
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
"chatgpt_plan_type": plan_type,
},
});
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn write_chatgpt_auth(codex_home: &Path) {
let id_token = fake_jwt("user@example.com", "workspace-1", "business");
let access_token = fake_jwt("user@example.com", "workspace-1", "business");
let auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(TokenData {
id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token)
.expect("id token should parse"),
access_token,
refresh_token: "refresh-token".to_string(),
account_id: Some("workspace-1".to_string()),
}),
last_refresh: Some(Utc::now()),
};
save_auth(codex_home, &auth, AuthCredentialsStoreMode::File)
.expect("chatgpt auth should save");
}
#[test]
fn loads_local_chatgpt_auth_from_managed_auth() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let auth = load_local_chatgpt_auth(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
)
.expect("chatgpt auth should load");
assert_eq!(auth.chatgpt_account_id, "workspace-1");
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
assert!(!auth.access_token.is_empty());
}
#[test]
fn rejects_missing_local_auth() {
let codex_home = TempDir::new().expect("tempdir");
let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None)
.expect_err("missing auth should fail");
assert_eq!(err, "no local auth available");
}
#[test]
fn rejects_api_key_auth() {
let codex_home = TempDir::new().expect("tempdir");
save_auth(
codex_home.path(),
&AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-test".to_string()),
tokens: None,
last_refresh: None,
},
AuthCredentialsStoreMode::File,
)
.expect("api key auth should save");
let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None)
.expect_err("api key auth should fail");
assert_eq!(err, "local auth is not a ChatGPT login");
}
#[test]
fn prefers_managed_auth_over_external_ephemeral_tokens() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
login_with_chatgpt_auth_tokens(
codex_home.path(),
&fake_jwt("user@example.com", "workspace-2", "enterprise"),
"workspace-2",
Some("enterprise"),
)
.expect("external auth should save");
let auth = load_local_chatgpt_auth(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
)
.expect("managed auth should win");
assert_eq!(auth.chatgpt_account_id, "workspace-1");
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
}
}

View file

@ -104,8 +104,6 @@ pub(crate) enum SignInOption {
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
const APP_SERVER_TUI_UNSUPPORTED_MESSAGE: &str = "Not available in app-server TUI yet.";
fn onboarding_request_id() -> codex_app_server_protocol::RequestId {
codex_app_server_protocol::RequestId::String(Uuid::new_v4().to_string())
}
@ -741,6 +739,7 @@ impl AuthModeWidget {
if matches!(
self.login_status,
LoginStatus::AuthMode(AppServerAuthMode::Chatgpt)
| LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens)
) {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
self.request_frame.schedule_frame();
@ -799,9 +798,8 @@ impl AuthModeWidget {
return;
}
self.set_error(Some(APP_SERVER_TUI_UNSUPPORTED_MESSAGE.to_string()));
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
self.set_error(/*message*/ None);
headless_chatgpt_login::start_headless_chatgpt_login(self);
}
pub(crate) fn on_account_login_completed(
@ -978,6 +976,20 @@ mod tests {
assert_eq!(widget.login_status, LoginStatus::NotAuthenticated);
}
#[tokio::test]
async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens);
let handled = widget.handle_existing_chatgpt_login();
assert_eq!(handled, true);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::ChatGptSuccess
));
}
/// Collects all buffer cell symbols that contain the OSC 8 open sequence
/// for the given URL. Returns the concatenated "inner" characters.
fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String {

View file

@ -1,6 +1,12 @@
#![allow(dead_code)]
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_core::auth::CLIENT_ID;
use codex_login::ServerOptions;
use codex_login::complete_device_code_login;
use codex_login::request_device_code;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
@ -13,17 +19,106 @@ use std::sync::Arc;
use std::sync::RwLock;
use tokio::sync::Notify;
use crate::local_chatgpt_auth::LocalChatgptAuth;
use crate::local_chatgpt_auth::load_local_chatgpt_auth;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use super::AuthModeWidget;
use super::ContinueInBrowserState;
use super::ContinueWithDeviceCodeState;
use super::SignInState;
use super::mark_url_hyperlink;
use super::onboarding_request_id;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, opts: ServerOptions) {
let _ = opts;
let _ = widget;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget) {
let mut opts = ServerOptions::new(
widget.codex_home.clone(),
CLIENT_ID.to_string(),
widget.forced_chatgpt_workspace_id.clone(),
widget.cli_auth_credentials_store_mode,
);
opts.open_browser = false;
let sign_in_state = widget.sign_in_state.clone();
let request_frame = widget.request_frame.clone();
let error = widget.error.clone();
let request_handle = widget.app_server_request_handle.clone();
let codex_home = widget.codex_home.clone();
let cli_auth_credentials_store_mode = widget.cli_auth_credentials_store_mode;
let forced_chatgpt_workspace_id = widget.forced_chatgpt_workspace_id.clone();
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
tokio::spawn(async move {
let device_code = match request_device_code(&opts).await {
Ok(device_code) => device_code,
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
fallback_to_browser_login(
request_handle,
sign_in_state,
request_frame,
error,
cancel,
)
.await;
} else {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
return;
}
};
if !set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: Some(device_code.clone()),
cancel: Some(cancel.clone()),
}),
) {
return;
}
tokio::select! {
_ = cancel.notified() => {}
result = complete_device_code_login(opts, device_code) => {
match result {
Ok(()) => {
let local_auth = load_local_chatgpt_auth(
&codex_home,
cli_auth_credentials_store_mode,
forced_chatgpt_workspace_id.as_deref(),
);
handle_chatgpt_auth_tokens_login_result_for_active_attempt(
request_handle,
sign_in_state,
request_frame,
error,
cancel,
local_auth,
).await;
}
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
}
}
}
});
}
pub(super) fn render_device_code_login(
@ -151,6 +246,159 @@ fn set_device_code_success_message_for_active_attempt(
true
}
fn set_device_code_error_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
error: &Arc<RwLock<Option<String>>>,
cancel: &Arc<Notify>,
message: String,
) -> bool {
if !set_device_code_state_for_active_attempt(
sign_in_state,
request_frame,
cancel,
SignInState::PickMode,
) {
return false;
}
*error.write().unwrap() = Some(message);
request_frame.schedule_frame();
true
}
async fn fallback_to_browser_login(
request_handle: codex_app_server_client::AppServerRequestHandle,
sign_in_state: Arc<RwLock<SignInState>>,
request_frame: FrameRequester,
error: Arc<RwLock<Option<String>>>,
cancel: Arc<Notify>,
) {
let should_fallback = {
let guard = sign_in_state.read().unwrap();
device_code_attempt_matches(&guard, &cancel)
};
if !should_fallback {
return;
}
match request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::Chatgpt,
})
.await
{
Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => {
*error.write().unwrap() = None;
let _updated = set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id,
auth_url,
}),
);
}
Ok(other) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
format!("Unexpected account/login/start response: {other:?}"),
);
}
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
}
}
async fn handle_chatgpt_auth_tokens_login_result_for_active_attempt(
request_handle: codex_app_server_client::AppServerRequestHandle,
sign_in_state: Arc<RwLock<SignInState>>,
request_frame: FrameRequester,
error: Arc<RwLock<Option<String>>>,
cancel: Arc<Notify>,
local_auth: Result<LocalChatgptAuth, String>,
) {
let local_auth = match local_auth {
Ok(local_auth) => local_auth,
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err,
);
return;
}
};
let result = request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::ChatgptAuthTokens {
access_token: local_auth.access_token,
chatgpt_account_id: local_auth.chatgpt_account_id,
chatgpt_plan_type: local_auth.chatgpt_plan_type,
},
})
.await;
apply_chatgpt_auth_tokens_login_response_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
result.map_err(|err| err.to_string()),
);
}
fn apply_chatgpt_auth_tokens_login_response_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
error: &Arc<RwLock<Option<String>>>,
cancel: &Arc<Notify>,
result: Result<LoginAccountResponse, String>,
) {
match result {
Ok(LoginAccountResponse::ChatgptAuthTokens {}) => {
*error.write().unwrap() = None;
let _updated = set_device_code_success_message_for_active_attempt(
sign_in_state,
request_frame,
cancel,
);
}
Ok(other) => {
set_device_code_error_for_active_attempt(
sign_in_state,
request_frame,
error,
cancel,
format!("Unexpected account/login/start response: {other:?}"),
);
}
Err(err) => {
set_device_code_error_for_active_attempt(
sign_in_state,
request_frame,
error,
cancel,
err,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -269,4 +517,30 @@ mod tests {
SignInState::ChatGptDeviceCode(_)
));
}
#[test]
fn chatgpt_auth_tokens_success_sets_success_message_without_login_id() {
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
let request_frame = FrameRequester::test_dummy();
let error = Arc::new(RwLock::new(None));
let cancel = match &*sign_in_state.read().unwrap() {
SignInState::ChatGptDeviceCode(state) => {
state.cancel.as_ref().expect("cancel handle").clone()
}
_ => panic!("expected device-code state"),
};
apply_chatgpt_auth_tokens_login_response_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
Ok(LoginAccountResponse::ChatgptAuthTokens {}),
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::ChatGptSuccessMessage
));
}
}