1
.playwright-mcp/console-2026-03-07T19-59-28-775Z.log
Normal file
@@ -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
|
||||
12
.playwright-mcp/console-2026-03-07T20-32-14-148Z.log
Normal file
@@ -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
|
||||
1
.playwright-mcp/console-2026-03-07T20-35-27-169Z.log
Normal file
@@ -0,0 +1 @@
|
||||
[ 157ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://rawpotion.io/favicon.ico:0
|
||||
4
.playwright-mcp/console-2026-03-07T21-49-57-898Z.log
Normal file
@@ -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
|
||||
@@ -109,4 +109,33 @@ pub trait ForestAuth: Send + Sync {
|
||||
access_token: &str,
|
||||
token_id: &str,
|
||||
) -> Result<(), AuthError>;
|
||||
|
||||
async fn update_username(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
new_username: &str,
|
||||
) -> Result<User, AuthError>;
|
||||
|
||||
async fn change_password(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
current_password: &str,
|
||||
new_password: &str,
|
||||
) -> Result<(), AuthError>;
|
||||
|
||||
async fn add_email(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
email: &str,
|
||||
) -> Result<UserEmail, AuthError>;
|
||||
|
||||
async fn remove_email(
|
||||
&self,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
email: &str,
|
||||
) -> Result<(), AuthError>;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ pub struct Artifact {
|
||||
pub struct ArtifactContext {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub web: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pr: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -59,6 +63,12 @@ pub struct ArtifactRef {
|
||||
pub struct ArtifactDestination {
|
||||
pub name: String,
|
||||
pub environment: String,
|
||||
#[serde(default)]
|
||||
pub type_organisation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub type_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub type_version: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -69,6 +79,24 @@ pub struct OrgMember {
|
||||
pub joined_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Destination {
|
||||
pub name: String,
|
||||
pub environment: String,
|
||||
pub organisation: String,
|
||||
#[serde(default)]
|
||||
pub metadata: std::collections::HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub dest_type: Option<DestinationType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DestinationType {
|
||||
pub organisation: String,
|
||||
pub name: String,
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum PlatformError {
|
||||
#[error("not authenticated")]
|
||||
@@ -140,6 +168,18 @@ pub trait ForestPlatform: Send + Sync {
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<OrgMember, PlatformError>;
|
||||
|
||||
async fn get_artifact_by_slug(
|
||||
&self,
|
||||
access_token: &str,
|
||||
slug: &str,
|
||||
) -> Result<Artifact, PlatformError>;
|
||||
|
||||
async fn list_destinations(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation: &str,
|
||||
) -> Result<Vec<Destination>, PlatformError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
115
crates/forage-core/src/session/file_store.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use super::{SessionData, SessionError, SessionId, SessionStore};
|
||||
|
||||
/// File-based session store. Each session is a JSON file in a directory.
|
||||
/// Suitable for local development — sessions survive server restarts.
|
||||
pub struct FileSessionStore {
|
||||
dir: PathBuf,
|
||||
max_inactive: Duration,
|
||||
}
|
||||
|
||||
impl FileSessionStore {
|
||||
pub fn new(dir: impl AsRef<Path>) -> Result<Self, SessionError> {
|
||||
let dir = dir.as_ref().to_path_buf();
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| SessionError::Store(format!("failed to create session dir: {e}")))?;
|
||||
Ok(Self {
|
||||
dir,
|
||||
max_inactive: Duration::days(30),
|
||||
})
|
||||
}
|
||||
|
||||
fn session_path(&self, id: &SessionId) -> PathBuf {
|
||||
// Use a safe filename: replace any non-alphanumeric chars
|
||||
let safe_name: String = id
|
||||
.as_str()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
self.dir.join(format!("{safe_name}.json"))
|
||||
}
|
||||
|
||||
/// Remove sessions inactive for longer than `max_inactive`.
|
||||
pub fn reap_expired(&self) {
|
||||
let cutoff = Utc::now() - self.max_inactive;
|
||||
let entries = match std::fs::read_dir(&self.dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
if let Ok(data) = serde_json::from_str::<SessionData>(&contents) {
|
||||
if data.last_seen_at < cutoff {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
std::fs::read_dir(&self.dir)
|
||||
.map(|e| {
|
||||
e.flatten()
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
== Some("json")
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SessionStore for FileSessionStore {
|
||||
async fn create(&self, data: SessionData) -> Result<SessionId, SessionError> {
|
||||
let id = SessionId::generate();
|
||||
let path = self.session_path(&id);
|
||||
let json = serde_json::to_string_pretty(&data)
|
||||
.map_err(|e| SessionError::Store(format!("serialize error: {e}")))?;
|
||||
std::fs::write(&path, json)
|
||||
.map_err(|e| SessionError::Store(format!("write error: {e}")))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn get(&self, id: &SessionId) -> Result<Option<SessionData>, SessionError> {
|
||||
let path = self.session_path(id);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => {
|
||||
let data: SessionData = serde_json::from_str(&contents)
|
||||
.map_err(|e| SessionError::Store(format!("deserialize error: {e}")))?;
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(SessionError::Store(format!("read error: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError> {
|
||||
let path = self.session_path(id);
|
||||
let json = serde_json::to_string_pretty(&data)
|
||||
.map_err(|e| SessionError::Store(format!("serialize error: {e}")))?;
|
||||
std::fs::write(&path, json)
|
||||
.map_err(|e| SessionError::Store(format!("write error: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &SessionId) -> Result<(), SessionError> {
|
||||
let path = self.session_path(id);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(SessionError::Store(format!("delete error: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<User, AuthError> {
|
||||
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<UserEmail, AuthError> {
|
||||
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<Artifact, PlatformError> {
|
||||
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<Vec<Destination>, PlatformError> {
|
||||
// DestinationService client not yet generated; return empty for now
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AppState> {
|
||||
@@ -23,6 +23,14 @@ pub fn router() -> Router<AppState> {
|
||||
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::<Vec<_>>(),
|
||||
tokens => tokens.iter().map(|t| context! {
|
||||
token_id => t.token_id,
|
||||
name => t.name,
|
||||
@@ -376,6 +386,7 @@ async fn tokens_page(
|
||||
}).collect::<Vec<_>>(),
|
||||
csrf_token => &session.csrf_token,
|
||||
created_token => None::<String>,
|
||||
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::<Vec<_>>(),
|
||||
tokens => tokens.iter().map(|t| context! {
|
||||
token_id => t.token_id,
|
||||
name => t.name,
|
||||
@@ -438,6 +451,7 @@ async fn create_token_submit(
|
||||
}).collect::<Vec<_>>(),
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, Response> {
|
||||
render_account(&state, &session, None)
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn render_account(
|
||||
state: &AppState,
|
||||
session: &Session,
|
||||
error: Option<&str>,
|
||||
) -> Result<Response, Response> {
|
||||
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::<Vec<_>>(),
|
||||
},
|
||||
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::<Vec<_>>(),
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<UpdateUsernameForm>,
|
||||
) -> Result<Response, Response> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<ChangePasswordForm>,
|
||||
) -> Result<Response, Response> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<AddEmailForm>,
|
||||
) -> Result<Response, Response> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<RemoveEmailForm>,
|
||||
) -> Result<Response, Response> {
|
||||
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."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ pub fn router() -> Router<AppState> {
|
||||
.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<ArtifactWithProject> = 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::<Vec<_>>(),
|
||||
}
|
||||
}).collect::<Vec<_>>(),
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
Path((org, project, slug)): Path<(String, String, String)>,
|
||||
) -> Result<Response, Response> {
|
||||
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::<Vec<_>>(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.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<minijinja::Value>,
|
||||
lanes: Vec<minijinja::Value>,
|
||||
}
|
||||
|
||||
fn build_timeline(items: Vec<ArtifactWithProject>, org_name: &str) -> TimelineData {
|
||||
struct RawRelease {
|
||||
value: minijinja::Value,
|
||||
has_dests: bool,
|
||||
}
|
||||
|
||||
let mut raw_releases: Vec<RawRelease> = 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<minijinja::Value> = 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<minijinja::Value> = env_set
|
||||
.into_iter()
|
||||
.map(|env| context! { name => env })
|
||||
.collect();
|
||||
|
||||
let mut timeline_items: Vec<minijinja::Value> = Vec::new();
|
||||
let mut hidden_buf: Vec<minijinja::Value> = 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<ArtifactWithProject> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
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<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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<Result<Vec<PersonalAccessToken>, AuthError>>,
|
||||
pub create_token_result: Option<Result<CreatedToken, AuthError>>,
|
||||
pub delete_token_result: Option<Result<(), AuthError>>,
|
||||
pub update_username_result: Option<Result<User, AuthError>>,
|
||||
pub change_password_result: Option<Result<(), AuthError>>,
|
||||
pub add_email_result: Option<Result<UserEmail, AuthError>>,
|
||||
pub remove_email_result: Option<Result<(), AuthError>>,
|
||||
}
|
||||
|
||||
/// Configurable mock behavior for platform (orgs, projects, artifacts).
|
||||
@@ -36,6 +40,8 @@ pub(crate) struct MockPlatformBehavior {
|
||||
pub add_member_result: Option<Result<OrgMember, PlatformError>>,
|
||||
pub remove_member_result: Option<Result<(), PlatformError>>,
|
||||
pub update_member_role_result: Option<Result<OrgMember, PlatformError>>,
|
||||
pub get_artifact_by_slug_result: Option<Result<Artifact, PlatformError>>,
|
||||
pub list_destinations_result: Option<Result<Vec<Destination>, 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<User, AuthError> {
|
||||
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<UserEmail, AuthError> {
|
||||
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<Artifact, PlatformError> {
|
||||
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<Vec<Destination>, PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.list_destinations_result.clone().unwrap_or(Ok(vec![]))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn make_templates() -> TemplateEngine {
|
||||
|
||||
313
crates/forage-server/src/tests/account_tests.rs
Normal file
@@ -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);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod account_tests;
|
||||
mod auth_tests;
|
||||
mod pages_tests;
|
||||
mod platform_tests;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
BIN
destinations-page.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
@@ -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"
|
||||
|
||||
|
||||
BIN
nav-dashboard-final.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
nav-dashboard.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
nav-final-account.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
nav-final-padded.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
nav-members.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
nav-projects.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
nav-pt3.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
nav-pt5.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
nav-spacing-fixed.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
nav-usage.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
nav-with-tailwind.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
releases-collapsed.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
releases-current.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
releases-v10-avatar-dots.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
releases-v2.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
releases-v3.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
releases-v4.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
releases-v5.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
releases-v6-dots.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
releases-v7-hidden.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
releases-v8-expanded.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
releases-v9-toggle.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
1332
static/css/style.css
174
static/js/swim-lanes.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* <swim-lanes> 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:
|
||||
* <swim-lanes>
|
||||
* <div data-lane="staging"></div>
|
||||
* <div data-lane="prod"></div>
|
||||
* <div data-swimlane-timeline>
|
||||
* <div data-release data-envs="staging,prod">...</div>
|
||||
* <div data-release data-envs="staging">...</div>
|
||||
* </div>
|
||||
* </swim-lanes>
|
||||
*/
|
||||
|
||||
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);
|
||||
@@ -8,52 +8,79 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body class="bg-white text-gray-900 antialiased">
|
||||
<nav class="border-b border-gray-200">
|
||||
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
{% if user is defined and user %}
|
||||
{# Authenticated nav #}
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="/dashboard" class="text-xl font-bold tracking-tight">forage</a>
|
||||
{% if user is defined and user %}
|
||||
{# ── Authenticated nav ─────────────────────────────────────── #}
|
||||
<nav class="border-b border-gray-200 pt-3">
|
||||
{# Top bar: breadcrumb + user actions #}
|
||||
<div class="max-w-6xl mx-auto px-4 pb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm min-w-0">
|
||||
<a href="/dashboard" class="text-lg font-bold tracking-tight shrink-0">forage</a>
|
||||
{% if current_org is defined and current_org %}
|
||||
<span class="text-sm text-gray-400">/</span>
|
||||
<span class="text-sm font-medium">{{ current_org }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
{% if current_org is defined and current_org %}
|
||||
<a href="/orgs/{{ current_org }}/projects" class="text-sm text-gray-600 hover:text-gray-900">Projects</a>
|
||||
<a href="/orgs/{{ current_org }}/settings/members" class="text-sm text-gray-600 hover:text-gray-900">Members</a>
|
||||
<a href="/orgs/{{ current_org }}/usage" class="text-sm text-gray-600 hover:text-gray-900">Usage</a>
|
||||
{% endif %}
|
||||
<span class="text-gray-300">/</span>
|
||||
{% if orgs is defined and orgs | length > 1 %}
|
||||
<details class="relative">
|
||||
<summary class="text-sm text-gray-600 hover:text-gray-900 cursor-pointer list-none">
|
||||
Switch org
|
||||
<summary class="font-medium text-gray-900 hover:text-black cursor-pointer list-none">
|
||||
{{ current_org }}
|
||||
<svg class="inline w-3 h-3 ml-0.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</summary>
|
||||
<div class="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-10">
|
||||
<div class="absolute left-0 mt-1 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-20 py-1">
|
||||
{% for org in orgs %}
|
||||
<a href="/orgs/{{ org.name }}/projects" class="block px-4 py-2 text-sm hover:bg-gray-50">{{ org.name }}</a>
|
||||
<a href="/orgs/{{ org.name }}/projects" class="block px-3 py-1.5 text-sm hover:bg-gray-50{% if org.name == current_org %} font-medium bg-gray-50{% endif %}">{{ org.name }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<a href="/orgs/{{ current_org }}/projects" class="font-medium text-gray-900 hover:text-black">{{ current_org }}</a>
|
||||
{% endif %}
|
||||
<a href="/settings/tokens" class="text-sm text-gray-600 hover:text-gray-900">Tokens</a>
|
||||
{% if project_name is defined and project_name %}
|
||||
<span class="text-gray-300">/</span>
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}" class="font-medium text-gray-900 hover:text-black truncate">{{ project_name }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-4 shrink-0">
|
||||
<a href="/settings/account" class="text-sm text-gray-500 hover:text-gray-900">{{ user.username }}</a>
|
||||
<form method="POST" action="/logout" class="inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="text-sm text-gray-600 hover:text-gray-900">Sign out</button>
|
||||
<button type="submit" class="text-sm text-gray-500 hover:text-gray-900">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Marketing nav #}
|
||||
</div>
|
||||
{# Tab navigation #}
|
||||
<div class="max-w-6xl mx-auto px-4 mt-2">
|
||||
<div class="flex gap-1 -mb-px overflow-x-auto">
|
||||
{% if current_org is defined and current_org %}
|
||||
{# Org-scoped tabs #}
|
||||
<a href="/dashboard" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'dashboard' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/orgs/{{ current_org }}/projects" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'projects' %} text-gray-900 border-gray-900{% endif %}">Projects</a>
|
||||
<a href="/orgs/{{ current_org }}/releases" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'releases' %} text-gray-900 border-gray-900{% endif %}">Releases</a>
|
||||
<a href="/orgs/{{ current_org }}/settings/members" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'members' %} text-gray-900 border-gray-900{% endif %}">Members</a>
|
||||
<a href="/orgs/{{ current_org }}/destinations" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'destinations' %} text-gray-900 border-gray-900{% endif %}">Destinations</a>
|
||||
<a href="/orgs/{{ current_org }}/usage" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'usage' %} text-gray-900 border-gray-900{% endif %}">Usage</a>
|
||||
<a href="/settings/tokens" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'tokens' %} text-gray-900 border-gray-900{% endif %}">Tokens</a>
|
||||
<a href="/settings/account" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'account' %} text-gray-900 border-gray-900{% endif %}">Settings</a>
|
||||
{% else %}
|
||||
{# Global tabs (dashboard, settings) #}
|
||||
<a href="/dashboard" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'dashboard' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/settings/tokens" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'tokens' %} text-gray-900 border-gray-900{% endif %}">Tokens</a>
|
||||
<a href="/settings/account" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'account' %} text-gray-900 border-gray-900{% endif %}">Settings</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% else %}
|
||||
{# ── Marketing nav ─────────────────────────────────────────── #}
|
||||
<nav class="border-b border-gray-200">
|
||||
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold tracking-tight">forage</a>
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="/pricing" class="text-sm text-gray-600 hover:text-gray-900">Pricing</a>
|
||||
<a href="/components" class="text-sm text-gray-600 hover:text-gray-900">Components</a>
|
||||
<a href="/login" class="text-sm font-medium px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-800">Sign in</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
87
templates/pages/account.html.jinja
Normal file
@@ -0,0 +1,87 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-2xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold">Account Settings</h1>
|
||||
<a href="/dashboard" class="text-sm text-gray-500 hover:text-gray-700">← Dashboard</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="mb-6 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Username #}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Username</h2>
|
||||
<form method="POST" action="/settings/account/username" class="flex gap-2">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="text" name="username" value="{{ user.username }}"
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<button type="submit" class="px-4 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 whitespace-nowrap">
|
||||
Update
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Emails #}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Email addresses</h2>
|
||||
<div class="space-y-2 mb-4">
|
||||
{% for email in user.emails %}
|
||||
<div class="flex items-center justify-between px-4 py-3 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-900">{{ email.email }}</span>
|
||||
{% if email.verified %}
|
||||
<span class="text-xs text-green-700 bg-green-100 px-1.5 py-0.5 rounded">verified</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-yellow-700 bg-yellow-100 px-1.5 py-0.5 rounded">unverified</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="POST" action="/settings/account/emails/remove">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="email" value="{{ email.email }}">
|
||||
<button type="submit" class="text-xs text-red-600 hover:text-red-800">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form method="POST" action="/settings/account/emails" class="flex gap-2">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="email" name="email" placeholder="new@example.com"
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<button type="submit" class="px-4 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 whitespace-nowrap">
|
||||
Add email
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Change password #}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Change password</h2>
|
||||
<form method="POST" action="/settings/account/password" class="space-y-3">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-700 mb-1">Current password</label>
|
||||
<input type="password" name="current_password" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-700 mb-1">New password</label>
|
||||
<input type="password" name="new_password" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-700 mb-1">Confirm new password</label>
|
||||
<input type="password" name="new_password_confirm" required
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800">
|
||||
Change password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
124
templates/pages/artifact_detail.html.jinja
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<div class="mb-8">
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}" class="text-sm text-gray-500 hover:text-gray-700">← {{ project_name }}</a>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<h1 class="text-2xl font-bold">{{ artifact.title }}</h1>
|
||||
{% if artifact.version %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-sm font-medium bg-green-100 text-green-800">{{ artifact.version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1 font-mono">{{ artifact.slug }}</p>
|
||||
</div>
|
||||
|
||||
{% if artifact.description %}
|
||||
<div class="mb-6">
|
||||
<p class="text-gray-700">{{ artifact.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Git info -->
|
||||
{% if artifact.commit_sha or artifact.branch %}
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Git</h3>
|
||||
<dl class="space-y-2 text-sm">
|
||||
{% if artifact.branch %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Branch</dt>
|
||||
<dd class="font-mono text-blue-700">{{ artifact.branch }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.commit_sha %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Commit</dt>
|
||||
<dd class="font-mono">{{ artifact.commit_sha[:8] }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.commit_message %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Message</dt>
|
||||
<dd class="text-gray-700 truncate ml-4">{{ artifact.commit_message }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.repo_url %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Repository</dt>
|
||||
<dd><a href="{{ artifact.repo_url }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">View repo</a></dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Source info -->
|
||||
{% if artifact.source_user or artifact.source_type %}
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Source</h3>
|
||||
<dl class="space-y-2 text-sm">
|
||||
{% if artifact.source_user %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Created by</dt>
|
||||
<dd class="text-gray-700">{{ artifact.source_user }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.source_email %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Email</dt>
|
||||
<dd class="text-gray-700">{{ artifact.source_email }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.source_type %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Type</dt>
|
||||
<dd class="text-gray-700">{{ artifact.source_type }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.run_url %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">CI Run</dt>
|
||||
<dd><a href="{{ artifact.run_url }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">View run</a></dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
{% if artifact.web or artifact.pr %}
|
||||
<div class="mb-8">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Links</h3>
|
||||
<div class="flex gap-3">
|
||||
{% if artifact.web %}
|
||||
<a href="{{ artifact.web }}" class="inline-flex items-center px-3 py-1.5 border border-gray-200 rounded-md text-sm text-gray-700 hover:bg-gray-50" target="_blank" rel="noopener">Web</a>
|
||||
{% endif %}
|
||||
{% if artifact.pr %}
|
||||
<a href="{{ artifact.pr }}" class="inline-flex items-center px-3 py-1.5 border border-gray-200 rounded-md text-sm text-gray-700 hover:bg-gray-50" target="_blank" rel="noopener">Pull request</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Destinations -->
|
||||
{% if artifact.destinations %}
|
||||
<div class="mb-8">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Destinations</h3>
|
||||
<div class="space-y-2">
|
||||
{% for dest in artifact.destinations %}
|
||||
<div class="flex items-center gap-3 p-3 border border-gray-200 rounded-lg">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">{{ dest.environment }}</span>
|
||||
<span class="text-sm text-gray-900">{{ dest.name }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-sm text-gray-500">
|
||||
<p>Created {{ artifact.created_at }}</p>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,66 +1,56 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-6xl mx-auto px-4 pt-8">
|
||||
<div class="flex gap-8">
|
||||
{# Org sidebar #}
|
||||
<aside class="w-64 shrink-0">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Organisations</h2>
|
||||
<ul class="space-y-1">
|
||||
{% for org in orgs %}
|
||||
<li>
|
||||
<a href="/orgs/{{ org.name }}/projects" class="block px-3 py-2 text-sm rounded-md hover:bg-gray-100">
|
||||
{{ org.name }}
|
||||
<span class="text-xs text-gray-400 ml-1">{{ org.role }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<form method="POST" action="/orgs" class="space-y-2">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="text" name="name" placeholder="new-org-name"
|
||||
class="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<button type="submit" class="w-full px-3 py-1.5 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800">
|
||||
Create organisation
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{# Main content #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold mb-6">Recent activity</h1>
|
||||
|
||||
{% if recent_activity %}
|
||||
<div class="space-y-3">
|
||||
{% for item in recent_activity %}
|
||||
<a href="/orgs/{{ item.org_name }}/projects/{{ item.project_name }}" class="block p-4 border border-gray-200 rounded-lg hover:border-gray-300 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium truncate">{{ item.title }}</p>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
{{ item.org_name }} / {{ item.project_name }}
|
||||
</p>
|
||||
{% if item.description %}
|
||||
<p class="text-sm text-gray-600 mt-1 truncate">{{ item.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right text-sm text-gray-400 shrink-0 ml-4">
|
||||
<p class="font-mono text-xs">{{ item.slug }}</p>
|
||||
<p>{{ item.created_at }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-8 border border-gray-200 rounded-lg text-center">
|
||||
<p class="text-gray-500">No recent activity</p>
|
||||
<p class="text-sm text-gray-400 mt-2">Deploy your first release with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<section class="max-w-2xl mx-auto px-4 py-12">
|
||||
{# Organisations #}
|
||||
<div class="mb-12">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Your organisations</h2>
|
||||
<div class="space-y-2">
|
||||
{% for org in orgs %}
|
||||
<a href="/orgs/{{ org.name }}/projects" class="flex items-center justify-between px-4 py-3 border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
<span class="font-medium text-gray-900">{{ org.name }}</span>
|
||||
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">{{ org.role }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form method="POST" action="/orgs" class="flex gap-2 mt-4">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="text" name="name" placeholder="new-org-name"
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<button type="submit" class="px-4 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 whitespace-nowrap">
|
||||
Create organisation
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Recent activity #}
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Recent activity</h2>
|
||||
|
||||
{% if recent_activity %}
|
||||
<div class="space-y-2">
|
||||
{% for item in recent_activity %}
|
||||
<a href="/orgs/{{ item.org_name }}/projects/{{ item.project_name }}" class="block px-4 py-3 border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium truncate">{{ item.title }}</p>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
{{ item.org_name }} / {{ item.project_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right text-sm text-gray-400 shrink-0 ml-4">
|
||||
<p class="font-mono text-xs">{{ item.slug }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-10 border border-gray-200 rounded-lg text-center">
|
||||
<p class="text-gray-500">No recent activity</p>
|
||||
<p class="text-sm text-gray-400 mt-2">Deploy your first release with <code class="bg-gray-100 px-1.5 py-0.5 rounded">forest release create</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
65
templates/pages/destinations.html.jinja
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold">Destinations</h1>
|
||||
</div>
|
||||
|
||||
{# ── Create destination form (admin only) ─────────────────── #}
|
||||
{% if is_admin %}
|
||||
<div class="border border-gray-200 rounded-lg p-5 mb-6">
|
||||
<h2 class="text-sm font-medium text-gray-900 mb-3">Add destination</h2>
|
||||
<form method="POST" action="/orgs/{{ org_name }}/destinations" class="flex items-end gap-3 flex-wrap">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<div class="flex-1 min-w-[140px]">
|
||||
<label for="dest-name" class="block text-xs text-gray-500 mb-1">Name</label>
|
||||
<input type="text" id="dest-name" name="name" required placeholder="my-service" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
|
||||
</div>
|
||||
<div class="min-w-[140px]">
|
||||
<label for="dest-env" class="block text-xs text-gray-500 mb-1">Environment</label>
|
||||
<select id="dest-env" name="environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
|
||||
<option value="staging">staging</option>
|
||||
<option value="preprod">preprod</option>
|
||||
<option value="production">production</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 bg-gray-900 text-white text-sm rounded-md hover:bg-gray-800">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Destination list ─────────────────────────────────────── #}
|
||||
{% if destinations | length > 0 %}
|
||||
<div class="space-y-2">
|
||||
{% for dest in destinations %}
|
||||
<div class="border border-gray-200 rounded-lg px-5 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {% if dest.environment == 'production' %}bg-purple-500{% elif dest.environment == 'preprod' %}bg-orange-500{% elif dest.environment == 'staging' %}bg-yellow-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">{{ dest.name }}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full {% if dest.environment == 'production' %}bg-purple-100 text-purple-800{% elif dest.environment == 'preprod' %}bg-orange-100 text-orange-800{% elif dest.environment == 'staging' %}bg-yellow-100 text-yellow-800{% else %}bg-gray-100 text-gray-600{% endif %}">{{ dest.environment }}</span>
|
||||
</div>
|
||||
{% if dest.artifact_title %}
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
Last: <a href="/orgs/{{ org_name }}/projects/{{ dest.project_name }}/releases/{{ dest.artifact_slug }}" class="text-gray-700 hover:text-black underline">{{ dest.artifact_title }}</a>
|
||||
in <a href="/orgs/{{ org_name }}/projects/{{ dest.project_name }}" class="text-gray-700 hover:text-black underline">{{ dest.project_name }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if dest.created_at %}
|
||||
<span class="text-xs text-gray-400 shrink-0" title="{{ dest.created_at | datetime }}">{{ dest.created_at | timeago }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="border border-gray-200 rounded-lg p-8 text-center text-gray-500">
|
||||
<p class="font-medium text-gray-700">No destinations yet</p>
|
||||
<p class="mt-1 text-sm">Destinations appear when releases are deployed with <code class="bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">forest release create --dest</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{{ artifact.title }}</p>
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}" class="font-medium text-gray-900 hover:text-blue-600">{{ artifact.title }}</a>
|
||||
{% if artifact.version %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">{{ artifact.version }}</span>
|
||||
{% endif %}
|
||||
|
||||
152
templates/pages/releases.html.jinja
Normal file
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-5xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold">Continuous deployment</h1>
|
||||
</div>
|
||||
|
||||
{% if timeline | length > 0 %}
|
||||
<swim-lanes>
|
||||
{# ── Environment swim lanes ──────────────────────────────── #}
|
||||
{% for lane in lanes %}
|
||||
<div data-lane="{{ lane.name }}"></div>
|
||||
{% endfor %}
|
||||
|
||||
{# ── Release timeline ─────────────────────────────────────── #}
|
||||
<div data-swimlane-timeline class="flex-1 space-y-3 min-w-0 ml-2">
|
||||
{% for item in timeline %}
|
||||
|
||||
{% if item.kind == "release" %}
|
||||
{# ── Visible release card ──────────────────────────────── #}
|
||||
{% set release = item.release %}
|
||||
<div data-release data-envs="{{ release.dest_envs }}" class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
{# Release header #}
|
||||
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="inline-block w-6 h-6 rounded-full bg-gray-200 shrink-0" data-avatar></span>
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}/releases/{{ release.slug }}" class="font-medium text-gray-900 hover:text-black truncate">
|
||||
{{ release.title }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0 flex-wrap">
|
||||
{% if release.branch %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||
{{ release.branch }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if release.commit_sha %}
|
||||
<span class="font-mono">{{ release.commit_sha[:7] }}</span>
|
||||
{% endif %}
|
||||
<span title="{{ release.created_at | datetime }}">{{ release.created_at | timeago }}</span>
|
||||
{% if release.source_user %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
{{ release.source_user }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="text-gray-400">
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}" class="hover:text-gray-700">{{ release.project_name }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Deployment steps (collapsed by default) #}
|
||||
<details class="border-t border-gray-100 group">
|
||||
<summary class="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer list-none hover:bg-gray-50">
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-gray-600 text-sm">Deployed to</span>
|
||||
{% for dest in release.destinations %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-100 text-orange-800{% elif 'stag' in dest.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in dest.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
|
||||
{{ dest.environment }}
|
||||
<span class="w-1.5 h-1.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-500{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-500{% elif 'stag' in dest.environment %}bg-yellow-500{% elif 'dev' in dest.environment %}bg-violet-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
<svg class="w-3 h-3 text-gray-400 shrink-0 ml-auto transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</summary>
|
||||
{% for dest in release.destinations %}
|
||||
<div class="px-4 py-2 flex items-center gap-3 text-sm {% if not loop.last %}border-b border-gray-50{% endif %} border-t border-gray-50">
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-gray-600">Deployed to</span>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-100 text-orange-800{% elif 'stag' in dest.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in dest.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
|
||||
{{ dest.environment }}
|
||||
<span class="w-1.5 h-1.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-500{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-500{% elif 'stag' in dest.environment %}bg-yellow-500{% elif 'dev' in dest.environment %}bg-violet-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
</span>
|
||||
<span class="text-gray-400 text-xs">{{ dest.name }}</span>
|
||||
{% if dest.type_name %}
|
||||
<span class="text-gray-400 text-xs">({{ dest.type_name }}{% if dest.type_version %} v{{ dest.type_version }}{% endif %})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{% elif item.kind == "hidden" %}
|
||||
{# ── Hidden commits group ──────────────────────────────── #}
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-2 py-2 px-1 text-sm text-gray-400 cursor-pointer hover:text-gray-600 list-none">
|
||||
<svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
{{ item.count }} hidden commit{{ "s" if item.count != 1 }}
|
||||
<span class="text-gray-300">·</span>
|
||||
<span class="group-open:hidden">Show commit{{ "s" if item.count != 1 }}</span>
|
||||
<span class="hidden group-open:inline">Hide commit{{ "s" if item.count != 1 }}</span>
|
||||
</summary>
|
||||
<div class="space-y-3 mt-1">
|
||||
{% for release in item.releases %}
|
||||
<div data-release data-envs="" class="border border-gray-200 rounded-lg overflow-hidden opacity-75">
|
||||
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="inline-block w-6 h-6 rounded-full bg-gray-200 shrink-0" data-avatar></span>
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}/releases/{{ release.slug }}" class="font-medium text-gray-900 hover:text-black truncate">
|
||||
{{ release.title }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0 flex-wrap">
|
||||
{% if release.commit_sha %}
|
||||
<span class="font-mono">{{ release.commit_sha[:7] }}</span>
|
||||
{% endif %}
|
||||
<span title="{{ release.created_at | datetime }}">{{ release.created_at | timeago }}</span>
|
||||
{% if release.source_user %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
{{ release.source_user }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="text-gray-400">
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}" class="hover:text-gray-700">{{ release.project_name }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</swim-lanes>
|
||||
|
||||
{% else %}
|
||||
{# ── Empty state ──────────────────────────────────────────── #}
|
||||
<div class="border border-gray-200 rounded-lg p-12 text-center">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/></svg>
|
||||
</div>
|
||||
<p class="font-medium text-gray-900 mb-1">No releases yet</p>
|
||||
<p class="text-sm text-gray-500 mb-6">Releases appear when you deploy with Forest CLI.</p>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-left">
|
||||
<p class="text-xs font-medium text-gray-700 mb-2">Get started with the CLI:</p>
|
||||
<pre class="text-xs text-gray-600 overflow-x-auto"><code>forest release create \
|
||||
--org {{ org_name }} \
|
||||
--project my-project \
|
||||
--dest staging:my-service</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script src="/static/js/swim-lanes.js"></script>
|
||||
{% endblock %}
|
||||
BIN
tooltip-hover.png
Normal file
|
After Width: | Height: | Size: 75 KiB |