I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

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 }