@@ -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, ¤t_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,
|
||||
¤t_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,
|
||||
¤t_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,
|
||||
¤t_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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user