From 985e8c9bc96c34b6b26e2dca2c561e935b2b3682 Mon Sep 17 00:00:00 2001 From: Ruben Rosario Date: Sat, 20 Jun 2026 19:56:41 +0100 Subject: [PATCH] fix: show oauth url and code properly on device auth popup - Auth flow now waits for user's Enter before starting - Start auth only when user presses Enter on DeviceAuth popup - Proper error handling: missing GOOGLE_CLIENT_ID shows clear message - Error messages displayed in popup with Retry option - Popup shows instructions before auth, URL+code during auth - Handle both verification_url and verification_uri field names from Google - Check HTTP status code and show error_description on failures - AuthError propagated to render function for display - Popup border turns green when URL+code are ready --- src/app.rs | 94 +++++++++++++++++++++++---------------- src/infrastructure/api.rs | 22 +++++++-- src/main.rs | 1 + src/ui/components.rs | 79 ++++++++++++++++++++++++-------- src/ui/mod.rs | 3 +- 5 files changed, 138 insertions(+), 61 deletions(-) diff --git a/src/app.rs b/src/app.rs index c0f1e2a..f9a0274 100644 --- a/src/app.rs +++ b/src/app.rs @@ -28,10 +28,18 @@ pub struct App { #[allow(dead_code)] pub api_client: Arc, pub needs_auth: bool, + pub auth_error: Option, + auth_tx: std_mpsc::Sender, auth_rx: std_mpsc::Receiver, sync_tx: mpsc::Sender, } +enum AuthEvent { + DeviceCode(String, String), + Complete, + Error(String), +} + #[allow(dead_code)] pub enum SyncCommand { TriggerSync, @@ -39,42 +47,11 @@ pub enum SyncCommand { Shutdown, } -enum AuthEvent { - DeviceCode(String, String), - Complete, - Error(String), -} - impl App { pub fn new(db: Arc, api_client: Arc, sync_tx: mpsc::Sender) -> Self { let has_token = api_client.token_file_exists(); let (auth_tx, auth_rx) = std_mpsc::channel(); - if !has_token { - let api = api_client.clone(); - let tx = auth_tx.clone(); - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async move { - match api.authenticate().await { - Ok((url, code)) => { - let _ = tx.send(AuthEvent::DeviceCode(url, code)); - loop { - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - if api.token_file_exists() { - let _ = tx.send(AuthEvent::Complete); - break; - } - } - } - Err(_) => { - let _ = tx.send(AuthEvent::Error("Auth failed".to_string())); - } - } - }); - }); - } - let show_popup = if has_token { None } else { @@ -109,11 +86,45 @@ impl App { db, api_client, needs_auth: !has_token, + auth_error: None, + auth_tx, auth_rx, sync_tx, } } + pub fn start_auth_process(&mut self) { + let api = self.api_client.clone(); + let tx = self.auth_tx.clone(); + self.auth_error = None; + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async move { + match api.authenticate().await { + Ok((url, code)) => { + let _ = tx.send(AuthEvent::DeviceCode(url, code)); + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + if api.token_file_exists() { + let _ = tx.send(AuthEvent::Complete); + break; + } + } + } + Err(e) => { + let msg = match &e { + crate::infrastructure::api::ApiError::Network(s) => format!("Network: {}", s), + crate::infrastructure::api::ApiError::Auth(s) => format!("Auth: {}", s), + crate::infrastructure::api::ApiError::Api(s) => format!("API: {}", s), + }; + let _ = tx.send(AuthEvent::Error(msg)); + } + } + }); + }); + } + pub fn poll_auth(&mut self) { if !self.needs_auth { return; @@ -121,20 +132,20 @@ impl App { while let Ok(event) = self.auth_rx.try_recv() { match event { AuthEvent::DeviceCode(url, code) => { - self.show_popup = Some(Popup::DeviceAuth { - url, - code, - }); + self.show_popup = Some(Popup::DeviceAuth { url, code }); } AuthEvent::Complete => { self.needs_auth = false; + self.auth_error = None; self.show_popup = None; let _ = self.sync_tx.try_send(SyncCommand::InitialSync); } AuthEvent::Error(msg) => { - self.needs_auth = false; - self.show_popup = None; - eprintln!("Auth error: {}", msg); + self.auth_error = Some(msg.clone()); + self.show_popup = Some(Popup::DeviceAuth { + url: String::new(), + code: String::new(), + }); } } } @@ -255,7 +266,12 @@ impl App { fn handle_popup_key(&mut self, key: KeyEvent, popup: &Popup) { match popup { - Popup::DeviceAuth { .. } => match key.code { + Popup::DeviceAuth { url, code: _ } => match key.code { + KeyCode::Enter => { + if url.is_empty() && self.auth_error.is_none() { + self.start_auth_process(); + } + } KeyCode::Esc => { self.show_popup = None; } diff --git a/src/infrastructure/api.rs b/src/infrastructure/api.rs index 6e7addf..720172d 100644 --- a/src/infrastructure/api.rs +++ b/src/infrastructure/api.rs @@ -70,6 +70,12 @@ impl ApiClient { } pub async fn authenticate(&self) -> Result<(String, String), ApiError> { + if self.client_id.is_empty() { + return Err(ApiError::Auth( + "GOOGLE_CLIENT_ID not set".to_string(), + )); + } + let params = serde_json::json!({ "client_id": self.client_id, "scope": "https://www.googleapis.com/auth/tasks", @@ -81,16 +87,26 @@ impl ApiClient { .json(¶ms) .send() .await - .map_err(|e| ApiError::Network(e.to_string()))?; + .map_err(|e| ApiError::Network(format!("HTTP request failed: {}", e)))?; + let status = resp.status(); let data: serde_json::Value = resp .json() .await - .map_err(|e| ApiError::Network(e.to_string()))?; + .map_err(|e| ApiError::Api(format!("Invalid response (status {}): {}", status, e)))?; + + if !status.is_success() { + let err_desc = data["error_description"] + .as_str() + .or_else(|| data["error"].as_str()) + .unwrap_or("unknown error"); + return Err(ApiError::Api(format!("OAuth error ({}): {}", status, err_desc))); + } let url = data["verification_url"] .as_str() - .unwrap_or(&data["verification_url"].to_string()) + .or_else(|| data["verification_uri"].as_str()) + .unwrap_or("https://www.google.com/device") .to_string(); let code = data["user_code"] .as_str() diff --git a/src/main.rs b/src/main.rs index 37fa4d7..bcedbec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -88,6 +88,7 @@ fn main() -> io::Result<()> { network_status: &app.network_status, task_list_scroll: app.task_list_scroll, detail_scroll: app.detail_scroll, + auth_error: app.auth_error.as_deref(), }; draw(frame, view); })?; diff --git a/src/ui/components.rs b/src/ui/components.rs index 31feb39..7197fd7 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -298,39 +298,82 @@ pub fn render_device_auth_popup( area: Rect, url: &str, code: &str, + error: Option<&str>, ) { - let popup_area = centered_rect(70, 9, area); + let popup_area = centered_rect(75, 11, area); + + let border_color = if error.is_some() { + Color::Red + } else if url.is_empty() { + POPUP_BORDER + } else { + Color::Green + }; + let block = Block::default() .borders(Borders::ALL) .style(Style::default().bg(POPUP_BG)) - .border_style(Style::default().fg(POPUP_BORDER)) + .border_style(Style::default().fg(border_color)) .title(" Google OAuth ") .title_alignment(Alignment::Left); - let text = Text::from(vec![ - Line::from(""), - Line::from(Span::styled( + let mut lines = Vec::new(); + + if let Some(err) = error { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" ERROR: {} ", err), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Press Enter to retry or Esc to cancel ", + Style::default().fg(Color::DarkGray), + ))); + } else if url.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Google Tasks Authorization Required ", + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars ", + Style::default().fg(Color::Yellow), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Press Enter to start authorization ", + Style::default().fg(Color::Cyan), + ))); + lines.push(Line::from(Span::styled( + " Press Esc to skip ", + Style::default().fg(Color::DarkGray), + ))); + } else { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( " Visit the following URL to authorize: ", Style::default().fg(Color::White), - )), - Line::from(""), - Line::from(Span::styled( + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( url, Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED), - )), - Line::from(" "), - Line::from(Span::styled( + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( format!(" Enter code: {} ", code), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - " After authorizing, press Enter to continue... ", + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Waiting for authorization... (Esc to cancel) ", Style::default().fg(Color::DarkGray), - )), - ]); + ))); + } - let paragraph = Paragraph::new(text) + let paragraph = Paragraph::new(Text::from(lines)) .block(block) .alignment(Alignment::Center); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3c9d736..9985a62 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -41,6 +41,7 @@ pub struct AppView<'a> { pub network_status: &'a NetworkStatus, pub task_list_scroll: u16, pub detail_scroll: u16, + pub auth_error: Option<&'a str>, } pub fn draw(frame: &mut Frame, view: AppView) { @@ -93,7 +94,7 @@ pub fn draw(frame: &mut Frame, view: AppView) { Popup::Input => render_input_popup(frame, area, view.popup_input, view.popup_cursor), Popup::DatePicker => render_date_picker(frame, area, view.draft_date), Popup::ConfirmDelete => render_confirm_popup(frame, area), - Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code), + Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error), } } }