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