From 8cf4fa3f13f4b61c1c44291af5a703a537675d92 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Sat, 12 Apr 2025 23:05:39 -0700 Subject: [PATCH] add ability to manage team members --- README.md | 5 + .../2025-04-10-042342_invitations/down.sql | 1 + .../2025-04-10-042342_invitations/up.sql | 10 + src/main.rs | 1 + src/nav.rs | 6 + src/schema.rs | 13 + src/team_invitations.rs | 202 +++++++++ src/teams.rs | 37 +- src/teams_router.rs | 416 ++++++++++++++++-- templates/accept-team-invitation.html | 25 ++ templates/team-members.html | 241 ++++++++++ templates/teams.html | 2 +- 12 files changed, 916 insertions(+), 43 deletions(-) create mode 100644 migrations/2025-04-10-042342_invitations/down.sql create mode 100644 migrations/2025-04-10-042342_invitations/up.sql create mode 100644 src/team_invitations.rs create mode 100644 templates/accept-team-invitation.html create mode 100644 templates/team-members.html diff --git a/README.md b/README.md index 7e3e8d6..17b6e9d 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,8 @@ which to broadcast messages via email and other means of communication. Shout.dev is in the early prototyping stage. Basic documentation and testing will be prioritized after core functionality is completed and shown to have practical value. + +## Notable Assumptions + +- OAuth provider provides accurate "email" field in the userinfo payload. +- OAuth provider does not allow users to change their emails after signup. diff --git a/migrations/2025-04-10-042342_invitations/down.sql b/migrations/2025-04-10-042342_invitations/down.sql new file mode 100644 index 0000000..08d9d20 --- /dev/null +++ b/migrations/2025-04-10-042342_invitations/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS team_invitations; diff --git a/migrations/2025-04-10-042342_invitations/up.sql b/migrations/2025-04-10-042342_invitations/up.sql new file mode 100644 index 0000000..b83bfa4 --- /dev/null +++ b/migrations/2025-04-10-042342_invitations/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS team_invitations ( + id UUID NOT NULL PRIMARY KEY, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + email TEXT NOT NULL, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + verification_code TEXT NOT NULL, + UNIQUE (team_id, email) +); +CREATE INDEX ON team_invitations(team_id); +CREATE INDEX ON team_invitations(verification_code); diff --git a/src/main.rs b/src/main.rs index ab6b060..585f6c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,7 @@ mod router; mod schema; mod sessions; mod settings; +mod team_invitations; mod team_memberships; mod teams; mod teams_router; diff --git a/src/nav.rs b/src/nav.rs index 0409071..136e33d 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -7,6 +7,7 @@ use crate::app_state::AppState; pub const NAVBAR_ITEM_TEAMS: &str = "teams"; pub const NAVBAR_ITEM_PROJECTS: &str = "projects"; pub const NAVBAR_ITEM_CHANNELS: &str = "channels"; +pub const NAVBAR_ITEM_TEAM_MEMBERS: &str = "team-members"; #[derive(Clone, Debug)] pub struct BreadcrumbTrail { @@ -202,6 +203,11 @@ impl Default for NavbarBuilder { "Channels", "/en/teams/{team_id}/channels", ) + .push_item( + NAVBAR_ITEM_TEAM_MEMBERS, + "Team Members", + "/en/teams/{team_id}/members", + ) } } diff --git a/src/schema.rs b/src/schema.rs index cea579c..314b5ff 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -81,6 +81,16 @@ diesel::table! { } } +diesel::table! { + team_invitations (id) { + id -> Uuid, + team_id -> Uuid, + email -> Text, + created_by -> Uuid, + verification_code -> Text, + } +} + diesel::table! { team_memberships (team_id, user_id) { team_id -> Uuid, @@ -114,6 +124,8 @@ diesel::joinable!(governors -> teams (team_id)); diesel::joinable!(messages -> channels (channel_id)); diesel::joinable!(messages -> projects (project_id)); diesel::joinable!(projects -> teams (team_id)); +diesel::joinable!(team_invitations -> teams (team_id)); +diesel::joinable!(team_invitations -> users (created_by)); diesel::joinable!(team_memberships -> teams (team_id)); diesel::joinable!(team_memberships -> users (user_id)); @@ -127,6 +139,7 @@ diesel::allow_tables_to_appear_in_same_query!( governors, messages, projects, + team_invitations, team_memberships, teams, users, diff --git a/src/team_invitations.rs b/src/team_invitations.rs new file mode 100644 index 0000000..4467b23 --- /dev/null +++ b/src/team_invitations.rs @@ -0,0 +1,202 @@ +use anyhow::{bail, Context as _, Result}; +use diesel::{ + dsl::{auto_type, AsSelect}, + pg::Pg, + prelude::*, + upsert::excluded, +}; +use tracing::Instrument; +use uuid::Uuid; + +use crate::{ + email::{is_permissible_email, MailSender as _, Mailer, Message}, + schema::{team_invitations, team_memberships, teams, users}, + settings::Settings, + team_memberships::TeamMembership, + teams::Team, + users::User, +}; + +#[derive(Clone, Debug, Identifiable, Insertable, Queryable, Selectable)] +#[diesel(table_name = team_invitations)] +#[diesel(check_for_backend(Pg))] +pub struct TeamInvitation { + pub id: Uuid, + pub team_id: Uuid, + pub email: String, + pub created_by: Uuid, + pub verification_code: String, +} + +impl TeamInvitation { + #[auto_type(no_type_alias)] + pub fn all() -> _ { + let select: AsSelect = TeamInvitation::as_select(); + team_invitations::table.select(select) + } + + #[auto_type(no_type_alias)] + pub fn with_id(id: &Uuid) -> _ { + team_invitations::id.eq(id) + } + + #[auto_type(no_type_alias)] + pub fn with_team_id<'a>(id: &'a Uuid) -> _ { + team_invitations::team_id.eq(id) + } + + #[auto_type(no_type_alias)] + pub fn with_verification_code<'a>(code: &'a str) -> _ { + team_invitations::verification_code.eq(code) + } + + #[auto_type(no_type_alias)] + pub fn with_email<'a>(email: &'a str) -> _ { + team_invitations::email.eq(email) + } + + /// Reload and join with Team and User tables. + pub fn repopulate(&self, db_conn: &mut PgConnection) -> Result { + PopulatedTeamInvitation::all() + .filter(TeamInvitation::with_id(&self.id)) + .first(db_conn) + .context("failed to load populated invitation") + } + + pub fn accept_for_user(&self, user_id: &Uuid, db_conn: &mut PgConnection) -> Result<()> { + let _guard = tracing::debug_span!( + "TeamInvitation::accept_for_user", + user_id = user_id.hyphenated().to_string(), + team_id = self.team_id.hyphenated().to_string() + ) + .entered(); + let user_id = *user_id; + let invitation_id = self.id; + let team_id = self.team_id; + db_conn + .transaction::<(), anyhow::Error, _>(move |conn| { + let n_inserted = diesel::insert_into(team_memberships::table) + .values(TeamMembership { user_id, team_id }) + .on_conflict((team_memberships::team_id, team_memberships::user_id)) + .do_nothing() + .execute(conn) + .context("failed to create team membership")?; + if n_inserted == 0 { + tracing::debug!("nothing inserted; team membership must already exist"); + } else { + tracing::debug!("inserted new team membership"); + } + let n_deleted = diesel::delete( + team_invitations::table.filter(TeamInvitation::with_id(&invitation_id)), + ) + .execute(conn) + .context("failed to delete invitation")?; + if n_deleted != 1 { + tracing::error!("expected to be deleting 1 invitation, but deleting {} instead; rolling back transaction", n_deleted); + } else { + tracing::debug!("deleted invitation"); + } + Ok(()) + }) + .context("transaction failed while accepting invitation") + .map(|_| ()) + } +} + +pub struct InvitationBuilder<'a> { + pub team_id: &'a Uuid, + pub email: &'a str, + pub created_by: &'a Uuid, +} + +impl InvitationBuilder<'_> { + /// Generate a team invitation and store it in the database. If a + /// conflicting invitation already exists, it will be updated with a new + /// creator and verification code, and the result will be returned. + pub fn insert(self, db_conn: &mut PgConnection) -> Result { + if !is_permissible_email(self.email) { + bail!("email address is expected to conform to standard format"); + } + diesel::insert_into(team_invitations::table) + .values(TeamInvitation { + id: Uuid::now_v7(), + team_id: *self.team_id, + email: self.email.to_lowercase(), + created_by: *self.created_by, + verification_code: format!("tminv-{}", Uuid::new_v4().hyphenated()), + }) + .on_conflict((team_invitations::team_id, team_invitations::email)) + .do_update() + .set(( + team_invitations::created_by.eq(excluded(team_invitations::created_by)), + team_invitations::verification_code + .eq(excluded(team_invitations::verification_code)), + )) + .get_result(db_conn) + .context("failed to insert team invitation") + } +} + +/// Inner join of TeamInvitation with its creator (User) and Team. +#[derive(Clone, Debug, Selectable, Queryable)] +pub struct PopulatedTeamInvitation { + #[diesel(embed)] + pub creator: User, + #[diesel(embed)] + pub invitation: TeamInvitation, + #[diesel(embed)] + pub team: Team, +} + +impl PopulatedTeamInvitation { + #[auto_type(no_type_alias)] + pub fn all() -> _ { + let as_select: AsSelect = Self::as_select(); + team_invitations::table + .inner_join(teams::table) + .inner_join(users::table.on(users::id.eq(team_invitations::created_by))) + .select(as_select) + } + + pub async fn send_by_email(&self, mailer: &Mailer, settings: &Settings) -> Result<()> { + async { + if !is_permissible_email(&self.invitation.email) { + bail!("email address is expected to conform to standard format"); + } + let invite_url = format!( + "{}{}/en/teams/{}/accept-invitation?verification_code={}", + settings.frontend_host, + settings.base_path, + self.team.id.simple(), + self.invitation.verification_code + ); + let message = format!( + "{} has invited you to the Shout.dev team, {}.\n\nAccept invitation: {}", + self.creator.email, + serde_json::to_string(&self.team.name)?, + invite_url, + ); + tracing::debug!("mailing team invitation with url: {}", invite_url); + mailer + .send_batch(vec![Message { + from: settings.email.verification_from.clone(), + to: self + .invitation + .email + .parse() + .context("failed to parse recipient as mailbox")?, + subject: "Shout.dev: You're invited!".to_owned(), + text_body: message, + }]) + .await + .remove(0)?; + tracing::debug!("invitation mailed"); + Ok(()) + } + .instrument(tracing::debug_span!( + "PopulatedTeamInvitation::send_by_email", + team_invitation = self.invitation.id.hyphenated().to_string() + )) + .await + } +} diff --git a/src/teams.rs b/src/teams.rs index ae15427..6c2e7c7 100644 --- a/src/teams.rs +++ b/src/teams.rs @@ -7,7 +7,10 @@ use uuid::Uuid; use crate::{ api_keys::ApiKey, - schema::{api_keys, teams}, + schema::{api_keys, team_invitations, team_memberships, teams, users}, + team_invitations::{PopulatedTeamInvitation, TeamInvitation}, + team_memberships::TeamMembership, + users::User, }; /// Teams are the fundamental organizing unit for billing and help to @@ -28,10 +31,42 @@ impl Team { teams::table.select(select) } + #[auto_type(no_type_alias)] + pub fn with_id(id: &Uuid) -> _ { + teams::id.eq(id) + } + #[auto_type(no_type_alias)] pub fn api_keys(&self) -> _ { let all: diesel::dsl::Select> = ApiKey::all(); let filter: Eq = ApiKey::with_team(&self.id); all.filter(filter) } + + #[auto_type(no_type_alias)] + pub fn invitations(&self) -> _ { + #[allow(clippy::type_complexity)] + let all: diesel::dsl::Select< + diesel::dsl::InnerJoin< + diesel::dsl::InnerJoin, + diesel::dsl::On< + users::table, + diesel::dsl::Eq, + >, + >, + AsSelect, + > = PopulatedTeamInvitation::all(); + let filter: Eq = TeamInvitation::with_team_id(&self.id); + all.filter(filter) + } + + #[auto_type(no_type_alias)] + pub fn members(&self) -> _ { + let select: AsSelect = User::as_select(); + let filter: Eq = TeamMembership::with_team_id(&self.id); + team_memberships::table + .inner_join(users::table) + .filter(filter) + .select(select) + } } diff --git a/src/teams_router.rs b/src/teams_router.rs index f2a3a40..81c43be 100644 --- a/src/teams_router.rs +++ b/src/teams_router.rs @@ -1,4 +1,4 @@ -use anyhow::Context as _; +use anyhow::{Context as _, Result}; use askama::Template; use axum::{ extract::{Path, State}, @@ -16,24 +16,47 @@ use crate::{ app_error::AppError, app_state::{AppState, DbConn}, csrf::generate_csrf_token, + email::{is_permissible_email, Mailer}, guards, - nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS}, + nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS, NAVBAR_ITEM_TEAM_MEMBERS}, projects::{Project, DEFAULT_PROJECT_NAME}, - schema::{api_keys, team_memberships, teams}, + schema::{api_keys, team_invitations, team_memberships, teams, users}, settings::Settings, + team_invitations::{InvitationBuilder, PopulatedTeamInvitation, TeamInvitation}, team_memberships::TeamMembership, teams::Team, - users::CurrentUser, + users::{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("/new-team", get(new_team_page)) - .route("/new-team", post(post_new_team)) + .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( @@ -71,6 +94,78 @@ async fn teams_page( )) } +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()) +} + async fn team_page( State(Settings { base_path, .. }): State, Path(team_id): Path, @@ -152,74 +247,313 @@ async fn remove_api_key( } } -async fn new_team_page( +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 csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?; + 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::email.asc()).load(conn)?; + let invitations = team.invitations().order_by(users::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 = "new-team.html")] + #[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("New Team", "new-team"), + .push_slug("Teams", "teams") + .push_slug(&team.name, &team.id.simple().to_string()) + .push_slug("Members", "members"), base_path, csrf_token, - navbar: navbar_template.with_active_item(NAVBAR_ITEM_TEAMS).build(), + 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 PostNewTeamForm { - name: String, +struct InviteTeamMemberForm { csrf_token: String, + email: String, } -async fn post_new_team( +async fn invite_team_member( + State(settings): State, + mailer: State, DbConn(db_conn): DbConn, - State(Settings { base_path, .. }): State, + Path(team_id): Path, CurrentUser(current_user): CurrentUser, - Form(form): Form, + 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 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(()) + 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() - .unwrap(); - ApiKey::generate_for_team(&db_conn, team_id).await?; - Ok(Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id)).into_response()) + .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()) + } } diff --git a/templates/accept-team-invitation.html b/templates/accept-team-invitation.html new file mode 100644 index 0000000..7a4c1ec --- /dev/null +++ b/templates/accept-team-invitation.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Shout.dev: Team Invitation{% endblock %} + +{% block main %} +
+
+

Team Invitation

+

+ {{ invitation.creator.email }} has invited you to join their team, + {{ invitation.team.name }}. This will let you send messages, + manage messaging channels, invite team members, and more. +

+
+ + + +
+
+
+{% endblock %} diff --git a/templates/team-members.html b/templates/team-members.html new file mode 100644 index 0000000..18c0a41 --- /dev/null +++ b/templates/team-members.html @@ -0,0 +1,241 @@ +{% extends "base.html" %} + +{% block title %}Shout.dev: Team Members{% endblock %} + +{% block main %} +{% include "breadcrumbs.html" %} +
+
+
+

Team Members

+
+ +
+
+
+
+ + + + + + + + + + {% for invitation in invitations %} + + + + + + {% endfor %} + {% for user in team_members %} + + + + + + {% endfor %} + +
EmailRoleActions
{{ invitation.invitation.email }}Invited + + +
{{ user.email }}Owner + {% if team_members.len() > 1 %} + + {% endif %} + +
+
+
+{% endblock %} diff --git a/templates/teams.html b/templates/teams.html index a52e894..3a047a1 100644 --- a/templates/teams.html +++ b/templates/teams.html @@ -24,7 +24,7 @@ {% for team in teams %} - + {{ team.name }}