feat: add basic website

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 19:46:13 +01:00
commit b439762877
71 changed files with 16576 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::Cookie;
use forage_core::session::{CachedOrg, CachedUser, SessionId};
use crate::state::AppState;
pub const SESSION_COOKIE: &str = "forage_session";
/// Maximum access token lifetime: 24 hours.
/// Defends against forest-server returning absolute timestamps instead of durations.
const MAX_TOKEN_LIFETIME_SECS: i64 = 86400;
/// Cap expires_in_seconds to a sane maximum.
pub fn cap_token_expiry(expires_in_seconds: i64) -> i64 {
expires_in_seconds.min(MAX_TOKEN_LIFETIME_SECS)
}
/// Active session data available to route handlers.
pub struct Session {
pub session_id: SessionId,
pub access_token: String,
pub user: CachedUser,
pub csrf_token: String,
}
/// Extractor that requires an active session. Redirects to /login if not authenticated.
/// Handles transparent token refresh when access token is near expiry.
impl FromRequestParts<AppState> for Session {
type Rejection = axum::response::Redirect;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let jar = CookieJar::from_headers(&parts.headers);
let session_id = jar
.get(SESSION_COOKIE)
.map(|c| SessionId::from_raw(c.value().to_string()))
.ok_or(axum::response::Redirect::to("/login"))?;
let mut session_data = state
.sessions
.get(&session_id)
.await
.ok()
.flatten()
.ok_or(axum::response::Redirect::to("/login"))?;
// Transparent token refresh
if session_data.needs_refresh() {
match state
.forest_client
.refresh_token(&session_data.refresh_token)
.await
{
Ok(tokens) => {
session_data.access_token = tokens.access_token;
session_data.refresh_token = tokens.refresh_token;
session_data.access_expires_at =
chrono::Utc::now() + chrono::Duration::seconds(cap_token_expiry(tokens.expires_in_seconds));
session_data.last_seen_at = chrono::Utc::now();
// Refresh the user cache too
if let Ok(user) = state
.forest_client
.get_user(&session_data.access_token)
.await
{
let orgs = state
.platform_client
.list_my_organisations(&session_data.access_token)
.await
.ok()
.unwrap_or_default()
.into_iter()
.map(|o| CachedOrg {
name: o.name,
role: o.role,
})
.collect();
session_data.user = Some(CachedUser {
user_id: user.user_id.clone(),
username: user.username.clone(),
emails: user.emails,
orgs,
});
}
let _ = state.sessions.update(&session_id, session_data.clone()).await;
}
Err(_) => {
// Refresh token rejected - session is dead
let _ = state.sessions.delete(&session_id).await;
return Err(axum::response::Redirect::to("/login"));
}
}
} else {
// Throttle last_seen_at writes: only update if older than 5 minutes
let now = chrono::Utc::now();
if now - session_data.last_seen_at > chrono::Duration::minutes(5) {
session_data.last_seen_at = now;
let _ = state.sessions.update(&session_id, session_data.clone()).await;
}
}
let user = session_data
.user
.ok_or(axum::response::Redirect::to("/login"))?;
Ok(Session {
session_id,
access_token: session_data.access_token,
user,
csrf_token: session_data.csrf_token,
})
}
}
/// Extractor that optionally provides session info. Never rejects.
/// Used for pages that behave differently when authenticated (e.g., login/signup redirect).
pub struct MaybeSession {
pub session: Option<Session>,
}
impl FromRequestParts<AppState> for MaybeSession {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let session = Session::from_request_parts(parts, state).await.ok();
Ok(MaybeSession { session })
}
}
/// Build a Set-Cookie header for the session.
pub fn session_cookie(session_id: &SessionId) -> CookieJar {
let cookie = Cookie::build((SESSION_COOKIE, session_id.to_string()))
.path("/")
.http_only(true)
.secure(true)
.same_site(axum_extra::extract::cookie::SameSite::Lax)
.build();
CookieJar::new().add(cookie)
}
/// Validate that a submitted CSRF token matches the session's token.
pub fn validate_csrf(session: &Session, submitted: &str) -> bool {
!session.csrf_token.is_empty() && session.csrf_token == submitted
}
/// Build a Set-Cookie header that clears the session cookie.
pub fn clear_session_cookie() -> CookieJar {
let mut cookie = Cookie::from(SESSION_COOKIE);
cookie.set_path("/");
cookie.make_removal();
CookieJar::new().add(cookie)
}

View File

@@ -0,0 +1,497 @@
use forage_core::auth::{
AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail,
};
use forage_core::platform::{
Artifact, ArtifactContext, ForestPlatform, Organisation, PlatformError,
};
use forage_grpc::organisation_service_client::OrganisationServiceClient;
use forage_grpc::release_service_client::ReleaseServiceClient;
use forage_grpc::users_service_client::UsersServiceClient;
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::Request;
fn bearer_request<T>(access_token: &str, msg: T) -> Result<Request<T>, String> {
let mut req = Request::new(msg);
let bearer: MetadataValue<_> = format!("Bearer {access_token}")
.parse()
.map_err(|_| "invalid token format".to_string())?;
req.metadata_mut().insert("authorization", bearer);
Ok(req)
}
/// Real gRPC client to forest-server's UsersService.
#[derive(Clone)]
pub struct GrpcForestClient {
channel: Channel,
}
impl GrpcForestClient {
/// Create a client that connects lazily (for when server may not be available at startup).
pub fn connect_lazy(endpoint: &str) -> anyhow::Result<Self> {
let channel = Channel::from_shared(endpoint.to_string())?.connect_lazy();
Ok(Self { channel })
}
fn client(&self) -> UsersServiceClient<Channel> {
UsersServiceClient::new(self.channel.clone())
}
fn org_client(&self) -> OrganisationServiceClient<Channel> {
OrganisationServiceClient::new(self.channel.clone())
}
fn release_client(&self) -> ReleaseServiceClient<Channel> {
ReleaseServiceClient::new(self.channel.clone())
}
fn authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, AuthError> {
bearer_request(access_token, msg).map_err(AuthError::Other)
}
}
fn map_status(status: tonic::Status) -> AuthError {
match status.code() {
tonic::Code::Unauthenticated => AuthError::InvalidCredentials,
tonic::Code::AlreadyExists => AuthError::AlreadyExists(status.message().into()),
tonic::Code::PermissionDenied => AuthError::NotAuthenticated,
tonic::Code::Unavailable => AuthError::Unavailable(status.message().into()),
_ => AuthError::Other(status.message().into()),
}
}
fn convert_user(u: forage_grpc::User) -> User {
User {
user_id: u.user_id,
username: u.username,
emails: u
.emails
.into_iter()
.map(|e| UserEmail {
email: e.email,
verified: e.verified,
})
.collect(),
}
}
fn convert_token(t: forage_grpc::PersonalAccessToken) -> PersonalAccessToken {
PersonalAccessToken {
token_id: t.token_id,
name: t.name,
scopes: t.scopes,
created_at: t.created_at.map(|ts| ts.to_string()),
last_used: t.last_used.map(|ts| ts.to_string()),
expires_at: t.expires_at.map(|ts| ts.to_string()),
}
}
#[async_trait::async_trait]
impl ForestAuth for GrpcForestClient {
async fn register(
&self,
username: &str,
email: &str,
password: &str,
) -> Result<AuthTokens, AuthError> {
let resp = self
.client()
.register(forage_grpc::RegisterRequest {
username: username.into(),
email: email.into(),
password: password.into(),
})
.await
.map_err(map_status)?
.into_inner();
let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?;
Ok(AuthTokens {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_in_seconds: tokens.expires_in_seconds,
})
}
async fn login(&self, identifier: &str, password: &str) -> Result<AuthTokens, AuthError> {
let login_identifier = if identifier.contains('@') {
forage_grpc::login_request::Identifier::Email(identifier.into())
} else {
forage_grpc::login_request::Identifier::Username(identifier.into())
};
let resp = self
.client()
.login(forage_grpc::LoginRequest {
identifier: Some(login_identifier),
password: password.into(),
})
.await
.map_err(map_status)?
.into_inner();
let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?;
Ok(AuthTokens {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_in_seconds: tokens.expires_in_seconds,
})
}
async fn refresh_token(&self, refresh_token: &str) -> Result<AuthTokens, AuthError> {
let resp = self
.client()
.refresh_token(forage_grpc::RefreshTokenRequest {
refresh_token: refresh_token.into(),
})
.await
.map_err(map_status)?
.into_inner();
let tokens = resp
.tokens
.ok_or(AuthError::Other("no tokens in response".into()))?;
Ok(AuthTokens {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_in_seconds: tokens.expires_in_seconds,
})
}
async fn logout(&self, refresh_token: &str) -> Result<(), AuthError> {
self.client()
.logout(forage_grpc::LogoutRequest {
refresh_token: refresh_token.into(),
})
.await
.map_err(map_status)?;
Ok(())
}
async fn get_user(&self, access_token: &str) -> Result<User, AuthError> {
let req = Self::authed_request(
access_token,
forage_grpc::TokenInfoRequest {},
)?;
let info = self
.client()
.token_info(req)
.await
.map_err(map_status)?
.into_inner();
let req = Self::authed_request(
access_token,
forage_grpc::GetUserRequest {
identifier: Some(forage_grpc::get_user_request::Identifier::UserId(
info.user_id,
)),
},
)?;
let resp = self
.client()
.get_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 list_tokens(
&self,
access_token: &str,
user_id: &str,
) -> Result<Vec<PersonalAccessToken>, AuthError> {
let req = Self::authed_request(
access_token,
forage_grpc::ListPersonalAccessTokensRequest {
user_id: user_id.into(),
},
)?;
let resp = self
.client()
.list_personal_access_tokens(req)
.await
.map_err(map_status)?
.into_inner();
Ok(resp.tokens.into_iter().map(convert_token).collect())
}
async fn create_token(
&self,
access_token: &str,
user_id: &str,
name: &str,
) -> Result<CreatedToken, AuthError> {
let req = Self::authed_request(
access_token,
forage_grpc::CreatePersonalAccessTokenRequest {
user_id: user_id.into(),
name: name.into(),
scopes: vec![],
expires_in_seconds: 0,
},
)?;
let resp = self
.client()
.create_personal_access_token(req)
.await
.map_err(map_status)?
.into_inner();
let token = resp
.token
.ok_or(AuthError::Other("no token in response".into()))?;
Ok(CreatedToken {
token: convert_token(token),
raw_token: resp.raw_token,
})
}
async fn delete_token(
&self,
access_token: &str,
token_id: &str,
) -> Result<(), AuthError> {
let req = Self::authed_request(
access_token,
forage_grpc::DeletePersonalAccessTokenRequest {
token_id: token_id.into(),
},
)?;
self.client()
.delete_personal_access_token(req)
.await
.map_err(map_status)?;
Ok(())
}
}
fn convert_organisations(
organisations: Vec<forage_grpc::Organisation>,
roles: Vec<String>,
) -> Vec<Organisation> {
organisations
.into_iter()
.zip(roles)
.map(|(org, role)| Organisation {
organisation_id: org.organisation_id,
name: org.name,
role,
})
.collect()
}
fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
let ctx = a.context.unwrap_or_default();
Artifact {
artifact_id: a.artifact_id,
slug: a.slug,
context: ArtifactContext {
title: ctx.title,
description: if ctx.description.as_deref() == Some("") {
None
} else {
ctx.description
},
},
created_at: a.created_at,
}
}
fn map_platform_status(status: tonic::Status) -> PlatformError {
match status.code() {
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
PlatformError::NotAuthenticated
}
tonic::Code::NotFound => PlatformError::NotFound(status.message().into()),
tonic::Code::Unavailable => PlatformError::Unavailable(status.message().into()),
_ => PlatformError::Other(status.message().into()),
}
}
fn platform_authed_request<T>(access_token: &str, msg: T) -> Result<Request<T>, PlatformError> {
bearer_request(access_token, msg).map_err(PlatformError::Other)
}
#[async_trait::async_trait]
impl ForestPlatform for GrpcForestClient {
async fn list_my_organisations(
&self,
access_token: &str,
) -> Result<Vec<Organisation>, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::ListMyOrganisationsRequest { role: String::new() },
)?;
let resp = self
.org_client()
.list_my_organisations(req)
.await
.map_err(map_platform_status)?
.into_inner();
Ok(convert_organisations(resp.organisations, resp.roles))
}
async fn list_projects(
&self,
access_token: &str,
organisation: &str,
) -> Result<Vec<String>, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::GetProjectsRequest {
query: Some(forage_grpc::get_projects_request::Query::Organisation(
forage_grpc::OrganisationRef {
organisation: organisation.into(),
},
)),
},
)?;
let resp = self
.release_client()
.get_projects(req)
.await
.map_err(map_platform_status)?
.into_inner();
Ok(resp.projects)
}
async fn list_artifacts(
&self,
access_token: &str,
organisation: &str,
project: &str,
) -> Result<Vec<Artifact>, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::GetArtifactsByProjectRequest {
project: Some(forage_grpc::Project {
organisation: organisation.into(),
project: project.into(),
}),
},
)?;
let resp = self
.release_client()
.get_artifacts_by_project(req)
.await
.map_err(map_platform_status)?
.into_inner();
Ok(resp.artifact.into_iter().map(convert_artifact).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_org(id: &str, name: &str) -> forage_grpc::Organisation {
forage_grpc::Organisation {
organisation_id: id.into(),
name: name.into(),
..Default::default()
}
}
fn make_artifact(slug: &str, ctx: Option<forage_grpc::ArtifactContext>) -> forage_grpc::Artifact {
forage_grpc::Artifact {
artifact_id: "a1".into(),
slug: slug.into(),
context: ctx,
created_at: "2026-01-01".into(),
..Default::default()
}
}
#[test]
fn convert_organisations_pairs_orgs_with_roles() {
let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")];
let roles = vec!["owner".into(), "member".into()];
let result = convert_organisations(orgs, roles);
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "alpha");
assert_eq!(result[0].role, "owner");
assert_eq!(result[1].name, "beta");
assert_eq!(result[1].role, "member");
}
#[test]
fn convert_organisations_truncates_when_roles_shorter() {
let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")];
let roles = vec!["owner".into()]; // only 1 role for 2 orgs
let result = convert_organisations(orgs, roles);
// zip truncates to shorter iterator
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "alpha");
}
#[test]
fn convert_organisations_empty() {
let result = convert_organisations(vec![], vec![]);
assert!(result.is_empty());
}
#[test]
fn convert_artifact_with_full_context() {
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
title: "My API".into(),
description: Some("A cool API".into()),
..Default::default()
}));
let result = convert_artifact(a);
assert_eq!(result.slug, "my-api");
assert_eq!(result.context.title, "My API");
assert_eq!(result.context.description.as_deref(), Some("A cool API"));
}
#[test]
fn convert_artifact_empty_description_becomes_none() {
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
title: "My API".into(),
description: Some(String::new()),
..Default::default()
}));
let result = convert_artifact(a);
assert!(result.context.description.is_none());
}
#[test]
fn convert_artifact_missing_context_uses_defaults() {
let a = make_artifact("my-api", None);
let result = convert_artifact(a);
assert_eq!(result.context.title, "");
assert!(result.context.description.is_none());
}
#[test]
fn convert_artifact_none_description_stays_none() {
let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext {
title: "My API".into(),
description: None,
..Default::default()
}));
let result = convert_artifact(a);
assert!(result.context.description.is_none());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,500 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::{Form, Router};
use chrono::Utc;
use minijinja::context;
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::session::{CachedOrg, CachedUser, SessionData, generate_csrf_token};
pub fn router() -> Router<AppState> {
Router::new()
.route("/signup", get(signup_page).post(signup_submit))
.route("/login", get(login_page).post(login_submit))
.route("/logout", post(logout_submit))
.route("/dashboard", get(dashboard))
.route(
"/settings/tokens",
get(tokens_page).post(create_token_submit),
)
.route("/settings/tokens/{id}/delete", post(delete_token_submit))
}
// ─── Signup ─────────────────────────────────────────────────────────
async fn signup_page(
State(state): State<AppState>,
maybe: MaybeSession,
) -> Result<Response, axum::http::StatusCode> {
if maybe.session.is_some() {
return Ok(Redirect::to("/dashboard").into_response());
}
render_signup(&state, "", "", "", None)
}
#[derive(Deserialize)]
struct SignupForm {
username: String,
email: String,
password: String,
password_confirm: String,
}
async fn signup_submit(
State(state): State<AppState>,
maybe: MaybeSession,
Form(form): Form<SignupForm>,
) -> Result<Response, axum::http::StatusCode> {
if maybe.session.is_some() {
return Ok(Redirect::to("/dashboard").into_response());
}
// Validate
if let Err(e) = validate_username(&form.username) {
return render_signup(&state, &form.username, &form.email, "", Some(e.0));
}
if let Err(e) = validate_email(&form.email) {
return render_signup(&state, &form.username, &form.email, "", Some(e.0));
}
if let Err(e) = validate_password(&form.password) {
return render_signup(&state, &form.username, &form.email, "", Some(e.0));
}
if form.password != form.password_confirm {
return render_signup(
&state,
&form.username,
&form.email,
"",
Some("Passwords do not match".into()),
);
}
// Register via forest-server
match state
.forest_client
.register(&form.username, &form.email, &form.password)
.await
{
Ok(tokens) => {
// Fetch user info for the session cache
let mut user_cache = state
.forest_client
.get_user(&tokens.access_token)
.await
.ok()
.map(|u| CachedUser {
user_id: u.user_id,
username: u.username,
emails: u.emails,
orgs: vec![],
});
// Cache org memberships in the session
if let Some(ref mut user) = user_cache
&& let Ok(orgs) = state
.platform_client
.list_my_organisations(&tokens.access_token)
.await
{
user.orgs = orgs
.into_iter()
.map(|o| CachedOrg {
name: o.name,
role: o.role,
})
.collect();
}
let now = Utc::now();
let session_data = SessionData {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
access_expires_at: now + chrono::Duration::seconds(auth::cap_token_expiry(tokens.expires_in_seconds)),
user: user_cache,
csrf_token: generate_csrf_token(),
created_at: now,
last_seen_at: now,
};
match state.sessions.create(session_data).await {
Ok(session_id) => {
let cookie = auth::session_cookie(&session_id);
Ok((cookie, Redirect::to("/dashboard")).into_response())
}
Err(_) => render_signup(
&state,
&form.username,
&form.email,
"",
Some("Internal error. Please try again.".into()),
),
}
}
Err(forage_core::auth::AuthError::AlreadyExists(_)) => render_signup(
&state,
&form.username,
&form.email,
"",
Some("Username or email already registered".into()),
),
Err(forage_core::auth::AuthError::Unavailable(msg)) => {
tracing::error!("forest-server unavailable: {msg}");
render_signup(
&state,
&form.username,
&form.email,
"",
Some("Service temporarily unavailable. Please try again.".into()),
)
}
Err(e) => render_signup(
&state,
&form.username,
&form.email,
"",
Some(e.to_string()),
),
}
}
fn render_signup(
state: &AppState,
username: &str,
email: &str,
_password: &str,
error: Option<String>,
) -> Result<Response, axum::http::StatusCode> {
let html = state
.templates
.render(
"pages/signup.html.jinja",
context! {
title => "Sign Up - Forage",
description => "Create your Forage account",
username => username,
email => email,
error => error,
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Html(html).into_response())
}
// ─── Login ──────────────────────────────────────────────────────────
async fn login_page(
State(state): State<AppState>,
maybe: MaybeSession,
) -> Result<Response, axum::http::StatusCode> {
if maybe.session.is_some() {
return Ok(Redirect::to("/dashboard").into_response());
}
render_login(&state, "", None)
}
#[derive(Deserialize)]
struct LoginForm {
identifier: String,
password: String,
}
async fn login_submit(
State(state): State<AppState>,
maybe: MaybeSession,
Form(form): Form<LoginForm>,
) -> Result<Response, axum::http::StatusCode> {
if maybe.session.is_some() {
return Ok(Redirect::to("/dashboard").into_response());
}
if form.identifier.is_empty() || form.password.is_empty() {
return render_login(
&state,
&form.identifier,
Some("Email/username and password are required".into()),
);
}
match state
.forest_client
.login(&form.identifier, &form.password)
.await
{
Ok(tokens) => {
let mut user_cache = state
.forest_client
.get_user(&tokens.access_token)
.await
.ok()
.map(|u| CachedUser {
user_id: u.user_id,
username: u.username,
emails: u.emails,
orgs: vec![],
});
// Cache org memberships in the session
if let Some(ref mut user) = user_cache
&& let Ok(orgs) = state
.platform_client
.list_my_organisations(&tokens.access_token)
.await
{
user.orgs = orgs
.into_iter()
.map(|o| CachedOrg {
name: o.name,
role: o.role,
})
.collect();
}
let now = Utc::now();
let session_data = SessionData {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
access_expires_at: now + chrono::Duration::seconds(auth::cap_token_expiry(tokens.expires_in_seconds)),
user: user_cache,
csrf_token: generate_csrf_token(),
created_at: now,
last_seen_at: now,
};
match state.sessions.create(session_data).await {
Ok(session_id) => {
let cookie = auth::session_cookie(&session_id);
Ok((cookie, Redirect::to("/dashboard")).into_response())
}
Err(_) => render_login(
&state,
&form.identifier,
Some("Internal error. Please try again.".into()),
),
}
}
Err(forage_core::auth::AuthError::InvalidCredentials) => render_login(
&state,
&form.identifier,
Some("Invalid email/username or password".into()),
),
Err(forage_core::auth::AuthError::Unavailable(msg)) => {
tracing::error!("forest-server unavailable: {msg}");
render_login(
&state,
&form.identifier,
Some("Service temporarily unavailable. Please try again.".into()),
)
}
Err(e) => render_login(&state, &form.identifier, Some(e.to_string())),
}
}
fn render_login(
state: &AppState,
identifier: &str,
error: Option<String>,
) -> Result<Response, axum::http::StatusCode> {
let html = state
.templates
.render(
"pages/login.html.jinja",
context! {
title => "Sign In - Forage",
description => "Sign in to your Forage account",
identifier => identifier,
error => error,
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Html(html).into_response())
}
// ─── Logout ─────────────────────────────────────────────────────────
async fn logout_submit(
State(state): State<AppState>,
session: Session,
Form(form): Form<CsrfForm>,
) -> Result<impl IntoResponse, Response> {
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(&state, StatusCode::FORBIDDEN, "Invalid request", "CSRF validation failed. Please try again."));
}
// Best-effort logout on forest-server
if let Ok(Some(data)) = state.sessions.get(&session.session_id).await {
let _ = state.forest_client.logout(&data.refresh_token).await;
}
let _ = state.sessions.delete(&session.session_id).await;
Ok((auth::clear_session_cookie(), Redirect::to("/")))
}
// ─── Dashboard ──────────────────────────────────────────────────────
async fn dashboard(
State(state): State<AppState>,
session: Session,
) -> Result<Response, Response> {
// Use cached org memberships from the session
let orgs = &session.user.orgs;
if let Some(first_org) = orgs.first() {
return Ok(Redirect::to(&format!("/orgs/{}/projects", first_org.name)).into_response());
}
// No orgs: show onboarding
let html = state
.templates
.render(
"pages/onboarding.html.jinja",
context! {
title => "Get Started - Forage",
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
},
)
.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())
}
// ─── Tokens ─────────────────────────────────────────────────────────
async fn tokens_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, Response> {
let tokens = state
.forest_client
.list_tokens(&session.access_token, &session.user.user_id)
.await
.unwrap_or_default();
let html = state
.templates
.render(
"pages/tokens.html.jinja",
context! {
title => "API Tokens - Forage",
description => "Manage your personal access tokens",
user => context! { username => session.user.username },
tokens => tokens.iter().map(|t| context! {
token_id => t.token_id,
name => t.name,
created_at => t.created_at,
last_used => t.last_used,
expires_at => t.expires_at,
}).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
created_token => None::<String>,
},
)
.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 CsrfForm {
_csrf: String,
}
#[derive(Deserialize)]
struct CreateTokenForm {
name: String,
_csrf: String,
}
async fn create_token_submit(
State(state): State<AppState>,
session: Session,
Form(form): Form<CreateTokenForm>,
) -> Result<Response, Response> {
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(&state, StatusCode::FORBIDDEN, "Invalid request", "CSRF validation failed. Please try again."));
}
let created = state
.forest_client
.create_token(&session.access_token, &session.user.user_id, &form.name)
.await
.map_err(|e| {
tracing::error!("failed to create token: {e}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
})?;
let tokens = state
.forest_client
.list_tokens(&session.access_token, &session.user.user_id)
.await
.unwrap_or_default();
let html = state
.templates
.render(
"pages/tokens.html.jinja",
context! {
title => "API Tokens - Forage",
description => "Manage your personal access tokens",
user => context! { username => session.user.username },
tokens => tokens.iter().map(|t| context! {
token_id => t.token_id,
name => t.name,
created_at => t.created_at,
last_used => t.last_used,
expires_at => t.expires_at,
}).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
created_token => Some(created.raw_token),
},
)
.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())
}
async fn delete_token_submit(
State(state): State<AppState>,
session: Session,
axum::extract::Path(token_id): axum::extract::Path<String>,
Form(form): Form<CsrfForm>,
) -> Result<Response, Response> {
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(&state, StatusCode::FORBIDDEN, "Invalid request", "CSRF validation failed. Please try again."));
}
state
.forest_client
.delete_token(&session.access_token, &token_id)
.await
.map_err(|e| {
tracing::error!("failed to delete token: {e}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
})?;
Ok(Redirect::to("/settings/tokens").into_response())
}

View File

@@ -0,0 +1,35 @@
mod auth;
mod pages;
mod platform;
use axum::Router;
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use minijinja::context;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.merge(pages::router())
.merge(auth::router())
.merge(platform::router())
}
/// Render an error page with the given status code, heading, and message.
fn error_page(state: &AppState, status: StatusCode, heading: &str, message: &str) -> Response {
let html = state.templates.render(
"pages/error.html.jinja",
context! {
title => format!("{} - Forage", heading),
description => message,
status => status.as_u16(),
heading => heading,
message => message,
},
);
match html {
Ok(body) => (status, Html(body)).into_response(),
Err(_) => status.into_response(),
}
}

View File

@@ -0,0 +1,59 @@
use axum::extract::State;
use axum::response::Html;
use axum::routing::get;
use axum::Router;
use minijinja::context;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(landing))
.route("/pricing", get(pricing))
.route("/components", get(components))
}
async fn landing(State(state): State<AppState>) -> Result<Html<String>, axum::http::StatusCode> {
let html = state
.templates
.render("pages/landing.html.jinja", context! {
title => "Forage - The Platform for Forest",
description => "Push a forest.cue manifest, get production infrastructure.",
})
.map_err(|e| {
tracing::error!("template error: {e:#}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Html(html))
}
async fn pricing(State(state): State<AppState>) -> Result<Html<String>, axum::http::StatusCode> {
let html = state
.templates
.render("pages/pricing.html.jinja", context! {
title => "Pricing - Forage",
description => "Simple, transparent pricing. Pay only for what you use.",
})
.map_err(|e| {
tracing::error!("template error: {e:#}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Html(html))
}
async fn components(State(state): State<AppState>) -> Result<Html<String>, axum::http::StatusCode> {
let html = state
.templates
.render("pages/components.html.jinja", context! {
title => "Components - Forage",
description => "Discover and share reusable forest components.",
})
.map_err(|e| {
tracing::error!("template error: {e:#}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Html(html))
}

View File

@@ -0,0 +1,166 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use forage_core::platform::validate_slug;
use forage_core::session::CachedOrg;
use minijinja::context;
use super::error_page;
use crate::auth::Session;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/orgs/{org}/projects", get(projects_list))
.route("/orgs/{org}/projects/{project}", get(project_detail))
.route("/orgs/{org}/usage", get(usage))
}
fn orgs_context(orgs: &[CachedOrg]) -> Vec<minijinja::Value> {
orgs.iter()
.map(|o| context! { name => o.name, role => o.role })
.collect()
}
async fn projects_list(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
) -> Result<Response, Response> {
if !validate_slug(&org) {
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation name."));
}
let orgs = &session.user.orgs;
if !orgs.iter().any(|o| o.name == org) {
return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation."));
}
let projects = state
.platform_client
.list_projects(&session.access_token, &org)
.await
.unwrap_or_default();
let html = state
.templates
.render(
"pages/projects.html.jinja",
context! {
title => format!("{org} - Projects - Forage"),
description => format!("Projects in {org}"),
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
orgs => orgs_context(orgs),
org_name => &org,
projects => projects,
},
)
.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())
}
async fn project_detail(
State(state): State<AppState>,
session: Session,
Path((org, project)): Path<(String, String)>,
) -> Result<Response, Response> {
if !validate_slug(&org) || !validate_slug(&project) {
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation or project name."));
}
let orgs = &session.user.orgs;
if !orgs.iter().any(|o| o.name == org) {
return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation."));
}
let artifacts = state
.platform_client
.list_artifacts(&session.access_token, &org, &project)
.await
.unwrap_or_default();
let html = state
.templates
.render(
"pages/project_detail.html.jinja",
context! {
title => format!("{project} - {org} - Forage"),
description => format!("Project {project} in {org}"),
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
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,
}).collect::<Vec<_>>(),
},
)
.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())
}
async fn usage(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
) -> Result<Response, Response> {
if !validate_slug(&org) {
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation name."));
}
let orgs = &session.user.orgs;
let current_org_data = orgs.iter().find(|o| o.name == org);
let current_org_data = match current_org_data {
Some(o) => o,
None => return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation.")),
};
let projects = state
.platform_client
.list_projects(&session.access_token, &org)
.await
.unwrap_or_default();
let html = state
.templates
.render(
"pages/usage.html.jinja",
context! {
title => format!("Usage - {org} - Forage"),
description => format!("Usage and plan for {org}"),
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
orgs => orgs_context(orgs),
org_name => &org,
role => &current_org_data.role,
project_count => projects.len(),
},
)
.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())
}

View File

@@ -0,0 +1,30 @@
use std::sync::Arc;
use crate::templates::TemplateEngine;
use forage_core::auth::ForestAuth;
use forage_core::platform::ForestPlatform;
use forage_core::session::SessionStore;
#[derive(Clone)]
pub struct AppState {
pub templates: TemplateEngine,
pub forest_client: Arc<dyn ForestAuth>,
pub platform_client: Arc<dyn ForestPlatform>,
pub sessions: Arc<dyn SessionStore>,
}
impl AppState {
pub fn new(
templates: TemplateEngine,
forest_client: Arc<dyn ForestAuth>,
platform_client: Arc<dyn ForestPlatform>,
sessions: Arc<dyn SessionStore>,
) -> Self {
Self {
templates,
forest_client,
platform_client,
sessions,
}
}
}

View File

@@ -0,0 +1,35 @@
use std::path::Path;
use anyhow::Context;
use minijinja::Environment;
#[derive(Clone)]
pub struct TemplateEngine {
env: Environment<'static>,
}
impl TemplateEngine {
pub fn from_path(path: &Path) -> anyhow::Result<Self> {
if !path.exists() {
anyhow::bail!("templates directory not found: {}", path.display());
}
let mut env = Environment::new();
env.set_loader(minijinja::path_loader(path));
Ok(Self { env })
}
pub fn new() -> anyhow::Result<Self> {
Self::from_path(Path::new("templates"))
}
pub fn render(&self, template: &str, ctx: minijinja::Value) -> anyhow::Result<String> {
let tmpl = self
.env
.get_template(template)
.with_context(|| format!("template not found: {template}"))?;
tmpl.render(ctx)
.with_context(|| format!("failed to render template: {template}"))
}
}