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 /// Verify that a usable session exists, confirming the stored token against the
94 /// account API. Returns the signed-in email on success, or a human-readable
95 /// reason it is not usable. Used by the ACP `authenticate` handler to answer the
96 /// editor's auth gate.
97 pub async fn verify_session() -> Result<String, String> {
98 let Some(creds) = credentials::load() else {
99 return Err("not signed in".to_string());
100 };
101
102 let url = format!("{}/api/v1/me", api_base().trim_end_matches('/'));
103 let response = reqwest::Client::new()
104 .get(&url)
105 .bearer_auth(&creds.access_token)
106 .send()
107 .await
108 .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?;
109
110 if response.status().is_success() {
111 let email = response
112 .json::<MeResponse>()
113 .await
114 .ok()
115 .and_then(|me| me.email)
116 .or(creds.email)
117 .unwrap_or_else(|| "(unknown)".to_string());
118 Ok(email)
119 } else {
120 Err(format!(
121 "session expired (HTTP {})",
122 response.status().as_u16()
123 ))
124 }
125 }
126
127 /// Run an interactive sign-in against the terminal, prompting for email and a
128 /// hidden password. Used by `sigit login`, which the editor launches in an
129 /// embedded terminal for ACP terminal-based authentication. Returns the signed-in
130 /// email or a human-readable error.
131 pub async fn interactive_login() -> Result<String, String> {
132 use std::io::{Write, stdin, stdout};
133
134 print!("siGit Code email: ");
135 stdout().flush().ok();
136 let mut email = String::new();
137 stdin()
138 .read_line(&mut email)
139 .map_err(|error| format!("could not read email: {error}"))?;
140
141 let password = rpassword::prompt_password("siGit Code password: ")
142 .map_err(|error| format!("could not read password: {error}"))?;
143
144 authenticate(email.trim(), &password).await
145 }
146
147 /// Clear the local session, notifying the server best-effort. Returns a message
148 /// suitable for display.
149 pub async fn end_session() -> String {
150 if let Some(token) = credentials::load_token() {
151 let url = format!("{}/api/v1/auth/sign_out", api_base().trim_end_matches('/'));
152 // A failed server call must not block local sign-out.
153 let _ = reqwest::Client::new()
154 .delete(&url)
155 .bearer_auth(&token)
156 .send()
157 .await;
158 }
159 if credentials::clear() {
160 "Signed out of siGit Code Cloud.".to_string()
161 } else {
162 "Not signed in.".to_string()
163 }
164 }
165
166 /// One-line description of the current session, verifying the token if reachable.
167 pub async fn status_line() -> String {
168 let Some(creds) = credentials::load() else {
169 return "Not signed in. Use `/login <email> <password>` to use siGit Code Cloud."
170 .to_string();
171 };
172
173 let url = format!("{}/api/v1/me", api_base().trim_end_matches('/'));
174 match reqwest::Client::new()
175 .get(&url)
176 .bearer_auth(&creds.access_token)
177 .send()
178 .await
179 {
180 Ok(response) if response.status().is_success() => {
181 let email = response
182 .json::<MeResponse>()
183 .await
184 .ok()
185 .and_then(|me| me.email)
186 .or(creds.email)
187 .unwrap_or_else(|| "(unknown)".to_string());
188 format!("Signed in to siGit Code Cloud as {email}.")
189 }
190 Ok(response) => format!(
191 "Session may be expired (HTTP {}). Use `/login` again.",
192 response.status().as_u16()
193 ),
194 Err(_) => {
195 let email = creds.email.unwrap_or_else(|| "(unknown)".to_string());
196 format!("Signed in as {email} (could not reach siGit Code Cloud to verify).")
197 }
198 }
199 }
200
201 /// Split a `/login` argument into `(email, password)`. The password is the rest
202 /// of the line after the first whitespace, so it may contain spaces.
203 pub fn parse_login_args(arg: &str) -> Option<(String, String)> {
204 let mut parts = arg.trim().splitn(2, char::is_whitespace);
205 let email = parts.next().unwrap_or("").trim();
206 let password = parts.next().unwrap_or("").trim();
207 if email.is_empty() || password.is_empty() {
208 None
209 } else {
210 Some((email.to_string(), password.to_string()))
211 }
212 }