1 //! Account access for siGit Code Cloud, surfaced as the `/login`, `/logout`,
2 //! and `/whoami` slash commands in both the TUI and ACP sessions.
3 //!
4 //! These functions authenticate against the siGit account API and store a
5 //! session token locally. The token is the credential used for siGit Code Cloud
6 //! requests. They perform no console I/O, so each slash surface can render the
7 //! returned message however it likes.
8 //!
9 //! Base URL: `$SIGIT_API_URL`, else `https://sigit.si`.
10
11 use serde::Deserialize;
12
13 use crate::credentials::{self, Credentials};
14
15 /// Default account API host. Override with `SIGIT_API_URL` (dev: `http://localhost:8088`).
16 const DEFAULT_API_URL: &str = "https://sigit.si";
17
18 fn api_base() -> String {
19 std::env::var("SIGIT_API_URL").unwrap_or_else(|_| DEFAULT_API_URL.to_string())
20 }
21
22 // Sign-in returns an `AccountStatus`, one of:
23 // "NotFound" (a bare JSON string)
24 // {"Ready":{"access_token":"…"}}
25 // {"Incomplete":{"status":<u32>}}
26 // Failures return {"error_code":<i32>,"message":"…"}. Parsed from a
27 // `serde_json::Value` rather than a struct because of the bare-string variant.
28
29 #[derive(Debug, Deserialize)]
30 struct MeResponse {
31 #[serde(default)]
32 email: Option<String>,
33 }
34
35 /// Authenticate with email and password, storing the session token on success.
36 /// Returns the signed-in email, or a human-readable error message.
37 pub async fn authenticate(email: &str, password: &str) -> Result<String, String> {
38 let email = email.trim();
39 if email.is_empty() || password.is_empty() {
40 return Err("email and password are required".to_string());
41 }
42
43 let url = format!("{}/api/v1/auth/sign_in", api_base().trim_end_matches('/'));
44 let response = reqwest::Client::new()
45 .post(&url)
46 .json(&serde_json::json!({ "email": email, "password": password }))
47 .send()
48 .await
49 .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?;
50
51 let status = response.status();
52 let body: serde_json::Value = response
53 .json()
54 .await
55 .map_err(|error| format!("unexpected response from siGit Code Cloud: {error}"))?;
56
57 if status.is_success() {
58 // AccountStatus::Ready
59 if let Some(token) = body
60 .get("Ready")
61 .and_then(|ready| ready.get("access_token"))
62 .and_then(|token| token.as_str())
63 .filter(|token| !token.trim().is_empty())
64 {
65 credentials::store(&Credentials {
66 access_token: token.to_string(),
67 email: Some(email.to_string()),
68 })?;
69 return Ok(email.to_string());
70 }
71 // AccountStatus::Incomplete
72 if body.get("Incomplete").is_some() {
73 return Err(
74 "your account is not verified yet. Check your email to confirm it, then sign in again."
75 .to_string(),
76 );
77 }
78 // AccountStatus::NotFound (a bare JSON string)
79 if body.as_str() == Some("NotFound") {
80 return Err(format!("no siGit account found for {email}."));
81 }
82 return Err("unexpected sign-in response from siGit Code Cloud".to_string());
83 }
84
85 // ErrorResponse { error_code, message }
86 let message = body
87 .get("message")
88 .and_then(|message| message.as_str())
89 .unwrap_or("sign-in failed");
90 Err(format!("sign-in failed: {message}"))
91 }
92
93 /// Clear the local session, notifying the server best-effort. Returns a message
94 /// suitable for display.
95 pub async fn end_session() -> String {
96 if let Some(token) = credentials::load_token() {
97 let url = format!("{}/api/v1/auth/sign_out", api_base().trim_end_matches('/'));
98 // A failed server call must not block local sign-out.
99 let _ = reqwest::Client::new()
100 .delete(&url)
101 .bearer_auth(&token)
102 .send()
103 .await;
104 }
105 if credentials::clear() {
106 "Signed out of siGit Code Cloud.".to_string()
107 } else {
108 "Not signed in.".to_string()
109 }
110 }
111
112 /// One-line description of the current session, verifying the token if reachable.
113 pub async fn status_line() -> String {
114 let Some(creds) = credentials::load() else {
115 return "Not signed in. Use `/login <email> <password>` to use siGit Code Cloud."
116 .to_string();
117 };
118
119 let url = format!("{}/api/v1/me", api_base().trim_end_matches('/'));
120 match reqwest::Client::new()
121 .get(&url)
122 .bearer_auth(&creds.access_token)
123 .send()
124 .await
125 {
126 Ok(response) if response.status().is_success() => {
127 let email = response
128 .json::<MeResponse>()
129 .await
130 .ok()
131 .and_then(|me| me.email)
132 .or(creds.email)
133 .unwrap_or_else(|| "(unknown)".to_string());
134 format!("Signed in to siGit Code Cloud as {email}.")
135 }
136 Ok(response) => format!(
137 "Session may be expired (HTTP {}). Use `/login` again.",
138 response.status().as_u16()
139 ),
140 Err(_) => {
141 let email = creds.email.unwrap_or_else(|| "(unknown)".to_string());
142 format!("Signed in as {email} (could not reach siGit Code Cloud to verify).")
143 }
144 }
145 }
146
147 /// Split a `/login` argument into `(email, password)`. The password is the rest
148 /// of the line after the first whitespace, so it may contain spaces.
149 pub fn parse_login_args(arg: &str) -> Option<(String, String)> {
150 let mut parts = arg.trim().splitn(2, char::is_whitespace);
151 let email = parts.next().unwrap_or("").trim();
152 let password = parts.next().unwrap_or("").trim();
153 if email.is_empty() || password.is_empty() {
154 None
155 } else {
156 Some((email.to_string(), password.to_string()))
157 }
158 }