1
0
Fork 0
forked from 2sys/shoutdotdev

add ability to manage team members

This commit is contained in:
Brent Schroeter 2025-04-12 23:05:39 -07:00
parent a07aff5008
commit 8cf4fa3f13
12 changed files with 916 additions and 43 deletions

View file

@ -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.

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS team_invitations;

View file

@ -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);

View file

@ -32,6 +32,7 @@ mod router;
mod schema;
mod sessions;
mod settings;
mod team_invitations;
mod team_memberships;
mod teams;
mod teams_router;

View file

@ -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",
)
}
}

View file

@ -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,

202
src/team_invitations.rs Normal file
View file

@ -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, Pg> = 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> {
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<TeamInvitation> {
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, Pg> = 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
}
}

View file

@ -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<api_keys::table, AsSelect<ApiKey, Pg>> = ApiKey::all();
let filter: Eq<api_keys::team_id, &Uuid> = 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<team_invitations::table, teams::table>,
diesel::dsl::On<
users::table,
diesel::dsl::Eq<users::id, team_invitations::created_by>,
>,
>,
AsSelect<PopulatedTeamInvitation, Pg>,
> = PopulatedTeamInvitation::all();
let filter: Eq<team_invitations::team_id, &Uuid> = TeamInvitation::with_team_id(&self.id);
all.filter(filter)
}
#[auto_type(no_type_alias)]
pub fn members(&self) -> _ {
let select: AsSelect<User, Pg> = User::as_select();
let filter: Eq<team_memberships::team_id, &Uuid> = TeamMembership::with_team_id(&self.id);
team_memberships::table
.inner_join(users::table)
.filter(filter)
.select(select)
}
}

View file

@ -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<AppState> {
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<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
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<Settings>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<PostNewTeamForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_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<Settings>,
Path(team_id): Path<Uuid>,
@ -152,74 +247,313 @@ async fn remove_api_key(
}
}
async fn new_team_page(
async fn team_members_page(
State(Settings { base_path, .. }): State<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
) -> Result<impl IntoResponse, AppError> {
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?;
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
let (team_members, invitations) = {
let team = team.clone();
db_conn
.interact::<_, Result<(Vec<User>, Vec<PopulatedTeamInvitation>)>>(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<PopulatedTeamInvitation>,
team_members: Vec<User>,
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<Settings>,
mailer: State<Mailer>,
DbConn(db_conn): DbConn,
State(Settings { base_path, .. }): State<Settings>,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<PostNewTeamForm>,
Form(form): Form<InviteTeamMemberForm>,
) -> Result<impl IntoResponse, AppError> {
let team = guards::require_team_membership(&current_user, &team_id, &db_conn).await?;
guards::require_valid_csrf_token(&form.csrf_token, &current_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<PopulatedTeamInvitation, AppError>>(move |conn| {
InvitationBuilder {
team_id: &team_id,
email: &form.email,
created_by: &current_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<Settings>,
State(navbar_template): State<NavbarBuilder>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<AcceptInvitationPageForm>,
) -> Result<impl IntoResponse, AppError> {
let csrf_token = generate_csrf_token(&db_conn, Some(current_user.id)).await?;
let maybe_invitation = db_conn
.interact::<_, Result<Option<PopulatedTeamInvitation>>>(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<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<PostAcceptInvitationForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_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<Option<TeamInvitation>>>(move |conn| {
TeamInvitation::all()
.filter(TeamInvitation::with_email(&current_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(&current_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<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<RemoveTeamMemberForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_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<usize, AppError>>(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<Settings>,
DbConn(db_conn): DbConn,
Path(team_id): Path<Uuid>,
CurrentUser(current_user): CurrentUser,
Form(form): Form<RemoveTeamInvitationForm>,
) -> Result<impl IntoResponse, AppError> {
guards::require_valid_csrf_token(&form.csrf_token, &current_user, &db_conn).await?;
let team = guards::require_team_membership(&current_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<usize, AppError>>(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())
}
}

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Shout.dev: Team Invitation{% endblock %}
{% block main %}
<main class="container mt-5">
<section class="mb-4">
<h1>Team Invitation</h1>
<p>
{{ invitation.creator.email }} has invited you to join their team,
<code>{{ invitation.team.name }}</code>. This will let you send messages,
manage messaging channels, invite team members, and more.
</p>
<form method="post" action="">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input
type="hidden"
name="verification_code"
value="{{ invitation.invitation.verification_code }}"
>
<button class="btn btn-primary" type="submit">Join Team</button>
</form>
</section>
</main>
{% endblock %}

241
templates/team-members.html Normal file
View file

@ -0,0 +1,241 @@
{% extends "base.html" %}
{% block title %}Shout.dev: Team Members{% endblock %}
{% block main %}
{% include "breadcrumbs.html" %}
<main class="container mt-5">
<section class="mb-4">
<div class="d-flex justify-content-between align-items-center">
<h1>Team Members</h1>
<div>
<div class="dropdown">
<button
class="btn btn-primary"
type="button"
data-bs-toggle="modal"
data-bs-target="#invite-modal"
>
Invite
</button>
<div
class="modal fade"
id="invite-modal"
tabindex="-1"
aria-labelledby="invite-modal-label"
aria-hidden="true"
>
<div class="modal-dialog">
<form
method="post"
action="{{ breadcrumbs.join("../invite-team-member") }}"
>
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="invite-modal-label">
Invite to Team
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Cancel"
></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="invite-modal-email-input" class="form-label">
Email address
</label>
<input
type="text"
name="email"
inputmode="email"
class="form-control"
id="invite-modal-email-input"
aria-describedby="invite-modal-email-help"
>
<div id="invite-modal-email-help" class="form-text">
Invite link will be sent to this email address. The
recipient will need to open the link and create an
account (if they have not already) in order to join the
team.
</div>
</div>
</div>
<div class="modal-footer">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button type="submit" class="btn btn-primary">Invite</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="mb-3">
<table class="table">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for invitation in invitations %}
<tr>
<td>{{ invitation.invitation.email }}</td>
<td>Invited</td>
<td>
<button
class="btn btn-sm btn-outline-danger"
type="button"
data-bs-toggle="modal"
data-bs-target="#confirm-remove-invite-modal-{{ invitation.invitation.id }}"
>
Remove
</button>
<div
class="modal fade"
id="confirm-remove-invite-modal-{{ invitation.invitation.id }}"
tabindex="-1"
aria-labelledby="confirm-remove-invite-label-{{ invitation.invitation.id }}"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1
class="modal-title fs-5"
id="confirm-remove-invite-label-{{ invitation.invitation.id }}"
>
Confirm
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Cancel"
></button>
</div>
<div class="modal-body">
Are you sure you want to cancel the invitation to
<code>{{ invitation.invitation.email }}</code>
from <code>{{ team_name }}</code>?
</div>
<div class="modal-footer">
<form
method="post"
action="{{ breadcrumbs.join("../remove-team-invitation") }}"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input
type="hidden"
name="invitation_id"
value="{{ invitation.invitation.id }}"
>
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button type="submit" class="btn btn-danger">Remove</button>
</form>
</div>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
{% for user in team_members %}
<tr>
<td>{{ user.email }}</td>
<td>Owner</td>
<td>
{% if team_members.len() > 1 %}
<button
class="btn btn-sm btn-outline-danger"
type="button"
data-bs-toggle="modal"
data-bs-target="#confirm-remove-member-modal-{{ user.id }}"
>
{% if user.id == current_user.id %}
Leave
{% else %}
Remove
{% endif %}
</button>
{% endif %}
<div
class="modal fade"
id="confirm-remove-member-modal-{{ user.id }}"
tabindex="-1"
aria-labelledby="confirm-remove-member-label-{{ user.id }}"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1
class="modal-title fs-5"
id="confirm-remove-member-label-{{ user.id }}"
>
Confirm
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Cancel"
></button>
</div>
<div class="modal-body">
Are you sure you want to remove
{% if user.id == current_user.id %}
yourself
{% else %}
<code>{{ user.email }}</code>
{% endif %}
from <code>{{ team_name }}</code>?
</div>
<div class="modal-footer">
<form
method="post"
action="{{ breadcrumbs.join("../remove-team-member") }}"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="user_id" value="{{ user.id }}">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button type="submit" class="btn btn-danger">Remove</button>
</form>
</div>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
{% endblock %}

View file

@ -24,7 +24,7 @@
{% for team in teams %}
<tr>
<td>
<a href="{{ base_path }}/en/teams/{{ team.id }}">
<a href="{{ base_path }}/en/teams/{{ team.id.simple() }}">
{{ team.name }}
</a>
</td>