broccoli_cli/commands/
login.rs1use std::io::Write;
2use std::thread;
3use std::time::Duration;
4
5use anyhow::{Context, bail};
6use clap::Args;
7use console::style;
8use serde::Deserialize;
9
10use crate::auth;
11
12#[derive(Args)]
13pub struct LoginArgs {
14 #[arg(long, default_value = "http://localhost:3000", env = "BROCCOLI_URL")]
16 pub server: String,
17}
18
19#[derive(Deserialize)]
20struct DeviceCodeResponse {
21 device_code: String,
22 user_code: String,
23 verification_url: String,
24 expires_in: u64,
25 interval: u64,
26}
27
28#[derive(Deserialize)]
29struct PollResponse {
30 #[serde(default)]
31 error: Option<String>,
32 #[serde(default)]
33 token: Option<String>,
34}
35
36pub fn run(args: LoginArgs) -> anyhow::Result<()> {
37 let client = reqwest::blocking::Client::new();
38
39 println!(
40 "{} Requesting device code from {}...",
41 style("→").blue().bold(),
42 style(&args.server).cyan()
43 );
44
45 let resp = client
46 .post(format!("{}/api/v1/auth/device-code", args.server))
47 .json(&serde_json::json!({}))
48 .send()
49 .context("Failed to connect to server. Is it running?")?;
50
51 if !resp.status().is_success() {
52 let status = resp.status();
53 let body = resp.text().unwrap_or_default();
54 bail!("Server returned {}: {}", status, body);
55 }
56
57 let device_code_resp: DeviceCodeResponse = resp
58 .json()
59 .context("Failed to parse device code response")?;
60
61 println!();
62 println!(
63 " Open {} and enter code:",
64 style(&device_code_resp.verification_url)
65 .underlined()
66 .cyan()
67 );
68 println!();
69 println!(" {}", style(&device_code_resp.user_code).bold().yellow());
70 println!();
71
72 let _ = open::that(&device_code_resp.verification_url);
74
75 let interval = Duration::from_secs(device_code_resp.interval);
76 let max_polls = device_code_resp.expires_in / device_code_resp.interval;
77
78 print!(" Waiting for authorization");
79 std::io::stdout().flush().ok();
80
81 for _ in 0..max_polls {
82 thread::sleep(interval);
83 print!(".");
84 std::io::stdout().flush().ok();
85
86 let poll_resp = client
87 .post(format!("{}/api/v1/auth/device-token", args.server))
88 .json(&serde_json::json!({
89 "device_code": device_code_resp.device_code
90 }))
91 .send();
92
93 let poll_resp = match poll_resp {
94 Ok(r) => r,
95 Err(e) => {
96 eprintln!("\n Connection error: {}. Retrying...", e);
97 continue;
98 }
99 };
100
101 let poll: PollResponse = match poll_resp.json() {
102 Ok(p) => p,
103 Err(_) => continue,
104 };
105
106 if let Some(token) = poll.token {
107 println!();
108 println!();
109
110 auth::save_credentials(&args.server, &token).context("Failed to save credentials")?;
111
112 println!("{} Logged in successfully!", style("✓").green().bold());
113 println!(
114 " Credentials saved to {}",
115 style("~/.config/broccoli/credentials.json").dim()
116 );
117
118 return Ok(());
119 }
120
121 if let Some(ref error) = poll.error {
122 match error.as_str() {
123 "authorization_pending" => continue,
124 "slow_down" => {
125 thread::sleep(Duration::from_secs(1));
127 continue;
128 }
129 "expired_token" => {
130 println!();
131 bail!("Device code expired. Run `broccoli login` again to get a new code.");
132 }
133 other => {
134 println!();
135 bail!("Unexpected error from server: {}", other);
136 }
137 }
138 }
139 }
140
141 println!();
142 bail!("Timed out waiting for authorization. Run `broccoli login` again.");
143}