forked from 2sys/shoutdotdev
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
|
Shout.dev is in the early prototyping stage. Basic documentation and testing
|
||||||
will be prioritized after core functionality is completed and shown to have
|
will be prioritized after core functionality is completed and shown to have
|
||||||
practical value.
|
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 schema;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
mod team_invitations;
|
||||||
mod team_memberships;
|
mod team_memberships;
|
||||||
mod teams;
|
mod teams;
|
||||||
mod teams_router;
|
mod teams_router;
|
||||||
|
|
|
@ -7,6 +7,7 @@ use crate::app_state::AppState;
|
||||||
pub const NAVBAR_ITEM_TEAMS: &str = "teams";
|
pub const NAVBAR_ITEM_TEAMS: &str = "teams";
|
||||||
pub const NAVBAR_ITEM_PROJECTS: &str = "projects";
|
pub const NAVBAR_ITEM_PROJECTS: &str = "projects";
|
||||||
pub const NAVBAR_ITEM_CHANNELS: &str = "channels";
|
pub const NAVBAR_ITEM_CHANNELS: &str = "channels";
|
||||||
|
pub const NAVBAR_ITEM_TEAM_MEMBERS: &str = "team-members";
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct BreadcrumbTrail {
|
pub struct BreadcrumbTrail {
|
||||||
|
@ -202,6 +203,11 @@ impl Default for NavbarBuilder {
|
||||||
"Channels",
|
"Channels",
|
||||||
"/en/teams/{team_id}/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! {
|
diesel::table! {
|
||||||
team_memberships (team_id, user_id) {
|
team_memberships (team_id, user_id) {
|
||||||
team_id -> Uuid,
|
team_id -> Uuid,
|
||||||
|
@ -114,6 +124,8 @@ diesel::joinable!(governors -> teams (team_id));
|
||||||
diesel::joinable!(messages -> channels (channel_id));
|
diesel::joinable!(messages -> channels (channel_id));
|
||||||
diesel::joinable!(messages -> projects (project_id));
|
diesel::joinable!(messages -> projects (project_id));
|
||||||
diesel::joinable!(projects -> teams (team_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 -> teams (team_id));
|
||||||
diesel::joinable!(team_memberships -> users (user_id));
|
diesel::joinable!(team_memberships -> users (user_id));
|
||||||
|
|
||||||
|
@ -127,6 +139,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||||
governors,
|
governors,
|
||||||
messages,
|
messages,
|
||||||
projects,
|
projects,
|
||||||
|
team_invitations,
|
||||||
team_memberships,
|
team_memberships,
|
||||||
teams,
|
teams,
|
||||||
users,
|
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::{
|
use crate::{
|
||||||
api_keys::ApiKey,
|
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
|
/// Teams are the fundamental organizing unit for billing and help to
|
||||||
|
@ -28,10 +31,42 @@ impl Team {
|
||||||
teams::table.select(select)
|
teams::table.select(select)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn with_id(id: &Uuid) -> _ {
|
||||||
|
teams::id.eq(id)
|
||||||
|
}
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[auto_type(no_type_alias)]
|
||||||
pub fn api_keys(&self) -> _ {
|
pub fn api_keys(&self) -> _ {
|
||||||
let all: diesel::dsl::Select<api_keys::table, AsSelect<ApiKey, Pg>> = ApiKey::all();
|
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);
|
let filter: Eq<api_keys::team_id, &Uuid> = ApiKey::with_team(&self.id);
|
||||||
all.filter(filter)
|
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 askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
|
@ -16,24 +16,47 @@ use crate::{
|
||||||
app_error::AppError,
|
app_error::AppError,
|
||||||
app_state::{AppState, DbConn},
|
app_state::{AppState, DbConn},
|
||||||
csrf::generate_csrf_token,
|
csrf::generate_csrf_token,
|
||||||
|
email::{is_permissible_email, Mailer},
|
||||||
guards,
|
guards,
|
||||||
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS},
|
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS, NAVBAR_ITEM_TEAM_MEMBERS},
|
||||||
projects::{Project, DEFAULT_PROJECT_NAME},
|
projects::{Project, DEFAULT_PROJECT_NAME},
|
||||||
schema::{api_keys, team_memberships, teams},
|
schema::{api_keys, team_invitations, team_memberships, teams, users},
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
|
team_invitations::{InvitationBuilder, PopulatedTeamInvitation, TeamInvitation},
|
||||||
team_memberships::TeamMembership,
|
team_memberships::TeamMembership,
|
||||||
teams::Team,
|
teams::Team,
|
||||||
users::CurrentUser,
|
users::{CurrentUser, User},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn new_router() -> Router<AppState> {
|
pub fn new_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/teams", get(teams_page))
|
.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}", get(team_page))
|
||||||
.route("/teams/{team_id}/new-api-key", post(post_new_api_key))
|
.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}/remove-api-key", post(remove_api_key))
|
||||||
.route("/new-team", get(new_team_page))
|
.route("/teams/{team_id}/members", get(team_members_page))
|
||||||
.route("/new-team", post(post_new_team))
|
.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(
|
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(
|
async fn team_page(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
Path(team_id): Path<Uuid>,
|
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(Settings { base_path, .. }): State<Settings>,
|
||||||
State(navbar_template): State<NavbarBuilder>,
|
State(navbar_template): State<NavbarBuilder>,
|
||||||
DbConn(db_conn): DbConn,
|
DbConn(db_conn): DbConn,
|
||||||
|
Path(team_id): Path<Uuid>,
|
||||||
CurrentUser(current_user): CurrentUser,
|
CurrentUser(current_user): CurrentUser,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> 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)]
|
#[derive(Template)]
|
||||||
#[template(path = "new-team.html")]
|
#[template(path = "team-members.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
base_path: String,
|
base_path: String,
|
||||||
breadcrumbs: BreadcrumbTrail,
|
breadcrumbs: BreadcrumbTrail,
|
||||||
csrf_token: String,
|
csrf_token: String,
|
||||||
|
current_user: User,
|
||||||
|
invitations: Vec<PopulatedTeamInvitation>,
|
||||||
|
team_members: Vec<User>,
|
||||||
|
team_name: String,
|
||||||
navbar: Navbar,
|
navbar: Navbar,
|
||||||
}
|
}
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
ResponseTemplate {
|
ResponseTemplate {
|
||||||
breadcrumbs: BreadcrumbTrail::from_base_path(&base_path)
|
breadcrumbs: BreadcrumbTrail::from_base_path(&base_path)
|
||||||
.with_i18n_slug("en")
|
.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,
|
base_path,
|
||||||
csrf_token,
|
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()?,
|
.render()?,
|
||||||
))
|
)
|
||||||
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct PostNewTeamForm {
|
struct InviteTeamMemberForm {
|
||||||
name: String,
|
|
||||||
csrf_token: String,
|
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,
|
DbConn(db_conn): DbConn,
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
Path(team_id): Path<Uuid>,
|
||||||
CurrentUser(current_user): CurrentUser,
|
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> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?;
|
guards::require_valid_csrf_token(&form.csrf_token, ¤t_user, &db_conn).await?;
|
||||||
|
|
||||||
let team_id = Uuid::now_v7();
|
let maybe_invitation = {
|
||||||
let team = Team {
|
let current_email = current_user.email.clone();
|
||||||
id: team_id,
|
db_conn
|
||||||
name: form.name,
|
.interact::<_, Result<Option<TeamInvitation>>>(move |conn| {
|
||||||
};
|
TeamInvitation::all()
|
||||||
let team_membership = TeamMembership {
|
.filter(TeamInvitation::with_email(¤t_email))
|
||||||
team_id,
|
.filter(TeamInvitation::with_verification_code(
|
||||||
user_id: current_user.id,
|
&form.verification_code,
|
||||||
};
|
))
|
||||||
db_conn
|
.filter(TeamInvitation::with_team_id(&team_id))
|
||||||
.interact::<_, Result<(), AppError>>(move |conn| {
|
.first(conn)
|
||||||
conn.transaction::<(), AppError, _>(move |conn| {
|
.optional()
|
||||||
diesel::insert_into(teams::table)
|
.context("failed to load team invitation")
|
||||||
.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()?
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
.await
|
||||||
.unwrap()
|
.unwrap()?;
|
||||||
.unwrap();
|
if n_deleted == 0 {
|
||||||
ApiKey::generate_for_team(&db_conn, team_id).await?;
|
Err(AppError::NotFound("no invitation found".to_owned()))
|
||||||
Ok(Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id)).into_response())
|
} 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 %}
|
{% for team in teams %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ base_path }}/en/teams/{{ team.id }}">
|
<a href="{{ base_path }}/en/teams/{{ team.id.simple() }}">
|
||||||
{{ team.name }}
|
{{ team.name }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
Loading…
Add table
Reference in a new issue