feat: add dashboard

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 20:31:18 +01:00
parent b439762877
commit d46c365112
21 changed files with 2955 additions and 1367 deletions

View File

@@ -1,21 +1,36 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::{Form, Router};
use forage_core::platform::validate_slug;
use forage_core::session::CachedOrg;
use minijinja::context;
use serde::Deserialize;
use super::error_page;
use crate::auth::Session;
use crate::auth::{self, Session};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/dashboard", get(dashboard))
.route("/orgs", post(create_org_submit))
.route("/orgs/{org}/projects", get(projects_list))
.route("/orgs/{org}/projects/{project}", get(project_detail))
.route("/orgs/{org}/usage", get(usage))
.route(
"/orgs/{org}/settings/members",
get(members_page).post(add_member_submit),
)
.route(
"/orgs/{org}/settings/members/{user_id}/role",
post(update_member_role_submit),
)
.route(
"/orgs/{org}/settings/members/{user_id}/remove",
post(remove_member_submit),
)
}
fn orgs_context(orgs: &[CachedOrg]) -> Vec<minijinja::Value> {
@@ -24,20 +39,236 @@ fn orgs_context(orgs: &[CachedOrg]) -> Vec<minijinja::Value> {
.collect()
}
#[allow(clippy::result_large_err)]
fn require_org_membership<'a>(
state: &AppState,
orgs: &'a [CachedOrg],
org: &str,
) -> Result<&'a CachedOrg, Response> {
if !validate_slug(org) {
return Err(error_page(
state,
StatusCode::BAD_REQUEST,
"Invalid request",
"Invalid organisation name.",
));
}
orgs.iter().find(|o| o.name == org).ok_or_else(|| {
error_page(
state,
StatusCode::FORBIDDEN,
"Access denied",
"You don't have access to this organisation.",
)
})
}
/// Require the user to be an admin or owner of the organisation.
#[allow(clippy::result_large_err)]
fn require_admin(state: &AppState, org: &CachedOrg) -> Result<(), Response> {
if org.role == "owner" || org.role == "admin" {
Ok(())
} else {
Err(error_page(
state,
StatusCode::FORBIDDEN,
"Access denied",
"You must be an admin to perform this action.",
))
}
}
// ─── Dashboard ──────────────────────────────────────────────────────
async fn dashboard(
State(state): State<AppState>,
session: Session,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
if orgs.is_empty() {
// No orgs: show onboarding with create org form
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.",
)
})?;
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();
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 html = state
.templates
.render(
"pages/dashboard.html.jinja",
context! {
title => "Dashboard - Forage",
description => "Your Forage dashboard",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
orgs => orgs_context(orgs),
recent_activity => recent_activity,
},
)
.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())
}
// ─── Create organisation ────────────────────────────────────────────
#[derive(Deserialize)]
struct CreateOrgForm {
name: String,
_csrf: String,
}
async fn create_org_submit(
State(state): State<AppState>,
session: Session,
Form(form): Form<CreateOrgForm>,
) -> 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.",
));
}
if !validate_slug(&form.name) {
// Re-render onboarding/dashboard with error
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,
error => "Invalid organisation name. Use lowercase letters, numbers, and hyphens only.",
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
})?;
return Ok(Html(html).into_response());
}
match state
.platform_client
.create_organisation(&session.access_token, &form.name)
.await
{
Ok(org_id) => {
// Update session with new org
if let Ok(Some(mut session_data)) = state.sessions.get(&session.session_id).await {
if let Some(ref mut user) = session_data.user {
user.orgs.push(CachedOrg {
organisation_id: org_id,
name: form.name.clone(),
role: "owner".into(),
});
}
let _ = state
.sessions
.update(&session.session_id, session_data)
.await;
}
Ok(Redirect::to(&format!("/orgs/{}/projects", form.name)).into_response())
}
Err(e) => {
tracing::error!("failed to create org: {e}");
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,
error => "Could not create organisation. Please try again.",
},
)
.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())
}
}
}
// ─── Projects list ──────────────────────────────────────────────────
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."));
}
require_org_membership(&state, orgs, &org)?;
let projects = state
.platform_client
@@ -62,25 +293,34 @@ async fn projects_list(
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
// ─── Project detail ─────────────────────────────────────────────────
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;
require_org_membership(&state, orgs, &org)?;
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."));
if !validate_slug(&project) {
return Err(error_page(
&state,
StatusCode::BAD_REQUEST,
"Invalid request",
"Invalid project name.",
));
}
let artifacts = state
@@ -102,38 +342,47 @@ 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,
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<_>>(),
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
// ─── Usage ──────────────────────────────────────────────────────────
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 current_org_data = require_org_membership(&state, orgs, &org)?;
let projects = state
.platform_client
@@ -159,8 +408,205 @@ async fn usage(
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
// ─── Members ────────────────────────────────────────────────────────
async fn members_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 members = state
.platform_client
.list_members(&session.access_token, &current_org.organisation_id)
.await
.unwrap_or_default();
let is_admin = current_org.role == "owner" || current_org.role == "admin";
let html = state
.templates
.render(
"pages/members.html.jinja",
context! {
title => format!("Members - {org} - Forage"),
description => format!("Members of {org}"),
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
orgs => orgs_context(orgs),
org_name => &org,
is_admin => is_admin,
members => members.iter().map(|m| context! {
user_id => m.user_id,
username => m.username,
role => m.role,
joined_at => m.joined_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())
}
#[derive(Deserialize)]
struct AddMemberForm {
username: String,
role: String,
_csrf: String,
}
async fn add_member_submit(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
Form(form): Form<AddMemberForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
let current_org = require_org_membership(&state, orgs, &org)?;
require_admin(&state, current_org)?;
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed. Please try again.",
));
}
let _ = state
.platform_client
.add_member(
&session.access_token,
&current_org.organisation_id,
&form.username,
&form.role,
)
.await
.map_err(|e| {
tracing::error!("failed to add member: {e}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
}
#[derive(Deserialize)]
struct UpdateRoleForm {
role: String,
_csrf: String,
}
async fn update_member_role_submit(
State(state): State<AppState>,
session: Session,
Path((org, user_id)): Path<(String, String)>,
Form(form): Form<UpdateRoleForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
let current_org = require_org_membership(&state, orgs, &org)?;
require_admin(&state, current_org)?;
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed. Please try again.",
));
}
let _ = state
.platform_client
.update_member_role(
&session.access_token,
&current_org.organisation_id,
&user_id,
&form.role,
)
.await
.map_err(|e| {
tracing::error!("failed to update member role: {e}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
}
#[derive(Deserialize)]
struct CsrfForm {
_csrf: String,
}
async fn remove_member_submit(
State(state): State<AppState>,
session: Session,
Path((org, user_id)): Path<(String, String)>,
Form(form): Form<CsrfForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
let current_org = require_org_membership(&state, orgs, &org)?;
require_admin(&state, current_org)?;
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed. Please try again.",
));
}
state
.platform_client
.remove_member(
&session.access_token,
&current_org.organisation_id,
&user_id,
)
.await
.map_err(|e| {
tracing::error!("failed to remove member: {e}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
}