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
This commit is contained in:
Ruben Rosario
2026-06-20 19:56:41 +01:00
parent 320a9c2572
commit 985e8c9bc9
5 changed files with 138 additions and 61 deletions
+55 -39
View File
@@ -28,10 +28,18 @@ pub struct App {
#[allow(dead_code)] #[allow(dead_code)]
pub api_client: Arc<ApiClient>, pub api_client: Arc<ApiClient>,
pub needs_auth: bool, pub needs_auth: bool,
pub auth_error: Option<String>,
auth_tx: std_mpsc::Sender<AuthEvent>,
auth_rx: std_mpsc::Receiver<AuthEvent>, auth_rx: std_mpsc::Receiver<AuthEvent>,
sync_tx: mpsc::Sender<SyncCommand>, sync_tx: mpsc::Sender<SyncCommand>,
} }
enum AuthEvent {
DeviceCode(String, String),
Complete,
Error(String),
}
#[allow(dead_code)] #[allow(dead_code)]
pub enum SyncCommand { pub enum SyncCommand {
TriggerSync, TriggerSync,
@@ -39,42 +47,11 @@ pub enum SyncCommand {
Shutdown, Shutdown,
} }
enum AuthEvent {
DeviceCode(String, String),
Complete,
Error(String),
}
impl App { impl App {
pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>) -> Self { pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>) -> Self {
let has_token = api_client.token_file_exists(); let has_token = api_client.token_file_exists();
let (auth_tx, auth_rx) = std_mpsc::channel(); 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 { let show_popup = if has_token {
None None
} else { } else {
@@ -109,11 +86,45 @@ impl App {
db, db,
api_client, api_client,
needs_auth: !has_token, needs_auth: !has_token,
auth_error: None,
auth_tx,
auth_rx, auth_rx,
sync_tx, 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) { pub fn poll_auth(&mut self) {
if !self.needs_auth { if !self.needs_auth {
return; return;
@@ -121,20 +132,20 @@ impl App {
while let Ok(event) = self.auth_rx.try_recv() { while let Ok(event) = self.auth_rx.try_recv() {
match event { match event {
AuthEvent::DeviceCode(url, code) => { AuthEvent::DeviceCode(url, code) => {
self.show_popup = Some(Popup::DeviceAuth { self.show_popup = Some(Popup::DeviceAuth { url, code });
url,
code,
});
} }
AuthEvent::Complete => { AuthEvent::Complete => {
self.needs_auth = false; self.needs_auth = false;
self.auth_error = None;
self.show_popup = None; self.show_popup = None;
let _ = self.sync_tx.try_send(SyncCommand::InitialSync); let _ = self.sync_tx.try_send(SyncCommand::InitialSync);
} }
AuthEvent::Error(msg) => { AuthEvent::Error(msg) => {
self.needs_auth = false; self.auth_error = Some(msg.clone());
self.show_popup = None; self.show_popup = Some(Popup::DeviceAuth {
eprintln!("Auth error: {}", msg); url: String::new(),
code: String::new(),
});
} }
} }
} }
@@ -255,7 +266,12 @@ impl App {
fn handle_popup_key(&mut self, key: KeyEvent, popup: &Popup) { fn handle_popup_key(&mut self, key: KeyEvent, popup: &Popup) {
match 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 => { KeyCode::Esc => {
self.show_popup = None; self.show_popup = None;
} }
+19 -3
View File
@@ -70,6 +70,12 @@ impl ApiClient {
} }
pub async fn authenticate(&self) -> Result<(String, String), ApiError> { 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!({ let params = serde_json::json!({
"client_id": self.client_id, "client_id": self.client_id,
"scope": "https://www.googleapis.com/auth/tasks", "scope": "https://www.googleapis.com/auth/tasks",
@@ -81,16 +87,26 @@ impl ApiClient {
.json(&params) .json(&params)
.send() .send()
.await .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 let data: serde_json::Value = resp
.json() .json()
.await .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"] let url = data["verification_url"]
.as_str() .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(); .to_string();
let code = data["user_code"] let code = data["user_code"]
.as_str() .as_str()
+1
View File
@@ -88,6 +88,7 @@ fn main() -> io::Result<()> {
network_status: &app.network_status, network_status: &app.network_status,
task_list_scroll: app.task_list_scroll, task_list_scroll: app.task_list_scroll,
detail_scroll: app.detail_scroll, detail_scroll: app.detail_scroll,
auth_error: app.auth_error.as_deref(),
}; };
draw(frame, view); draw(frame, view);
})?; })?;
+61 -18
View File
@@ -298,39 +298,82 @@ pub fn render_device_auth_popup(
area: Rect, area: Rect,
url: &str, url: &str,
code: &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() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.style(Style::default().bg(POPUP_BG)) .style(Style::default().bg(POPUP_BG))
.border_style(Style::default().fg(POPUP_BORDER)) .border_style(Style::default().fg(border_color))
.title(" Google OAuth ") .title(" Google OAuth ")
.title_alignment(Alignment::Left); .title_alignment(Alignment::Left);
let text = Text::from(vec![ let mut lines = Vec::new();
Line::from(""),
Line::from(Span::styled( 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: ", " Visit the following URL to authorize: ",
Style::default().fg(Color::White), Style::default().fg(Color::White),
)), )));
Line::from(""), lines.push(Line::from(""));
Line::from(Span::styled( lines.push(Line::from(Span::styled(
url, url,
Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED), Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED),
)), )));
Line::from(" "), lines.push(Line::from(""));
Line::from(Span::styled( lines.push(Line::from(Span::styled(
format!(" Enter code: {} ", code), format!(" Enter code: {} ", code),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)), )));
Line::from(""), lines.push(Line::from(""));
Line::from(Span::styled( lines.push(Line::from(Span::styled(
" After authorizing, press Enter to continue... ", " Waiting for authorization... (Esc to cancel) ",
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)), )));
]); }
let paragraph = Paragraph::new(text) let paragraph = Paragraph::new(Text::from(lines))
.block(block) .block(block)
.alignment(Alignment::Center); .alignment(Alignment::Center);
+2 -1
View File
@@ -41,6 +41,7 @@ pub struct AppView<'a> {
pub network_status: &'a NetworkStatus, pub network_status: &'a NetworkStatus,
pub task_list_scroll: u16, pub task_list_scroll: u16,
pub detail_scroll: u16, pub detail_scroll: u16,
pub auth_error: Option<&'a str>,
} }
pub fn draw(frame: &mut Frame, view: AppView) { 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::Input => render_input_popup(frame, area, view.popup_input, view.popup_cursor),
Popup::DatePicker => render_date_picker(frame, area, view.draft_date), Popup::DatePicker => render_date_picker(frame, area, view.draft_date),
Popup::ConfirmDelete => render_confirm_popup(frame, area), 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),
} }
} }
} }