use anyhow::Context as _; use askama::Template; use axum::{ extract::{Path, State}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, Router, }; use axum_extra::extract::Form; use diesel::prelude::*; use serde::Deserialize; use uuid::Uuid; use crate::{ api_keys::ApiKey, app_error::AppError, app_state::{AppState, DbConn}, csrf::generate_csrf_token, guards, nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS}, projects::{Project, DEFAULT_PROJECT_NAME}, schema::{api_keys, team_memberships, teams}, settings::Settings, team_memberships::TeamMembership, teams::Team, users::CurrentUser, }; pub fn new_router() -> Router { Router::new() .route("/teams", get(teams_page)) .route("/teams/{team_id}", get(team_page)) .route("/teams/{team_id}/new-api-key", post(post_new_api_key)) .route("/teams/{team_id}/remove-api-key", post(remove_api_key)) .route("/new-team", get(new_team_page)) .route("/new-team", post(post_new_team)) } async fn teams_page( State(Settings { base_path, .. }): State, State(navbar_template): State, DbConn(conn): DbConn, CurrentUser(current_user): CurrentUser, ) -> Result { let teams: Vec = { let current_user = current_user.clone(); conn.interact(move |conn| current_user.team_memberships().load(conn)) .await .unwrap() .context("failed to load team memberships") .map(|memberships| memberships.into_iter().map(|(_, team)| team).collect())? }; #[derive(Template)] #[template(path = "teams.html")] struct ResponseTemplate { base_path: String, breadcrumbs: BreadcrumbTrail, navbar: Navbar, teams: Vec, } Ok(Html( ResponseTemplate { breadcrumbs: BreadcrumbTrail::from_base_path(&base_path) .with_i18n_slug("en") .push_slug("Teams", "teams"), base_path, navbar: navbar_template.with_active_item(NAVBAR_ITEM_TEAMS).build(), teams, } .render()?, )) } async fn team_page( State(Settings { base_path, .. }): State, Path(team_id): Path, ) -> impl IntoResponse { Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id)) } #[derive(Deserialize)] struct PostNewApiKeyForm { csrf_token: String, } async fn post_new_api_key( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, Form(form): Form, ) -> Result { guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?; let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; ApiKey::generate_for_team(&db_conn, team.id).await?; Ok(Redirect::to(&format!( "{}/en/teams/{}/projects", base_path, team.id.simple() )) .into_response()) } #[derive(Deserialize)] struct RemoveApiKeyForm { csrf_token: String, key_id: Uuid, } async fn remove_api_key( State(Settings { base_path, .. }): State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, Form(form): Form, ) -> Result { guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?; let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; let n_deleted = { let team_id = team.id; db_conn .interact::<_, Result>(move |conn| { diesel::delete( api_keys::table .filter(ApiKey::with_team(&team_id)) .filter(ApiKey::with_id(&form.key_id)), ) .execute(conn) .context("failed to delete API key from database") .map_err(Into::into) }) .await .unwrap()? }; assert!( n_deleted < 2, "there should never be more than 1 API key with the same ID" ); if n_deleted == 0 { Err(AppError::NotFoundError( "no API key with that ID and team found".to_owned(), )) } else { Ok(Redirect::to(&format!( "{}/en/teams/{}/projects", base_path, team.id.simple() )) .into_response()) } } async fn new_team_page( State(Settings { base_path, .. }): State, State(navbar_template): State, DbConn(db_conn): DbConn, CurrentUser(current_user): CurrentUser, ) -> Result { let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; #[derive(Template)] #[template(path = "new-team.html")] struct ResponseTemplate { base_path: String, breadcrumbs: BreadcrumbTrail, csrf_token: String, navbar: Navbar, } Ok(Html( ResponseTemplate { breadcrumbs: BreadcrumbTrail::from_base_path(&base_path) .with_i18n_slug("en") .push_slug("New Team", "new-team"), base_path, csrf_token, navbar: navbar_template.with_active_item(NAVBAR_ITEM_TEAMS).build(), } .render()?, )) } #[derive(Deserialize)] struct PostNewTeamForm { name: String, csrf_token: String, } async fn post_new_team( DbConn(db_conn): DbConn, State(Settings { base_path, .. }): State, CurrentUser(current_user): CurrentUser, Form(form): Form, ) -> Result { guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?; let team_id = Uuid::now_v7(); let team = Team { id: team_id, name: form.name, }; let team_membership = TeamMembership { team_id, user_id: current_user.id, }; db_conn .interact::<_, Result<(), AppError>>(move |conn| { conn.transaction::<(), AppError, _>(move |conn| { diesel::insert_into(teams::table) .values(&team) .execute(conn)?; diesel::insert_into(team_memberships::table) .values(&team_membership) .execute(conn)?; Project::insert_new(conn, &team.id, DEFAULT_PROJECT_NAME)?; Ok(()) }) }) .await .unwrap() .unwrap(); ApiKey::generate_for_team(&db_conn, team_id).await?; Ok(Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id)).into_response()) }