1use crate::api::Client;
2use crate::credentials::{self, Credentials};
3use crate::error::{BbError, Result};
4use crate::output::{self, Format};
5use crate::secret::SecretString;
6use serde::Serialize;
7
8const TOKEN_HELP_URL: &str = "https://id.atlassian.com/manage-profile/security/api-tokens";
9
10#[derive(Debug, Serialize)]
11pub struct AuthStatus {
12 pub email: String,
13 pub token: String,
15 pub account: Option<String>,
16}
17
18fn print_status(format: Format, status: &AuthStatus, unverified_label: &str) -> Result<()> {
21 match format {
22 Format::Json => output::print_json(status),
23 Format::Human => {
24 output::print_table(
25 &["FIELD", "VALUE"],
26 vec![
27 vec!["email".into(), status.email.clone()],
28 vec!["token".into(), status.token.clone()],
29 vec![
30 "account".into(),
31 status
32 .account
33 .clone()
34 .unwrap_or_else(|| unverified_label.into()),
35 ],
36 ],
37 );
38 Ok(())
39 }
40 }
41}
42
43pub async fn login(email: Option<String>, token_stdin: bool, format: Format) -> Result<()> {
44 if !format.is_json() {
45 output::info("bb authenticates with an atlassian api token");
46 output::info(&format!("create one at {TOKEN_HELP_URL}"));
47 }
48
49 let would_prompt = email.is_none() || !token_stdin;
52 if would_prompt && !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
53 return Err(BbError::Config(
54 "no email/token on a non-interactive stdin — pass --email and --token-stdin".into(),
55 ));
56 }
57
58 let email = match email {
59 Some(value) => value,
60 None => inquire::Text::new("atlassian account email:")
61 .prompt()
62 .map_err(|e| BbError::Config(format!("cancelled: {e}")))?,
63 };
64
65 let token = if token_stdin {
66 let mut buf = String::new();
67 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
68 SecretString::from(buf.trim().to_string())
69 } else {
70 let entered = inquire::Password::new("api token:")
72 .with_display_mode(inquire::PasswordDisplayMode::Masked)
73 .without_confirmation()
74 .prompt()
75 .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
76 SecretString::from(entered)
77 };
78
79 let email = email.trim().to_string();
80 if email.is_empty() || !email.contains('@') {
81 return Err(BbError::Config(
82 "email must be the atlassian account email address".into(),
83 ));
84 }
85
86 let creds = Credentials {
87 email: email.clone(),
88 token: token.clone(),
89 };
90
91 let spinner = output::spinner("verifying token");
93 let client = Client::from_env(creds.clone())?;
94 let user: crate::api::models::User = client.get_json("/user").await?;
95 spinner.finish_and_clear();
96
97 credentials::store(&email, &token)?;
98
99 let status = AuthStatus {
100 email,
101 token: creds.redacted_token(),
102 account: user.display_name,
103 };
104
105 if !format.is_json() {
106 output::success("token verified and saved to the os keyring");
107 }
108 print_status(format, &status, "-")?;
109
110 Ok(())
111}
112
113pub async fn status(format: Format) -> Result<()> {
114 let creds = credentials::load()?;
115 let redacted = creds.redacted_token();
116
117 let account = match Client::from_env(creds.clone()) {
119 Ok(client) => client
120 .get_json::<crate::api::models::User>("/user")
121 .await
122 .ok()
123 .and_then(|u| u.display_name),
124 Err(_) => None,
125 };
126
127 let status = AuthStatus {
128 email: creds.email.clone(),
129 token: redacted,
130 account,
131 };
132
133 print_status(format, &status, "unverified")?;
134
135 Ok(())
136}
137
138pub fn logout(format: Format) -> Result<()> {
139 credentials::delete()?;
140 let legacy = credentials::legacy_config_path();
141 let legacy_exists = legacy.exists();
142
143 match format {
144 Format::Json => output::print_json(&serde_json::json!({ "removed": true }))?,
145 Format::Human => {
146 if legacy_exists {
147 output::warn(&format!(
148 "a legacy plaintext credential file still exists at {} — delete it",
149 legacy.display()
150 ));
151 }
152 output::success("credentials removed from the os keyring");
153 }
154 }
155 Ok(())
156}