1use crate::config::{ApiConfig, CliAuthState, SshConfig};
2use chrono::{DateTime, Utc};
3use reqwest::{Client, StatusCode};
4use serde::{Deserialize, Serialize};
5use std::env;
6use tokio::time::Duration;
7
8const CLI_LOGIN_REQUIRED_HINT: &str =
9 "Run `xbp login` first so XBP can verify your session against the dashboard.";
10
11#[derive(Debug, Clone, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub(crate) struct CliAuthSessionResponse {
14 pub(crate) user: CliAuthSessionUser,
15 pub(crate) token: CliAuthSessionToken,
16}
17
18#[derive(Debug, Clone, Deserialize)]
19pub(crate) struct CliAuthSessionUser {
20 pub(crate) id: String,
21 pub(crate) name: String,
22 pub(crate) email: String,
23}
24
25#[derive(Debug, Clone, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub(crate) struct CliAuthSessionToken {
28 pub(crate) id: String,
29 pub(crate) label: Option<String>,
30 pub(crate) prefix: String,
31 pub(crate) created_at: Option<String>,
32 pub(crate) expires_at: Option<String>,
33 pub(crate) last_used_at: Option<String>,
34}
35
36#[derive(Debug)]
37pub(crate) enum CliSessionError {
38 Unauthorized,
39 Other(String),
40}
41
42#[derive(Debug, Clone)]
43enum CliTokenSource {
44 Config(String),
45 Env(String),
46}
47
48#[derive(Debug, Clone, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub(crate) struct VersionActivityLinearInitiative {
51 pub(crate) id: String,
52 pub(crate) name: String,
53 pub(crate) url: Option<String>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub(crate) struct CliVersionActivityPayload {
59 pub(crate) command_kind: String,
60 pub(crate) repository_owner: Option<String>,
61 pub(crate) repository_name: Option<String>,
62 pub(crate) scope_kind: String,
63 pub(crate) scope_label: String,
64 pub(crate) version: String,
65 pub(crate) tag_name: Option<String>,
66 pub(crate) title: Option<String>,
67 pub(crate) release_url: Option<String>,
68 pub(crate) message_markdown: Option<String>,
69 pub(crate) published_initiatives: Vec<VersionActivityLinearInitiative>,
70}
71
72pub(crate) fn cli_request_client() -> Result<Client, String> {
73 Client::builder()
74 .timeout(Duration::from_secs(20))
75 .build()
76 .map_err(|e| format!("Failed to create HTTP client: {}", e))
77}
78
79pub(crate) async fn fetch_cli_session(
80 client: &Client,
81 api: &ApiConfig,
82 token: &str,
83) -> Result<CliAuthSessionResponse, CliSessionError> {
84 let response = client
85 .get(api.cli_auth_session_endpoint())
86 .bearer_auth(token)
87 .send()
88 .await
89 .map_err(|e| CliSessionError::Other(format!("Failed to verify CLI login token: {}", e)))?;
90
91 if matches!(
92 response.status(),
93 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
94 ) {
95 return Err(CliSessionError::Unauthorized);
96 }
97
98 let response = response.error_for_status().map_err(|e| {
99 CliSessionError::Other(format!("CLI login token verification failed: {}", e))
100 })?;
101
102 response
103 .json::<CliAuthSessionResponse>()
104 .await
105 .map_err(|e| CliSessionError::Other(format!("Failed to parse CLI session response: {}", e)))
106}
107
108pub(crate) fn save_cli_login_state(
109 token: String,
110 session: &CliAuthSessionResponse,
111) -> Result<(), String> {
112 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
113 config.xbp_api_token = Some(token);
114 config.cli_auth = Some(cli_auth_state_from_session(session));
115 config.save()
116}
117
118pub(crate) fn clear_cli_login_state(clear_token: bool) -> Result<(), String> {
119 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
120 if clear_token {
121 config.xbp_api_token = None;
122 }
123 config.cli_auth = None;
124 config.save()
125}
126
127pub(crate) fn resolve_cli_access_token() -> Result<String, String> {
128 resolve_cli_token_source()
129 .map(|source| source.token().to_string())
130 .ok_or_else(|| CLI_LOGIN_REQUIRED_HINT.to_string())
131}
132
133pub(crate) async fn require_authenticated_cli_session() -> Result<CliAuthSessionResponse, String> {
134 let token_source =
135 resolve_cli_token_source().ok_or_else(|| CLI_LOGIN_REQUIRED_HINT.to_string())?;
136 let api = ApiConfig::load();
137 let client = cli_request_client()?;
138 let token = token_source.token().to_string();
139
140 match fetch_cli_session(&client, &api, &token).await {
141 Ok(session) => {
142 if matches!(token_source, CliTokenSource::Config(_)) {
143 save_verified_state(&session)?;
144 }
145 Ok(session)
146 }
147 Err(CliSessionError::Unauthorized) => {
148 if matches!(token_source, CliTokenSource::Config(_)) {
149 let _ = clear_cli_login_state(true);
150 }
151 Err("Your stored CLI session is no longer valid. Run `xbp login` again.".to_string())
152 }
153 Err(CliSessionError::Other(error)) => Err(error),
154 }
155}
156
157pub(crate) async fn run_login_status() -> Result<(), String> {
158 let stored = SshConfig::load().ok().and_then(|cfg| cfg.cli_auth);
159 let token_source = resolve_cli_token_source();
160
161 let Some(token_source) = token_source else {
162 println!("CLI login status: not signed in.");
163 if let Some(stored) = stored {
164 if let Some(expires_at) = stored.token_expires_at {
165 println!("Last known token expiry: {}", expires_at.to_rfc3339());
166 }
167 }
168 println!("{}", CLI_LOGIN_REQUIRED_HINT);
169 return Ok(());
170 };
171
172 let api = ApiConfig::load();
173 let client = cli_request_client()?;
174 match fetch_cli_session(&client, &api, token_source.token()).await {
175 Ok(session) => {
176 if matches!(token_source, CliTokenSource::Config(_)) {
177 save_verified_state(&session)?;
178 }
179 println!("CLI login status: signed in.");
180 println!("User: {} <{}>", session.user.name, session.user.email);
181 println!("Token id: {}", session.token.id);
182 println!("Token prefix: {}", session.token.prefix);
183 if let Some(label) = session.token.label.as_deref() {
184 println!("Token label: {}", label);
185 }
186 if let Some(created_at) = session.token.created_at.as_deref() {
187 println!("Issued at: {}", created_at);
188 }
189 if let Some(expires_at) = session.token.expires_at.as_deref() {
190 println!("Expires at: {}", expires_at);
191 } else {
192 println!("Expires at: none");
193 }
194 if let Some(last_used_at) = session.token.last_used_at.as_deref() {
195 println!("Last used at: {}", last_used_at);
196 }
197 println!("Token source: {}", token_source.label());
198 Ok(())
199 }
200 Err(CliSessionError::Unauthorized) => {
201 if matches!(token_source, CliTokenSource::Config(_)) {
202 let _ = clear_cli_login_state(true);
203 }
204 Err("The current CLI token is no longer valid. Run `xbp login` again.".to_string())
205 }
206 Err(CliSessionError::Other(error)) => Err(error),
207 }
208}
209
210pub(crate) async fn run_logout() -> Result<(), String> {
211 let token_source = resolve_cli_token_source();
212 if token_source.is_none() {
213 let _ = clear_cli_login_state(true);
214 println!("CLI login status: already signed out.");
215 return Ok(());
216 }
217
218 let token_source = token_source.expect("checked above");
219 let api = ApiConfig::load();
220 let client = cli_request_client()?;
221
222 let response = client
223 .delete(api.cli_auth_session_endpoint())
224 .bearer_auth(token_source.token())
225 .send()
226 .await
227 .map_err(|e| format!("Failed to contact dashboard while signing out: {}", e))?;
228
229 if !matches!(
230 response.status(),
231 StatusCode::OK
232 | StatusCode::NO_CONTENT
233 | StatusCode::UNAUTHORIZED
234 | StatusCode::FORBIDDEN
235 | StatusCode::NOT_FOUND
236 ) {
237 return Err(format!(
238 "Dashboard sign-out failed with status {} {}.",
239 response.status().as_u16(),
240 response.status().canonical_reason().unwrap_or("")
241 ));
242 }
243
244 if matches!(token_source, CliTokenSource::Config(_)) {
245 clear_cli_login_state(true)?;
246 } else {
247 clear_cli_login_state(false)?;
248 }
249
250 println!("CLI login status: signed out.");
251 if matches!(token_source, CliTokenSource::Env(_)) {
252 println!("Environment token remains set in `XBP_API_TOKEN` until you unset it.");
253 }
254 Ok(())
255}
256
257pub(crate) async fn fetch_linear_api_key_from_dashboard() -> Result<Option<String>, String> {
258 let Some(token_source) = resolve_cli_token_source() else {
259 return Ok(None);
260 };
261 let api = ApiConfig::load();
262 let client = cli_request_client()?;
263 let response = client
264 .get(api.cli_linear_key_endpoint())
265 .bearer_auth(token_source.token())
266 .send()
267 .await
268 .map_err(|e| format!("Failed to fetch Linear key from dashboard: {}", e))?;
269
270 match response.status() {
271 StatusCode::OK => {
272 #[derive(Deserialize)]
273 #[serde(rename_all = "camelCase")]
274 struct LinearKeyResponse {
275 api_key: String,
276 }
277
278 let body = response
279 .json::<LinearKeyResponse>()
280 .await
281 .map_err(|e| format!("Failed to parse dashboard Linear key response: {}", e))?;
282 Ok(Some(body.api_key))
283 }
284 StatusCode::NOT_FOUND => Ok(None),
285 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(
286 "The current CLI session cannot read dashboard integrations. Run `xbp login` again."
287 .to_string(),
288 ),
289 _ => Err(format!(
290 "Dashboard Linear key lookup failed with status {} {}.",
291 response.status().as_u16(),
292 response.status().canonical_reason().unwrap_or("")
293 )),
294 }
295}
296
297pub(crate) async fn post_version_activity(
298 payload: &CliVersionActivityPayload,
299) -> Result<(), String> {
300 let Some(token_source) = resolve_cli_token_source() else {
301 return Err(CLI_LOGIN_REQUIRED_HINT.to_string());
302 };
303
304 let api = ApiConfig::load();
305 let client = cli_request_client()?;
306 let response = client
307 .post(api.cli_version_activity_endpoint())
308 .bearer_auth(token_source.token())
309 .json(payload)
310 .send()
311 .await
312 .map_err(|e| format!("Failed to sync version activity to dashboard: {}", e))?;
313
314 if response.status().is_success() {
315 return Ok(());
316 }
317
318 if should_skip_missing_version_activity_route(response.status()) {
319 return Ok(());
320 }
321
322 Err(format!(
323 "Dashboard version activity sync failed with status {} {}.",
324 response.status().as_u16(),
325 response.status().canonical_reason().unwrap_or("")
326 ))
327}
328
329fn should_skip_missing_version_activity_route(status: StatusCode) -> bool {
330 status == StatusCode::NOT_FOUND
331}
332
333fn resolve_cli_token_source() -> Option<CliTokenSource> {
334 if let Ok(value) = env::var("XBP_API_TOKEN") {
335 let trimmed = value.trim();
336 if !trimmed.is_empty() {
337 return Some(CliTokenSource::Env(trimmed.to_string()));
338 }
339 }
340
341 SshConfig::load()
342 .ok()
343 .and_then(|cfg| cfg.xbp_api_token)
344 .map(|value| value.trim().to_string())
345 .filter(|value| !value.is_empty())
346 .map(CliTokenSource::Config)
347}
348
349fn parse_optional_datetime(value: Option<&str>) -> Option<DateTime<Utc>> {
350 let raw = value?.trim();
351 if raw.is_empty() {
352 return None;
353 }
354
355 DateTime::parse_from_rfc3339(raw)
356 .ok()
357 .map(|value| value.with_timezone(&Utc))
358}
359
360#[cfg(test)]
361mod tests {
362 use super::should_skip_missing_version_activity_route;
363 use reqwest::StatusCode;
364
365 #[test]
366 fn version_activity_sync_skips_missing_dashboard_route() {
367 assert!(should_skip_missing_version_activity_route(
368 StatusCode::NOT_FOUND
369 ));
370 assert!(!should_skip_missing_version_activity_route(
371 StatusCode::INTERNAL_SERVER_ERROR
372 ));
373 assert!(!should_skip_missing_version_activity_route(
374 StatusCode::UNAUTHORIZED
375 ));
376 }
377}
378
379fn cli_auth_state_from_session(session: &CliAuthSessionResponse) -> CliAuthState {
380 CliAuthState {
381 user_id: Some(session.user.id.clone()),
382 user_name: Some(session.user.name.clone()),
383 user_email: Some(session.user.email.clone()),
384 token_label: session.token.label.clone(),
385 token_prefix: Some(session.token.prefix.clone()),
386 token_created_at: parse_optional_datetime(session.token.created_at.as_deref()),
387 token_expires_at: parse_optional_datetime(session.token.expires_at.as_deref()),
388 last_verified_at: Some(Utc::now()),
389 }
390}
391
392fn save_verified_state(session: &CliAuthSessionResponse) -> Result<(), String> {
393 let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
394 if config.xbp_api_token.is_none() {
395 return Ok(());
396 }
397 config.cli_auth = Some(cli_auth_state_from_session(session));
398 config.save()
399}
400
401impl CliTokenSource {
402 fn token(&self) -> &str {
403 match self {
404 Self::Config(value) | Self::Env(value) => value.as_str(),
405 }
406 }
407
408 fn label(&self) -> &'static str {
409 match self {
410 Self::Config(_) => "config",
411 Self::Env(_) => "environment",
412 }
413 }
414}