feat: add swimlanes

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 22:53:48 +01:00
parent 9fe1630986
commit 45353089c2
51 changed files with 3845 additions and 147 deletions

View File

@@ -109,4 +109,33 @@ pub trait ForestAuth: Send + Sync {
access_token: &str,
token_id: &str,
) -> Result<(), AuthError>;
async fn update_username(
&self,
access_token: &str,
user_id: &str,
new_username: &str,
) -> Result<User, AuthError>;
async fn change_password(
&self,
access_token: &str,
user_id: &str,
current_password: &str,
new_password: &str,
) -> Result<(), AuthError>;
async fn add_email(
&self,
access_token: &str,
user_id: &str,
email: &str,
) -> Result<UserEmail, AuthError>;
async fn remove_email(
&self,
access_token: &str,
user_id: &str,
email: &str,
) -> Result<(), AuthError>;
}

View File

@@ -36,6 +36,10 @@ pub struct Artifact {
pub struct ArtifactContext {
pub title: String,
pub description: Option<String>,
#[serde(default)]
pub web: Option<String>,
#[serde(default)]
pub pr: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -59,6 +63,12 @@ pub struct ArtifactRef {
pub struct ArtifactDestination {
pub name: String,
pub environment: String,
#[serde(default)]
pub type_organisation: Option<String>,
#[serde(default)]
pub type_name: Option<String>,
#[serde(default)]
pub type_version: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -69,6 +79,24 @@ pub struct OrgMember {
pub joined_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Destination {
pub name: String,
pub environment: String,
pub organisation: String,
#[serde(default)]
pub metadata: std::collections::HashMap<String, String>,
#[serde(default)]
pub dest_type: Option<DestinationType>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DestinationType {
pub organisation: String,
pub name: String,
pub version: u64,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum PlatformError {
#[error("not authenticated")]
@@ -140,6 +168,18 @@ pub trait ForestPlatform: Send + Sync {
user_id: &str,
role: &str,
) -> Result<OrgMember, PlatformError>;
async fn get_artifact_by_slug(
&self,
access_token: &str,
slug: &str,
) -> Result<Artifact, PlatformError>;
async fn list_destinations(
&self,
access_token: &str,
organisation: &str,
) -> Result<Vec<Destination>, PlatformError>;
}
#[cfg(test)]

View File

@@ -0,0 +1,115 @@
use std::path::{Path, PathBuf};
use chrono::{Duration, Utc};
use super::{SessionData, SessionError, SessionId, SessionStore};
/// File-based session store. Each session is a JSON file in a directory.
/// Suitable for local development — sessions survive server restarts.
pub struct FileSessionStore {
dir: PathBuf,
max_inactive: Duration,
}
impl FileSessionStore {
pub fn new(dir: impl AsRef<Path>) -> Result<Self, SessionError> {
let dir = dir.as_ref().to_path_buf();
std::fs::create_dir_all(&dir)
.map_err(|e| SessionError::Store(format!("failed to create session dir: {e}")))?;
Ok(Self {
dir,
max_inactive: Duration::days(30),
})
}
fn session_path(&self, id: &SessionId) -> PathBuf {
// Use a safe filename: replace any non-alphanumeric chars
let safe_name: String = id
.as_str()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
self.dir.join(format!("{safe_name}.json"))
}
/// Remove sessions inactive for longer than `max_inactive`.
pub fn reap_expired(&self) {
let cutoff = Utc::now() - self.max_inactive;
let entries = match std::fs::read_dir(&self.dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(data) = serde_json::from_str::<SessionData>(&contents) {
if data.last_seen_at < cutoff {
let _ = std::fs::remove_file(&path);
}
}
}
}
}
pub fn session_count(&self) -> usize {
std::fs::read_dir(&self.dir)
.map(|e| {
e.flatten()
.filter(|e| {
e.path()
.extension()
.and_then(|ext| ext.to_str())
== Some("json")
})
.count()
})
.unwrap_or(0)
}
}
#[async_trait::async_trait]
impl SessionStore for FileSessionStore {
async fn create(&self, data: SessionData) -> Result<SessionId, SessionError> {
let id = SessionId::generate();
let path = self.session_path(&id);
let json = serde_json::to_string_pretty(&data)
.map_err(|e| SessionError::Store(format!("serialize error: {e}")))?;
std::fs::write(&path, json)
.map_err(|e| SessionError::Store(format!("write error: {e}")))?;
Ok(id)
}
async fn get(&self, id: &SessionId) -> Result<Option<SessionData>, SessionError> {
let path = self.session_path(id);
match std::fs::read_to_string(&path) {
Ok(contents) => {
let data: SessionData = serde_json::from_str(&contents)
.map_err(|e| SessionError::Store(format!("deserialize error: {e}")))?;
Ok(Some(data))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(SessionError::Store(format!("read error: {e}"))),
}
}
async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError> {
let path = self.session_path(id);
let json = serde_json::to_string_pretty(&data)
.map_err(|e| SessionError::Store(format!("serialize error: {e}")))?;
std::fs::write(&path, json)
.map_err(|e| SessionError::Store(format!("write error: {e}")))?;
Ok(())
}
async fn delete(&self, id: &SessionId) -> Result<(), SessionError> {
let path = self.session_path(id);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(SessionError::Store(format!("delete error: {e}"))),
}
}
}

View File

@@ -1,5 +1,7 @@
mod file_store;
mod store;
pub use file_store::FileSessionStore;
pub use store::InMemorySessionStore;
use crate::auth::UserEmail;
@@ -85,7 +87,7 @@ pub fn generate_csrf_token() -> String {
}
/// Server-side session data. Never exposed to the browser.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionData {
pub access_token: String,
pub refresh_token: String,