add ability to manage team members
This commit is contained in:
parent
a07aff5008
commit
8cf4fa3f13
12 changed files with 916 additions and 43 deletions
|
@ -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.
|
||||
|
|
1
migrations/2025-04-10-042342_invitations/down.sql
Normal file
1
migrations/2025-04-10-042342_invitations/down.sql
Normal file
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS team_invitations;
|
10
migrations/2025-04-10-042342_invitations/up.sql
Normal file
10
migrations/2025-04-10-042342_invitations/up.sql
Normal 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);
|
|
@ -32,6 +32,7 @@ mod router;
|
|||
mod schema;
|
||||
mod sessions;
|
||||
mod settings;
|
||||
mod team_invitations;
|
||||
mod team_memberships;
|
||||
mod teams;
|
||||
mod teams_router;
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
202
src/team_invitations.rs
Normal 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
|
||||
}
|
||||
}
|
37
src/teams.rs
37
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<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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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, ¤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<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(¤t_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(¤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<PopulatedTeamInvitation, AppError>>(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<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, ¤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<Option<TeamInvitation>>>(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<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, ¤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<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, ¤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<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())
|
||||
}
|
||||
}
|
||||
|
|
25
templates/accept-team-invitation.html
Normal file
25
templates/accept-team-invitation.html
Normal 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
241
templates/team-members.html
Normal 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 %}
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue