Compare commits
7 commits
588bf33d6e
...
02166b2d61
Author | SHA1 | Date | |
---|---|---|---|
02166b2d61 | |||
8cf4fa3f13 | |||
a07aff5008 | |||
3f7e0018c3 | |||
7893aa65e3 | |||
2c15cdfd11 | |||
1f08b5a590 |
28 changed files with 1340 additions and 258 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);
|
|
@ -1,64 +1,48 @@
|
||||||
use std::fmt::{self, Display};
|
use std::fmt::{self, Display};
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Redirect, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use validator::ValidationErrors;
|
use validator::ValidationErrors;
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AuthRedirectInfo {
|
|
||||||
base_path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Custom error type that maps to appropriate HTTP responses.
|
/// Custom error type that maps to appropriate HTTP responses.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
InternalServerError(anyhow::Error),
|
InternalServerError(anyhow::Error),
|
||||||
ForbiddenError(String),
|
Forbidden(String),
|
||||||
NotFoundError(String),
|
NotFound(String),
|
||||||
BadRequestError(String),
|
BadRequest(String),
|
||||||
TooManyRequestsError(String),
|
TooManyRequests(String),
|
||||||
AuthRedirect(AuthRedirectInfo),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppError {
|
impl AppError {
|
||||||
pub fn auth_redirect_from_base_path(base_path: String) -> Self {
|
|
||||||
Self::AuthRedirect(AuthRedirectInfo { base_path })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_validation_errors(errs: ValidationErrors) -> Self {
|
pub fn from_validation_errors(errs: ValidationErrors) -> Self {
|
||||||
// TODO: customize validation errors formatting
|
// TODO: customize validation errors formatting
|
||||||
Self::BadRequestError(
|
Self::BadRequest(serde_json::to_string(&errs).unwrap_or("validation error".to_string()))
|
||||||
serde_json::to_string(&errs).unwrap_or("validation error".to_string()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
match self {
|
match self {
|
||||||
Self::AuthRedirect(AuthRedirectInfo { base_path }) => {
|
|
||||||
tracing::debug!("Handling AuthRedirect");
|
|
||||||
Redirect::to(&format!("{}/auth/login", base_path)).into_response()
|
|
||||||
}
|
|
||||||
Self::InternalServerError(err) => {
|
Self::InternalServerError(err) => {
|
||||||
tracing::error!("Application error: {:?}", err);
|
tracing::error!("Application error: {:?}", err);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong").into_response()
|
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong").into_response()
|
||||||
}
|
}
|
||||||
Self::ForbiddenError(client_message) => {
|
Self::Forbidden(client_message) => {
|
||||||
tracing::info!("Forbidden: {}", client_message);
|
tracing::info!("Forbidden: {}", client_message);
|
||||||
(StatusCode::FORBIDDEN, client_message).into_response()
|
(StatusCode::FORBIDDEN, client_message).into_response()
|
||||||
}
|
}
|
||||||
Self::NotFoundError(client_message) => {
|
Self::NotFound(client_message) => {
|
||||||
tracing::info!("Not found: {}", client_message);
|
tracing::info!("Not found: {}", client_message);
|
||||||
(StatusCode::NOT_FOUND, client_message).into_response()
|
(StatusCode::NOT_FOUND, client_message).into_response()
|
||||||
}
|
}
|
||||||
Self::TooManyRequestsError(client_message) => {
|
Self::TooManyRequests(client_message) => {
|
||||||
// Debug level so that if this is from a runaway loop, it won't
|
// Debug level so that if this is from a runaway loop, it won't
|
||||||
// overwhelm server logs
|
// overwhelm server logs
|
||||||
tracing::debug!("Too many requests: {}", client_message);
|
tracing::debug!("Too many requests: {}", client_message);
|
||||||
(StatusCode::TOO_MANY_REQUESTS, client_message).into_response()
|
(StatusCode::TOO_MANY_REQUESTS, client_message).into_response()
|
||||||
}
|
}
|
||||||
Self::BadRequestError(client_message) => {
|
Self::BadRequest(client_message) => {
|
||||||
tracing::info!("Bad user input: {}", client_message);
|
tracing::info!("Bad user input: {}", client_message);
|
||||||
(StatusCode::BAD_REQUEST, client_message).into_response()
|
(StatusCode::BAD_REQUEST, client_message).into_response()
|
||||||
}
|
}
|
||||||
|
@ -79,18 +63,17 @@ where
|
||||||
impl Display for AppError {
|
impl Display for AppError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
AppError::AuthRedirect(info) => write!(f, "AuthRedirect: {:?}", info),
|
|
||||||
AppError::InternalServerError(inner) => inner.fmt(f),
|
AppError::InternalServerError(inner) => inner.fmt(f),
|
||||||
AppError::ForbiddenError(client_message) => {
|
AppError::Forbidden(client_message) => {
|
||||||
write!(f, "ForbiddenError: {}", client_message)
|
write!(f, "ForbiddenError: {}", client_message)
|
||||||
}
|
}
|
||||||
AppError::NotFoundError(client_message) => {
|
AppError::NotFound(client_message) => {
|
||||||
write!(f, "NotFoundError: {}", client_message)
|
write!(f, "NotFoundError: {}", client_message)
|
||||||
}
|
}
|
||||||
AppError::BadRequestError(client_message) => {
|
AppError::BadRequest(client_message) => {
|
||||||
write!(f, "BadRequestError: {}", client_message)
|
write!(f, "BadRequestError: {}", client_message)
|
||||||
}
|
}
|
||||||
AppError::TooManyRequestsError(client_message) => {
|
AppError::TooManyRequests(client_message) => {
|
||||||
write!(f, "TooManyRequestsError: {}", client_message)
|
write!(f, "TooManyRequestsError: {}", client_message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
115
src/auth.rs
115
src/auth.rs
|
@ -1,11 +1,10 @@
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use async_session::{Session, SessionStore as _};
|
use async_session::{Session, SessionStore};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{FromRequestParts, Query, State},
|
extract::{Query, State},
|
||||||
http::request::Parts,
|
|
||||||
response::{IntoResponse, Redirect},
|
response::{IntoResponse, Redirect},
|
||||||
routing::get,
|
routing::get,
|
||||||
RequestPartsExt, Router,
|
Router,
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use oauth2::{
|
use oauth2::{
|
||||||
|
@ -13,7 +12,6 @@ use oauth2::{
|
||||||
ClientSecret, CsrfToken, RedirectUrl, RefreshToken, TokenResponse, TokenUrl,
|
ClientSecret, CsrfToken, RedirectUrl, RefreshToken, TokenResponse, TokenUrl,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{trace_span, Instrument};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app_error::AppError,
|
app_error::AppError,
|
||||||
|
@ -24,7 +22,8 @@ use crate::{
|
||||||
|
|
||||||
const SESSION_KEY_AUTH_CSRF_TOKEN: &str = "oauth_csrf_token";
|
const SESSION_KEY_AUTH_CSRF_TOKEN: &str = "oauth_csrf_token";
|
||||||
const SESSION_KEY_AUTH_REFRESH_TOKEN: &str = "oauth_refresh_token";
|
const SESSION_KEY_AUTH_REFRESH_TOKEN: &str = "oauth_refresh_token";
|
||||||
const SESSION_KEY_AUTH_INFO: &str = "auth";
|
pub const SESSION_KEY_AUTH_INFO: &str = "auth";
|
||||||
|
pub const SESSION_KEY_AUTH_REDIRECT: &str = "post_auth_redirect";
|
||||||
|
|
||||||
/// Creates a new OAuth2 client to be stored in global application state.
|
/// Creates a new OAuth2 client to be stored in global application state.
|
||||||
pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient> {
|
pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient> {
|
||||||
|
@ -39,8 +38,11 @@ pub fn new_oauth_client(settings: &Settings) -> Result<BasicClient> {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.set_redirect_uri(
|
.set_redirect_uri(
|
||||||
RedirectUrl::new(settings.auth.redirect_url.clone())
|
RedirectUrl::new(format!(
|
||||||
.context("failed to create new redirection URL")?,
|
"{}{}/auth/callback",
|
||||||
|
settings.frontend_host, settings.base_path
|
||||||
|
))
|
||||||
|
.context("failed to create new redirection URL")?,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,29 +66,33 @@ async fn start_login(
|
||||||
AppSession(maybe_session): AppSession,
|
AppSession(maybe_session): AppSession,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
if let Some(session) = maybe_session {
|
let mut session = if let Some(value) = maybe_session {
|
||||||
if session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).is_some() {
|
value
|
||||||
tracing::debug!("already logged in, redirecting...");
|
} else {
|
||||||
return Ok(Redirect::to(&format!("{}/", base_path)).into_response());
|
Session::new()
|
||||||
}
|
};
|
||||||
|
|
||||||
|
if session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).is_some() {
|
||||||
|
tracing::debug!("already logged in, redirecting...");
|
||||||
|
return Ok(Redirect::to(&format!("{}/", base_path)).into_response());
|
||||||
}
|
}
|
||||||
|
assert!(session.get_raw(SESSION_KEY_AUTH_REFRESH_TOKEN).is_none());
|
||||||
|
|
||||||
let csrf_token = CsrfToken::new_random();
|
let csrf_token = CsrfToken::new_random();
|
||||||
let (auth_url, _csrf_token) = state
|
|
||||||
.oauth_client
|
|
||||||
.authorize_url(|| csrf_token.clone())
|
|
||||||
.url();
|
|
||||||
let mut session = Session::new();
|
|
||||||
session.insert(SESSION_KEY_AUTH_CSRF_TOKEN, &csrf_token)?;
|
session.insert(SESSION_KEY_AUTH_CSRF_TOKEN, &csrf_token)?;
|
||||||
let cookie_value = session_store
|
let (auth_url, _csrf_token) = state.oauth_client.authorize_url(|| csrf_token).url();
|
||||||
.store_session(session)
|
let jar = if let Some(cookie_value) = session_store.store_session(session).await? {
|
||||||
.await?
|
tracing::debug!("adding session cookie to jar");
|
||||||
.ok_or(anyhow::anyhow!("cookie value from store_session() is None"))?;
|
jar.add(
|
||||||
let jar = jar.add(
|
Cookie::build((auth_settings.cookie_name.clone(), cookie_value))
|
||||||
Cookie::build((auth_settings.cookie_name.clone(), cookie_value))
|
.same_site(SameSite::Lax)
|
||||||
.same_site(SameSite::Lax)
|
.http_only(true)
|
||||||
.http_only(true)
|
.path("/"),
|
||||||
.path("/"),
|
)
|
||||||
);
|
} else {
|
||||||
|
tracing::debug!("inferred that session cookie already in jar");
|
||||||
|
jar
|
||||||
|
};
|
||||||
Ok((jar, Redirect::to(auth_url.as_ref())).into_response())
|
Ok((jar, Redirect::to(auth_url.as_ref())).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -150,18 +156,21 @@ async fn callback(
|
||||||
State(ReqwestClient(reqwest_client)): State<ReqwestClient>,
|
State(ReqwestClient(reqwest_client)): State<ReqwestClient>,
|
||||||
AppSession(session): AppSession,
|
AppSession(session): AppSession,
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
let mut session = if let Some(session) = session {
|
let mut session = session.ok_or_else(|| {
|
||||||
session
|
tracing::debug!("unable to load session");
|
||||||
} else {
|
AppError::Forbidden(
|
||||||
return Err(AppError::auth_redirect_from_base_path(base_path));
|
"our apologies: authentication session expired or lost, please try again".to_owned(),
|
||||||
};
|
)
|
||||||
|
})?;
|
||||||
let session_csrf_token: String = session.get(SESSION_KEY_AUTH_CSRF_TOKEN).ok_or_else(|| {
|
let session_csrf_token: String = session.get(SESSION_KEY_AUTH_CSRF_TOKEN).ok_or_else(|| {
|
||||||
tracing::debug!("oauth csrf token not found on session");
|
tracing::debug!("oauth csrf token not found on session");
|
||||||
AppError::auth_redirect_from_base_path(base_path.clone())
|
AppError::Forbidden(
|
||||||
|
"our apologies: authentication session expired or lost, please try again".to_owned(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
if session_csrf_token != query.state {
|
if session_csrf_token != query.state {
|
||||||
tracing::debug!("oauth csrf tokens did not match");
|
tracing::debug!("oauth csrf tokens did not match");
|
||||||
return Err(AppError::ForbiddenError(
|
return Err(AppError::Forbidden(
|
||||||
"OAuth CSRF tokens do not match.".to_string(),
|
"OAuth CSRF tokens do not match.".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -180,6 +189,12 @@ async fn callback(
|
||||||
.json()
|
.json()
|
||||||
.await?;
|
.await?;
|
||||||
tracing::debug!("updating session");
|
tracing::debug!("updating session");
|
||||||
|
|
||||||
|
let redirect_target: Option<String> = session.get(SESSION_KEY_AUTH_REDIRECT);
|
||||||
|
// Remove this since we don't need or want it sticking around, for both UX
|
||||||
|
// and security hygiene reasons
|
||||||
|
session.remove(SESSION_KEY_AUTH_REDIRECT);
|
||||||
|
|
||||||
session.insert(SESSION_KEY_AUTH_INFO, &auth_info)?;
|
session.insert(SESSION_KEY_AUTH_INFO, &auth_info)?;
|
||||||
session.insert(SESSION_KEY_AUTH_REFRESH_TOKEN, response.refresh_token())?;
|
session.insert(SESSION_KEY_AUTH_REFRESH_TOKEN, response.refresh_token())?;
|
||||||
if state.session_store.store_session(session).await?.is_some() {
|
if state.session_store.store_session(session).await?.is_some() {
|
||||||
|
@ -189,7 +204,9 @@ async fn callback(
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
tracing::debug!("successfully authenticated");
|
tracing::debug!("successfully authenticated");
|
||||||
Ok(Redirect::to(&format!("{}/", base_path)))
|
Ok(Redirect::to(
|
||||||
|
&redirect_target.unwrap_or(format!("{}/", base_path)),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Data stored in the visitor's session upon successful authentication.
|
/// Data stored in the visitor's session upon successful authentication.
|
||||||
|
@ -198,29 +215,3 @@ pub struct AuthInfo {
|
||||||
pub sub: String,
|
pub sub: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for AuthInfo {
|
|
||||||
type Rejection = AppError;
|
|
||||||
|
|
||||||
async fn from_request_parts(
|
|
||||||
parts: &mut Parts,
|
|
||||||
state: &AppState,
|
|
||||||
) -> Result<Self, <Self as FromRequestParts<AppState>>::Rejection> {
|
|
||||||
async move {
|
|
||||||
let session = parts
|
|
||||||
.extract_with_state::<AppSession, AppState>(state)
|
|
||||||
.await?
|
|
||||||
.0
|
|
||||||
.ok_or(AppError::auth_redirect_from_base_path(
|
|
||||||
state.settings.base_path.clone(),
|
|
||||||
))?;
|
|
||||||
let user = session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).ok_or(
|
|
||||||
AppError::auth_redirect_from_base_path(state.settings.base_path.clone()),
|
|
||||||
)?;
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
// The Span.enter() guard pattern doesn't play nicely with async
|
|
||||||
.instrument(trace_span!("AuthInfo from_request_parts()"))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -9,7 +9,6 @@ use axum::{
|
||||||
use axum_extra::extract::Form;
|
use axum_extra::extract::Form;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use rand::Rng as _;
|
use rand::Rng as _;
|
||||||
use regex::Regex;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
@ -18,7 +17,7 @@ use crate::{
|
||||||
app_state::{AppState, DbConn},
|
app_state::{AppState, DbConn},
|
||||||
channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL},
|
channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL},
|
||||||
csrf::generate_csrf_token,
|
csrf::generate_csrf_token,
|
||||||
email::{MailSender as _, Mailer},
|
email::{is_permissible_email, MailSender as _, Mailer},
|
||||||
guards,
|
guards,
|
||||||
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_CHANNELS},
|
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_CHANNELS},
|
||||||
schema::channels,
|
schema::channels,
|
||||||
|
@ -40,7 +39,7 @@ fn get_channel_by_params<'a>(
|
||||||
.filter(Channel::with_team(team_id))
|
.filter(Channel::with_team(team_id))
|
||||||
.first(conn)
|
.first(conn)
|
||||||
{
|
{
|
||||||
diesel::QueryResult::Err(diesel::result::Error::NotFound) => Err(AppError::NotFoundError(
|
diesel::QueryResult::Err(diesel::result::Error::NotFound) => Err(AppError::NotFound(
|
||||||
"Channel with that team and ID not found.".to_string(),
|
"Channel with that team and ID not found.".to_string(),
|
||||||
)),
|
)),
|
||||||
diesel::QueryResult::Err(err) => Err(err.into()),
|
diesel::QueryResult::Err(err) => Err(err.into()),
|
||||||
|
@ -153,7 +152,7 @@ async fn post_new_channel(
|
||||||
.await
|
.await
|
||||||
.unwrap()?,
|
.unwrap()?,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::BadRequestError(
|
return Err(AppError::BadRequest(
|
||||||
"Channel type not recognized.".to_string(),
|
"Channel type not recognized.".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -189,7 +188,7 @@ async fn channel_page(
|
||||||
.unwrap()?
|
.unwrap()?
|
||||||
{
|
{
|
||||||
None => {
|
None => {
|
||||||
return Err(AppError::NotFoundError(
|
return Err(AppError::NotFound(
|
||||||
"Channel with that team and ID not found".to_string(),
|
"Channel with that team and ID not found".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -272,7 +271,7 @@ async fn update_channel(
|
||||||
.context("Failed to load Channel while updating.")?
|
.context("Failed to load Channel while updating.")?
|
||||||
};
|
};
|
||||||
if updated_rows != 1 {
|
if updated_rows != 1 {
|
||||||
return Err(AppError::NotFoundError(
|
return Err(AppError::NotFound(
|
||||||
"Channel with that team and ID not found".to_string(),
|
"Channel with that team and ID not found".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -308,7 +307,7 @@ async fn update_channel_email_recipient(
|
||||||
guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
|
||||||
if !is_permissible_email(&form_body.recipient) {
|
if !is_permissible_email(&form_body.recipient) {
|
||||||
return Err(AppError::BadRequestError(
|
return Err(AppError::BadRequest(
|
||||||
"Unable to validate email address format.".to_string(),
|
"Unable to validate email address format.".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -374,15 +373,6 @@ async fn update_channel_email_recipient(
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the email address matches a format recognized as "valid".
|
|
||||||
/// Not all "legal" email addresses will be accepted, but addresses that are
|
|
||||||
/// "illegal" and/or could result in unexpected behavior should be rejected.
|
|
||||||
fn is_permissible_email(address: &str) -> bool {
|
|
||||||
let re = Regex::new(r"^[a-zA-Z0-9._+-]+@([a-zA-Z0-9_-]+.)+[a-zA-Z]+$")
|
|
||||||
.expect("email validation regex should parse");
|
|
||||||
re.is_match(address)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct VerifyEmailFormBody {
|
struct VerifyEmailFormBody {
|
||||||
csrf_token: String,
|
csrf_token: String,
|
||||||
|
@ -400,7 +390,7 @@ async fn verify_email(
|
||||||
guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||||
|
|
||||||
if form_body.code.len() != VERIFICATION_CODE_LEN {
|
if form_body.code.len() != VERIFICATION_CODE_LEN {
|
||||||
return Err(AppError::BadRequestError(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"Verification code must be {} characters long.",
|
"Verification code must be {} characters long.",
|
||||||
VERIFICATION_CODE_LEN
|
VERIFICATION_CODE_LEN
|
||||||
)));
|
)));
|
||||||
|
@ -414,15 +404,13 @@ async fn verify_email(
|
||||||
let channel = get_channel_by_params(conn, &team_id, &channel_id)?;
|
let channel = get_channel_by_params(conn, &team_id, &channel_id)?;
|
||||||
let config: EmailBackendConfig = channel.backend_config.try_into()?;
|
let config: EmailBackendConfig = channel.backend_config.try_into()?;
|
||||||
if config.verified {
|
if config.verified {
|
||||||
return Err(AppError::BadRequestError(
|
return Err(AppError::BadRequest(
|
||||||
"Channel's email address is already verified.".to_string(),
|
"Channel's email address is already verified.".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
const MAX_VERIFICATION_GUESSES: u32 = 100;
|
const MAX_VERIFICATION_GUESSES: u32 = 100;
|
||||||
if config.verification_code_guesses > MAX_VERIFICATION_GUESSES {
|
if config.verification_code_guesses > MAX_VERIFICATION_GUESSES {
|
||||||
return Err(AppError::BadRequestError(
|
return Err(AppError::BadRequest("Verification expired.".to_string()));
|
||||||
"Verification expired.".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let new_config = if config.verification_code == verification_code {
|
let new_config = if config.verification_code == verification_code {
|
||||||
EmailBackendConfig {
|
EmailBackendConfig {
|
||||||
|
|
10
src/email.rs
10
src/email.rs
|
@ -2,12 +2,22 @@ use anyhow::{Context, Result};
|
||||||
use axum::extract::FromRef;
|
use axum::extract::FromRef;
|
||||||
use futures::Future;
|
use futures::Future;
|
||||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||||
|
use regex::Regex;
|
||||||
use serde::{Serialize, Serializer};
|
use serde::{Serialize, Serializer};
|
||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
|
|
||||||
const POSTMARK_EMAIL_BATCH_URL: &str = "https://api.postmarkapp.com/email/batch";
|
const POSTMARK_EMAIL_BATCH_URL: &str = "https://api.postmarkapp.com/email/batch";
|
||||||
|
|
||||||
|
/// Returns true if the email address matches a format recognized as "valid".
|
||||||
|
/// Not all "legal" email addresses will be accepted, but addresses that are
|
||||||
|
/// "illegal" and/or could result in unexpected behavior should be rejected.
|
||||||
|
pub fn is_permissible_email(address: &str) -> bool {
|
||||||
|
let re = Regex::new(r"^[a-zA-Z0-9._+-]+@([a-zA-Z0-9_-]+.)+[a-zA-Z]+$")
|
||||||
|
.expect("email validation regex should parse");
|
||||||
|
re.is_match(address)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
#[serde(rename = "From")]
|
#[serde(rename = "From")]
|
||||||
|
|
|
@ -4,7 +4,10 @@ use diesel::prelude::*;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app_error::AppError, csrf::validate_csrf_token, team_memberships::TeamMembership, teams::Team,
|
app_error::AppError,
|
||||||
|
csrf::validate_csrf_token,
|
||||||
|
team_memberships::{PopulatedTeamMembership, TeamMembership},
|
||||||
|
teams::Team,
|
||||||
users::User,
|
users::User,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -17,27 +20,23 @@ pub async fn require_team_membership(
|
||||||
team_id: &Uuid,
|
team_id: &Uuid,
|
||||||
db_conn: &Connection,
|
db_conn: &Connection,
|
||||||
) -> Result<Team, AppError> {
|
) -> Result<Team, AppError> {
|
||||||
let maybe_team = {
|
let current_user_id = current_user.id;
|
||||||
let current_user_id = current_user.id;
|
let team_id = *team_id;
|
||||||
let team_id = *team_id;
|
db_conn
|
||||||
db_conn
|
.interact::<_, Result<Option<PopulatedTeamMembership>>>(move |conn| {
|
||||||
.interact::<_, Result<Option<(Team, _)>>>(move |conn| {
|
PopulatedTeamMembership::all()
|
||||||
TeamMembership::all()
|
.filter(TeamMembership::with_user_id(¤t_user_id))
|
||||||
.filter(TeamMembership::with_user_id(¤t_user_id))
|
.filter(TeamMembership::with_team_id(&team_id))
|
||||||
.filter(TeamMembership::with_team_id(&team_id))
|
.first(conn)
|
||||||
.first(conn)
|
.optional()
|
||||||
.optional()
|
.map_err(Into::into)
|
||||||
.map_err(Into::into)
|
})
|
||||||
})
|
.await
|
||||||
.await
|
.unwrap()?
|
||||||
.unwrap()?
|
.map(|team_membership| team_membership.team)
|
||||||
};
|
.ok_or(AppError::Forbidden(
|
||||||
match maybe_team {
|
"not a member of requested team".to_owned(),
|
||||||
Some((team, _)) => Ok(team),
|
))
|
||||||
None => Err(AppError::ForbiddenError(
|
|
||||||
"not a member of requested team".to_string(),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a ForbiddenError if the CSRF token parameters do not match an entry
|
/// Returns a ForbiddenError if the CSRF token parameters do not match an entry
|
||||||
|
@ -50,6 +49,6 @@ pub async fn require_valid_csrf_token(
|
||||||
if validate_csrf_token(db_conn, csrf_token, Some(current_user.id)).await? {
|
if validate_csrf_token(db_conn, csrf_token, Some(current_user.id)).await? {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(AppError::ForbiddenError("invalid CSRF token".to_string()))
|
Err(AppError::Forbidden("invalid CSRF token".to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -124,7 +124,7 @@ async fn project_page(
|
||||||
.filter(Project::with_team(&team_id))
|
.filter(Project::with_team(&team_id))
|
||||||
.first(conn)
|
.first(conn)
|
||||||
{
|
{
|
||||||
diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFoundError(
|
diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFound(
|
||||||
"Project with that team and ID not found.".to_string(),
|
"Project with that team and ID not found.".to_string(),
|
||||||
)),
|
)),
|
||||||
other => other
|
other => other
|
||||||
|
@ -213,7 +213,7 @@ async fn update_enabled_channels(
|
||||||
.filter(Project::with_team(&team_id))
|
.filter(Project::with_team(&team_id))
|
||||||
.first(conn)
|
.first(conn)
|
||||||
{
|
{
|
||||||
diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFoundError(
|
diesel::QueryResult::Err(diesel::NotFound) => Err(AppError::NotFound(
|
||||||
"Project with that team and ID not found.".to_string(),
|
"Project with that team and ID not found.".to_string(),
|
||||||
)),
|
)),
|
||||||
other => other
|
other => other
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -8,20 +8,29 @@ use crate::app_state::AppState;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
|
/// Prefix under which to nest all routes. If specified, include leading
|
||||||
|
/// slash but no trailing slash, for example "/app". For default behavior,
|
||||||
|
/// leave as empty string.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub base_path: String,
|
pub base_path: String,
|
||||||
|
|
||||||
|
/// postgresql:// URL.
|
||||||
pub database_url: String,
|
pub database_url: String,
|
||||||
|
|
||||||
/// When set to 1, embedded Diesel migrations will be run on startup.
|
/// When set to 1, embedded Diesel migrations will be run on startup.
|
||||||
pub run_database_migrations: Option<u8>,
|
pub run_database_migrations: Option<u8>,
|
||||||
|
|
||||||
|
/// Address for server to bind to
|
||||||
#[serde(default = "default_host")]
|
#[serde(default = "default_host")]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
|
|
||||||
|
/// Port for server to bind to
|
||||||
#[serde(default = "default_port")]
|
#[serde(default = "default_port")]
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
|
||||||
|
/// Host visible to end users, for example "https://shout.dev"
|
||||||
|
pub frontend_host: String,
|
||||||
|
|
||||||
pub auth: AuthSettings,
|
pub auth: AuthSettings,
|
||||||
|
|
||||||
pub email: EmailSettings,
|
pub email: EmailSettings,
|
||||||
|
@ -39,7 +48,6 @@ fn default_host() -> String {
|
||||||
pub struct AuthSettings {
|
pub struct AuthSettings {
|
||||||
pub client_id: String,
|
pub client_id: String,
|
||||||
pub client_secret: String,
|
pub client_secret: String,
|
||||||
pub redirect_url: String,
|
|
||||||
pub auth_url: String,
|
pub auth_url: String,
|
||||||
pub token_url: String,
|
pub token_url: String,
|
||||||
pub userinfo_url: String,
|
pub userinfo_url: String,
|
||||||
|
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,15 +21,6 @@ pub struct TeamMembership {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TeamMembership {
|
impl TeamMembership {
|
||||||
#[diesel::dsl::auto_type(no_type_alias)]
|
|
||||||
pub fn all() -> _ {
|
|
||||||
let select: AsSelect<(Team, User), Pg> = <(Team, User)>::as_select();
|
|
||||||
team_memberships::table
|
|
||||||
.inner_join(teams::table)
|
|
||||||
.inner_join(users::table)
|
|
||||||
.select(select)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[auto_type(no_type_alias)]
|
#[auto_type(no_type_alias)]
|
||||||
pub fn with_team_id<'a>(id: &'a Uuid) -> _ {
|
pub fn with_team_id<'a>(id: &'a Uuid) -> _ {
|
||||||
team_memberships::team_id.eq(id)
|
team_memberships::team_id.eq(id)
|
||||||
|
@ -40,3 +31,22 @@ impl TeamMembership {
|
||||||
team_memberships::user_id.eq(id)
|
team_memberships::user_id.eq(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Queryable, Selectable)]
|
||||||
|
pub struct PopulatedTeamMembership {
|
||||||
|
#[diesel(embed)]
|
||||||
|
pub team: Team,
|
||||||
|
#[diesel(embed)]
|
||||||
|
pub user: User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PopulatedTeamMembership {
|
||||||
|
#[auto_type(no_type_alias)]
|
||||||
|
pub fn all() -> _ {
|
||||||
|
let select: AsSelect<Self, Pg> = Self::as_select();
|
||||||
|
team_memberships::table
|
||||||
|
.inner_join(teams::table)
|
||||||
|
.inner_join(users::table)
|
||||||
|
.select(select)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
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,87 +94,6 @@ async fn teams_page(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn team_page(
|
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
|
||||||
Path(team_id): Path<Uuid>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct PostNewApiKeyForm {
|
|
||||||
csrf_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_new_api_key(
|
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
|
||||||
DbConn(db_conn): DbConn,
|
|
||||||
Path(team_id): Path<Uuid>,
|
|
||||||
CurrentUser(current_user): CurrentUser,
|
|
||||||
Form(form): Form<PostNewApiKeyForm>,
|
|
||||||
) -> 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?;
|
|
||||||
|
|
||||||
ApiKey::generate_for_team(&db_conn, team.id).await?;
|
|
||||||
Ok(Redirect::to(&format!(
|
|
||||||
"{}/en/teams/{}/projects",
|
|
||||||
base_path,
|
|
||||||
team.id.simple()
|
|
||||||
))
|
|
||||||
.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct RemoveApiKeyForm {
|
|
||||||
csrf_token: String,
|
|
||||||
key_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn remove_api_key(
|
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
|
||||||
DbConn(db_conn): DbConn,
|
|
||||||
Path(team_id): Path<Uuid>,
|
|
||||||
CurrentUser(current_user): CurrentUser,
|
|
||||||
Form(form): Form<RemoveApiKeyForm>,
|
|
||||||
) -> 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?;
|
|
||||||
|
|
||||||
let n_deleted = {
|
|
||||||
let team_id = team.id;
|
|
||||||
db_conn
|
|
||||||
.interact::<_, Result<usize, AppError>>(move |conn| {
|
|
||||||
diesel::delete(
|
|
||||||
api_keys::table
|
|
||||||
.filter(ApiKey::with_team(&team_id))
|
|
||||||
.filter(ApiKey::with_id(&form.key_id)),
|
|
||||||
)
|
|
||||||
.execute(conn)
|
|
||||||
.context("failed to delete API key from database")
|
|
||||||
.map_err(Into::into)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()?
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
n_deleted < 2,
|
|
||||||
"there should never be more than 1 API key with the same ID"
|
|
||||||
);
|
|
||||||
if n_deleted == 0 {
|
|
||||||
Err(AppError::NotFoundError(
|
|
||||||
"no API key with that ID and team found".to_owned(),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Ok(Redirect::to(&format!(
|
|
||||||
"{}/en/teams/{}/projects",
|
|
||||||
base_path,
|
|
||||||
team.id.simple()
|
|
||||||
))
|
|
||||||
.into_response())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn new_team_page(
|
async fn new_team_page(
|
||||||
State(Settings { base_path, .. }): State<Settings>,
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
State(navbar_template): State<NavbarBuilder>,
|
State(navbar_template): State<NavbarBuilder>,
|
||||||
|
@ -223,3 +165,395 @@ async fn post_new_team(
|
||||||
ApiKey::generate_for_team(&db_conn, team_id).await?;
|
ApiKey::generate_for_team(&db_conn, team_id).await?;
|
||||||
Ok(Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id)).into_response())
|
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>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PostNewApiKeyForm {
|
||||||
|
csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_new_api_key(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path(team_id): Path<Uuid>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Form(form): Form<PostNewApiKeyForm>,
|
||||||
|
) -> 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?;
|
||||||
|
|
||||||
|
ApiKey::generate_for_team(&db_conn, team.id).await?;
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"{}/en/teams/{}/projects",
|
||||||
|
base_path,
|
||||||
|
team.id.simple()
|
||||||
|
))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RemoveApiKeyForm {
|
||||||
|
csrf_token: String,
|
||||||
|
key_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_api_key(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path(team_id): Path<Uuid>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Form(form): Form<RemoveApiKeyForm>,
|
||||||
|
) -> 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?;
|
||||||
|
|
||||||
|
let n_deleted = {
|
||||||
|
let team_id = team.id;
|
||||||
|
db_conn
|
||||||
|
.interact::<_, Result<usize, AppError>>(move |conn| {
|
||||||
|
diesel::delete(
|
||||||
|
api_keys::table
|
||||||
|
.filter(ApiKey::with_team(&team_id))
|
||||||
|
.filter(ApiKey::with_id(&form.key_id)),
|
||||||
|
)
|
||||||
|
.execute(conn)
|
||||||
|
.context("failed to delete API key from database")
|
||||||
|
.map_err(Into::into)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()?
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
n_deleted < 2,
|
||||||
|
"there should never be more than 1 API key with the same ID"
|
||||||
|
);
|
||||||
|
if n_deleted == 0 {
|
||||||
|
Err(AppError::NotFound(
|
||||||
|
"no API key with that ID and team found".to_owned(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(Redirect::to(&format!(
|
||||||
|
"{}/en/teams/{}/projects",
|
||||||
|
base_path,
|
||||||
|
team.id.simple()
|
||||||
|
))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn team_members_page(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
State(navbar_template): State<NavbarBuilder>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path(team_id): Path<Uuid>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
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 = "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("Teams", "teams")
|
||||||
|
.push_slug(&team.name, &team.id.simple().to_string())
|
||||||
|
.push_slug("Members", "members"),
|
||||||
|
base_path,
|
||||||
|
csrf_token,
|
||||||
|
current_user,
|
||||||
|
invitations,
|
||||||
|
navbar: navbar_template
|
||||||
|
.with_param("team_id", &team.id.simple().to_string())
|
||||||
|
.with_active_item(NAVBAR_ITEM_TEAM_MEMBERS)
|
||||||
|
.build(),
|
||||||
|
team_members,
|
||||||
|
team_name: team.name,
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct InviteTeamMemberForm {
|
||||||
|
csrf_token: String,
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invite_team_member(
|
||||||
|
State(settings): State<Settings>,
|
||||||
|
mailer: State<Mailer>,
|
||||||
|
DbConn(db_conn): DbConn,
|
||||||
|
Path(team_id): Path<Uuid>,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
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 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()?;
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
99
src/users.rs
99
src/users.rs
|
@ -1,5 +1,15 @@
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{extract::FromRequestParts, http::request::Parts, RequestPartsExt};
|
use async_session::{Session, SessionStore as _};
|
||||||
|
use axum::{
|
||||||
|
extract::{FromRequestParts, OriginalUri},
|
||||||
|
http::{request::Parts, Method},
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
RequestPartsExt,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::{
|
||||||
|
cookie::{Cookie, SameSite},
|
||||||
|
CookieJar,
|
||||||
|
};
|
||||||
use diesel::{
|
use diesel::{
|
||||||
associations::Identifiable,
|
associations::Identifiable,
|
||||||
deserialize::Queryable,
|
deserialize::Queryable,
|
||||||
|
@ -13,8 +23,9 @@ use uuid::Uuid;
|
||||||
use crate::{
|
use crate::{
|
||||||
app_error::AppError,
|
app_error::AppError,
|
||||||
app_state::AppState,
|
app_state::AppState,
|
||||||
auth::AuthInfo,
|
auth::{AuthInfo, SESSION_KEY_AUTH_INFO, SESSION_KEY_AUTH_REDIRECT},
|
||||||
schema::{team_memberships, teams, users},
|
schema::{team_memberships, teams, users},
|
||||||
|
sessions::AppSession,
|
||||||
team_memberships::TeamMembership,
|
team_memberships::TeamMembership,
|
||||||
teams::Team,
|
teams::Team,
|
||||||
};
|
};
|
||||||
|
@ -57,20 +68,54 @@ impl<S> FromRequestParts<S> for CurrentUser
|
||||||
where
|
where
|
||||||
S: Into<AppState> + Clone + Sync,
|
S: Into<AppState> + Clone + Sync,
|
||||||
{
|
{
|
||||||
type Rejection = AppError;
|
type Rejection = CurrentUserRejection;
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
let state: AppState = state.clone().into();
|
let app_state: AppState = state.clone().into();
|
||||||
let auth_info = parts
|
let mut session =
|
||||||
.extract_with_state::<AuthInfo, AppState>(&state)
|
if let AppSession(Some(value)) = parts.extract_with_state(&app_state).await? {
|
||||||
.await
|
value
|
||||||
.map_err(|_| {
|
} else {
|
||||||
AppError::auth_redirect_from_base_path(state.settings.base_path.clone())
|
Session::new()
|
||||||
})?;
|
};
|
||||||
let current_user = state
|
let auth_info = if let Some(value) = session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO) {
|
||||||
.db_pool
|
value
|
||||||
.get()
|
} else {
|
||||||
.await?
|
let jar: CookieJar = parts.extract().await?;
|
||||||
|
let method: Method = parts.extract().await?;
|
||||||
|
let jar = if method == Method::GET {
|
||||||
|
let OriginalUri(uri) = parts.extract().await?;
|
||||||
|
session.insert(
|
||||||
|
SESSION_KEY_AUTH_REDIRECT,
|
||||||
|
uri.path_and_query()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or(format!("{}/", app_state.settings.base_path)),
|
||||||
|
)?;
|
||||||
|
if let Some(cookie_value) = app_state.session_store.store_session(session).await? {
|
||||||
|
tracing::debug!("adding session cookie to jar");
|
||||||
|
jar.add(
|
||||||
|
Cookie::build((app_state.settings.auth.cookie_name.clone(), cookie_value))
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.http_only(true)
|
||||||
|
.path("/"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tracing::debug!("inferred that session cookie already in jar");
|
||||||
|
jar
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If request method is not GET then do not attempt to infer the
|
||||||
|
// redirect target, as there may be no GET handler defined for
|
||||||
|
// it.
|
||||||
|
jar
|
||||||
|
};
|
||||||
|
return Err(Self::Rejection::SetCookiesAndRedirect(
|
||||||
|
jar,
|
||||||
|
format!("{}/auth/login", app_state.settings.base_path),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let db_conn = app_state.db_pool.get().await?;
|
||||||
|
let current_user = db_conn
|
||||||
.interact(move |conn| {
|
.interact(move |conn| {
|
||||||
let maybe_current_user = User::all()
|
let maybe_current_user = User::all()
|
||||||
.filter(User::with_uid(&auth_info.sub))
|
.filter(User::with_uid(&auth_info.sub))
|
||||||
|
@ -112,3 +157,29 @@ where
|
||||||
Ok(CurrentUser(current_user))
|
Ok(CurrentUser(current_user))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum CurrentUserRejection {
|
||||||
|
AppError(AppError),
|
||||||
|
SetCookiesAndRedirect(CookieJar, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Easily convert semi-arbitrary errors to InternalServerError
|
||||||
|
impl<E> From<E> for CurrentUserRejection
|
||||||
|
where
|
||||||
|
E: Into<AppError>,
|
||||||
|
{
|
||||||
|
fn from(err: E) -> Self {
|
||||||
|
Self::AppError(err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for CurrentUserRejection {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
match self {
|
||||||
|
Self::AppError(err) => err.into_response(),
|
||||||
|
Self::SetCookiesAndRedirect(jar, redirect_to) => {
|
||||||
|
(jar, Redirect::to(&redirect_to)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -67,9 +67,8 @@ async fn say_get(
|
||||||
query.validate().map_err(AppError::from_validation_errors)?;
|
query.validate().map_err(AppError::from_validation_errors)?;
|
||||||
|
|
||||||
let api_key = {
|
let api_key = {
|
||||||
let query_key = try_parse_as_uuid(&query.key).or(Err(AppError::ForbiddenError(
|
let query_key = try_parse_as_uuid(&query.key)
|
||||||
"key not accepted".to_string(),
|
.or(Err(AppError::Forbidden("key not accepted".to_string())))?;
|
||||||
)))?;
|
|
||||||
db_conn
|
db_conn
|
||||||
.interact::<_, Result<ApiKey, AppError>>(move |conn| {
|
.interact::<_, Result<ApiKey, AppError>>(move |conn| {
|
||||||
update(api_keys::table.filter(ApiKey::with_id(&query_key)))
|
update(api_keys::table.filter(ApiKey::with_id(&query_key)))
|
||||||
|
@ -78,7 +77,7 @@ async fn say_get(
|
||||||
.get_result(conn)
|
.get_result(conn)
|
||||||
.optional()
|
.optional()
|
||||||
.context("failed to get API key")?
|
.context("failed to get API key")?
|
||||||
.ok_or(AppError::ForbiddenError("key not accepted.".to_string()))
|
.ok_or(AppError::Forbidden("key not accepted.".to_string()))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()?
|
.unwrap()?
|
||||||
|
@ -146,7 +145,7 @@ async fn say_get(
|
||||||
.unwrap()?
|
.unwrap()?
|
||||||
.is_none()
|
.is_none()
|
||||||
{
|
{
|
||||||
return Err(AppError::TooManyRequestsError(
|
return Err(AppError::TooManyRequests(
|
||||||
"team rate limit exceeded".to_string(),
|
"team rate limit exceeded".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
93
static/geist/OFL.txt
Normal file
93
static/geist/OFL.txt
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
BIN
static/geist/geist_variable.ttf
Normal file
BIN
static/geist/geist_variable.ttf
Normal file
Binary file not shown.
41
static/logo.svg
Normal file
41
static/logo.svg
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Created with Vectornator (http://vectornator.io/) -->
|
||||||
|
<svg height="1024.0px" stroke-miterlimit="10" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" version="1.1" viewBox="0 0 1024 1024" width="1024.0px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<defs>
|
||||||
|
<image height="32" id="Image" width="32" xlink:href="data:image/png;base64,
|
||||||
|
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAIRlWElmTU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAACCgAwAEAAAAAQAAACAAAAAAX7wP8AAAAAlwSFlzAAALEwAACxMBAJqcGAAAABxpRE9UAAAAAgAAAAAAAAAQAAAAKAAAABAAAAAQAAAA/SNwdToAAADJSURBVFgJzJJRDsMgDEO5/2/vsGvSpuqTQgiGThMaUmVip3aytdRaC085PnXHQ57hHb4jNMv42QCX0X2yEMVdL5WiGla1J/+7v281RPXZAEqXmhQXPsq4fayn/tOGyRBx+78Y4NVSb5rZLm6NB/qohm+wKRZ+bkKyISJHr8yQYhgIQ4/+/W0DWKg/fgh/p8dz3b0jwtZej4bUhr6POzp1iik5GCIzhDOMXmiRb+qmGATTMzKEN6TXkOO57t4RYghliGaIJxx1hicAAAD///tXxmIAAAClSURBVM2SUQ6DMAxDe//f3WHXZPKkJ5mEpu1HgUjIrWMnFtDa53vwHJOFPqLb1aOi7nT3C4YRusfP7hNPuSadE2FvJPaqgfSE+OC4X+Il2QnRGwgv9HnwzqVzIjrLpaPcAyd0vqePmpaIhQDV8u0BRsu3BphZfkuA0ecjaKkrm+F/YKBwxoe+1JbNEGCLdsvQleCPB9DnfCrEf/crAijEnUHYJ/wBtf7/1NmEySMAAAAASUVORK5CYII="/>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer-1">
|
||||||
|
<path d="M0 128C0 57.3076 57.3076 0 128 0L896 0C966.692 0 1024 57.3076 1024 128L1024 896C1024 966.692 966.692 1024 896 1024L128 1024C57.3076 1024 0 966.692 0 896L0 128Z" fill="#0093a6" fill-rule="nonzero" opacity="1" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
<g id="Layer-3">
|
||||||
|
<path d="M96 512L384 512L384 576L96 576L96 512Z" fill="#ffffff" fill-rule="nonzero" opacity="1" stroke="none"/>
|
||||||
|
<path d="M416 256L416 832L704 544L416 256Z" fill="none" opacity="1" stroke="#ffffff" stroke-linecap="butt" stroke-linejoin="bevel" stroke-width="64"/>
|
||||||
|
<path d="M704 256L768 256L768 832L704 832L704 256Z" fill="#ffffff" fill-rule="nonzero" opacity="1" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
<g id="Layer-4">
|
||||||
|
<path d="M352 160L480 160L480 256L352 256L352 160Z" fill="#0093a6" fill-rule="nonzero" opacity="1" stroke="none"/>
|
||||||
|
<path d="M352 832L480 832L480 928L352 928L352 832Z" fill="#0093a6" fill-rule="nonzero" opacity="1" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
<g id="Layer-5">
|
||||||
|
<g opacity="1">
|
||||||
|
<path d="M520.055 256L608 85.3258" fill="none" opacity="1" stroke="#ffffff" stroke-linecap="butt" stroke-linejoin="miter" stroke-width="32"/>
|
||||||
|
<path d="M607.131 156.875L608 85.3258L550.239 127.56" fill="none" opacity="1" stroke="#ffffff" stroke-linecap="butt" stroke-linejoin="miter" stroke-width="32"/>
|
||||||
|
</g>
|
||||||
|
<g opacity="1">
|
||||||
|
<path d="M584.055 320L672 149.326" fill="none" opacity="1" stroke="#ffffff" stroke-linecap="butt" stroke-linejoin="miter" stroke-width="32"/>
|
||||||
|
<path d="M671.131 220.875L672 149.326L614.239 191.56" fill="none" opacity="1" stroke="#ffffff" stroke-linecap="butt" stroke-linejoin="miter" stroke-width="32"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="Layer-2" visibility="hidden">
|
||||||
|
<g transform="matrix(32 0 0 32 0 0)">
|
||||||
|
<clipPath id="cp">
|
||||||
|
<path d="M0 0L32 0L32 32L0 32Z"/>
|
||||||
|
</clipPath>
|
||||||
|
<g clip-path="url(#cp)">
|
||||||
|
<use opacity="0.490461" xlink:href="#Image"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.2 KiB |
13
static/main.css
Normal file
13
static/main.css
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
:root {
|
||||||
|
--bs-font-sans-serif: Geist, "Noto Sans", Roboto, "Segoe UI", system-ui, -apple-system, "Helvetica Neue", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji";
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] {
|
||||||
|
--bs-body-bg: rgb(27, 28, 30);
|
||||||
|
--bs-tertiary-bg-rgb: 36, 38, 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Geist;
|
||||||
|
src: url("./geist/geist_variable.ttf");
|
||||||
|
}
|
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 %}
|
|
@ -4,6 +4,7 @@
|
||||||
<title>{% block title %}Shout.dev{% endblock %}</title>
|
<title>{% block title %}Shout.dev{% endblock %}</title>
|
||||||
{% include "meta_tags.html" %}
|
{% include "meta_tags.html" %}
|
||||||
<link rel="stylesheet" href="{{ base_path }}/bootstrap-5.3.3-dist/css/bootstrap.min.css">
|
<link rel="stylesheet" href="{{ base_path }}/bootstrap-5.3.3-dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="{{ base_path }}/main.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include "nav.html" %}
|
{% include "nav.html" %}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="#">Shout.dev</a>
|
<a class="navbar-brand" href="{{ base_path }}/" style="line-height: 0;">
|
||||||
|
<img src="{{ base_path }}/logo.svg" alt="Shout.dev" title="Shout.dev" width="32" height="32">
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
class="navbar-toggler"
|
class="navbar-toggler"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
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