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:
+55
-39
@@ -28,10 +28,18 @@ pub struct App {
|
||||
#[allow(dead_code)]
|
||||
pub api_client: Arc<ApiClient>,
|
||||
pub needs_auth: bool,
|
||||
pub auth_error: Option<String>,
|
||||
auth_tx: std_mpsc::Sender<AuthEvent>,
|
||||
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
||||
sync_tx: mpsc::Sender<SyncCommand>,
|
||||
}
|
||||
|
||||
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<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>) -> 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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
})?;
|
||||
|
||||
+61
-18
@@ -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);
|
||||
|
||||
|
||||
+2
-1
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user