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
|
||||
will be prioritized after core functionality is completed and shown to have
|
||||
practical value.
|
||||
|
||||
## Notable Assumptions
|
||||
|
||||
- OAuth provider provides accurate "email" field in the userinfo payload.
|
||||
- OAuth provider does not allow users to change their emails after signup.
|
||||
|
|
1
migrations/2025-04-10-042342_invitations/down.sql
Normal file
1
migrations/2025-04-10-042342_invitations/down.sql
Normal file
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS team_invitations;
|
10
migrations/2025-04-10-042342_invitations/up.sql
Normal file
10
migrations/2025-04-10-042342_invitations/up.sql
Normal file
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE IF NOT EXISTS team_invitations (
|
||||
id UUID NOT NULL PRIMARY KEY,
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
verification_code TEXT NOT NULL,
|
||||
UNIQUE (team_id, email)
|
||||
);
|
||||
CREATE INDEX ON team_invitations(team_id);
|
||||
CREATE INDEX ON team_invitations(verification_code);
|
|
@ -1,64 +1,48 @@
|
|||
use std::fmt::{self, Display};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Redirect, Response};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use validator::ValidationErrors;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AuthRedirectInfo {
|
||||
base_path: String,
|
||||
}
|
||||
|
||||
/// Custom error type that maps to appropriate HTTP responses.
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
InternalServerError(anyhow::Error),
|
||||
ForbiddenError(String),
|
||||
NotFoundError(String),
|
||||
BadRequestError(String),
|
||||
TooManyRequestsError(String),
|
||||
AuthRedirect(AuthRedirectInfo),
|
||||
Forbidden(String),
|
||||
NotFound(String),
|
||||
BadRequest(String),
|
||||
TooManyRequests(String),
|
||||
}
|
||||
|
||||
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 {
|
||||
// TODO: customize validation errors formatting
|
||||
Self::BadRequestError(
|
||||
serde_json::to_string(&errs).unwrap_or("validation error".to_string()),
|
||||
)
|
||||
Self::BadRequest(serde_json::to_string(&errs).unwrap_or("validation error".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
Self::AuthRedirect(AuthRedirectInfo { base_path }) => {
|
||||
tracing::debug!("Handling AuthRedirect");
|
||||
Redirect::to(&format!("{}/auth/login", base_path)).into_response()
|
||||
}
|
||||
Self::InternalServerError(err) => {
|
||||
tracing::error!("Application error: {:?}", err);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong").into_response()
|
||||
}
|
||||
Self::ForbiddenError(client_message) => {
|
||||
Self::Forbidden(client_message) => {
|
||||
tracing::info!("Forbidden: {}", client_message);
|
||||
(StatusCode::FORBIDDEN, client_message).into_response()
|
||||
}
|
||||
Self::NotFoundError(client_message) => {
|
||||
Self::NotFound(client_message) => {
|
||||
tracing::info!("Not found: {}", client_message);
|
||||
(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
|
||||
// overwhelm server logs
|
||||
tracing::debug!("Too many requests: {}", client_message);
|
||||
(StatusCode::TOO_MANY_REQUESTS, client_message).into_response()
|
||||
}
|
||||
Self::BadRequestError(client_message) => {
|
||||
Self::BadRequest(client_message) => {
|
||||
tracing::info!("Bad user input: {}", client_message);
|
||||
(StatusCode::BAD_REQUEST, client_message).into_response()
|
||||
}
|
||||
|
@ -79,18 +63,17 @@ where
|
|||
impl Display for AppError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
AppError::AuthRedirect(info) => write!(f, "AuthRedirect: {:?}", info),
|
||||
AppError::InternalServerError(inner) => inner.fmt(f),
|
||||
AppError::ForbiddenError(client_message) => {
|
||||
AppError::Forbidden(client_message) => {
|
||||
write!(f, "ForbiddenError: {}", client_message)
|
||||
}
|
||||
AppError::NotFoundError(client_message) => {
|
||||
AppError::NotFound(client_message) => {
|
||||
write!(f, "NotFoundError: {}", client_message)
|
||||
}
|
||||
AppError::BadRequestError(client_message) => {
|
||||
AppError::BadRequest(client_message) => {
|
||||
write!(f, "BadRequestError: {}", client_message)
|
||||
}
|
||||
AppError::TooManyRequestsError(client_message) => {
|
||||
AppError::TooManyRequests(client_message) => {
|
||||
write!(f, "TooManyRequestsError: {}", client_message)
|
||||
}
|
||||
}
|
||||
|
|
115
src/auth.rs
115
src/auth.rs
|
@ -1,11 +1,10 @@
|
|||
use anyhow::{Context, Result};
|
||||
use async_session::{Session, SessionStore as _};
|
||||
use async_session::{Session, SessionStore};
|
||||
use axum::{
|
||||
extract::{FromRequestParts, Query, State},
|
||||
http::request::Parts,
|
||||
extract::{Query, State},
|
||||
response::{IntoResponse, Redirect},
|
||||
routing::get,
|
||||
RequestPartsExt, Router,
|
||||
Router,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use oauth2::{
|
||||
|
@ -13,7 +12,6 @@ use oauth2::{
|
|||
ClientSecret, CsrfToken, RedirectUrl, RefreshToken, TokenResponse, TokenUrl,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{trace_span, Instrument};
|
||||
|
||||
use crate::{
|
||||
app_error::AppError,
|
||||
|
@ -24,7 +22,8 @@ use crate::{
|
|||
|
||||
const SESSION_KEY_AUTH_CSRF_TOKEN: &str = "oauth_csrf_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.
|
||||
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(
|
||||
RedirectUrl::new(settings.auth.redirect_url.clone())
|
||||
.context("failed to create new redirection URL")?,
|
||||
RedirectUrl::new(format!(
|
||||
"{}{}/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,
|
||||
jar: CookieJar,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
if let Some(session) = maybe_session {
|
||||
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());
|
||||
}
|
||||
let mut session = if let Some(value) = maybe_session {
|
||||
value
|
||||
} else {
|
||||
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 (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)?;
|
||||
let cookie_value = session_store
|
||||
.store_session(session)
|
||||
.await?
|
||||
.ok_or(anyhow::anyhow!("cookie value from store_session() is None"))?;
|
||||
let jar = jar.add(
|
||||
Cookie::build((auth_settings.cookie_name.clone(), cookie_value))
|
||||
.same_site(SameSite::Lax)
|
||||
.http_only(true)
|
||||
.path("/"),
|
||||
);
|
||||
let (auth_url, _csrf_token) = state.oauth_client.authorize_url(|| csrf_token).url();
|
||||
let jar = if let Some(cookie_value) = session_store.store_session(session).await? {
|
||||
tracing::debug!("adding session cookie to jar");
|
||||
jar.add(
|
||||
Cookie::build((auth_settings.cookie_name.clone(), cookie_value))
|
||||
.same_site(SameSite::Lax)
|
||||
.http_only(true)
|
||||
.path("/"),
|
||||
)
|
||||
} else {
|
||||
tracing::debug!("inferred that session cookie already in jar");
|
||||
jar
|
||||
};
|
||||
Ok((jar, Redirect::to(auth_url.as_ref())).into_response())
|
||||
}
|
||||
|
||||
|
@ -150,18 +156,21 @@ async fn callback(
|
|||
State(ReqwestClient(reqwest_client)): State<ReqwestClient>,
|
||||
AppSession(session): AppSession,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let mut session = if let Some(session) = session {
|
||||
session
|
||||
} else {
|
||||
return Err(AppError::auth_redirect_from_base_path(base_path));
|
||||
};
|
||||
let mut session = session.ok_or_else(|| {
|
||||
tracing::debug!("unable to load session");
|
||||
AppError::Forbidden(
|
||||
"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(|| {
|
||||
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 {
|
||||
tracing::debug!("oauth csrf tokens did not match");
|
||||
return Err(AppError::ForbiddenError(
|
||||
return Err(AppError::Forbidden(
|
||||
"OAuth CSRF tokens do not match.".to_string(),
|
||||
));
|
||||
}
|
||||
|
@ -180,6 +189,12 @@ async fn callback(
|
|||
.json()
|
||||
.await?;
|
||||
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_REFRESH_TOKEN, response.refresh_token())?;
|
||||
if state.session_store.store_session(session).await?.is_some() {
|
||||
|
@ -189,7 +204,9 @@ async fn callback(
|
|||
.into());
|
||||
}
|
||||
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.
|
||||
|
@ -198,29 +215,3 @@ pub struct AuthInfo {
|
|||
pub sub: 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 diesel::prelude::*;
|
||||
use rand::Rng as _;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
@ -18,7 +17,7 @@ use crate::{
|
|||
app_state::{AppState, DbConn},
|
||||
channels::{BackendConfig, Channel, EmailBackendConfig, CHANNEL_BACKEND_EMAIL},
|
||||
csrf::generate_csrf_token,
|
||||
email::{MailSender as _, Mailer},
|
||||
email::{is_permissible_email, MailSender as _, Mailer},
|
||||
guards,
|
||||
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_CHANNELS},
|
||||
schema::channels,
|
||||
|
@ -40,7 +39,7 @@ fn get_channel_by_params<'a>(
|
|||
.filter(Channel::with_team(team_id))
|
||||
.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(),
|
||||
)),
|
||||
diesel::QueryResult::Err(err) => Err(err.into()),
|
||||
|
@ -153,7 +152,7 @@ async fn post_new_channel(
|
|||
.await
|
||||
.unwrap()?,
|
||||
_ => {
|
||||
return Err(AppError::BadRequestError(
|
||||
return Err(AppError::BadRequest(
|
||||
"Channel type not recognized.".to_string(),
|
||||
));
|
||||
}
|
||||
|
@ -189,7 +188,7 @@ async fn channel_page(
|
|||
.unwrap()?
|
||||
{
|
||||
None => {
|
||||
return Err(AppError::NotFoundError(
|
||||
return Err(AppError::NotFound(
|
||||
"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.")?
|
||||
};
|
||||
if updated_rows != 1 {
|
||||
return Err(AppError::NotFoundError(
|
||||
return Err(AppError::NotFound(
|
||||
"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?;
|
||||
|
||||
if !is_permissible_email(&form_body.recipient) {
|
||||
return Err(AppError::BadRequestError(
|
||||
return Err(AppError::BadRequest(
|
||||
"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)]
|
||||
struct VerifyEmailFormBody {
|
||||
csrf_token: String,
|
||||
|
@ -400,7 +390,7 @@ async fn verify_email(
|
|||
guards::require_team_membership(¤t_user, &team_id, &db_conn).await?;
|
||||
|
||||
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_LEN
|
||||
)));
|
||||
|
@ -414,15 +404,13 @@ async fn verify_email(
|
|||
let channel = get_channel_by_params(conn, &team_id, &channel_id)?;
|
||||
let config: EmailBackendConfig = channel.backend_config.try_into()?;
|
||||
if config.verified {
|
||||
return Err(AppError::BadRequestError(
|
||||
return Err(AppError::BadRequest(
|
||||
"Channel's email address is already verified.".to_string(),
|
||||
));
|
||||
}
|
||||
const MAX_VERIFICATION_GUESSES: u32 = 100;
|
||||
if config.verification_code_guesses > MAX_VERIFICATION_GUESSES {
|
||||
return Err(AppError::BadRequestError(
|
||||
"Verification expired.".to_string(),
|
||||
));
|
||||
return Err(AppError::BadRequest("Verification expired.".to_string()));
|
||||
}
|
||||
let new_config = if config.verification_code == verification_code {
|
||||
EmailBackendConfig {
|
||||
|
|
10
src/email.rs
10
src/email.rs
|
@ -2,12 +2,22 @@ use anyhow::{Context, Result};
|
|||
use axum::extract::FromRef;
|
||||
use futures::Future;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||
use regex::Regex;
|
||||
use serde::{Serialize, Serializer};
|
||||
|
||||
use crate::app_state::AppState;
|
||||
|
||||
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)]
|
||||
pub struct Message {
|
||||
#[serde(rename = "From")]
|
||||
|
|
|
@ -4,7 +4,10 @@ use diesel::prelude::*;
|
|||
use uuid::Uuid;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
|
@ -17,27 +20,23 @@ pub async fn require_team_membership(
|
|||
team_id: &Uuid,
|
||||
db_conn: &Connection,
|
||||
) -> Result<Team, AppError> {
|
||||
let maybe_team = {
|
||||
let current_user_id = current_user.id;
|
||||
let team_id = *team_id;
|
||||
db_conn
|
||||
.interact::<_, Result<Option<(Team, _)>>>(move |conn| {
|
||||
TeamMembership::all()
|
||||
.filter(TeamMembership::with_user_id(¤t_user_id))
|
||||
.filter(TeamMembership::with_team_id(&team_id))
|
||||
.first(conn)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
})
|
||||
.await
|
||||
.unwrap()?
|
||||
};
|
||||
match maybe_team {
|
||||
Some((team, _)) => Ok(team),
|
||||
None => Err(AppError::ForbiddenError(
|
||||
"not a member of requested team".to_string(),
|
||||
)),
|
||||
}
|
||||
let current_user_id = current_user.id;
|
||||
let team_id = *team_id;
|
||||
db_conn
|
||||
.interact::<_, Result<Option<PopulatedTeamMembership>>>(move |conn| {
|
||||
PopulatedTeamMembership::all()
|
||||
.filter(TeamMembership::with_user_id(¤t_user_id))
|
||||
.filter(TeamMembership::with_team_id(&team_id))
|
||||
.first(conn)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
})
|
||||
.await
|
||||
.unwrap()?
|
||||
.map(|team_membership| team_membership.team)
|
||||
.ok_or(AppError::Forbidden(
|
||||
"not a member of requested team".to_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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? {
|
||||
Ok(())
|
||||
} 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 sessions;
|
||||
mod settings;
|
||||
mod team_invitations;
|
||||
mod team_memberships;
|
||||
mod teams;
|
||||
mod teams_router;
|
||||
|
|
|
@ -7,6 +7,7 @@ use crate::app_state::AppState;
|
|||
pub const NAVBAR_ITEM_TEAMS: &str = "teams";
|
||||
pub const NAVBAR_ITEM_PROJECTS: &str = "projects";
|
||||
pub const NAVBAR_ITEM_CHANNELS: &str = "channels";
|
||||
pub const NAVBAR_ITEM_TEAM_MEMBERS: &str = "team-members";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BreadcrumbTrail {
|
||||
|
@ -202,6 +203,11 @@ impl Default for NavbarBuilder {
|
|||
"Channels",
|
||||
"/en/teams/{team_id}/channels",
|
||||
)
|
||||
.push_item(
|
||||
NAVBAR_ITEM_TEAM_MEMBERS,
|
||||
"Team Members",
|
||||
"/en/teams/{team_id}/members",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -124,7 +124,7 @@ async fn project_page(
|
|||
.filter(Project::with_team(&team_id))
|
||||
.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(),
|
||||
)),
|
||||
other => other
|
||||
|
@ -213,7 +213,7 @@ async fn update_enabled_channels(
|
|||
.filter(Project::with_team(&team_id))
|
||||
.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(),
|
||||
)),
|
||||
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! {
|
||||
team_memberships (team_id, user_id) {
|
||||
team_id -> Uuid,
|
||||
|
@ -114,6 +124,8 @@ diesel::joinable!(governors -> teams (team_id));
|
|||
diesel::joinable!(messages -> channels (channel_id));
|
||||
diesel::joinable!(messages -> projects (project_id));
|
||||
diesel::joinable!(projects -> teams (team_id));
|
||||
diesel::joinable!(team_invitations -> teams (team_id));
|
||||
diesel::joinable!(team_invitations -> users (created_by));
|
||||
diesel::joinable!(team_memberships -> teams (team_id));
|
||||
diesel::joinable!(team_memberships -> users (user_id));
|
||||
|
||||
|
@ -127,6 +139,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||
governors,
|
||||
messages,
|
||||
projects,
|
||||
team_invitations,
|
||||
team_memberships,
|
||||
teams,
|
||||
users,
|
||||
|
|
|
@ -8,20 +8,29 @@ use crate::app_state::AppState;
|
|||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
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)]
|
||||
pub base_path: String,
|
||||
|
||||
/// postgresql:// URL.
|
||||
pub database_url: String,
|
||||
|
||||
/// When set to 1, embedded Diesel migrations will be run on startup.
|
||||
pub run_database_migrations: Option<u8>,
|
||||
|
||||
/// Address for server to bind to
|
||||
#[serde(default = "default_host")]
|
||||
pub host: String,
|
||||
|
||||
/// Port for server to bind to
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
|
||||
/// Host visible to end users, for example "https://shout.dev"
|
||||
pub frontend_host: String,
|
||||
|
||||
pub auth: AuthSettings,
|
||||
|
||||
pub email: EmailSettings,
|
||||
|
@ -39,7 +48,6 @@ fn default_host() -> String {
|
|||
pub struct AuthSettings {
|
||||
pub client_id: String,
|
||||
pub client_secret: String,
|
||||
pub redirect_url: String,
|
||||
pub auth_url: String,
|
||||
pub token_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 {
|
||||
#[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)]
|
||||
pub fn with_team_id<'a>(id: &'a Uuid) -> _ {
|
||||
team_memberships::team_id.eq(id)
|
||||
|
@ -40,3 +31,22 @@ impl TeamMembership {
|
|||
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::{
|
||||
api_keys::ApiKey,
|
||||
schema::{api_keys, teams},
|
||||
schema::{api_keys, team_invitations, team_memberships, teams, users},
|
||||
team_invitations::{PopulatedTeamInvitation, TeamInvitation},
|
||||
team_memberships::TeamMembership,
|
||||
users::User,
|
||||
};
|
||||
|
||||
/// Teams are the fundamental organizing unit for billing and help to
|
||||
|
@ -28,10 +31,42 @@ impl Team {
|
|||
teams::table.select(select)
|
||||
}
|
||||
|
||||
#[auto_type(no_type_alias)]
|
||||
pub fn with_id(id: &Uuid) -> _ {
|
||||
teams::id.eq(id)
|
||||
}
|
||||
|
||||
#[auto_type(no_type_alias)]
|
||||
pub fn api_keys(&self) -> _ {
|
||||
let all: diesel::dsl::Select<api_keys::table, AsSelect<ApiKey, Pg>> = ApiKey::all();
|
||||
let filter: Eq<api_keys::team_id, &Uuid> = ApiKey::with_team(&self.id);
|
||||
all.filter(filter)
|
||||
}
|
||||
|
||||
#[auto_type(no_type_alias)]
|
||||
pub fn invitations(&self) -> _ {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let all: diesel::dsl::Select<
|
||||
diesel::dsl::InnerJoin<
|
||||
diesel::dsl::InnerJoin<team_invitations::table, teams::table>,
|
||||
diesel::dsl::On<
|
||||
users::table,
|
||||
diesel::dsl::Eq<users::id, team_invitations::created_by>,
|
||||
>,
|
||||
>,
|
||||
AsSelect<PopulatedTeamInvitation, Pg>,
|
||||
> = PopulatedTeamInvitation::all();
|
||||
let filter: Eq<team_invitations::team_id, &Uuid> = TeamInvitation::with_team_id(&self.id);
|
||||
all.filter(filter)
|
||||
}
|
||||
|
||||
#[auto_type(no_type_alias)]
|
||||
pub fn members(&self) -> _ {
|
||||
let select: AsSelect<User, Pg> = User::as_select();
|
||||
let filter: Eq<team_memberships::team_id, &Uuid> = TeamMembership::with_team_id(&self.id);
|
||||
team_memberships::table
|
||||
.inner_join(users::table)
|
||||
.filter(filter)
|
||||
.select(select)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use anyhow::Context as _;
|
||||
use anyhow::{Context as _, Result};
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
|
@ -16,24 +16,47 @@ use crate::{
|
|||
app_error::AppError,
|
||||
app_state::{AppState, DbConn},
|
||||
csrf::generate_csrf_token,
|
||||
email::{is_permissible_email, Mailer},
|
||||
guards,
|
||||
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS},
|
||||
nav::{BreadcrumbTrail, Navbar, NavbarBuilder, NAVBAR_ITEM_TEAMS, NAVBAR_ITEM_TEAM_MEMBERS},
|
||||
projects::{Project, DEFAULT_PROJECT_NAME},
|
||||
schema::{api_keys, team_memberships, teams},
|
||||
schema::{api_keys, team_invitations, team_memberships, teams, users},
|
||||
settings::Settings,
|
||||
team_invitations::{InvitationBuilder, PopulatedTeamInvitation, TeamInvitation},
|
||||
team_memberships::TeamMembership,
|
||||
teams::Team,
|
||||
users::CurrentUser,
|
||||
users::{CurrentUser, User},
|
||||
};
|
||||
|
||||
pub fn new_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/teams", get(teams_page))
|
||||
.route("/new-team", get(new_team_page))
|
||||
.route("/new-team", post(post_new_team))
|
||||
.route("/teams/{team_id}", get(team_page))
|
||||
.route("/teams/{team_id}/new-api-key", post(post_new_api_key))
|
||||
.route("/teams/{team_id}/remove-api-key", post(remove_api_key))
|
||||
.route("/new-team", get(new_team_page))
|
||||
.route("/new-team", post(post_new_team))
|
||||
.route("/teams/{team_id}/members", get(team_members_page))
|
||||
.route(
|
||||
"/teams/{team_id}/invite-team-member",
|
||||
post(invite_team_member),
|
||||
)
|
||||
.route(
|
||||
"/teams/{team_id}/accept-invitation",
|
||||
get(accept_invitation_page),
|
||||
)
|
||||
.route(
|
||||
"/teams/{team_id}/accept-invitation",
|
||||
post(post_accept_invitation),
|
||||
)
|
||||
.route(
|
||||
"/teams/{team_id}/remove-team-member",
|
||||
post(remove_team_member),
|
||||
)
|
||||
.route(
|
||||
"/teams/{team_id}/remove-team-invitation",
|
||||
post(remove_team_invitation),
|
||||
)
|
||||
}
|
||||
|
||||
async fn teams_page(
|
||||
|
@ -71,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(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
State(navbar_template): State<NavbarBuilder>,
|
||||
|
@ -223,3 +165,395 @@ async fn post_new_team(
|
|||
ApiKey::generate_for_team(&db_conn, team_id).await?;
|
||||
Ok(Redirect::to(&format!("{}/en/teams/{}/projects", base_path, team_id)).into_response())
|
||||
}
|
||||
|
||||
async fn team_page(
|
||||
State(Settings { base_path, .. }): State<Settings>,
|
||||
Path(team_id): Path<Uuid>,
|
||||
) -> 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 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::{
|
||||
associations::Identifiable,
|
||||
deserialize::Queryable,
|
||||
|
@ -13,8 +23,9 @@ use uuid::Uuid;
|
|||
use crate::{
|
||||
app_error::AppError,
|
||||
app_state::AppState,
|
||||
auth::AuthInfo,
|
||||
auth::{AuthInfo, SESSION_KEY_AUTH_INFO, SESSION_KEY_AUTH_REDIRECT},
|
||||
schema::{team_memberships, teams, users},
|
||||
sessions::AppSession,
|
||||
team_memberships::TeamMembership,
|
||||
teams::Team,
|
||||
};
|
||||
|
@ -57,20 +68,54 @@ impl<S> FromRequestParts<S> for CurrentUser
|
|||
where
|
||||
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> {
|
||||
let state: AppState = state.clone().into();
|
||||
let auth_info = parts
|
||||
.extract_with_state::<AuthInfo, AppState>(&state)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::auth_redirect_from_base_path(state.settings.base_path.clone())
|
||||
})?;
|
||||
let current_user = state
|
||||
.db_pool
|
||||
.get()
|
||||
.await?
|
||||
let app_state: AppState = state.clone().into();
|
||||
let mut session =
|
||||
if let AppSession(Some(value)) = parts.extract_with_state(&app_state).await? {
|
||||
value
|
||||
} else {
|
||||
Session::new()
|
||||
};
|
||||
let auth_info = if let Some(value) = session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO) {
|
||||
value
|
||||
} else {
|
||||
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| {
|
||||
let maybe_current_user = User::all()
|
||||
.filter(User::with_uid(&auth_info.sub))
|
||||
|
@ -112,3 +157,29 @@ where
|
|||
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)?;
|
||||
|
||||
let api_key = {
|
||||
let query_key = try_parse_as_uuid(&query.key).or(Err(AppError::ForbiddenError(
|
||||
"key not accepted".to_string(),
|
||||
)))?;
|
||||
let query_key = try_parse_as_uuid(&query.key)
|
||||
.or(Err(AppError::Forbidden("key not accepted".to_string())))?;
|
||||
db_conn
|
||||
.interact::<_, Result<ApiKey, AppError>>(move |conn| {
|
||||
update(api_keys::table.filter(ApiKey::with_id(&query_key)))
|
||||
|
@ -78,7 +77,7 @@ async fn say_get(
|
|||
.get_result(conn)
|
||||
.optional()
|
||||
.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
|
||||
.unwrap()?
|
||||
|
@ -146,7 +145,7 @@ async fn say_get(
|
|||
.unwrap()?
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::TooManyRequestsError(
|
||||
return Err(AppError::TooManyRequests(
|
||||
"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>
|
||||
{% 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 }}/main.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "nav.html" %}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||
<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
|
||||
class="navbar-toggler"
|
||||
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 %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ base_path }}/en/teams/{{ team.id }}">
|
||||
<a href="{{ base_path }}/en/teams/{{ team.id.simple() }}">
|
||||
{{ team.name }}
|
||||
</a>
|
||||
</td>
|
||||
|
|
Loading…
Add table
Reference in a new issue