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
10pub const SCOPES: [(&str, &str); 6] = [
21 (
22 "read:user:bitbucket",
23 "required — login verifies the token against /user",
24 ),
25 (
26 "read:pullrequest:bitbucket",
27 "pr list, view, diff, files, commits, mine",
28 ),
29 (
30 "read:repository:bitbucket",
31 "branch list, repo list, default reviewers, the pr mine scan",
32 ),
33 (
34 "write:pullrequest:bitbucket",
35 "pr create, comment, resolve, request-changes",
36 ),
37 (
38 "read:project:bitbucket",
39 "project list, and repo create's project picker",
40 ),
41 (
42 "admin:repository:bitbucket",
43 "repo create — no combination of the read scopes covers it",
44 ),
45];
46
47pub fn scope_lines(indent: &str) -> String {
53 let width = SCOPES.iter().map(|(s, _)| s.len()).max().unwrap_or(0);
54 SCOPES
55 .iter()
56 .map(|(scope, why)| format!("{indent}{scope:<width$} {why}"))
57 .collect::<Vec<_>>()
58 .join("\n")
59}
60
61pub fn login_long_about() -> String {
65 format!(
66 "Store an atlassian api token in the os keyring.
67
68Create the token at {TOKEN_HELP_URL},
69choosing \"Create API token with scopes\" and Bitbucket as the product, then grant:
70
71{}
72
73The first three are the read-only floor. Grant the rest only for the commands
74named beside them: writing to a pull request, listing projects, and creating a
75repository are each a separate grant.",
76 scope_lines(" ")
77 )
78}
79
80fn print_onboarding() {
86 output::heading("bb authenticates with an atlassian api token");
87 output::info(
88 "atlassian retired the older bitbucket credential on 2026-07-28 — an api token is \
89 the only one left",
90 );
91 println!();
92 output::info(&format!("1. open {TOKEN_HELP_URL}"));
93 output::info("2. choose \"Create API token with scopes\", then pick Bitbucket as the product");
94 output::info("3. grant these scopes:");
95 println!("{}", scope_lines(" "));
96 output::info(
97 " the first three are the read-only floor; grant the rest only for the commands \
98 beside them",
99 );
100 output::info("4. copy the token — atlassian shows it once — and paste it below");
101 println!();
102}
103
104fn verification_hint(err: &BbError) -> Option<&'static str> {
110 match err {
111 BbError::Auth => Some(
112 "the email or token was rejected — the username must be your atlassian account \
113 email, and the password the api token itself, not your atlassian password",
114 ),
115 BbError::Api { status: 403, .. } => Some(
116 "the token was accepted but the request was refused — most likely the \
117 read:user:bitbucket scope is missing; a revoked token or an organisation \
118 access policy gives the same answer",
119 ),
120 _ => None,
121 }
122}
123
124#[derive(Debug, Serialize)]
125pub struct AuthStatus {
126 pub email: String,
127 pub token: String,
129 pub account: Option<String>,
130}
131
132fn print_status(format: Format, status: &AuthStatus, unverified_label: &str) -> Result<()> {
135 match format {
136 Format::Json => output::print_json(status),
137 Format::Human => {
138 output::print_table(
139 &["FIELD", "VALUE"],
140 vec![
141 vec!["email".into(), status.email.clone()],
142 vec!["token".into(), status.token.clone()],
143 vec![
144 "account".into(),
145 status
146 .account
147 .clone()
148 .unwrap_or_else(|| unverified_label.into()),
149 ],
150 ],
151 );
152 Ok(())
153 }
154 }
155}
156
157pub async fn login(email: Option<String>, token_stdin: bool, format: Format) -> Result<()> {
158 let would_prompt = email.is_none() || !token_stdin;
161
162 if would_prompt && !format.is_json() {
163 print_onboarding();
164 }
165
166 if would_prompt && !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
167 return Err(BbError::Config(
168 "no email/token on a non-interactive stdin — pass --email and --token-stdin".into(),
169 ));
170 }
171
172 let email = match email {
173 Some(value) => value,
174 None => inquire::Text::new("atlassian account email:")
175 .prompt()
176 .map_err(|e| BbError::Config(format!("cancelled: {e}")))?,
177 };
178
179 let token = if token_stdin {
180 let mut buf = String::new();
181 std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
182 SecretString::from(buf.trim().to_string())
183 } else {
184 let entered = inquire::Password::new("api token:")
186 .with_display_mode(inquire::PasswordDisplayMode::Masked)
187 .without_confirmation()
188 .prompt()
189 .map_err(|e| BbError::Config(format!("cancelled: {e}")))?;
190 SecretString::from(entered)
191 };
192
193 let email = email.trim().to_string();
194 if email.is_empty() || !email.contains('@') {
195 return Err(BbError::Config(
196 "email must be the atlassian account email address".into(),
197 ));
198 }
199
200 let creds = Credentials {
201 email: email.clone(),
202 token: token.clone(),
203 };
204
205 let spinner = output::spinner("verifying token");
207 let client = Client::from_env(creds.clone())?;
208 let verified = client.get_json::<crate::api::models::User>("/user").await;
209 spinner.finish_and_clear();
210 let user = match verified {
211 Ok(user) => user,
212 Err(err) => {
213 if let Some(hint) = verification_hint(&err) {
214 output::warn(hint);
215 }
216 return Err(err);
217 }
218 };
219
220 credentials::store(&email, &token)?;
221
222 let status = AuthStatus {
223 email,
224 token: creds.redacted_token(),
225 account: user.display_name,
226 };
227
228 if !format.is_json() {
229 output::success("token verified and saved to the os keyring");
230 }
231 print_status(format, &status, "-")?;
232
233 Ok(())
234}
235
236pub async fn status(format: Format) -> Result<()> {
237 let creds = credentials::load()?;
238 let redacted = creds.redacted_token();
239
240 let account = match Client::from_env(creds.clone()) {
242 Ok(client) => client
243 .get_json::<crate::api::models::User>("/user")
244 .await
245 .ok()
246 .and_then(|u| u.display_name),
247 Err(_) => None,
248 };
249
250 let status = AuthStatus {
251 email: creds.email.clone(),
252 token: redacted,
253 account,
254 };
255
256 print_status(format, &status, "unverified")?;
257
258 Ok(())
259}
260
261pub fn logout(format: Format) -> Result<()> {
262 credentials::delete()?;
263 let legacy = credentials::legacy_config_path();
264 let legacy_exists = legacy.exists();
265
266 match format {
267 Format::Json => output::print_json(&serde_json::json!({ "removed": true }))?,
268 Format::Human => {
269 if legacy_exists {
270 output::warn(&format!(
271 "a legacy plaintext credential file still exists at {} — delete it",
272 legacy.display()
273 ));
274 }
275 output::success("credentials removed from the os keyring");
276 }
277 }
278 Ok(())
279}