use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Credentials {
pub api_url: String,
pub access_token: String,
pub refresh_token: String,
pub expires_at: u64,
pub user: Option<UserInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
pub id: String,
pub name: String,
pub github_username: Option<String>,
}
impl Credentials {
pub fn is_expired(&self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
self.expires_at <= now + 60
}
}
pub struct CredentialStore {
path: PathBuf,
}
impl CredentialStore {
pub fn new() -> Result<Self> {
let config_dir = dirs::config_dir()
.ok_or_else(|| Error::Config("Could not find config directory".into()))?
.join("indra");
std::fs::create_dir_all(&config_dir)
.map_err(|e| Error::Config(format!("Failed to create config dir: {}", e)))?;
Ok(Self {
path: config_dir.join("credentials.json"),
})
}
pub fn load(&self, api_url: &str) -> Result<Option<Credentials>> {
if !self.path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&self.path)
.map_err(|e| Error::Config(format!("Failed to read credentials: {}", e)))?;
let all_creds: Vec<Credentials> = serde_json::from_str(&content)
.map_err(|e| Error::Config(format!("Failed to parse credentials: {}", e)))?;
Ok(all_creds.into_iter().find(|c| c.api_url == api_url))
}
pub fn save(&self, creds: Credentials) -> Result<()> {
let mut all_creds: Vec<Credentials> = if self.path.exists() {
let content = std::fs::read_to_string(&self.path).unwrap_or_else(|_| "[]".into());
serde_json::from_str(&content).unwrap_or_default()
} else {
vec![]
};
if let Some(existing) = all_creds.iter_mut().find(|c| c.api_url == creds.api_url) {
*existing = creds;
} else {
all_creds.push(creds);
}
let content = serde_json::to_string_pretty(&all_creds)
.map_err(|e| Error::Config(format!("Failed to serialize credentials: {}", e)))?;
std::fs::write(&self.path, content)
.map_err(|e| Error::Config(format!("Failed to write credentials: {}", e)))?;
Ok(())
}
pub fn remove(&self, api_url: &str) -> Result<()> {
if !self.path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&self.path)
.map_err(|e| Error::Config(format!("Failed to read credentials: {}", e)))?;
let mut all_creds: Vec<Credentials> = serde_json::from_str(&content).unwrap_or_default();
all_creds.retain(|c| c.api_url != api_url);
let content = serde_json::to_string_pretty(&all_creds)
.map_err(|e| Error::Config(format!("Failed to serialize credentials: {}", e)))?;
std::fs::write(&self.path, content)
.map_err(|e| Error::Config(format!("Failed to write credentials: {}", e)))?;
Ok(())
}
pub fn path(&self) -> &PathBuf {
&self.path
}
}
impl Default for CredentialStore {
fn default() -> Self {
Self::new().expect("Failed to create credential store")
}
}
#[cfg(feature = "sync")]
use reqwest::blocking::Client;
#[cfg(feature = "sync")]
pub fn refresh_access_token(
client: &Client,
api_url: &str,
refresh_token: &str,
) -> Result<Credentials> {
let url = format!("{}/auth/refresh", api_url.trim_end_matches('/'));
let resp = client
.post(&url)
.json(&serde_json::json!({ "refresh_token": refresh_token }))
.send()
.map_err(|e| Error::Http(format!("Refresh request failed: {}", e)))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
return Err(Error::Http(format!(
"Token refresh failed ({}): {}",
status, text
)));
}
#[derive(Deserialize)]
struct RefreshResponse {
access_token: String,
refresh_token: String,
expires_in: u64,
user: Option<UserInfo>,
}
let data: RefreshResponse = resp
.json()
.map_err(|e| Error::Http(format!("Invalid refresh response: {}", e)))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(Credentials {
api_url: api_url.to_string(),
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: now + data.expires_in,
user: data.user,
})
}