use anyhow::{Context as _, Result}; use askama::Template; use axum::{ extract::{Path, State}, response::{Html, IntoResponse as _, Redirect, Response}, routing::{get, post}, Router, }; use axum_extra::extract::Form; use diesel::prelude::*; use serde::Deserialize; use uuid::Uuid; use crate::{ api_keys::{self, ApiKey}, app_error::AppError, app_state::{AppState, DbConn}, csrf::generate_csrf_token, email::{is_permissible_email, Mailer}, guards, nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS, NAVBAR_ITEM_TEAM_MEMBERS}, projects::{Project, DEFAULT_PROJECT_NAME}, settings::Settings, team_invitations::{self, InvitationBuilder, PopulatedTeamInvitation, TeamInvitation}, team_memberships::{self, TeamMembership}, teams::{self, Team}, users::{self, CurrentUser, User}, }; pub fn new_router() -> Router { Router::new() .route("/teams", get(teams_page)) .route("/new-team", get(new_team_page)) .route("/new-team", post(post_new_team)) .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("/teams/{team_id}/members", get(team_members_page)) .route( "/teams/{team_id}/invite-team-member", post(invite_team_member), ) .route( "/teams/{team_id}/accept-invitation", get(accept_invitation_page), ) .route( "/teams/{team_id}/accept-invitation", post(post_accept_invitation), ) .route( "/teams/{team_id}/remove-team-member", post(remove_team_member), ) .route( "/teams/{team_id}/remove-team-invitation", post(remove_team_invitation), ) } 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()?, ) .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()?, ) .into_response()) } #[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(move |conn| -> Result<(), AppError> { conn.transaction(move |conn| -> Result<(), AppError> { diesel::insert_into(teams::table) .values(&team) .execute(conn) .context("failed to insert team")?; diesel::insert_into(team_memberships::table) .values(&team_membership) .execute(conn) .context("failed to insert team membership")?; Ok(()) })?; Project::lazy_getter() .with_team_id(team_id) .with_name(DEFAULT_PROJECT_NAME.to_owned()) .build() .context("failed to build project lazy getter")? .execute(conn) .context("failed to insert project")?; diesel::insert_into(api_keys::table) .values(ApiKey::new_from_team_id(team_id)) .execute(conn) .context("failed to insert api key")?; Ok(()) }) .await .unwrap()?; Ok(Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id)).into_response()) } async fn team_page( State(Settings { base_path, .. }): State, Path(team_id): Path, ) -> Response { Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id)).into_response() } #[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?; db_conn .interact(move |conn| -> Result<()> { diesel::insert_into(api_keys::table) .values(ApiKey::new_from_team_id(team_id)) .execute(conn) .context("failed to insert api key") .map(|_| ()) }) .await .unwrap()?; 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::NotFound( "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 team_members_page( State(Settings { base_path, .. }): State, State(navbar_template): State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, ) -> Result { let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; let (team_members, invitations) = { let team = team.clone(); db_conn .interact::<_, Result<(Vec, Vec)>>(move |conn| { let team_members = team .members() .order_by(users::dsl::email.asc()) .load(conn)?; let invitations = team .invitations() .order_by(users::dsl::email.asc()) .load(conn)?; Ok((team_members, invitations)) }) .await .unwrap()? }; let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; #[derive(Template)] #[template(path = "team-members.html")] struct ResponseTemplate { base_path: String, breadcrumbs: BreadcrumbTrail, csrf_token: String, current_user: User, invitations: Vec, team_members: Vec, team_name: String, navbar: Navbar, } Ok(Html( ResponseTemplate { breadcrumbs: BreadcrumbTrail::from_base_path(&base_path) .with_i18n_slug("en") .push_slug("Teams", "teams") .push_slug(&team.name, &team.id.simple().to_string()) .push_slug("Members", "members"), base_path, csrf_token, current_user, invitations, navbar: navbar_template .with_param("team_id", &team.id.simple().to_string()) .with_active_item(NAVBAR_ITEM_TEAM_MEMBERS) .build(), team_members, team_name: team.name, } .render()?, ) .into_response()) } #[derive(Deserialize)] struct InviteTeamMemberForm { csrf_token: String, email: String, } async fn invite_team_member( State(settings): State, mailer: State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, Form(form): Form, ) -> Result { let team = guards::require_team_membership(¤t_user, &team_id, &db_conn).await?; guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?; if !is_permissible_email(&form.email) { return Err(AppError::BadRequest( "Unable to validate email address format.".to_owned(), )); } let invitation = { let team_id = team.id; db_conn .interact::<_, Result>(move |conn| { InvitationBuilder { team_id: &team_id, email: &form.email, created_by: ¤t_user.id, } .insert(conn)? .repopulate(conn) .map_err(Into::into) }) .await .unwrap()? }; invitation.send_by_email(&mailer, &settings).await?; Ok(Redirect::to(&format!( "{}/en/teams/{}/members", settings.base_path, team_id )) .into_response()) } #[derive(Deserialize)] struct AcceptInvitationPageForm { verification_code: String, } async fn accept_invitation_page( State(Settings { base_path, .. }): State, State(navbar_template): State, DbConn(db_conn): DbConn, Path(team_id): Path, CurrentUser(current_user): CurrentUser, Form(form): Form, ) -> Result { let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; let maybe_invitation = db_conn .interact::<_, Result>>(move |conn| { PopulatedTeamInvitation::all() .filter(TeamInvitation::with_verification_code( &form.verification_code, )) .first(conn) .optional() .context("failed to load populated team invitation") }) .await .unwrap()?; if let Some(invitation) = maybe_invitation { if invitation.invitation.email != current_user.email.to_lowercase() { Err(AppError::Forbidden(format!( "please use the account with email {} to accept this invitation", invitation.invitation.email ))) } else if invitation.team.id != team_id { Err(AppError::BadRequest("team ids do not match".to_owned())) } else { #[derive(Template)] #[template(path = "accept-team-invitation.html")] struct ResponseTemplate { base_path: String, csrf_token: String, invitation: PopulatedTeamInvitation, navbar: Navbar, } Ok(Html( ResponseTemplate { base_path, csrf_token, invitation, navbar: navbar_template .with_param("team_id", &team_id.simple().to_string()) .with_active_item(NAVBAR_ITEM_TEAM_MEMBERS) .build(), } .render()?, ) .into_response()) } } else { Err(AppError::NotFound( "unable to find team invitation".to_owned(), )) } } #[derive(Deserialize)] struct PostAcceptInvitationForm { csrf_token: String, verification_code: String, } async fn post_accept_invitation( 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 maybe_invitation = { let current_email = current_user.email.clone(); db_conn .interact::<_, Result>>(move |conn| { TeamInvitation::all() .filter(TeamInvitation::with_email(¤t_email)) .filter(TeamInvitation::with_verification_code( &form.verification_code, )) .filter(TeamInvitation::with_team_id(&team_id)) .first(conn) .optional() .context("failed to load team invitation") }) .await .unwrap()? }; let invitation = maybe_invitation.ok_or(AppError::NotFound( "invitation for this team, email address, and verification code not found".to_owned(), ))?; db_conn .interact::<_, Result<()>>(move |conn| invitation.accept_for_user(¤t_user.id, conn)) .await .unwrap()?; Ok(Redirect::to(&format!("{}/en/teams/{}", base_path, team_id)).into_response()) } #[derive(Deserialize)] struct RemoveTeamMemberForm { csrf_token: String, user_id: Uuid, } async fn remove_team_member( 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?; tracing::debug!( "{} requesting that {} be removed from team {}", current_user.id, form.user_id, team_id ); let n_deleted = { let team_id = team.id; db_conn .interact::<_, Result>(move |conn| { diesel::delete( team_memberships::table .filter(TeamMembership::with_team_id(&team_id)) .filter(TeamMembership::with_user_id(&form.user_id)), ) .execute(conn) .context("failed to delete team membership from database") .map_err(Into::into) }) .await .unwrap()? }; if n_deleted == 0 { Err(AppError::NotFound("no team membership found".to_owned())) } else { Ok(Redirect::to(&format!( "{}/en/teams/{}/members", base_path, team.id.simple() )) .into_response()) } } #[derive(Deserialize)] struct RemoveTeamInvitationForm { csrf_token: String, invitation_id: Uuid, } async fn remove_team_invitation( 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?; tracing::debug!( "{} requesting that invitation {} be removed from team {}", current_user.id, form.invitation_id, team_id ); let n_deleted = db_conn .interact::<_, Result>(move |conn| { diesel::delete( team_invitations::table.filter(TeamInvitation::with_id(&form.invitation_id)), ) .execute(conn) .context("failed to delete invitation from database") .map_err(Into::into) }) .await .unwrap()?; if n_deleted == 0 { Err(AppError::NotFound("no invitation found".to_owned())) } else { Ok(Redirect::to(&format!( "{}/en/teams/{}/members", base_path, team.id.simple() )) .into_response()) } }