1use std::time::Duration;
14
15use crate::cli::DialoguerExt;
16use async_trait::async_trait;
17use clap::Args;
18use dialoguer::{Confirm, Input, Password, theme::ColorfulTheme};
19
20use crate::cli::lifecycle::{
21 CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg, SecretRef,
22 resolve_scopes,
23};
24use zad::config::{GcalServiceCfg, ProjectConfig};
25use zad::error::{Result, ZadError};
26use zad::oauth::{LoopbackConfig, RedirectScheme, run_loopback_flow};
27use zad::secrets::{self, Scope};
28use zad::service::gcal::{AUTH_URL, GcalHttp, TOKEN_URL};
29
30const DEFAULT_SCOPES: &[&str] = &["calendars.read", "events.read", "events.write"];
31const ALL_SCOPES: &[&str] = &[
32 "calendars.read",
33 "events.read",
34 "events.write",
35 "events.invite",
36 "events.remind",
37];
38
39const GCP_CREDENTIALS_URL: &str = "https://console.cloud.google.com/apis/credentials";
43
44const LOOPBACK_TIMEOUT: Duration = Duration::from_secs(120);
48
49pub struct GcalSecrets {
58 pub client_id: String,
59 pub client_secret: String,
60 pub refresh_token: String,
61}
62
63#[derive(Debug, Args)]
68pub struct CreateArgs {
69 #[command(flatten)]
70 pub base: CreateArgsBase,
71 #[command(flatten)]
72 pub scopes: ScopesArg,
73
74 #[arg(long)]
78 pub client_id: Option<String>,
79
80 #[arg(long, conflicts_with = "client_id")]
82 pub client_id_env: Option<String>,
83
84 #[arg(long, conflicts_with = "client_secret_env")]
88 pub client_secret: Option<String>,
89
90 #[arg(long, conflicts_with = "client_secret")]
92 pub client_secret_env: Option<String>,
93
94 #[arg(long, conflicts_with = "refresh_token_env")]
99 pub refresh_token: Option<String>,
100
101 #[arg(long, conflicts_with = "refresh_token")]
103 pub refresh_token_env: Option<String>,
104
105 #[arg(long)]
108 pub default_calendar: Option<String>,
109
110 #[arg(long)]
114 pub self_email: Option<String>,
115}
116
117impl CreateArgsLike for CreateArgs {
118 fn base(&self) -> &CreateArgsBase {
119 &self.base
120 }
121}
122
123pub struct GcalLifecycle;
128
129#[async_trait]
130impl LifecycleService for GcalLifecycle {
131 const NAME: &'static str = "gcal";
132 const DISPLAY: &'static str = "Google Calendar";
133 type Cfg = GcalServiceCfg;
134 type Secrets = GcalSecrets;
135
136 fn enable_in_project(cfg: &mut ProjectConfig) {
137 cfg.enable_gcal();
138 }
139
140 fn disable_in_project(cfg: &mut ProjectConfig) {
141 cfg.disable_gcal();
142 }
143
144 async fn validate(_cfg: &GcalServiceCfg, creds: &mut GcalSecrets) -> Result<String> {
145 let http = GcalHttp::unscoped(
146 creds.client_id.clone(),
147 creds.client_secret.clone(),
148 creds.refresh_token.clone(),
149 );
150 let info = http.userinfo().await?;
151 let email = info.email.unwrap_or_else(|| "<unknown>".into());
152 http.probe_calendar_list().await?;
155 Ok(email)
156 }
157
158 fn store_secrets(creds: &GcalSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
159 let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
160 let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
161 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
162 secrets::store(&client_id_acct, &creds.client_id)?;
163 secrets::store(&client_secret_acct, &creds.client_secret)?;
164 secrets::store(&refresh_acct, &creds.refresh_token)?;
165 Ok(vec![
166 SecretRef {
167 label: "client id",
168 account: client_id_acct,
169 present: true,
170 },
171 SecretRef {
172 label: "client secret",
173 account: client_secret_acct,
174 present: true,
175 },
176 SecretRef {
177 label: "refresh token",
178 account: refresh_acct,
179 present: true,
180 },
181 ])
182 }
183
184 fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
185 let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
186 let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
187 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
188 secrets::delete(&client_id_acct)?;
189 secrets::delete(&client_secret_acct)?;
190 secrets::delete(&refresh_acct)?;
191 Ok(vec![
192 SecretRef {
193 label: "client id",
194 account: client_id_acct,
195 present: false,
196 },
197 SecretRef {
198 label: "client secret",
199 account: client_secret_acct,
200 present: false,
201 },
202 SecretRef {
203 label: "refresh token",
204 account: refresh_acct,
205 present: false,
206 },
207 ])
208 }
209
210 fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
211 let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
212 let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
213 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
214 let client_id_present = secrets::load(&client_id_acct)?.is_some();
215 let client_secret_present = secrets::load(&client_secret_acct)?.is_some();
216 let refresh_present = secrets::load(&refresh_acct)?.is_some();
217 Ok(vec![
218 SecretRef {
219 label: "client id",
220 account: client_id_acct,
221 present: client_id_present,
222 },
223 SecretRef {
224 label: "client secret",
225 account: client_secret_acct,
226 present: client_secret_present,
227 },
228 SecretRef {
229 label: "refresh token",
230 account: refresh_acct,
231 present: refresh_present,
232 },
233 ])
234 }
235
236 fn load_secrets(scope: Scope<'_>) -> Result<Option<GcalSecrets>> {
237 let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
238 let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
239 let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
240 let (Some(id), Some(secret), Some(refresh)) = (
241 secrets::load(&client_id_acct)?,
242 secrets::load(&client_secret_acct)?,
243 secrets::load(&refresh_acct)?,
244 ) else {
245 return Ok(None);
246 };
247 Ok(Some(GcalSecrets {
248 client_id: id,
249 client_secret: secret,
250 refresh_token: refresh,
251 }))
252 }
253
254 fn cfg_human(cfg: &GcalServiceCfg) -> Vec<(&'static str, String)> {
255 let mut out = vec![];
256 if let Some(c) = &cfg.default_calendar {
257 out.push(("calendar", c.clone()));
258 }
259 if let Some(e) = &cfg.self_email {
260 out.push(("email", e.clone()));
261 }
262 out
263 }
264
265 fn cfg_json(cfg: &GcalServiceCfg) -> serde_json::Value {
266 serde_json::json!({
267 "default_calendar": cfg.default_calendar,
268 "self_email": cfg.self_email,
269 })
270 }
271
272 fn scopes_of(cfg: &GcalServiceCfg) -> &[String] {
273 &cfg.scopes
274 }
275
276 fn post_create_hint(_cfg: &GcalServiceCfg) -> Option<String> {
277 None
281 }
282}
283
284#[async_trait]
285impl CliLifecycle for GcalLifecycle {
286 type CreateArgs = CreateArgs;
287
288 async fn resolve(
289 args: &CreateArgs,
290 non_interactive: bool,
291 ) -> Result<(GcalServiceCfg, GcalSecrets)> {
292 let open_browser = !args.base.no_browser;
293
294 let scopes = resolve_scopes(
295 args.scopes.scopes.as_deref(),
296 DEFAULT_SCOPES,
297 ALL_SCOPES,
298 non_interactive,
299 )?;
300
301 let client_id = resolve_client_id(
302 args.client_id.as_deref(),
303 args.client_id_env.as_deref(),
304 open_browser,
305 non_interactive,
306 )?;
307
308 let client_secret = resolve_client_secret(
309 args.client_secret.as_deref(),
310 args.client_secret_env.as_deref(),
311 non_interactive,
312 )?;
313
314 let refresh_token = if let Some(v) = args.refresh_token.clone() {
315 v
316 } else if let Some(env) = args.refresh_token_env.as_deref() {
317 std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()))?
318 } else {
319 resolve_refresh_via_loopback(
320 &client_id,
321 &client_secret,
322 &scopes,
323 open_browser,
324 non_interactive,
325 )
326 .await?
327 };
328
329 Ok((
330 GcalServiceCfg {
331 scopes,
332 default_calendar: args.default_calendar.clone(),
333 self_email: args.self_email.clone(),
334 },
335 GcalSecrets {
336 client_id,
337 client_secret,
338 refresh_token,
339 },
340 ))
341 }
342}
343
344fn theme() -> ColorfulTheme {
349 ColorfulTheme::default()
350}
351
352fn resolve_client_id(
353 flag: Option<&str>,
354 env_flag: Option<&str>,
355 open_browser: bool,
356 non_interactive: bool,
357) -> Result<String> {
358 if let Some(env) = env_flag {
359 return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
360 }
361 if let Some(v) = flag {
362 return Ok(v.to_string());
363 }
364 if non_interactive {
365 return Err(ZadError::MissingRequired("--client-id or --client-id-env"));
366 }
367
368 println!();
369 println!("Google Calendar uses OAuth 2.0. You need a Google Cloud OAuth client:");
370 println!(" 1. Open the Google Cloud Console credentials page:");
371 println!(" {GCP_CREDENTIALS_URL}");
372 println!(" 2. Create an OAuth client of type \"Desktop app\".");
373 println!(" 3. Enable the \"Google Calendar API\" under APIs & Services → Library.");
374 println!(" 4. Copy the Client ID and Client Secret back here.");
375 if open_browser {
376 let _ = open::that(GCP_CREDENTIALS_URL);
377 }
378
379 let v: String = Input::with_theme(&theme())
380 .with_prompt("Google OAuth Client ID")
381 .interact_text()
382 .into_zad()?;
383 Ok(v.trim().to_string())
384}
385
386fn resolve_client_secret(
387 flag: Option<&str>,
388 env_flag: Option<&str>,
389 non_interactive: bool,
390) -> Result<String> {
391 if let Some(env) = env_flag {
392 return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
393 }
394 if let Some(v) = flag {
395 return Ok(v.to_string());
396 }
397 if non_interactive {
398 return Err(ZadError::MissingRequired(
399 "--client-secret or --client-secret-env",
400 ));
401 }
402
403 let v = Password::with_theme(&theme())
404 .with_prompt("Google OAuth Client Secret")
405 .interact()
406 .into_zad()?;
407 Ok(v)
408}
409
410async fn resolve_refresh_via_loopback(
414 client_id: &str,
415 client_secret: &str,
416 zad_scopes: &[String],
417 open_browser: bool,
418 non_interactive: bool,
419) -> Result<String> {
420 if non_interactive {
421 return Err(ZadError::MissingRequired(
422 "--refresh-token or --refresh-token-env (non-interactive mode cannot open a browser)",
423 ));
424 }
425
426 println!();
427 println!("No refresh token provided — starting the browser OAuth flow.");
428 println!(
429 "Make sure the OAuth client you created in Google Cloud Console is of type \"Desktop app\"."
430 );
431 let want = Confirm::with_theme(&theme())
432 .with_prompt("Continue with the browser flow?")
433 .default(true)
434 .interact()
435 .into_zad()?;
436 if !want {
437 return Err(ZadError::Invalid(
438 "browser OAuth flow declined by operator; pass --refresh-token to skip it".into(),
439 ));
440 }
441
442 let google_scopes = google_scopes_for(zad_scopes);
443 let cfg = LoopbackConfig {
444 service_name: "gcal",
445 display_name: "Google Calendar",
446 auth_url: AUTH_URL.to_string(),
447 token_url: TOKEN_URL.to_string(),
448 client_id: client_id.to_string(),
449 client_secret: Some(client_secret.to_string()),
450 scopes: google_scopes,
451 extra_auth_params: vec![
452 ("access_type".into(), "offline".into()),
457 ("prompt".into(), "consent".into()),
458 ("include_granted_scopes".into(), "true".into()),
462 ],
463 timeout: LOOPBACK_TIMEOUT,
464 redirect_scheme: RedirectScheme::Http,
465 };
466 let tokens = run_loopback_flow(&cfg, open_browser).await?;
467 tokens.refresh_token.ok_or_else(|| ZadError::Service {
468 name: "gcal",
469 message: "Google did not return a refresh token. Check that the consent screen \
470 granted access and that the OAuth client is type 'Desktop app'. \
471 Re-run `zad service create gcal` to retry."
472 .into(),
473 })
474}
475
476pub fn google_scopes_for(zad_scopes: &[String]) -> Vec<String> {
483 let mut out: Vec<String> = vec!["openid".into(), "email".into()];
484 let has = |s: &str| zad_scopes.iter().any(|z| z == s);
485
486 if has("events.write")
487 || has("events.invite")
488 || has("events.remind")
489 || (!has("calendars.read") && !has("events.read"))
490 {
491 out.push("https://www.googleapis.com/auth/calendar.events".into());
494 } else if has("events.read") {
495 out.push("https://www.googleapis.com/auth/calendar.events.readonly".into());
496 }
497
498 if has("calendars.read") && !has("events.write") {
499 out.push("https://www.googleapis.com/auth/calendar.calendarlist.readonly".into());
504 }
505
506 out.sort();
507 out.dedup();
508 out
509}