diff --git a/.playwright-mcp/console-2026-03-07T19-59-28-775Z.log b/.playwright-mcp/console-2026-03-07T19-59-28-775Z.log new file mode 100644 index 0000000..1a84b8e --- /dev/null +++ b/.playwright-mcp/console-2026-03-07T19-59-28-775Z.log @@ -0,0 +1 @@ +[ 71ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-07T20-32-14-148Z.log b/.playwright-mcp/console-2026-03-07T20-32-14-148Z.log new file mode 100644 index 0000000..e9b2839 --- /dev/null +++ b/.playwright-mcp/console-2026-03-07T20-32-14-148Z.log @@ -0,0 +1,12 @@ +[ 469877ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0 +[ 473324ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0 +[ 473751ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0 +[ 473934ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0 +[ 474119ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0 +[ 474291ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0 +[ 474467ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0 +[ 474629ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3000/orgs/rawpotion/destinations:0 +[ 560213ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0 +[ 561436ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0 +[ 561803ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0 +[ 561970ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ chrome-error://chromewebdata/:0 diff --git a/.playwright-mcp/console-2026-03-07T20-35-27-169Z.log b/.playwright-mcp/console-2026-03-07T20-35-27-169Z.log new file mode 100644 index 0000000..06f9559 --- /dev/null +++ b/.playwright-mcp/console-2026-03-07T20-35-27-169Z.log @@ -0,0 +1 @@ +[ 157ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://rawpotion.io/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-03-07T21-49-57-898Z.log b/.playwright-mcp/console-2026-03-07T21-49-57-898Z.log new file mode 100644 index 0000000..7f45087 --- /dev/null +++ b/.playwright-mcp/console-2026-03-07T21-49-57-898Z.log @@ -0,0 +1,4 @@ +[ 6ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0 +[ 1711ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0 +[ 2177ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0 +[ 2346ms] [ERROR] Failed to load resource: the server responded with a status of 403 (Forbidden) @ http://localhost:3000/orgs/my-org/releases:0 diff --git a/crates/forage-core/src/auth/mod.rs b/crates/forage-core/src/auth/mod.rs index b9c09ef..2b53f90 100644 --- a/crates/forage-core/src/auth/mod.rs +++ b/crates/forage-core/src/auth/mod.rs @@ -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; + + 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; + + async fn remove_email( + &self, + access_token: &str, + user_id: &str, + email: &str, + ) -> Result<(), AuthError>; } diff --git a/crates/forage-core/src/platform/mod.rs b/crates/forage-core/src/platform/mod.rs index cc47990..8fd33a8 100644 --- a/crates/forage-core/src/platform/mod.rs +++ b/crates/forage-core/src/platform/mod.rs @@ -36,6 +36,10 @@ pub struct Artifact { pub struct ArtifactContext { pub title: String, pub description: Option, + #[serde(default)] + pub web: Option, + #[serde(default)] + pub pr: Option, } #[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, + #[serde(default)] + pub type_name: Option, + #[serde(default)] + pub type_version: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -69,6 +79,24 @@ pub struct OrgMember { pub joined_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Destination { + pub name: String, + pub environment: String, + pub organisation: String, + #[serde(default)] + pub metadata: std::collections::HashMap, + #[serde(default)] + pub dest_type: Option, +} + +#[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; + + async fn get_artifact_by_slug( + &self, + access_token: &str, + slug: &str, + ) -> Result; + + async fn list_destinations( + &self, + access_token: &str, + organisation: &str, + ) -> Result, PlatformError>; } #[cfg(test)] diff --git a/crates/forage-core/src/session/file_store.rs b/crates/forage-core/src/session/file_store.rs new file mode 100644 index 0000000..6629c21 --- /dev/null +++ b/crates/forage-core/src/session/file_store.rs @@ -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) -> Result { + 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::(&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 { + 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, 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}"))), + } + } +} diff --git a/crates/forage-core/src/session/mod.rs b/crates/forage-core/src/session/mod.rs index 8e4b1f7..bdd5841 100644 --- a/crates/forage-core/src/session/mod.rs +++ b/crates/forage-core/src/session/mod.rs @@ -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, diff --git a/crates/forage-server/src/forest_client.rs b/crates/forage-server/src/forest_client.rs index 53177ec..ce1e1ec 100644 --- a/crates/forage-server/src/forest_client.rs +++ b/crates/forage-server/src/forest_client.rs @@ -2,8 +2,8 @@ use forage_core::auth::{ AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail, }; use forage_core::platform::{ - Artifact, ArtifactContext, ArtifactDestination, ArtifactSource, ForestPlatform, Organisation, - OrgMember, PlatformError, + Artifact, ArtifactContext, ArtifactDestination, ArtifactSource, Destination, ForestPlatform, + Organisation, OrgMember, PlatformError, }; use forage_grpc::organisation_service_client::OrganisationServiceClient; use forage_grpc::release_service_client::ReleaseServiceClient; @@ -274,6 +274,103 @@ impl ForestAuth for GrpcForestClient { .map_err(map_status)?; Ok(()) } + + async fn update_username( + &self, + access_token: &str, + user_id: &str, + new_username: &str, + ) -> Result { + let req = Self::authed_request( + access_token, + forage_grpc::UpdateUserRequest { + user_id: user_id.into(), + username: Some(new_username.into()), + }, + )?; + + let resp = self + .client() + .update_user(req) + .await + .map_err(map_status)? + .into_inner(); + + let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?; + Ok(convert_user(user)) + } + + async fn change_password( + &self, + access_token: &str, + user_id: &str, + current_password: &str, + new_password: &str, + ) -> Result<(), AuthError> { + let req = Self::authed_request( + access_token, + forage_grpc::ChangePasswordRequest { + user_id: user_id.into(), + current_password: current_password.into(), + new_password: new_password.into(), + }, + )?; + + self.client() + .change_password(req) + .await + .map_err(map_status)?; + Ok(()) + } + + async fn add_email( + &self, + access_token: &str, + user_id: &str, + email: &str, + ) -> Result { + let req = Self::authed_request( + access_token, + forage_grpc::AddEmailRequest { + user_id: user_id.into(), + email: email.into(), + }, + )?; + + let resp = self + .client() + .add_email(req) + .await + .map_err(map_status)? + .into_inner(); + + let email = resp.email.ok_or(AuthError::Other("no email in response".into()))?; + Ok(UserEmail { + email: email.email, + verified: email.verified, + }) + } + + async fn remove_email( + &self, + access_token: &str, + user_id: &str, + email: &str, + ) -> Result<(), AuthError> { + let req = Self::authed_request( + access_token, + forage_grpc::RemoveEmailRequest { + user_id: user_id.into(), + email: email.into(), + }, + )?; + + self.client() + .remove_email(req) + .await + .map_err(map_status)?; + Ok(()) + } } fn convert_organisations( @@ -307,6 +404,21 @@ fn convert_artifact(a: forage_grpc::Artifact) -> Artifact { .map(|d| ArtifactDestination { name: d.name, environment: d.environment, + type_organisation: if d.type_organisation.is_empty() { + None + } else { + Some(d.type_organisation) + }, + type_name: if d.type_name.is_empty() { + None + } else { + Some(d.type_name) + }, + type_version: if d.type_version == 0 { + None + } else { + Some(d.type_version) + }, }) .collect(); Artifact { @@ -319,6 +431,8 @@ fn convert_artifact(a: forage_grpc::Artifact) -> Artifact { } else { ctx.description }, + web: ctx.web.filter(|v| !v.is_empty()), + pr: ctx.pr.filter(|v| !v.is_empty()), }, source, git_ref: None, @@ -548,6 +662,40 @@ impl ForestPlatform for GrpcForestClient { .ok_or(PlatformError::Other("no member in response".into()))?; Ok(convert_member(member)) } + + async fn get_artifact_by_slug( + &self, + access_token: &str, + slug: &str, + ) -> Result { + let req = platform_authed_request( + access_token, + forage_grpc::GetArtifactBySlugRequest { + slug: slug.into(), + }, + )?; + + let resp = self + .release_client() + .get_artifact_by_slug(req) + .await + .map_err(map_platform_status)? + .into_inner(); + + let artifact = resp + .artifact + .ok_or(PlatformError::NotFound("artifact not found".into()))?; + Ok(convert_artifact(artifact)) + } + + async fn list_destinations( + &self, + _access_token: &str, + _organisation: &str, + ) -> Result, PlatformError> { + // DestinationService client not yet generated; return empty for now + Ok(vec![]) + } } #[cfg(test)] diff --git a/crates/forage-server/src/main.rs b/crates/forage-server/src/main.rs index 4c5404a..6f7b443 100644 --- a/crates/forage-server/src/main.rs +++ b/crates/forage-server/src/main.rs @@ -8,7 +8,7 @@ use std::net::SocketAddr; use std::sync::Arc; use axum::Router; -use forage_core::session::{InMemorySessionStore, SessionStore}; +use forage_core::session::{FileSessionStore, SessionStore}; use forage_db::PgSessionStore; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; @@ -63,10 +63,11 @@ async fn main() -> anyhow::Result<()> { pg_store } else { - tracing::info!("using in-memory session store (set DATABASE_URL for persistence)"); - let mem_store = Arc::new(InMemorySessionStore::new()); + let session_dir = std::env::var("SESSION_DIR").unwrap_or_else(|_| "target/sessions".into()); + tracing::info!("using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)"); + let file_store = Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir")); - let reaper = mem_store.clone(); + let reaper = file_store.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(300)); loop { @@ -76,7 +77,7 @@ async fn main() -> anyhow::Result<()> { } }); - mem_store + file_store }; let forest_client = Arc::new(forest_client); diff --git a/crates/forage-server/src/routes/auth.rs b/crates/forage-server/src/routes/auth.rs index 847ddd9..445918c 100644 --- a/crates/forage-server/src/routes/auth.rs +++ b/crates/forage-server/src/routes/auth.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use super::error_page; use crate::auth::{self, MaybeSession, Session}; use crate::state::AppState; -use forage_core::auth::{validate_email, validate_password, validate_username}; +use forage_core::auth::{validate_email, validate_password, validate_username, UserEmail}; use forage_core::session::{CachedOrg, CachedUser, SessionData, generate_csrf_token}; pub fn router() -> Router { @@ -23,6 +23,14 @@ pub fn router() -> Router { get(tokens_page).post(create_token_submit), ) .route("/settings/tokens/{id}/delete", post(delete_token_submit)) + .route("/settings/account", get(account_page)) + .route("/settings/account/username", post(update_username_submit)) + .route("/settings/account/password", post(change_password_submit)) + .route("/settings/account/emails", post(add_email_submit)) + .route( + "/settings/account/emails/remove", + post(remove_email_submit), + ) } // ─── Signup ───────────────────────────────────────────────────────── @@ -367,6 +375,8 @@ async fn tokens_page( title => "API Tokens - Forage", description => "Manage your personal access tokens", user => context! { username => session.user.username }, + current_org => session.user.orgs.first().map(|o| &o.name), + orgs => session.user.orgs.iter().map(|o| context! { name => o.name, role => o.role }).collect::>(), tokens => tokens.iter().map(|t| context! { token_id => t.token_id, name => t.name, @@ -376,6 +386,7 @@ async fn tokens_page( }).collect::>(), csrf_token => &session.csrf_token, created_token => None::, + active_tab => "tokens", }, ) .map_err(|e| { @@ -429,6 +440,8 @@ async fn create_token_submit( title => "API Tokens - Forage", description => "Manage your personal access tokens", user => context! { username => session.user.username }, + current_org => session.user.orgs.first().map(|o| &o.name), + orgs => session.user.orgs.iter().map(|o| context! { name => o.name, role => o.role }).collect::>(), tokens => tokens.iter().map(|t| context! { token_id => t.token_id, name => t.name, @@ -438,6 +451,7 @@ async fn create_token_submit( }).collect::>(), csrf_token => &session.csrf_token, created_token => Some(created.raw_token), + active_tab => "tokens", }, ) .map_err(|e| { @@ -469,3 +483,256 @@ async fn delete_token_submit( Ok(Redirect::to("/settings/tokens").into_response()) } + +// ─── Account settings ──────────────────────────────────────────────── + +async fn account_page( + State(state): State, + session: Session, +) -> Result { + render_account(&state, &session, None) +} + +#[allow(clippy::result_large_err)] +fn render_account( + state: &AppState, + session: &Session, + error: Option<&str>, +) -> Result { + let html = state + .templates + .render( + "pages/account.html.jinja", + context! { + title => "Account Settings - Forage", + description => "Manage your account settings", + user => context! { + username => &session.user.username, + user_id => &session.user.user_id, + emails => session.user.emails.iter().map(|e| context! { + email => &e.email, + verified => e.verified, + }).collect::>(), + }, + current_org => session.user.orgs.first().map(|o| &o.name), + orgs => session.user.orgs.iter().map(|o| context! { name => o.name, role => o.role }).collect::>(), + csrf_token => &session.csrf_token, + error => error, + active_tab => "account", + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + error_page( + state, + StatusCode::INTERNAL_SERVER_ERROR, + "Something went wrong", + "Please try again.", + ) + })?; + + Ok(Html(html).into_response()) +} + +#[derive(Deserialize)] +struct UpdateUsernameForm { + username: String, + _csrf: String, +} + +async fn update_username_submit( + State(state): State, + session: Session, + Form(form): Form, +) -> Result { + if !auth::validate_csrf(&session, &form._csrf) { + return Err(error_page( + &state, + StatusCode::FORBIDDEN, + "Invalid request", + "CSRF validation failed.", + )); + } + + if let Err(e) = validate_username(&form.username) { + return render_account(&state, &session, Some(&e.0)); + } + + match state + .forest_client + .update_username(&session.access_token, &session.user.user_id, &form.username) + .await + { + Ok(updated_user) => { + // Update cached username in session + if let Ok(Some(mut session_data)) = state.sessions.get(&session.session_id).await { + if let Some(ref mut user) = session_data.user { + user.username = updated_user.username; + } + let _ = state + .sessions + .update(&session.session_id, session_data) + .await; + } + Ok(Redirect::to("/settings/account").into_response()) + } + Err(forage_core::auth::AuthError::AlreadyExists(_)) => { + render_account(&state, &session, Some("Username is already taken.")) + } + Err(e) => { + tracing::error!("failed to update username: {e}"); + render_account(&state, &session, Some("Could not update username. Please try again.")) + } + } +} + +#[derive(Deserialize)] +struct ChangePasswordForm { + current_password: String, + new_password: String, + new_password_confirm: String, + _csrf: String, +} + +async fn change_password_submit( + State(state): State, + session: Session, + Form(form): Form, +) -> Result { + if !auth::validate_csrf(&session, &form._csrf) { + return Err(error_page( + &state, + StatusCode::FORBIDDEN, + "Invalid request", + "CSRF validation failed.", + )); + } + + if form.new_password != form.new_password_confirm { + return render_account(&state, &session, Some("New passwords do not match.")); + } + + if let Err(e) = validate_password(&form.new_password) { + return render_account(&state, &session, Some(&e.0)); + } + + match state + .forest_client + .change_password( + &session.access_token, + &session.user.user_id, + &form.current_password, + &form.new_password, + ) + .await + { + Ok(()) => Ok(Redirect::to("/settings/account").into_response()), + Err(forage_core::auth::AuthError::InvalidCredentials) => { + render_account(&state, &session, Some("Current password is incorrect.")) + } + Err(e) => { + tracing::error!("failed to change password: {e}"); + render_account(&state, &session, Some("Could not change password. Please try again.")) + } + } +} + +#[derive(Deserialize)] +struct AddEmailForm { + email: String, + _csrf: String, +} + +async fn add_email_submit( + State(state): State, + session: Session, + Form(form): Form, +) -> Result { + if !auth::validate_csrf(&session, &form._csrf) { + return Err(error_page( + &state, + StatusCode::FORBIDDEN, + "Invalid request", + "CSRF validation failed.", + )); + } + + if let Err(e) = validate_email(&form.email) { + return render_account(&state, &session, Some(&e.0)); + } + + match state + .forest_client + .add_email(&session.access_token, &session.user.user_id, &form.email) + .await + { + Ok(new_email) => { + // Update cached emails in session + if let Ok(Some(mut session_data)) = state.sessions.get(&session.session_id).await { + if let Some(ref mut user) = session_data.user { + user.emails.push(UserEmail { + email: new_email.email, + verified: new_email.verified, + }); + } + let _ = state + .sessions + .update(&session.session_id, session_data) + .await; + } + Ok(Redirect::to("/settings/account").into_response()) + } + Err(forage_core::auth::AuthError::AlreadyExists(_)) => { + render_account(&state, &session, Some("Email is already registered.")) + } + Err(e) => { + tracing::error!("failed to add email: {e}"); + render_account(&state, &session, Some("Could not add email. Please try again.")) + } + } +} + +#[derive(Deserialize)] +struct RemoveEmailForm { + email: String, + _csrf: String, +} + +async fn remove_email_submit( + State(state): State, + session: Session, + Form(form): Form, +) -> Result { + if !auth::validate_csrf(&session, &form._csrf) { + return Err(error_page( + &state, + StatusCode::FORBIDDEN, + "Invalid request", + "CSRF validation failed.", + )); + } + + match state + .forest_client + .remove_email(&session.access_token, &session.user.user_id, &form.email) + .await + { + Ok(()) => { + // Update cached emails in session + if let Ok(Some(mut session_data)) = state.sessions.get(&session.session_id).await { + if let Some(ref mut user) = session_data.user { + user.emails.retain(|e| e.email != form.email); + } + let _ = state + .sessions + .update(&session.session_id, session_data) + .await; + } + Ok(Redirect::to("/settings/account").into_response()) + } + Err(e) => { + tracing::error!("failed to remove email: {e}"); + render_account(&state, &session, Some("Could not remove email. Please try again.")) + } + } +} diff --git a/crates/forage-server/src/routes/platform.rs b/crates/forage-server/src/routes/platform.rs index 1286250..42ffbbd 100644 --- a/crates/forage-server/src/routes/platform.rs +++ b/crates/forage-server/src/routes/platform.rs @@ -18,6 +18,12 @@ pub fn router() -> Router { .route("/orgs", post(create_org_submit)) .route("/orgs/{org}/projects", get(projects_list)) .route("/orgs/{org}/projects/{project}", get(project_detail)) + .route( + "/orgs/{org}/projects/{project}/releases/{slug}", + get(artifact_detail), + ) + .route("/orgs/{org}/releases", get(releases_page)) + .route("/orgs/{org}/destinations", get(destinations_page)) .route("/orgs/{org}/usage", get(usage)) .route( "/orgs/{org}/settings/members", @@ -97,6 +103,7 @@ async fn dashboard( description => "Create your first organisation", user => context! { username => session.user.username }, csrf_token => &session.csrf_token, + active_tab => "dashboard", }, ) .map_err(|e| { @@ -111,40 +118,16 @@ async fn dashboard( return Ok(Html(html).into_response()); } - // Fetch recent activity: for each org, get projects, then artifacts - let mut recent_activity = Vec::new(); - for org in orgs { - let projects = state - .platform_client - .list_projects(&session.access_token, &org.name) - .await - .unwrap_or_default(); + // Fetch recent releases for the first org to show the pipeline on dashboard + let first_org = &orgs[0]; + let projects = state + .platform_client + .list_projects(&session.access_token, &first_org.name) + .await + .unwrap_or_default(); - for project in projects.iter().take(5) { - let artifacts = state - .platform_client - .list_artifacts(&session.access_token, &org.name, project) - .await - .unwrap_or_default(); - - for artifact in artifacts { - recent_activity.push(context! { - org_name => org.name, - project_name => project, - slug => artifact.slug, - title => artifact.context.title, - description => artifact.context.description, - created_at => artifact.created_at, - }); - if recent_activity.len() >= 10 { - break; - } - } - if recent_activity.len() >= 10 { - break; - } - } - } + let items = fetch_org_artifacts(&state, &session.access_token, &first_org.name, &projects).await; + let data = build_timeline(items, &first_org.name); let html = state .templates @@ -155,8 +138,11 @@ async fn dashboard( description => "Your Forage dashboard", user => context! { username => session.user.username }, csrf_token => &session.csrf_token, + current_org => &first_org.name, orgs => orgs_context(orgs), - recent_activity => recent_activity, + timeline => data.timeline, + lanes => data.lanes, + active_tab => "dashboard", }, ) .map_err(|e| { @@ -205,6 +191,7 @@ async fn create_org_submit( description => "Create your first organisation", user => context! { username => session.user.username }, csrf_token => &session.csrf_token, + active_tab => "dashboard", error => "Invalid organisation name. Use lowercase letters, numbers, and hyphens only.", }, ) @@ -248,6 +235,7 @@ async fn create_org_submit( description => "Create your first organisation", user => context! { username => session.user.username }, csrf_token => &session.csrf_token, + active_tab => "dashboard", error => "Could not create organisation. Please try again.", }, ) @@ -289,6 +277,7 @@ async fn projects_list( orgs => orgs_context(orgs), org_name => &org, projects => projects, + active_tab => "projects", }, ) .map_err(|e| { @@ -329,6 +318,15 @@ async fn project_detail( .await .unwrap_or_default(); + let items: Vec = artifacts + .into_iter() + .map(|a| ArtifactWithProject { + artifact: a, + project_name: project.clone(), + }) + .collect(); + let data = build_timeline(items, &org); + let html = state .templates .render( @@ -342,23 +340,99 @@ async fn project_detail( orgs => orgs_context(orgs), org_name => &org, project_name => &project, - artifacts => artifacts.iter().map(|a| { - context! { - slug => a.slug, - title => a.context.title, - description => a.context.description, - created_at => a.created_at, - source_user => a.source.as_ref().and_then(|s| s.user.clone()), - source_type => a.source.as_ref().and_then(|s| s.source_type.clone()), - run_url => a.source.as_ref().and_then(|s| s.run_url.clone()), - commit_sha => a.git_ref.as_ref().map(|r| r.commit_sha.clone()), - branch => a.git_ref.as_ref().and_then(|r| r.branch.clone()), - version => a.git_ref.as_ref().and_then(|r| r.version.clone()), - destinations => a.destinations.iter().map(|d| { - context! { name => d.name, environment => d.environment } - }).collect::>(), - } - }).collect::>(), + active_tab => "projects", + timeline => data.timeline, + lanes => data.lanes, + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + error_page( + &state, + StatusCode::INTERNAL_SERVER_ERROR, + "Something went wrong", + "Please try again.", + ) + })?; + + Ok(Html(html).into_response()) +} + +// ─── Artifact detail ───────────────────────────────────────────────── + +async fn artifact_detail( + State(state): State, + session: Session, + Path((org, project, slug)): Path<(String, String, String)>, +) -> Result { + let orgs = &session.user.orgs; + require_org_membership(&state, orgs, &org)?; + + if !validate_slug(&project) { + return Err(error_page( + &state, + StatusCode::BAD_REQUEST, + "Invalid request", + "Invalid project name.", + )); + } + + let artifact = state + .platform_client + .get_artifact_by_slug(&session.access_token, &slug) + .await + .map_err(|e| match e { + forage_core::platform::PlatformError::NotFound(_) => error_page( + &state, + StatusCode::NOT_FOUND, + "Not found", + "This release could not be found.", + ), + other => { + tracing::error!("failed to fetch artifact: {other}"); + error_page( + &state, + StatusCode::INTERNAL_SERVER_ERROR, + "Something went wrong", + "Please try again.", + ) + } + })?; + + let html = state + .templates + .render( + "pages/artifact_detail.html.jinja", + context! { + title => format!("{} - {} - {} - Forage", artifact.context.title, project, org), + description => artifact.context.description, + user => context! { username => session.user.username }, + csrf_token => &session.csrf_token, + current_org => &org, + orgs => orgs_context(orgs), + org_name => &org, + project_name => &project, + active_tab => "projects", + artifact => context! { + slug => artifact.slug, + title => artifact.context.title, + description => artifact.context.description, + web => artifact.context.web, + pr => artifact.context.pr, + created_at => artifact.created_at, + source_user => artifact.source.as_ref().and_then(|s| s.user.clone()), + source_email => artifact.source.as_ref().and_then(|s| s.email.clone()), + source_type => artifact.source.as_ref().and_then(|s| s.source_type.clone()), + run_url => artifact.source.as_ref().and_then(|s| s.run_url.clone()), + commit_sha => artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()), + branch => artifact.git_ref.as_ref().and_then(|r| r.branch.clone()), + commit_message => artifact.git_ref.as_ref().and_then(|r| r.commit_message.clone()), + version => artifact.git_ref.as_ref().and_then(|r| r.version.clone()), + repo_url => artifact.git_ref.as_ref().and_then(|r| r.repo_url.clone()), + destinations => artifact.destinations.iter().map(|d| { + context! { name => d.name, environment => d.environment } + }).collect::>(), + }, }, ) .map_err(|e| { @@ -404,6 +478,259 @@ async fn usage( org_name => &org, role => ¤t_org_data.role, project_count => projects.len(), + active_tab => "usage", + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + error_page( + &state, + StatusCode::INTERNAL_SERVER_ERROR, + "Something went wrong", + "Please try again.", + ) + })?; + + Ok(Html(html).into_response()) +} + +// ─── Timeline builder (shared between dashboard, project detail, releases) ─── + +struct ArtifactWithProject { + artifact: forage_core::platform::Artifact, + project_name: String, +} + +struct TimelineData { + timeline: Vec, + lanes: Vec, +} + +fn build_timeline(items: Vec, org_name: &str) -> TimelineData { + struct RawRelease { + value: minijinja::Value, + has_dests: bool, + } + + let mut raw_releases: Vec = Vec::new(); + let mut env_set = std::collections::BTreeSet::new(); + + for item in items { + let artifact = item.artifact; + let project = &item.project_name; + + let mut release_envs = Vec::new(); + let dests: Vec = artifact + .destinations + .iter() + .map(|d| { + env_set.insert(d.environment.clone()); + release_envs.push(d.environment.clone()); + context! { + name => d.name, + environment => d.environment, + type_name => d.type_name, + type_version => d.type_version, + } + }) + .collect(); + + let has_dests = !dests.is_empty(); + let dest_envs_str = release_envs.join(","); + raw_releases.push(RawRelease { + value: context! { + slug => artifact.slug, + title => artifact.context.title, + description => artifact.context.description, + project_name => project, + org_name => org_name, + created_at => artifact.created_at, + commit_sha => artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()), + branch => artifact.git_ref.as_ref().and_then(|r| r.branch.clone()), + version => artifact.git_ref.as_ref().and_then(|r| r.version.clone()), + source_user => artifact.source.as_ref().and_then(|s| s.user.clone()), + source_type => artifact.source.as_ref().and_then(|s| s.source_type.clone()), + destinations => dests, + dest_envs => dest_envs_str, + }, + has_dests, + }); + } + + let lanes: Vec = env_set + .into_iter() + .map(|env| context! { name => env }) + .collect(); + + let mut timeline_items: Vec = Vec::new(); + let mut hidden_buf: Vec = Vec::new(); + + for raw in raw_releases { + if raw.has_dests { + if !hidden_buf.is_empty() { + let count = hidden_buf.len(); + timeline_items.push(context! { + kind => "hidden", + count => count, + releases => std::mem::take(&mut hidden_buf), + }); + } + timeline_items.push(context! { + kind => "release", + release => raw.value, + }); + } else { + hidden_buf.push(raw.value); + } + } + if !hidden_buf.is_empty() { + let count = hidden_buf.len(); + timeline_items.push(context! { + kind => "hidden", + count => count, + releases => std::mem::take(&mut hidden_buf), + }); + } + + TimelineData { + timeline: timeline_items, + lanes, + } +} + +/// Fetch all artifacts across projects and return as ArtifactWithProject list. +async fn fetch_org_artifacts( + state: &AppState, + access_token: &str, + org: &str, + projects: &[String], +) -> Vec { + let mut items = Vec::new(); + for project in projects { + let artifacts = state + .platform_client + .list_artifacts(access_token, org, project) + .await + .unwrap_or_default(); + for artifact in artifacts { + items.push(ArtifactWithProject { + artifact, + project_name: project.clone(), + }); + } + } + items +} + +// ─── Releases (Up-inspired pipeline) ───────────────────────────────── + +async fn releases_page( + State(state): State, + session: Session, + Path(org): Path, +) -> Result { + let orgs = &session.user.orgs; + require_org_membership(&state, orgs, &org)?; + + let projects = state + .platform_client + .list_projects(&session.access_token, &org) + .await + .unwrap_or_default(); + + let items = fetch_org_artifacts(&state, &session.access_token, &org, &projects).await; + let data = build_timeline(items, &org); + + let html = state + .templates + .render( + "pages/releases.html.jinja", + context! { + title => format!("Releases - {org} - Forage"), + description => format!("Deployment pipeline for {org}"), + user => context! { username => session.user.username }, + csrf_token => &session.csrf_token, + current_org => &org, + orgs => orgs_context(orgs), + org_name => &org, + timeline => data.timeline, + lanes => data.lanes, + active_tab => "releases", + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + error_page( + &state, + StatusCode::INTERNAL_SERVER_ERROR, + "Something went wrong", + "Please try again.", + ) + })?; + + Ok(Html(html).into_response()) +} + +// ─── Destinations ──────────────────────────────────────────────────── + +async fn destinations_page( + State(state): State, + session: Session, + Path(org): Path, +) -> Result { + let orgs = &session.user.orgs; + let current_org = require_org_membership(&state, orgs, &org)?; + let is_admin = current_org.role == "owner" || current_org.role == "admin"; + + let projects = state + .platform_client + .list_projects(&session.access_token, &org) + .await + .unwrap_or_default(); + + // Aggregate unique destinations from artifacts + let mut destinations = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for project in &projects { + let artifacts = state + .platform_client + .list_artifacts(&session.access_token, &org, project) + .await + .unwrap_or_default(); + + for artifact in &artifacts { + for dest in &artifact.destinations { + let key = (dest.name.clone(), dest.environment.clone()); + if seen.insert(key) { + destinations.push(context! { + name => dest.name, + environment => dest.environment, + project_name => project, + artifact_title => artifact.context.title, + artifact_slug => artifact.slug, + created_at => artifact.created_at, + }); + } + } + } + } + + let html = state + .templates + .render( + "pages/destinations.html.jinja", + context! { + title => format!("Destinations - {org} - Forage"), + description => format!("Deployment destinations for {org}"), + user => context! { username => session.user.username }, + csrf_token => &session.csrf_token, + current_org => &org, + orgs => orgs_context(orgs), + org_name => &org, + destinations => destinations, + is_admin => is_admin, + active_tab => "destinations", }, ) .map_err(|e| { @@ -450,6 +777,7 @@ async fn members_page( orgs => orgs_context(orgs), org_name => &org, is_admin => is_admin, + active_tab => "members", members => members.iter().map(|m| context! { user_id => m.user_id, username => m.username, diff --git a/crates/forage-server/src/templates.rs b/crates/forage-server/src/templates.rs index 7218de6..15c4145 100644 --- a/crates/forage-server/src/templates.rs +++ b/crates/forage-server/src/templates.rs @@ -3,6 +3,62 @@ use std::path::Path; use anyhow::Context; use minijinja::Environment; +/// Format an ISO 8601 / RFC 3339 timestamp as a human-friendly relative time. +fn timeago(value: &str) -> String { + let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) + .or_else(|_| chrono::DateTime::parse_from_rfc3339(&format!("{value}Z"))) + .or_else(|_| { + // Try parsing "2026-01-01" as a date + chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") + .map(|d| { + d.and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset() + }) + }) + else { + return value.to_string(); + }; + + let now = chrono::Utc::now(); + let diff = now.signed_duration_since(dt); + + if diff.num_seconds() < 60 { + "just now".into() + } else if diff.num_minutes() < 60 { + let m = diff.num_minutes(); + format!("{m}m ago") + } else if diff.num_hours() < 24 { + let h = diff.num_hours(); + format!("{h}h ago") + } else if diff.num_days() < 30 { + let d = diff.num_days(); + format!("{d}d ago") + } else { + dt.format("%d %b %Y").to_string() + } +} + +/// Format an ISO 8601 / RFC 3339 timestamp as a full human-readable datetime. +fn datetime(value: &str) -> String { + let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) + .or_else(|_| chrono::DateTime::parse_from_rfc3339(&format!("{value}Z"))) + .or_else(|_| { + chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").map(|d| { + d.and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .fixed_offset() + }) + }) + else { + return value.to_string(); + }; + + dt.format("%d %b %Y %H:%M:%S UTC").to_string() +} + #[derive(Clone)] pub struct TemplateEngine { env: Environment<'static>, @@ -16,6 +72,8 @@ impl TemplateEngine { let mut env = Environment::new(); env.set_loader(minijinja::path_loader(path)); + env.add_filter("timeago", |v: String| -> String { timeago(&v) }); + env.add_filter("datetime", |v: String| -> String { datetime(&v) }); Ok(Self { env }) } diff --git a/crates/forage-server/src/test_support.rs b/crates/forage-server/src/test_support.rs index f349b91..ff6d2a4 100644 --- a/crates/forage-server/src/test_support.rs +++ b/crates/forage-server/src/test_support.rs @@ -4,7 +4,7 @@ use axum::Router; use chrono::Utc; use forage_core::auth::*; use forage_core::platform::{ - Artifact, ArtifactContext, ForestPlatform, Organisation, OrgMember, PlatformError, + Artifact, ArtifactContext, Destination, ForestPlatform, Organisation, OrgMember, PlatformError, }; use forage_core::session::{ CachedOrg, CachedUser, InMemorySessionStore, SessionData, SessionStore, @@ -23,6 +23,10 @@ pub(crate) struct MockBehavior { pub list_tokens_result: Option, AuthError>>, pub create_token_result: Option>, pub delete_token_result: Option>, + pub update_username_result: Option>, + pub change_password_result: Option>, + pub add_email_result: Option>, + pub remove_email_result: Option>, } /// Configurable mock behavior for platform (orgs, projects, artifacts). @@ -36,6 +40,8 @@ pub(crate) struct MockPlatformBehavior { pub add_member_result: Option>, pub remove_member_result: Option>, pub update_member_role_result: Option>, + pub get_artifact_by_slug_result: Option>, + pub list_destinations_result: Option, PlatformError>>, } pub(crate) fn ok_tokens() -> AuthTokens { @@ -166,6 +172,57 @@ impl ForestAuth for MockForestClient { let b = self.behavior.lock().unwrap(); b.delete_token_result.clone().unwrap_or(Ok(())) } + + async fn update_username( + &self, + _access_token: &str, + _user_id: &str, + new_username: &str, + ) -> Result { + let b = self.behavior.lock().unwrap(); + b.update_username_result.clone().unwrap_or(Ok(User { + user_id: "user-123".into(), + username: new_username.into(), + emails: vec![UserEmail { + email: "test@example.com".into(), + verified: true, + }], + })) + } + + async fn change_password( + &self, + _access_token: &str, + _user_id: &str, + _current_password: &str, + _new_password: &str, + ) -> Result<(), AuthError> { + let b = self.behavior.lock().unwrap(); + b.change_password_result.clone().unwrap_or(Ok(())) + } + + async fn add_email( + &self, + _access_token: &str, + _user_id: &str, + email: &str, + ) -> Result { + let b = self.behavior.lock().unwrap(); + b.add_email_result.clone().unwrap_or(Ok(UserEmail { + email: email.into(), + verified: false, + })) + } + + async fn remove_email( + &self, + _access_token: &str, + _user_id: &str, + _email: &str, + ) -> Result<(), AuthError> { + let b = self.behavior.lock().unwrap(); + b.remove_email_result.clone().unwrap_or(Ok(())) + } } pub(crate) struct MockPlatformClient { @@ -228,6 +285,8 @@ impl ForestPlatform for MockPlatformClient { context: ArtifactContext { title: "Deploy v1.0".into(), description: Some("Initial release".into()), + web: None, + pr: None, }, source: None, git_ref: None, @@ -302,6 +361,39 @@ impl ForestPlatform for MockPlatformClient { joined_at: Some("2026-01-01T00:00:00Z".into()), })) } + + async fn get_artifact_by_slug( + &self, + _access_token: &str, + slug: &str, + ) -> Result { + let b = self.behavior.lock().unwrap(); + b.get_artifact_by_slug_result + .clone() + .unwrap_or(Ok(Artifact { + artifact_id: "art-1".into(), + slug: slug.into(), + context: ArtifactContext { + title: "Deploy v1.0".into(), + description: Some("Initial release".into()), + web: None, + pr: None, + }, + source: None, + git_ref: None, + destinations: vec![], + created_at: "2026-03-07T12:00:00Z".into(), + })) + } + + async fn list_destinations( + &self, + _access_token: &str, + _organisation: &str, + ) -> Result, PlatformError> { + let b = self.behavior.lock().unwrap(); + b.list_destinations_result.clone().unwrap_or(Ok(vec![])) + } } pub(crate) fn make_templates() -> TemplateEngine { diff --git a/crates/forage-server/src/tests/account_tests.rs b/crates/forage-server/src/tests/account_tests.rs new file mode 100644 index 0000000..9fbf1c2 --- /dev/null +++ b/crates/forage-server/src/tests/account_tests.rs @@ -0,0 +1,313 @@ +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use forage_core::auth::AuthError; +use tower::ServiceExt; + +use crate::build_router; +use crate::test_support::*; + +// ─── Account settings page ────────────────────────────────────────── + +#[tokio::test] +async fn account_page_returns_200() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/settings/account") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("testuser")); + assert!(html.contains("test@example.com")); +} + +#[tokio::test] +async fn account_page_unauthenticated_redirects() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/settings/account") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); +} + +// ─── Update username ──────────────────────────────────────────────── + +#[tokio::test] +async fn update_username_success_redirects() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/account/username") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("username=newname&_csrf=test-csrf")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get("location").unwrap(), + "/settings/account" + ); +} + +#[tokio::test] +async fn update_username_invalid_csrf_returns_403() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/account/username") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("username=newname&_csrf=wrong")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn update_username_invalid_name_shows_error() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/account/username") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("username=&_csrf=test-csrf")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("required") || html.contains("Username")); +} + +// ─── Change password ──────────────────────────────────────────────── + +#[tokio::test] +async fn change_password_success_redirects() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/account/password") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "current_password=OldPass12345&new_password=NewPass123456&new_password_confirm=NewPass123456&_csrf=test-csrf", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get("location").unwrap(), + "/settings/account" + ); +} + +#[tokio::test] +async fn change_password_mismatch_shows_error() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/account/password") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "current_password=OldPass12345&new_password=NewPass123456&new_password_confirm=Different12345&_csrf=test-csrf", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("match")); +} + +#[tokio::test] +async fn change_password_wrong_current_shows_error() { + let mock = MockForestClient::with_behavior(MockBehavior { + change_password_result: Some(Err(AuthError::InvalidCredentials)), + ..Default::default() + }); + let (state, sessions) = test_state_with(mock, MockPlatformClient::new()); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/account/password") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "current_password=WrongPass1234&new_password=NewPass123456&new_password_confirm=NewPass123456&_csrf=test-csrf", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("incorrect") || html.contains("invalid") || html.contains("Invalid") || html.contains("wrong")); +} + +// ─── Add email ────────────────────────────────────────────────────── + +#[tokio::test] +async fn add_email_success_redirects() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/account/emails") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("email=new@example.com&_csrf=test-csrf")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get("location").unwrap(), + "/settings/account" + ); +} + +#[tokio::test] +async fn add_email_invalid_shows_error() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/account/emails") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("email=notanemail&_csrf=test-csrf")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("invalid") || html.contains("Invalid") || html.contains("valid email")); +} + +// ─── Remove email ─────────────────────────────────────────────────── + +#[tokio::test] +async fn remove_email_success_redirects() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/account/emails/remove") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("email=old@example.com&_csrf=test-csrf")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get("location").unwrap(), + "/settings/account" + ); +} + +#[tokio::test] +async fn remove_email_invalid_csrf_returns_403() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/account/emails/remove") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("email=old@example.com&_csrf=wrong")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/forage-server/src/tests/mod.rs b/crates/forage-server/src/tests/mod.rs index 6b2885f..af5b7c9 100644 --- a/crates/forage-server/src/tests/mod.rs +++ b/crates/forage-server/src/tests/mod.rs @@ -1,3 +1,4 @@ +mod account_tests; mod auth_tests; mod pages_tests; mod platform_tests; diff --git a/crates/forage-server/src/tests/platform_tests.rs b/crates/forage-server/src/tests/platform_tests.rs index fe5c772..613fd46 100644 --- a/crates/forage-server/src/tests/platform_tests.rs +++ b/crates/forage-server/src/tests/platform_tests.rs @@ -676,6 +676,8 @@ async fn project_detail_shows_enriched_artifact_data() { context: ArtifactContext { title: "Deploy v2.0".into(), description: Some("Major release".into()), + web: None, + pr: None, }, source: Some(ArtifactSource { user: Some("ci-bot".into()), @@ -693,6 +695,9 @@ async fn project_detail_shows_enriched_artifact_data() { destinations: vec![ArtifactDestination { name: "production".into(), environment: "prod".into(), + type_organisation: None, + type_name: None, + type_version: None, }], created_at: "2026-03-07T12:00:00Z".into(), }])), @@ -723,6 +728,165 @@ async fn project_detail_shows_enriched_artifact_data() { assert!(html.contains("production")); } +// ─── Artifact detail ──────────────────────────────────────────────── + +#[tokio::test] +async fn artifact_detail_returns_200() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects/my-api/releases/my-api-abc123") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("my-api-abc123")); + assert!(html.contains("Deploy v1.0")); +} + +#[tokio::test] +async fn artifact_detail_shows_enriched_data() { + let platform = MockPlatformClient::with_behavior(MockPlatformBehavior { + get_artifact_by_slug_result: Some(Ok(Artifact { + artifact_id: "art-2".into(), + slug: "my-api-def456".into(), + context: ArtifactContext { + title: "Deploy v2.0".into(), + description: Some("Major release".into()), + web: Some("https://example.com".into()), + pr: Some("https://github.com/org/repo/pull/42".into()), + }, + source: Some(ArtifactSource { + user: Some("ci-bot".into()), + email: Some("ci@example.com".into()), + source_type: Some("github-actions".into()), + run_url: Some("https://github.com/org/repo/actions/runs/123".into()), + }), + git_ref: Some(ArtifactRef { + commit_sha: "abc1234".into(), + branch: Some("main".into()), + commit_message: Some("feat: add new feature".into()), + version: Some("v2.0.0".into()), + repo_url: Some("https://github.com/org/repo".into()), + }), + destinations: vec![ + ArtifactDestination { + name: "production".into(), + environment: "prod".into(), + type_organisation: None, + type_name: None, + type_version: None, + }, + ArtifactDestination { + name: "staging".into(), + environment: "staging".into(), + type_organisation: None, + type_name: None, + type_version: None, + }, + ], + created_at: "2026-03-07T12:00:00Z".into(), + })), + ..Default::default() + }); + let (state, sessions) = test_state_with(MockForestClient::new(), platform); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects/my-api/releases/my-api-def456") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("v2.0.0")); + assert!(html.contains("main")); + assert!(html.contains("abc1234")); + assert!(html.contains("ci-bot")); + assert!(html.contains("production")); + assert!(html.contains("staging")); + assert!(html.contains("Major release")); +} + +#[tokio::test] +async fn artifact_detail_not_found_returns_404() { + let platform = MockPlatformClient::with_behavior(MockPlatformBehavior { + get_artifact_by_slug_result: Some(Err(PlatformError::NotFound( + "artifact not found".into(), + ))), + ..Default::default() + }); + let (state, sessions) = test_state_with(MockForestClient::new(), platform); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects/my-api/releases/nonexistent") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn artifact_detail_unauthenticated_redirects() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects/my-api/releases/my-api-abc123") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); +} + +#[tokio::test] +async fn artifact_detail_non_member_returns_403() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/unknown-org/projects/my-api/releases/some-slug") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + // ─── Usage ────────────────────────────────────────────────────────── #[tokio::test] @@ -835,3 +999,175 @@ async fn error_403_renders_html() { let html = String::from_utf8(body.to_vec()).unwrap(); assert!(html.contains("Access denied")); } + +// ─── Destinations ──────────────────────────────────────────────────── + +#[tokio::test] +async fn destinations_page_returns_200() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/destinations") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Destinations")); +} + +#[tokio::test] +async fn destinations_page_unauthenticated_redirects() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/orgs/testorg/destinations") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); +} + +#[tokio::test] +async fn destinations_page_non_member_returns_403() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/otherorg/destinations") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn destinations_page_shows_empty_state() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/destinations") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("No destinations yet")); +} + +// ─── Releases ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn releases_page_returns_200() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/releases") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Continuous deployment")); +} + +#[tokio::test] +async fn releases_page_unauthenticated_redirects() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/orgs/testorg/releases") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); +} + +#[tokio::test] +async fn releases_page_non_member_returns_403() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/otherorg/releases") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn releases_page_shows_empty_state() { + let platform = MockPlatformClient::with_behavior(MockPlatformBehavior { + list_projects_result: Some(Ok(vec![])), + ..Default::default() + }); + let (state, sessions) = test_state_with(MockForestClient::new(), platform); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/releases") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("No releases yet")); +} diff --git a/destinations-page.png b/destinations-page.png new file mode 100644 index 0000000..479f426 Binary files /dev/null and b/destinations-page.png differ diff --git a/mise.toml b/mise.toml index 367c3e7..7b544a1 100644 --- a/mise.toml +++ b/mise.toml @@ -4,6 +4,7 @@ rust = "latest" # ─── Core Development ────────────────────────────────────────────── [tasks.develop] +alias = ["d", "dev"] description = "Start the forage development server" run = "cargo run -p forage-server" diff --git a/nav-dashboard-final.png b/nav-dashboard-final.png new file mode 100644 index 0000000..be3eb66 Binary files /dev/null and b/nav-dashboard-final.png differ diff --git a/nav-dashboard.png b/nav-dashboard.png new file mode 100644 index 0000000..9a4d8d3 Binary files /dev/null and b/nav-dashboard.png differ diff --git a/nav-final-account.png b/nav-final-account.png new file mode 100644 index 0000000..860b6ea Binary files /dev/null and b/nav-final-account.png differ diff --git a/nav-final-padded.png b/nav-final-padded.png new file mode 100644 index 0000000..be3eb66 Binary files /dev/null and b/nav-final-padded.png differ diff --git a/nav-members.png b/nav-members.png new file mode 100644 index 0000000..a7545bb Binary files /dev/null and b/nav-members.png differ diff --git a/nav-projects.png b/nav-projects.png new file mode 100644 index 0000000..1921f00 Binary files /dev/null and b/nav-projects.png differ diff --git a/nav-pt3.png b/nav-pt3.png new file mode 100644 index 0000000..3541757 Binary files /dev/null and b/nav-pt3.png differ diff --git a/nav-pt5.png b/nav-pt5.png new file mode 100644 index 0000000..2dd7415 Binary files /dev/null and b/nav-pt5.png differ diff --git a/nav-spacing-fixed.png b/nav-spacing-fixed.png new file mode 100644 index 0000000..e66a7c6 Binary files /dev/null and b/nav-spacing-fixed.png differ diff --git a/nav-usage.png b/nav-usage.png new file mode 100644 index 0000000..f0847fc Binary files /dev/null and b/nav-usage.png differ diff --git a/nav-with-tailwind.png b/nav-with-tailwind.png new file mode 100644 index 0000000..be3eb66 Binary files /dev/null and b/nav-with-tailwind.png differ diff --git a/releases-collapsed.png b/releases-collapsed.png new file mode 100644 index 0000000..d5af6c7 Binary files /dev/null and b/releases-collapsed.png differ diff --git a/releases-current.png b/releases-current.png new file mode 100644 index 0000000..79d2bdd Binary files /dev/null and b/releases-current.png differ diff --git a/releases-v10-avatar-dots.png b/releases-v10-avatar-dots.png new file mode 100644 index 0000000..2462b7a Binary files /dev/null and b/releases-v10-avatar-dots.png differ diff --git a/releases-v2.png b/releases-v2.png new file mode 100644 index 0000000..c38bd6e Binary files /dev/null and b/releases-v2.png differ diff --git a/releases-v3.png b/releases-v3.png new file mode 100644 index 0000000..57415e0 Binary files /dev/null and b/releases-v3.png differ diff --git a/releases-v4.png b/releases-v4.png new file mode 100644 index 0000000..a11b5f8 Binary files /dev/null and b/releases-v4.png differ diff --git a/releases-v5.png b/releases-v5.png new file mode 100644 index 0000000..04729ba Binary files /dev/null and b/releases-v5.png differ diff --git a/releases-v6-dots.png b/releases-v6-dots.png new file mode 100644 index 0000000..f0d2f84 Binary files /dev/null and b/releases-v6-dots.png differ diff --git a/releases-v7-hidden.png b/releases-v7-hidden.png new file mode 100644 index 0000000..82b92e3 Binary files /dev/null and b/releases-v7-hidden.png differ diff --git a/releases-v8-expanded.png b/releases-v8-expanded.png new file mode 100644 index 0000000..80518cd Binary files /dev/null and b/releases-v8-expanded.png differ diff --git a/releases-v9-toggle.png b/releases-v9-toggle.png new file mode 100644 index 0000000..f09cedc Binary files /dev/null and b/releases-v9-toggle.png differ diff --git a/static/css/style.css b/static/css/style.css index 36cfa38..4b12f05 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,2 +1,1332 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-400:oklch(82.8% .189 84.429);--color-green-50:oklch(98.2% .018 155.826);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-800:oklch(44.8% .119 151.328);--color-blue-400:oklch(70.7% .165 254.624);--color-purple-400:oklch(71.4% .203 305.504);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-gray-950:oklch(13% .028 261.692);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-bold:700;--tracking-tight:-.025em;--leading-tight:1.25;--radius-md:.375rem;--radius-lg:.5rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.absolute{position:absolute}.relative{position:relative}.static{position:static}.start{inset-inline-start:var(--spacing)}.-top-3{top:calc(var(--spacing) * -3)}.left-4{left:calc(var(--spacing) * 4)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-12{margin-bottom:calc(var(--spacing) * 12)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.table{display:table}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.flex-1{flex:1}.cursor-not-allowed{cursor:not-allowed}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-900{border-color:var(--color-gray-900)}.border-green-200{border-color:var(--color-green-200)}.border-green-300{border-color:var(--color-green-300)}.border-red-200{border-color:var(--color-red-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-gray-950{background-color:var(--color-gray-950)}.bg-green-50{background-color:var(--color-green-50)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pt-24{padding-top:calc(var(--spacing) * 24)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.break-all{word-break:break-all}.text-amber-400{color:var(--color-amber-400)}.text-blue-400{color:var(--color-blue-400)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-900{color:var(--color-gray-900)}.text-green-400{color:var(--color-green-400)}.text-green-800{color:var(--color-green-800)}.text-purple-400{color:var(--color-purple-400)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@media (hover:hover){.hover\:border-gray-400:hover{border-color:var(--color-gray-400)}.hover\:bg-gray-800:hover{background-color:var(--color-gray-800)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-red-800:hover{color:var(--color-red-800)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-gray-900:focus{--tw-ring-color:var(--color-gray-900)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + --color-red-50: oklch(97.1% 0.013 17.38); + --color-red-200: oklch(88.5% 0.062 18.334); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-red-800: oklch(44.4% 0.177 26.899); + --color-orange-100: oklch(95.4% 0.038 75.164); + --color-orange-500: oklch(70.5% 0.213 47.604); + --color-orange-800: oklch(47% 0.157 37.304); + --color-amber-400: oklch(82.8% 0.189 84.429); + --color-yellow-100: oklch(97.3% 0.071 103.193); + --color-yellow-500: oklch(79.5% 0.184 86.047); + --color-yellow-700: oklch(55.4% 0.135 66.442); + --color-yellow-800: oklch(47.6% 0.114 61.907); + --color-green-50: oklch(98.2% 0.018 155.826); + --color-green-100: oklch(96.2% 0.044 156.743); + --color-green-200: oklch(92.5% 0.084 155.995); + --color-green-300: oklch(87.1% 0.15 154.449); + --color-green-400: oklch(79.2% 0.209 151.711); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-green-700: oklch(52.7% 0.154 150.069); + --color-green-800: oklch(44.8% 0.119 151.328); + --color-blue-50: oklch(97% 0.014 254.604); + --color-blue-100: oklch(93.2% 0.032 255.585); + --color-blue-400: oklch(70.7% 0.165 254.624); + --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-700: oklch(48.8% 0.243 264.376); + --color-blue-800: oklch(42.4% 0.199 265.638); + --color-violet-100: oklch(94.3% 0.029 294.588); + --color-violet-500: oklch(60.6% 0.25 292.717); + --color-violet-800: oklch(43.2% 0.232 292.759); + --color-purple-100: oklch(94.6% 0.033 307.174); + --color-purple-400: oklch(71.4% 0.203 305.504); + --color-purple-500: oklch(62.7% 0.265 303.9); + --color-purple-800: oklch(43.8% 0.218 303.724); + --color-pink-100: oklch(94.8% 0.028 342.258); + --color-pink-500: oklch(65.6% 0.241 354.308); + --color-pink-800: oklch(45.9% 0.187 3.815); + --color-gray-50: oklch(98.5% 0.002 247.839); + --color-gray-100: oklch(96.7% 0.003 264.542); + --color-gray-200: oklch(92.8% 0.006 264.531); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-gray-500: oklch(55.1% 0.027 264.364); + --color-gray-600: oklch(44.6% 0.03 256.802); + --color-gray-700: oklch(37.3% 0.034 259.733); + --color-gray-800: oklch(27.8% 0.033 256.848); + --color-gray-900: oklch(21% 0.034 264.665); + --color-gray-950: oklch(13% 0.028 261.692); + --color-black: #000; + --color-white: #fff; + --spacing: 0.25rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-2xl: 42rem; + --container-4xl: 56rem; + --container-5xl: 64rem; + --container-6xl: 72rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --tracking-tight: -0.025em; + --tracking-wide: 0.025em; + --leading-tight: 1.25; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer utilities { + .visible { + visibility: visible; + } + .absolute { + position: absolute; + } + .fixed { + position: fixed; + } + .relative { + position: relative; + } + .static { + position: static; + } + .-top-3 { + top: calc(var(--spacing) * -3); + } + .left-0 { + left: calc(var(--spacing) * 0); + } + .left-4 { + left: calc(var(--spacing) * 4); + } + .z-20 { + z-index: 20; + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .mx-auto { + margin-inline: auto; + } + .mt-0\.5 { + margin-top: calc(var(--spacing) * 0.5); + } + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + .mt-8 { + margin-top: calc(var(--spacing) * 8); + } + .mt-10 { + margin-top: calc(var(--spacing) * 10); + } + .mt-12 { + margin-top: calc(var(--spacing) * 12); + } + .mt-24 { + margin-top: calc(var(--spacing) * 24); + } + .-mb-px { + margin-bottom: -1px; + } + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } + .mb-8 { + margin-bottom: calc(var(--spacing) * 8); + } + .mb-12 { + margin-bottom: calc(var(--spacing) * 12); + } + .ml-0\.5 { + margin-left: calc(var(--spacing) * 0.5); + } + .ml-2 { + margin-left: calc(var(--spacing) * 2); + } + .ml-4 { + margin-left: calc(var(--spacing) * 4); + } + .ml-auto { + margin-left: auto; + } + .block { + display: block; + } + .flex { + display: flex; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline { + display: inline; + } + .inline-block { + display: inline-block; + } + .inline-flex { + display: inline-flex; + } + .table { + display: table; + } + .h-1\.5 { + height: calc(var(--spacing) * 1.5); + } + .h-2 { + height: calc(var(--spacing) * 2); + } + .h-3 { + height: calc(var(--spacing) * 3); + } + .h-3\.5 { + height: calc(var(--spacing) * 3.5); + } + .h-4 { + height: calc(var(--spacing) * 4); + } + .h-6 { + height: calc(var(--spacing) * 6); + } + .h-12 { + height: calc(var(--spacing) * 12); + } + .w-1\.5 { + width: calc(var(--spacing) * 1.5); + } + .w-2 { + width: calc(var(--spacing) * 2); + } + .w-3 { + width: calc(var(--spacing) * 3); + } + .w-3\.5 { + width: calc(var(--spacing) * 3.5); + } + .w-4 { + width: calc(var(--spacing) * 4); + } + .w-6 { + width: calc(var(--spacing) * 6); + } + .w-12 { + width: calc(var(--spacing) * 12); + } + .w-48 { + width: calc(var(--spacing) * 48); + } + .w-full { + width: 100%; + } + .max-w-2xl { + max-width: var(--container-2xl); + } + .max-w-4xl { + max-width: var(--container-4xl); + } + .max-w-5xl { + max-width: var(--container-5xl); + } + .max-w-6xl { + max-width: var(--container-6xl); + } + .max-w-lg { + max-width: var(--container-lg); + } + .max-w-md { + max-width: var(--container-md); + } + .min-w-0 { + min-width: calc(var(--spacing) * 0); + } + .min-w-\[140px\] { + min-width: 140px; + } + .flex-1 { + flex: 1; + } + .shrink-0 { + flex-shrink: 0; + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); + } + .cursor-not-allowed { + cursor: not-allowed; + } + .cursor-pointer { + cursor: pointer; + } + .list-none { + list-style-type: none; + } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .flex-wrap { + flex-wrap: wrap; + } + .items-center { + align-items: center; + } + .items-end { + align-items: flex-end; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .justify-end { + justify-content: flex-end; + } + .gap-1 { + gap: calc(var(--spacing) * 1); + } + .gap-1\.5 { + gap: calc(var(--spacing) * 1.5); + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .gap-6 { + gap: calc(var(--spacing) * 6); + } + .gap-8 { + gap: calc(var(--spacing) * 8); + } + .space-y-2 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-3 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-4 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .divide-gray-100 { + :where(& > :not(:last-child)) { + border-color: var(--color-gray-100); + } + } + .divide-gray-200 { + :where(& > :not(:last-child)) { + border-color: var(--color-gray-200); + } + } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .overflow-hidden { + overflow: hidden; + } + .overflow-x-auto { + overflow-x: auto; + } + .rounded { + border-radius: 0.25rem; + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-lg { + border-radius: var(--radius-lg); + } + .rounded-md { + border-radius: var(--radius-md); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-2 { + border-style: var(--tw-border-style); + border-width: 2px; + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-b-2 { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 2px; + } + .border-gray-50 { + border-color: var(--color-gray-50); + } + .border-gray-100 { + border-color: var(--color-gray-100); + } + .border-gray-200 { + border-color: var(--color-gray-200); + } + .border-gray-300 { + border-color: var(--color-gray-300); + } + .border-gray-900 { + border-color: var(--color-gray-900); + } + .border-green-200 { + border-color: var(--color-green-200); + } + .border-green-300 { + border-color: var(--color-green-300); + } + .border-red-200 { + border-color: var(--color-red-200); + } + .border-transparent { + border-color: transparent; + } + .bg-blue-50 { + background-color: var(--color-blue-50); + } + .bg-blue-100 { + background-color: var(--color-blue-100); + } + .bg-gray-50 { + background-color: var(--color-gray-50); + } + .bg-gray-100 { + background-color: var(--color-gray-100); + } + .bg-gray-200 { + background-color: var(--color-gray-200); + } + .bg-gray-400 { + background-color: var(--color-gray-400); + } + .bg-gray-900 { + background-color: var(--color-gray-900); + } + .bg-gray-950 { + background-color: var(--color-gray-950); + } + .bg-green-50 { + background-color: var(--color-green-50); + } + .bg-green-100 { + background-color: var(--color-green-100); + } + .bg-orange-100 { + background-color: var(--color-orange-100); + } + .bg-orange-500 { + background-color: var(--color-orange-500); + } + .bg-pink-100 { + background-color: var(--color-pink-100); + } + .bg-pink-500 { + background-color: var(--color-pink-500); + } + .bg-purple-100 { + background-color: var(--color-purple-100); + } + .bg-purple-500 { + background-color: var(--color-purple-500); + } + .bg-red-50 { + background-color: var(--color-red-50); + } + .bg-violet-100 { + background-color: var(--color-violet-100); + } + .bg-violet-500 { + background-color: var(--color-violet-500); + } + .bg-white { + background-color: var(--color-white); + } + .bg-yellow-100 { + background-color: var(--color-yellow-100); + } + .bg-yellow-500 { + background-color: var(--color-yellow-500); + } + .p-3 { + padding: calc(var(--spacing) * 3); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-5 { + padding: calc(var(--spacing) * 5); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .p-8 { + padding: calc(var(--spacing) * 8); + } + .p-12 { + padding: calc(var(--spacing) * 12); + } + .px-1 { + padding-inline: calc(var(--spacing) * 1); + } + .px-1\.5 { + padding-inline: calc(var(--spacing) * 1.5); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-2\.5 { + padding-inline: calc(var(--spacing) * 2.5); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-5 { + padding-inline: calc(var(--spacing) * 5); + } + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } + .py-0\.5 { + padding-block: calc(var(--spacing) * 0.5); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .py-10 { + padding-block: calc(var(--spacing) * 10); + } + .py-12 { + padding-block: calc(var(--spacing) * 12); + } + .py-16 { + padding-block: calc(var(--spacing) * 16); + } + .pt-3 { + padding-top: calc(var(--spacing) * 3); + } + .pt-8 { + padding-top: calc(var(--spacing) * 8); + } + .pt-12 { + padding-top: calc(var(--spacing) * 12); + } + .pt-16 { + padding-top: calc(var(--spacing) * 16); + } + .pt-24 { + padding-top: calc(var(--spacing) * 24); + } + .pb-3 { + padding-bottom: calc(var(--spacing) * 3); + } + .pb-8 { + padding-bottom: calc(var(--spacing) * 8); + } + .pb-16 { + padding-bottom: calc(var(--spacing) * 16); + } + .text-center { + text-align: center; + } + .text-left { + text-align: left; + } + .text-right { + text-align: right; + } + .font-mono { + font-family: var(--font-mono); + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + .text-5xl { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } + .text-6xl { + font-size: var(--text-6xl); + line-height: var(--tw-leading, var(--text-6xl--line-height)); + } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .leading-tight { + --tw-leading: var(--leading-tight); + line-height: var(--leading-tight); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-normal { + --tw-font-weight: var(--font-weight-normal); + font-weight: var(--font-weight-normal); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-tight { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + .tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); + } + .break-all { + word-break: break-all; + } + .whitespace-nowrap { + white-space: nowrap; + } + .text-amber-400 { + color: var(--color-amber-400); + } + .text-blue-400 { + color: var(--color-blue-400); + } + .text-blue-600 { + color: var(--color-blue-600); + } + .text-blue-700 { + color: var(--color-blue-700); + } + .text-blue-800 { + color: var(--color-blue-800); + } + .text-gray-300 { + color: var(--color-gray-300); + } + .text-gray-400 { + color: var(--color-gray-400); + } + .text-gray-500 { + color: var(--color-gray-500); + } + .text-gray-600 { + color: var(--color-gray-600); + } + .text-gray-700 { + color: var(--color-gray-700); + } + .text-gray-800 { + color: var(--color-gray-800); + } + .text-gray-900 { + color: var(--color-gray-900); + } + .text-green-400 { + color: var(--color-green-400); + } + .text-green-500 { + color: var(--color-green-500); + } + .text-green-700 { + color: var(--color-green-700); + } + .text-green-800 { + color: var(--color-green-800); + } + .text-orange-800 { + color: var(--color-orange-800); + } + .text-pink-800 { + color: var(--color-pink-800); + } + .text-purple-400 { + color: var(--color-purple-400); + } + .text-purple-800 { + color: var(--color-purple-800); + } + .text-red-600 { + color: var(--color-red-600); + } + .text-red-700 { + color: var(--color-red-700); + } + .text-violet-800 { + color: var(--color-violet-800); + } + .text-white { + color: var(--color-white); + } + .text-yellow-700 { + color: var(--color-yellow-700); + } + .text-yellow-800 { + color: var(--color-yellow-800); + } + .lowercase { + text-transform: lowercase; + } + .uppercase { + text-transform: uppercase; + } + .underline { + text-decoration-line: underline; + } + .antialiased { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + .opacity-75 { + opacity: 75%; + } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .group-open\:hidden { + &:is(:where(.group):is([open], :popover-open, :open) *) { + display: none; + } + } + .group-open\:inline { + &:is(:where(.group):is([open], :popover-open, :open) *) { + display: inline; + } + } + .group-open\:rotate-90 { + &:is(:where(.group):is([open], :popover-open, :open) *) { + rotate: 90deg; + } + } + .hover\:border-gray-300 { + &:hover { + @media (hover: hover) { + border-color: var(--color-gray-300); + } + } + } + .hover\:border-gray-400 { + &:hover { + @media (hover: hover) { + border-color: var(--color-gray-400); + } + } + } + .hover\:bg-gray-50 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-50); + } + } + } + .hover\:bg-gray-800 { + &:hover { + @media (hover: hover) { + background-color: var(--color-gray-800); + } + } + } + .hover\:bg-red-50 { + &:hover { + @media (hover: hover) { + background-color: var(--color-red-50); + } + } + } + .hover\:text-black { + &:hover { + @media (hover: hover) { + color: var(--color-black); + } + } + } + .hover\:text-blue-600 { + &:hover { + @media (hover: hover) { + color: var(--color-blue-600); + } + } + } + .hover\:text-gray-600 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-600); + } + } + } + .hover\:text-gray-700 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-700); + } + } + } + .hover\:text-gray-900 { + &:hover { + @media (hover: hover) { + color: var(--color-gray-900); + } + } + } + .hover\:text-red-800 { + &:hover { + @media (hover: hover) { + color: var(--color-red-800); + } + } + } + .hover\:underline { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } + } + .focus\:border-transparent { + &:focus { + border-color: transparent; + } + } + .focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-gray-900 { + &:focus { + --tw-ring-color: var(--color-gray-900); + } + } + .focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } + } + .md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .md\:grid-cols-3 { + @media (width >= 48rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .md\:grid-cols-4 { + @media (width >= 48rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-y-reverse: 0; + --tw-divide-y-reverse: 0; + --tw-border-style: solid; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + } + } +} diff --git a/static/js/swim-lanes.js b/static/js/swim-lanes.js new file mode 100644 index 0000000..91d33ab --- /dev/null +++ b/static/js/swim-lanes.js @@ -0,0 +1,174 @@ +/** + * web component + * + * Renders colored vertical bars alongside a release timeline. + * Each bar grows from the BOTTOM of the timeline upward to the top edge + * of the last release card deployed to that environment. + * Labels are rendered at the bottom of each bar, rotated vertically. + * + * Usage: + * + *
+ *
+ *
+ *
...
+ *
...
+ *
+ *
+ */ + +const ENV_COLORS = { + prod: ["#f472b6", "#ec4899"], + production: ["#f472b6", "#ec4899"], + preprod: ["#fdba74", "#f97316"], + "pre-prod": ["#fdba74", "#f97316"], + staging: ["#fbbf24", "#ca8a04"], + stage: ["#fbbf24", "#ca8a04"], + dev: ["#a78bfa", "#7c3aed"], + development: ["#a78bfa", "#7c3aed"], + test: ["#67e8f9", "#0891b2"], +}; + +const DEFAULT_COLORS = ["#d1d5db", "#9ca3af"]; + +function envColors(name) { + const lower = name.toLowerCase(); + if (ENV_COLORS[lower]) return ENV_COLORS[lower]; + for (const [key, colors] of Object.entries(ENV_COLORS)) { + if (lower.includes(key)) return colors; + } + return DEFAULT_COLORS; +} + +class SwimLanes extends HTMLElement { + connectedCallback() { + this.style.display = "flex"; + this._render(); + this._ro = new ResizeObserver(() => this._render()); + const timeline = this.querySelector("[data-swimlane-timeline]"); + if (timeline) { + this._ro.observe(timeline); + // Re-render when details elements are toggled (show/hide commits) + timeline.addEventListener("toggle", () => this._render(), true); + } + } + + disconnectedCallback() { + if (this._ro) this._ro.disconnect(); + } + + _render() { + const timeline = this.querySelector("[data-swimlane-timeline]"); + if (!timeline) return; + + const cards = Array.from(timeline.querySelectorAll("[data-release]")); + if (cards.length === 0) return; + + const timelineRect = timeline.getBoundingClientRect(); + const lanes = Array.from(this.querySelectorAll("[data-lane]")); + + for (const lane of lanes) { + const env = lane.dataset.lane; + const [barColor, labelColor] = envColors(env); + + // Find the LAST (bottommost) card deployed to this env + let lastCard = null; + for (const card of cards) { + const envs = (card.dataset.envs || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (envs.includes(env)) lastCard = card; + } + + // Bar height: from bottom of timeline up to top of the last deployed card + let barHeight = 0; + if (lastCard) { + const cardRect = lastCard.getBoundingClientRect(); + barHeight = timelineRect.bottom - cardRect.top; + } + + // Style the lane container + lane.style.width = "14px"; + lane.style.marginRight = "4px"; + lane.style.position = "relative"; + lane.style.minHeight = timelineRect.height + "px"; + lane.style.flexShrink = "0"; + + // Create or update bar (anchored to bottom) + let bar = lane.querySelector(".lane-bar"); + if (!bar) { + bar = document.createElement("div"); + bar.className = "lane-bar"; + bar.style.position = "absolute"; + bar.style.bottom = "0"; + bar.style.left = "0"; + bar.style.width = "100%"; + bar.style.borderRadius = "9999px"; + lane.appendChild(bar); + } + bar.style.height = barHeight + "px"; + bar.style.backgroundColor = barColor; + + // Place dots on the lane for each card deployed to this env + const existingDots = lane.querySelectorAll(".lane-dot"); + let dotIndex = 0; + for (const card of cards) { + const envs = (card.dataset.envs || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (!envs.includes(env)) continue; + + const avatar = card.querySelector("[data-avatar]"); + const anchor = avatar || card; + const anchorRect = anchor.getBoundingClientRect(); + const centerY = anchorRect.top + anchorRect.height / 2 - timelineRect.top; + + let dot = existingDots[dotIndex]; + if (!dot) { + dot = document.createElement("div"); + dot.className = "lane-dot"; + dot.style.position = "absolute"; + dot.style.left = "50%"; + dot.style.transform = "translateX(-50%)"; + dot.style.width = "8px"; + dot.style.height = "8px"; + dot.style.borderRadius = "50%"; + dot.style.backgroundColor = "#fff"; + dot.style.border = "2px solid " + barColor; + dot.style.zIndex = "1"; + lane.appendChild(dot); + } + dot.style.top = centerY - 4 + "px"; + dot.style.borderColor = barColor; + dotIndex++; + } + // Remove extra dots from previous renders + for (let i = dotIndex; i < existingDots.length; i++) { + existingDots[i].remove(); + } + + // Create or update label (at the very bottom, below bars) + let label = lane.querySelector(".lane-label"); + if (!label) { + label = document.createElement("span"); + label.className = "lane-label"; + label.style.position = "absolute"; + label.style.bottom = "-4px"; + label.style.left = "50%"; + label.style.writingMode = "vertical-lr"; + label.style.transform = "translateX(-50%) translateY(100%) rotate(180deg)"; + label.style.fontSize = "10px"; + label.style.fontWeight = "500"; + label.style.whiteSpace = "nowrap"; + label.style.paddingTop = "6px"; + lane.appendChild(label); + } + label.textContent = env; + label.style.color = labelColor; + } + } +} + +customElements.define("swim-lanes", SwimLanes); diff --git a/templates/base.html.jinja b/templates/base.html.jinja index dc55aed..e658dc8 100644 --- a/templates/base.html.jinja +++ b/templates/base.html.jinja @@ -8,52 +8,79 @@ -