feat: accept workspace invite email

This commit is contained in:
Zack Fu Zi Xiang 2024-04-29 19:40:20 +08:00
parent 418341f49f
commit 6e74449ab1
No known key found for this signature in database
29 changed files with 758 additions and 76 deletions

View file

@ -45,6 +45,8 @@ jobs:
sed -i 's/GOTRUE_MAILER_AUTOCONFIRM=.*/GOTRUE_MAILER_AUTOCONFIRM=false/' .env
sed -i 's/API_EXTERNAL_URL=http:\/\/your-host/API_EXTERNAL_URL=http:\/\/localhost/' .env
sed -i 's/GOTRUE_RATE_LIMIT_EMAIL_SENT=100/GOTRUE_RATE_LIMIT_EMAIL_SENT=1000/' .env
sed -i 's/APPFLOWY_MAILER_SMTP_USERNAME=.*/APPFLOWY_MAILER_SMTP_USERNAME=${{ secrets.CI_GOTRUE_SMTP_USER }}/' .env
sed -i 's/APPFLOWY_MAILER_SMTP_PASSWORD=.*/APPFLOWY_MAILER_SMTP_PASSWORD=${{ secrets.CI_GOTRUE_SMTP_PASS }}/' .env
- name: Update Nginx Configuration
run: |

View file

@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO public.af_workspace_invitation (\n workspace_id,\n inviter,\n invitee_email,\n role_id\n )\n VALUES (\n $1,\n (SELECT uid FROM public.af_user WHERE uuid = $2),\n $3,\n $4\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "060018c2bfdfcfb4b06186b6bc29b06b810745569363613d6779444ead6a3cd7"
}

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COUNT(*)\n FROM public.af_workspace_member\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "073a6c450bfc161ba7551509831c59018897a49ac61ae894a71d31be1cca3591"
}

View file

@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO public.af_workspace_invitation (\n id,\n workspace_id,\n inviter,\n invitee_email,\n role_id\n )\n VALUES (\n $1,\n $2,\n (SELECT uid FROM public.af_user WHERE uuid = $3),\n $4,\n $5\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "52b936c6adf43ec5c7e777ad9379dec30b750fefad73684e552481f709006d04"
}

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT workspace_name\n FROM public.af_workspace\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_name",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "6821f1e02da2c71cdf0566a163c85ff185bf0ba89c770254c9c15880ba76a553"
}

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT name FROM af_user WHERE uuid = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "7f6b1db5fd7b4e235f1e04d9d990fa2d47edfed23e692fbab778d387b2861a22"
}

176
Cargo.lock generated
View file

@ -627,10 +627,12 @@ dependencies = [
"gotrue",
"gotrue-entity",
"governor",
"handlebars",
"image",
"infra",
"itertools 0.11.0",
"lazy_static",
"lettre",
"log",
"mime",
"once_cell",
@ -1153,6 +1155,12 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "base64"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51"
[[package]]
name = "base64ct"
version = "1.6.0"
@ -1458,6 +1466,16 @@ dependencies = [
"windows-targets 0.52.0",
]
[[package]]
name = "chumsky"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
dependencies = [
"hashbrown 0.14.3",
"stacker",
]
[[package]]
name = "ciborium"
version = "0.2.2"
@ -2294,6 +2312,22 @@ dependencies = [
"serde",
]
[[package]]
name = "email-encoding"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60d1d33cdaede7e24091f039632eb5d3c7469fe5b066a985281a34fc70fa317f"
dependencies = [
"base64 0.22.0",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112"
[[package]]
name = "encoding_rs"
version = "0.8.33"
@ -2776,6 +2810,20 @@ dependencies = [
"crunchy",
]
[[package]]
name = "handlebars"
version = "5.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d08485b96a0e6393e9e4d1b8d48cf74ad6c063cd905eb33f42c1ce3f0377539b"
dependencies = [
"log",
"pest",
"pest_derive",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "hashbrown"
version = "0.11.2"
@ -2867,6 +2915,17 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "hostname"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba"
dependencies = [
"cfg-if 1.0.0",
"libc",
"windows",
]
[[package]]
name = "html5ever"
version = "0.26.0"
@ -3322,6 +3381,34 @@ dependencies = [
"spin 0.5.2",
]
[[package]]
name = "lettre"
version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a62049a808f1c4e2356a2a380bd5f2aca3b011b0b482cf3b914ba1731426969"
dependencies = [
"async-trait",
"base64 0.22.0",
"chumsky",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna 0.5.0",
"mime",
"native-tls",
"nom",
"percent-encoding",
"quoted_printable",
"socket2",
"tokio",
"tokio-native-tls",
"url",
]
[[package]]
name = "libc"
version = "0.2.152"
@ -3910,6 +3997,51 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "pest"
version = "2.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fb059dee1a7b802f036316d790138c613a4e8b180c822e3925a662e9f0c95"
dependencies = [
"memchr",
"thiserror",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f73541b156d32197eecda1a4014d7f868fd2bcb3c550d5386087cfba442bf69c"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c35eeed0a3fab112f75165fdc026b3913f4183133f19b49be773ac9ea966e8bd"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]]
name = "pest_meta"
version = "2.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2adbf29bb9776f28caece835398781ab24435585fe0d4dc1374a61db5accedca"
dependencies = [
"once_cell",
"pest",
"sha2",
]
[[package]]
name = "petgraph"
version = "0.6.4"
@ -4326,6 +4458,15 @@ version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "psm"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5787f7cda34e3033a72192c018bc5883100330f362ef279a8cbccfce8bb4e874"
dependencies = [
"cc",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
@ -4401,6 +4542,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79ec282e887b434b68c18fe5c121d38e72a5cf35119b59e54ec5b992ea9c8eb0"
[[package]]
name = "radium"
version = "0.7.0"
@ -5659,6 +5806,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "stacker"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c886bd4480155fd3ef527d45e9ac8dd7118a898a46530b7b94c3e21866259fce"
dependencies = [
"cc",
"cfg-if 1.0.0",
"libc",
"psm",
"winapi",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
@ -6351,6 +6511,12 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "ucd-trie"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9"
[[package]]
name = "unicase"
version = "2.7.0"
@ -6774,6 +6940,16 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core",
"windows-targets 0.52.0",
]
[[package]]
name = "windows-core"
version = "0.52.0"

View file

@ -95,6 +95,8 @@ collab-stream.workspace = true
serde_repr = "0.1.18"
tonic-build = "0.11.0"
log = "0.4.20"
lettre = { version = "0.11.7", features = ["tokio1", "tokio1-native-tls"] }
handlebars = "5.1.2"
[dev-dependencies]

View file

@ -48,6 +48,8 @@ pub enum WebAppError {
Askama(askama::Error),
GoTrue(gotrue_entity::error::GoTrueError),
ExtApi(ext::error::Error),
Redis(redis::RedisError),
BadRequest(String),
}
impl IntoResponse for WebAppError {
@ -62,10 +64,24 @@ impl IntoResponse for WebAppError {
Redirect::to("/login").into_response()
},
WebAppError::ExtApi(e) => e.into_response(),
WebAppError::Redis(e) => {
tracing::error!("redis error: {:?}", e);
status::StatusCode::INTERNAL_SERVER_ERROR.into_response()
},
WebAppError::BadRequest(e) => {
tracing::error!("bad request: {:?}", e);
status::StatusCode::BAD_REQUEST.into_response()
},
}
}
}
impl From<redis::RedisError> for WebAppError {
fn from(v: redis::RedisError) -> Self {
WebAppError::Redis(v)
}
}
impl From<askama::Error> for WebAppError {
fn from(v: askama::Error) -> Self {
WebAppError::Askama(v)

View file

@ -35,3 +35,29 @@ pub struct WebApiCreateSSOProviderRequest {
pub type_: String,
pub metadata_url: String,
}
#[derive(Deserialize)]
pub struct WebAppOAuthLoginRequest {
// Use for Login
pub refresh_token: Option<String>,
// Use actions (with params) after login
pub action: Option<OAuthLoginAction>,
pub workspace_invitation_id: Option<String>,
pub workspace_name: Option<String>,
pub workspace_icon: Option<String>,
pub user_name: Option<String>,
pub user_icon: Option<String>,
pub workspace_member_count: Option<String>,
// Errors
pub error: Option<String>,
pub error_code: Option<i64>,
pub error_description: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OAuthLoginAction {
AcceptWorkspaceInvite,
}

View file

@ -6,7 +6,7 @@ use axum::{
http::request::Parts,
response::{IntoResponse, Redirect},
};
use axum_extra::extract::CookieJar;
use axum_extra::extract::{cookie::Cookie, CookieJar};
use gotrue::grant::{Grant, RefreshTokenGrant};
use gotrue_entity::dto::GotrueTokenResponse;
use jwt::{Claims, Header};
@ -218,3 +218,9 @@ fn expect_redis_value_data(v: &redis::Value) -> redis::RedisResult<&[u8]> {
))),
}
}
pub fn new_session_cookie(id: uuid::Uuid) -> Cookie<'static> {
let mut cookie = Cookie::new("session_id", id.to_string());
cookie.set_path("/");
cookie
}

View file

@ -4,6 +4,20 @@ use gotrue_entity::{dto::User, sso::SSOProvider};
use crate::{askama_entities::WorkspaceWithMembers, ext::entities::WorkspaceUsageLimits};
#[derive(Template)]
#[template(path = "pages/redirect.html")]
pub struct Redirect {
pub redirect_url: String,
}
#[derive(Template)]
#[template(path = "pages/open_appflowy_or_download.html")]
pub struct OpenAppFlowyOrDownload {}
#[derive(Template)]
#[template(path = "pages/login_callback.html")]
pub struct LoginCallback {}
#[derive(Template)]
#[template(path = "components/user_usage.html")]
pub struct UserUsage {

View file

@ -7,7 +7,7 @@ use crate::models::{
WebApiInviteUserRequest, WebApiPutUserRequest,
};
use crate::response::WebApiResponse;
use crate::session::{self, UserSession};
use crate::session::{self, new_session_cookie, UserSession};
use crate::{models::WebApiLoginRequest, AppState};
use axum::extract::Path;
use axum::http::{status, HeaderMap};
@ -54,7 +54,7 @@ pub fn router() -> Router<AppState> {
.route("/admin/sso/:provider_id", delete(admin_delete_sso_handler))
}
pub async fn admin_delete_sso_handler(
async fn admin_delete_sso_handler(
State(state): State<AppState>,
session: UserSession,
Path(provider_id): Path<String>,
@ -67,7 +67,7 @@ pub async fn admin_delete_sso_handler(
Ok(WebApiResponse::<()>::from_str("SSO Deleted".into()))
}
pub async fn admin_create_sso_handler(
async fn admin_create_sso_handler(
State(state): State<AppState>,
session: UserSession,
Form(param): Form<WebApiCreateSSOProviderRequest>,
@ -103,7 +103,7 @@ pub async fn admin_create_sso_handler(
/// The client application should implement handling for this URL format, typically through the
/// `sign_in_with_url` method in the `client-api` crate. See [client_api::Client::sign_in_with_url] for more details.
///
pub async fn open_app_handler(session: UserSession) -> Result<HeaderMap, WebApiError<'static>> {
async fn open_app_handler(session: UserSession) -> Result<HeaderMap, WebApiError<'static>> {
let app_sign_in_url = format!(
"appflowy-flutter://login-callback#access_token={}&expires_at={}&expires_in={}&refresh_token={}&token_type={}",
session.token.access_token,
@ -117,7 +117,7 @@ pub async fn open_app_handler(session: UserSession) -> Result<HeaderMap, WebApiE
// Invite another user, this will trigger email sending
// to the target user
pub async fn invite_handler(
async fn invite_handler(
State(state): State<AppState>,
Form(param): Form<WebApiInviteUserRequest>,
) -> Result<WebApiResponse<()>, WebApiError<'static>> {
@ -134,7 +134,7 @@ pub async fn invite_handler(
Ok(WebApiResponse::<()>::from_str("Invitation sent".into()))
}
pub async fn workspace_invite_handler(
async fn workspace_invite_handler(
State(state): State<AppState>,
session: UserSession,
Path(workspace_id): Path<String>,
@ -151,7 +151,7 @@ pub async fn workspace_invite_handler(
Ok(WebApiResponse::<()>::from_str("Invitation sent".into()))
}
pub async fn leave_workspace_handler(
async fn leave_workspace_handler(
State(state): State<AppState>,
session: UserSession,
Path(workspace_id): Path<String>,
@ -166,7 +166,7 @@ pub async fn leave_workspace_handler(
Ok(WebApiResponse::<()>::from_str("Left workspace".into()))
}
pub async fn invite_accept_handler(
async fn invite_accept_handler(
State(state): State<AppState>,
session: UserSession,
Path(invite_id): Path<String>,
@ -181,7 +181,7 @@ pub async fn invite_accept_handler(
Ok(htmx_trigger("workspaceInvitationAccepted"))
}
pub async fn change_password_handler(
async fn change_password_handler(
State(state): State<AppState>,
session: UserSession,
Form(param): Form<WebApiChangePasswordRequest>,
@ -205,7 +205,7 @@ pub async fn change_password_handler(
Ok(WebApiResponse::<()>::from_str("Password changed".into()))
}
pub async fn post_oauth_login_handler(
async fn post_oauth_login_handler(
header_map: HeaderMap,
Path(provider): Path<String>,
) -> Result<WebApiResponse<String>, WebApiError<'static>> {
@ -219,7 +219,7 @@ pub async fn post_oauth_login_handler(
Ok(oauth_url.into())
}
pub async fn admin_update_user_handler(
async fn admin_update_user_handler(
State(state): State<AppState>,
session: UserSession,
Path(user_uuid): Path<String>,
@ -240,7 +240,7 @@ pub async fn admin_update_user_handler(
Ok(res.into())
}
pub async fn post_user_generate_link_handler(
async fn post_user_generate_link_handler(
State(state): State<AppState>,
session: UserSession,
Path(email): Path<String>,
@ -258,7 +258,7 @@ pub async fn post_user_generate_link_handler(
Ok(res.action_link)
}
pub async fn admin_delete_user_handler(
async fn admin_delete_user_handler(
State(state): State<AppState>,
session: UserSession,
Path(user_uuid): Path<String>,
@ -276,7 +276,7 @@ pub async fn admin_delete_user_handler(
Ok(().into())
}
pub async fn admin_add_user_handler(
async fn admin_add_user_handler(
State(state): State<AppState>,
session: UserSession,
Form(param): Form<WebApiAdminCreateUserRequest>,
@ -294,7 +294,7 @@ pub async fn admin_add_user_handler(
Ok(WebApiResponse::<()>::from_str("User created".into()))
}
pub async fn login_refresh_handler(
async fn login_refresh_handler(
State(state): State<AppState>,
jar: CookieJar,
Path(refresh_token): Path<String>,
@ -321,7 +321,7 @@ pub async fn login_refresh_handler(
// login and set the cookie
// sign up if not exist
pub async fn sign_in_handler(
async fn sign_in_handler(
State(state): State<AppState>,
jar: CookieJar,
Form(param): Form<WebApiLoginRequest>,
@ -345,7 +345,7 @@ pub async fn sign_in_handler(
session_login(State(state), token, jar).await
}
pub async fn sign_up_handler(
async fn sign_up_handler(
State(state): State<AppState>,
jar: CookieJar,
Form(param): Form<WebApiLoginRequest>,
@ -374,7 +374,7 @@ pub async fn sign_up_handler(
}
}
pub async fn logout_handler(
async fn logout_handler(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<(CookieJar, HeaderMap), WebApiError<'static>> {
@ -405,12 +405,6 @@ fn htmx_trigger(trigger: &str) -> HeaderMap {
h
}
fn new_session_cookie(id: uuid::Uuid) -> Cookie<'static> {
let mut cookie = Cookie::new("session_id", id.to_string());
cookie.set_path("/");
cookie
}
async fn session_login(
State(state): State<AppState>,
token: GotrueTokenResponse,

View file

@ -1,14 +1,17 @@
use crate::askama_entities::WorkspaceWithMembers;
use crate::error::WebAppError;
use crate::ext::api::{
get_pending_workspace_invitations, get_user_owned_workspaces, get_user_profile,
get_user_workspace_limit, get_user_workspace_usages, get_user_workspaces, get_workspace_members,
accept_workspace_invitation, get_pending_workspace_invitations, get_user_owned_workspaces,
get_user_profile, get_user_workspace_limit, get_user_workspace_usages, get_user_workspaces,
get_workspace_members, verify_token_cloud,
};
use crate::session::UserSession;
use crate::models::{OAuthLoginAction, WebAppOAuthLoginRequest};
use crate::session::{self, new_session_cookie, UserSession};
use askama::Template;
use axum::extract::{Path, State};
use axum::extract::{Path, Query, State};
use axum::response::Result;
use axum::{response::Html, routing::get, Router};
use axum_extra::extract::CookieJar;
use gotrue_entity::dto::User;
use crate::{templates, AppState};
@ -19,15 +22,17 @@ pub fn router(state: AppState) -> Router<AppState> {
.nest_service("/components", component_router().with_state(state))
}
pub fn page_router() -> Router<AppState> {
fn page_router() -> Router<AppState> {
Router::new()
.route("/", get(home_handler))
.route("/login", get(login_handler))
.route("/login-callback", get(login_callback_handler))
.route("/login-callback-query", get(login_callback_query_handler))
.route("/home", get(home_handler))
.route("/admin/home", get(admin_home_handler))
}
pub fn component_router() -> Router<AppState> {
fn component_router() -> Router<AppState> {
Router::new()
// User actions
.route("/user/navigate", get(user_navigate_handler))
@ -49,7 +54,96 @@ pub fn component_router() -> Router<AppState> {
.route("/admin/sso/:sso_provider_id", get(admin_sso_detail_handler))
}
pub async fn admin_sso_detail_handler(
async fn login_callback_handler() -> Result<Html<String>, WebAppError> {
render_template(templates::LoginCallback {})
}
async fn login_callback_query_handler(
State(state): State<AppState>,
Query(query): Query<WebAppOAuthLoginRequest>,
mut jar: CookieJar,
) -> Result<(CookieJar, Html<String>), WebAppError> {
if let Some(err) = query.error {
tracing::error!(
"OAuth login error: {:?}, code: {:?}, description: {:?}",
err,
query.error_code,
query.error_description
);
return Ok((jar, render_template(templates::Redirect {
redirect_url: format!(
"https://appflowy.io/invitation/expired?workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}",
query.workspace_name.unwrap_or_default(),
query.workspace_icon.unwrap_or_default(),
query.user_name.unwrap_or_default(),
query.user_icon.unwrap_or_default(),
query.workspace_member_count.unwrap_or_default()),
})?));
};
let token = state
.gotrue_client
.token(&gotrue::grant::Grant::RefreshToken(
gotrue::grant::RefreshTokenGrant {
refresh_token: query.refresh_token.ok_or(WebAppError::BadRequest(
"refresh_token not found".to_string(),
))?,
},
))
.await?;
// Do another round of refresh_token to consume and invalidate the old one
let token = state
.gotrue_client
.token(&gotrue::grant::Grant::RefreshToken(
gotrue::grant::RefreshTokenGrant {
refresh_token: token.refresh_token,
},
))
.await?;
verify_token_cloud(
token.access_token.as_str(),
state.appflowy_cloud_url.as_str(),
)
.await?;
let new_session_id = uuid::Uuid::new_v4();
let new_session = session::UserSession::new(new_session_id.to_string(), token);
state.session_store.put_user_session(&new_session).await?;
jar = jar.add(new_session_cookie(new_session_id));
match query.action {
Some(action) => match action {
OAuthLoginAction::AcceptWorkspaceInvite => {
let invite_id = query
.workspace_invitation_id
.ok_or(WebAppError::BadRequest(
"workspace_invitation_id not found".to_string(),
))?;
if let Err(err) = accept_workspace_invitation(
&new_session.token.access_token,
&invite_id,
&state.appflowy_cloud_url,
)
.await
{
tracing::error!("accepting workspace invitation: {:?}", err);
return Ok((
jar,
render_template(templates::Redirect {
redirect_url: "https://test.appflowy.io/invitation/expired".to_string(),
})?,
));
};
Ok((jar, render_template(templates::OpenAppFlowyOrDownload {})?))
},
},
None => Ok((jar, home_handler(State(state), new_session).await?)),
}
}
async fn admin_sso_detail_handler(
State(state): State<AppState>,
session: UserSession,
Path(sso_provider_id): Path<String>,
@ -68,11 +162,11 @@ pub async fn admin_sso_detail_handler(
})
}
pub async fn admin_sso_create_handler() -> Result<Html<String>, WebAppError> {
async fn admin_sso_create_handler() -> Result<Html<String>, WebAppError> {
render_template(templates::SsoCreate)
}
pub async fn admin_sso_handler(
async fn admin_sso_handler(
State(state): State<AppState>,
session: UserSession,
) -> Result<Html<String>, WebAppError> {
@ -86,15 +180,15 @@ pub async fn admin_sso_handler(
render_template(templates::SsoList { sso_providers })
}
pub async fn user_navigate_handler() -> Result<Html<String>, WebAppError> {
async fn user_navigate_handler() -> Result<Html<String>, WebAppError> {
render_template(templates::Navigate)
}
pub async fn admin_navigate_handler() -> Result<Html<String>, WebAppError> {
async fn admin_navigate_handler() -> Result<Html<String>, WebAppError> {
render_template(templates::AdminNavigate)
}
pub async fn shared_workspaces_handler(
async fn shared_workspaces_handler(
State(state): State<AppState>,
session: UserSession,
) -> Result<Html<String>, WebAppError> {
@ -115,7 +209,7 @@ pub async fn shared_workspaces_handler(
render_template(templates::SharedWorkspaces { shared_workspaces })
}
pub async fn user_invite_handler(
async fn user_invite_handler(
State(state): State<AppState>,
session: UserSession,
) -> Result<Html<String>, WebAppError> {
@ -158,7 +252,7 @@ pub async fn user_invite_handler(
})
}
pub async fn user_usage_handler(
async fn user_usage_handler(
State(state): State<AppState>,
session: UserSession,
) -> Result<Html<String>, WebAppError> {
@ -188,7 +282,7 @@ pub async fn user_usage_handler(
})
}
pub async fn workspace_usage_handler(
async fn workspace_usage_handler(
State(app_state): State<AppState>,
session: UserSession,
) -> Result<Html<String>, WebAppError> {
@ -201,11 +295,11 @@ pub async fn workspace_usage_handler(
render_template(templates::WorkspaceUsageList { workspace_usages })
}
pub async fn admin_users_create_handler() -> Result<Html<String>, WebAppError> {
async fn admin_users_create_handler() -> Result<Html<String>, WebAppError> {
render_template(templates::CreateUser)
}
pub async fn user_user_handler(
async fn user_user_handler(
State(state): State<AppState>,
session: UserSession,
) -> Result<Html<String>, WebAppError> {
@ -216,17 +310,17 @@ pub async fn user_user_handler(
render_template(templates::UserDetails { user: &user })
}
pub async fn login_handler(State(state): State<AppState>) -> Result<Html<String>, WebAppError> {
async fn login_handler(State(state): State<AppState>) -> Result<Html<String>, WebAppError> {
let external = state.gotrue_client.settings().await?.external;
let oauth_providers = external.oauth_providers();
render_template(templates::Login { oauth_providers })
}
pub async fn user_change_password_handler() -> Result<Html<String>, WebAppError> {
async fn user_change_password_handler() -> Result<Html<String>, WebAppError> {
render_template(templates::ChangePassword)
}
pub async fn home_handler(
async fn home_handler(
State(state): State<AppState>,
session: UserSession,
) -> Result<Html<String>, WebAppError> {
@ -240,7 +334,7 @@ pub async fn home_handler(
})
}
pub async fn admin_home_handler(
async fn admin_home_handler(
State(state): State<AppState>,
session: UserSession,
) -> Result<Html<String>, WebAppError> {
@ -251,7 +345,7 @@ pub async fn admin_home_handler(
render_template(templates::AdminHome { user: &user })
}
pub async fn admin_users_handler(
async fn admin_users_handler(
State(state): State<AppState>,
session: UserSession,
) -> Result<Html<String>, WebAppError> {
@ -273,7 +367,7 @@ pub async fn admin_users_handler(
render_template(templates::AdminUsers { users: &users })
}
pub async fn admin_user_details_handler(
async fn admin_user_details_handler(
State(state): State<AppState>,
session: UserSession,
Path(user_id): Path<String>,
@ -281,8 +375,7 @@ pub async fn admin_user_details_handler(
let user = state
.gotrue_client
.admin_user_details(&session.token.access_token, &user_id)
.await
.unwrap(); // TODO: handle error
.await?;
render_template(templates::AdminUserDetails { user: &user })
}

View file

@ -0,0 +1,15 @@
<script>
window.onload = function() {
const current_url = window.location.href;
if (current_url.includes('/login-callback')) {
var redirect_url = current_url.replace('/login-callback', '/login-callback-query');
if (redirect_url.includes('?')) { // If '?' exists, replace '#' with '&'
redirect_url = redirect_url.replace('#', '&');
} else { // If '?' does not exist, replace '#' with '?'
redirect_url = redirect_url.replace('#', '?');
}
// alert(redirect_url);
window.location.href = redirect_url;
}
};
</script>

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Open App or Redirect</title>
<script>
function openApp() {
var appUrl = 'appflowy-flutter://';
var fallbackUrl = 'https://appflowy.io/download'; // URL to download page
var timeout = 2000; // 2 seconds
var timer = setTimeout(function () {
window.location = fallbackUrl;
}, timeout);
window.addEventListener('blur', function onWindowBlur() {
clearTimeout(timer); // Clear the timer if user focuses on the new tab/window (app opened)
window.removeEventListener('blur', onWindowBlur);
});
window.location = appUrl; // Try opening the app
}
window.onload = openApp; // Try to open the app right when the page loads
</script>
</head>
<body>
<h1>Opening AppFlowy</h1>
<p>If the app does not open, you will be redirected to the download page.</p>
</body>
</html>

View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0;url={{ redirect_url|escape }}">
<title>Redirecting...</title>
</head>
<body>
<p>If you are not redirected, <a href="{{ redirect_url|escape }}">click here</a>.</p>
</body>
</html>

View file

@ -106,3 +106,10 @@ OPENAI_API_KEY=
APPFLOWY_HISTORY_URL=http://history:50051
APPFLOWY_HISTORY_REDIS_URL=redis://redis:6379
APPFLOWY_HISTORY_DATABASE_URL=postgres://postgres:password@postgres:5432/postgres
# AppFlowy Cloud Mailer
APPFLOWY_MAILER_SMTP_HOST=smtp.gmail.com
APPFLOWY_MAILER_SMTP_USERNAME=email_sender@some_company.com
APPFLOWY_MAILER_SMTP_PASSWORD=email_sender_password
APPFLOWY_MAILER_WORKSPACE_INVITE_TEMPLATE_URL=https://raw.githubusercontent.com/AppFlowy-IO/Appflowy-Cloud/main/assets/mailer_templates/build_production/workspace_invitation.html

View file

@ -75,6 +75,12 @@ APPFLOWY_S3_SECRET_KEY=minioadmin
APPFLOWY_S3_BUCKET=appflowy
#APPFLOWY_S3_REGION=us-east-1
# AppFlowy Cloud Mailer
APPFLOWY_MAILER_SMTP_HOST=smtp.gmail.com
APPFLOWY_MAILER_SMTP_USERNAME=notify@appflowy.io
APPFLOWY_MAILER_SMTP_PASSWORD=email_sender_password
APPFLOWY_MAILER_WORKSPACE_INVITE_TEMPLATE_URL=https://raw.githubusercontent.com/AppFlowy-IO/Appflowy-Cloud/feat/support-invitation-html/assets/mailer_templates/build_production/workspace_invitation.html
RUST_LOG=info
# PgAdmin

View file

@ -212,3 +212,16 @@ pub async fn select_email_from_user_uuid(
.await?;
Ok(email)
}
#[inline]
pub async fn select_name_from_uuid(pool: &PgPool, user_uuid: &Uuid) -> Result<String, AppError> {
let email = sqlx::query_scalar!(
r#"
SELECT name FROM af_user WHERE uuid = $1
"#,
user_uuid
)
.fetch_one(pool)
.await?;
Ok(email)
}

View file

@ -247,6 +247,7 @@ pub async fn upsert_workspace_member_with_txn(
#[inline]
pub async fn insert_workspace_invitation(
txn: &mut Transaction<'_, sqlx::Postgres>,
invite_id: &uuid::Uuid,
workspace_id: &uuid::Uuid,
inviter_uuid: &Uuid,
invitee_email: &str,
@ -256,6 +257,7 @@ pub async fn insert_workspace_invitation(
sqlx::query!(
r#"
INSERT INTO public.af_workspace_invitation (
id,
workspace_id,
inviter,
invitee_email,
@ -263,11 +265,13 @@ pub async fn insert_workspace_invitation(
)
VALUES (
$1,
(SELECT uid FROM public.af_user WHERE uuid = $2),
$3,
$4
$2,
(SELECT uid FROM public.af_user WHERE uuid = $3),
$4,
$5
)
"#,
invite_id,
workspace_id,
inviter_uuid,
invitee_email,
@ -665,3 +669,39 @@ pub async fn select_workspace_total_collab_bytes(
))),
}
}
#[inline]
pub async fn select_workspace_name_from_workspace_id(
pool: &PgPool,
workspace_id: &Uuid,
) -> Result<Option<String>, AppError> {
let workspace_name = sqlx::query_scalar!(
r#"
SELECT workspace_name
FROM public.af_workspace
WHERE workspace_id = $1
"#,
workspace_id
)
.fetch_one(pool)
.await?;
Ok(workspace_name)
}
#[inline]
pub async fn select_workspace_member_count_from_workspace_id(
pool: &PgPool,
workspace_id: &Uuid,
) -> Result<Option<i64>, AppError> {
let workspace_count = sqlx::query_scalar!(
r#"
SELECT COUNT(*)
FROM public.af_workspace_member
WHERE workspace_id = $1
"#,
workspace_id
)
.fetch_one(pool)
.await?;
Ok(workspace_count)
}

View file

@ -249,6 +249,8 @@ async fn post_workspace_invite_handler(
) -> Result<JsonAppResponse<()>> {
let invited_members = payload.into_inner();
workspace::ops::invite_workspace_members(
&state.mailer,
&state.gotrue_admin,
&state.pg_pool,
&state.gotrue_client,
&user_uuid,

View file

@ -4,6 +4,7 @@ use crate::api::file_storage::file_storage_scope;
use crate::api::user::user_scope;
use crate::api::workspace::{collab_scope, workspace_scope};
use crate::api::ws::ws_scope;
use crate::mailer::Mailer;
use access_control::access::{enable_access_control, AccessControl};
use crate::biz::actix_ws::server::RealtimeServerActor;
@ -239,6 +240,14 @@ pub async fn init_state(config: &Config, rt_cmd_tx: RTCommandSender) -> Result<A
tonic_proto::history::history_client::HistoryClient::connect(config.grpc_history.addrs.clone())
.await?;
let mailer = Mailer::new(
config.mailer.smtp_username.clone(),
config.mailer.smtp_password.expose_secret().clone(),
&config.mailer.smtp_host,
&config.mailer.workspace_invite_template_url,
)
.await?;
info!("Application state initialized");
Ok(AppState {
pg_pool,
@ -256,6 +265,7 @@ pub async fn init_state(config: &Config, rt_cmd_tx: RTCommandSender) -> Result<A
access_control,
metrics,
gotrue_admin,
mailer,
#[cfg(feature = "ai_enable")]
appflowy_ai_client,
#[cfg(feature = "history")]

View file

@ -1,4 +1,6 @@
use crate::biz::workspace::access_control::WorkspaceAccessControl;
use crate::mailer::{Mailer, WorkspaceInviteMailerParam};
use crate::state::GoTrueAdmin;
use anyhow::Context;
use app_error::AppError;
use database::collab::upsert_collab_member_with_txn;
@ -20,7 +22,7 @@ use database_entity::dto::{
WorkspaceUsage,
};
use gotrue::params::MagicLinkParams;
use gotrue::params::{GenerateLinkParams, GenerateLinkType};
use shared_entity::dto::workspace_dto::{
CreateWorkspaceMember, WorkspaceMemberChangeset, WorkspaceMemberInvitation,
};
@ -162,6 +164,8 @@ pub async fn accept_workspace_invite(
#[instrument(level = "debug", skip_all, err)]
pub async fn invite_workspace_members(
mailer: &Mailer,
gotrue_admin: &GoTrueAdmin,
pg_pool: &PgPool,
gotrue_client: &gotrue::api::Client,
inviter: &Uuid,
@ -172,27 +176,71 @@ pub async fn invite_workspace_members(
.begin()
.await
.context("Begin transaction to invite workspace members")?;
let admin_token = gotrue_admin.token(gotrue_client).await?;
for invitation in invitations {
gotrue_client
.magic_link(
&MagicLinkParams {
let inviter_name = database::user::select_name_from_uuid(pg_pool, inviter).await?;
let workspace_name =
database::workspace::select_workspace_name_from_workspace_id(pg_pool, workspace_id)
.await?
.unwrap_or_default();
let workspace_member_count =
database::workspace::select_workspace_member_count_from_workspace_id(pg_pool, workspace_id)
.await?
.unwrap_or_default()
.to_string();
// default icon until we have workspace icon
let workspace_icon_url =
"https://miro.medium.com/v2/resize:fit:2400/1*mTPfm7CwU31-tLhtLNkyJw.png".to_string();
let user_icon_url =
"https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"
.to_string();
let invite_id = uuid::Uuid::new_v4();
let accept_url = gotrue_client
.admin_generate_link(
&admin_token,
&GenerateLinkParams {
type_: GenerateLinkType::MagicLink,
email: invitation.email.clone(),
redirect_to: format!(
"/web/login-callback?action=accept_workspace_invite&workspace_invitation_id={}&workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}",
invite_id, workspace_name,
workspace_icon_url,
inviter_name,
user_icon_url,
workspace_member_count,
),
..Default::default()
},
Some("/web/login#redirect_to=invite".to_owned()),
)
.await?;
.await?
.action_link;
// Generate a link such that when clicked, the user is added to the workspace.
insert_workspace_invitation(
&mut txn,
&invite_id,
workspace_id,
inviter,
invitation.email.as_str(),
invitation.role,
)
.await?;
mailer
.send_workspace_invite(
invitation.email,
WorkspaceInviteMailerParam {
user_icon_url,
username: inviter_name,
workspace_name,
workspace_icon_url,
workspace_member_count,
accept_url,
},
)
.await?;
}
txn

View file

@ -17,6 +17,15 @@ pub struct Config {
pub s3: S3Setting,
pub appflowy_ai: AppFlowyAISetting,
pub grpc_history: GrpcHistorySetting,
pub mailer: MailerSetting,
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct MailerSetting {
pub smtp_host: String,
pub smtp_username: String,
pub smtp_password: Secret<String>,
pub workspace_invite_template_url: String,
}
#[derive(serde::Deserialize, Clone, Debug)]
@ -162,6 +171,12 @@ pub fn get_configuration() -> Result<Config, anyhow::Error> {
grpc_history: GrpcHistorySetting {
addrs: get_env_var("APPFLOWY_GRPC_HISTORY_ADDRS", "http://localhost:50051"),
},
mailer: MailerSetting {
smtp_host: get_env_var("APPFLOWY_MAILER_SMTP_HOST", "smtp.gmail.com"),
smtp_username: get_env_var("APPFLOWY_MAILER_SMTP_USERNAME", "sender@example.com"),
smtp_password: get_env_var("APPFLOWY_MAILER_SMTP_PASSWORD", "password").into(),
workspace_invite_template_url: get_env_var("APPFLOWY_MAILER_WORKSPACE_INVITE_TEMPLATE_URL", "https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/workspace_invitation.html"),
},
};
Ok(config)
}

View file

@ -3,6 +3,7 @@ pub mod application;
pub mod biz;
pub mod config;
pub mod domain;
pub mod mailer;
pub mod middleware;
mod self_signed;
pub mod state;

85
src/mailer.rs Normal file
View file

@ -0,0 +1,85 @@
use lettre::message::header::ContentType;
use lettre::message::Message;
use lettre::transport::smtp::authentication::Credentials;
use lettre::AsyncSmtpTransport;
use lettre::AsyncTransport;
use std::sync::Arc;
use std::sync::RwLock;
lazy_static::lazy_static! {
static ref HANDLEBARS: Arc<RwLock<handlebars::Handlebars<'static>>> =
Arc::new(handlebars::Handlebars::new().into());
}
#[derive(Clone)]
pub struct Mailer {
smtp_transport: AsyncSmtpTransport<lettre::Tokio1Executor>,
}
impl Mailer {
pub async fn new(
smtp_username: String,
smtp_password: String,
smtp_host: &str,
workspace_invite_template_url: &str,
) -> Result<Self, anyhow::Error> {
let creds = Credentials::new(smtp_username, smtp_password);
let smtp_transport = AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(smtp_host)?
.credentials(creds)
.build();
let http_client = reqwest::Client::new();
let workspace_invite_template = http_client
.get(workspace_invite_template_url)
.send()
.await?
.error_for_status()?
.text()
.await?;
HANDLEBARS
.write()
.unwrap()
.register_template_string("workspace_invite", &workspace_invite_template)
.unwrap();
Ok(Self { smtp_transport })
}
pub async fn send_workspace_invite(
&self,
email: String,
param: WorkspaceInviteMailerParam,
) -> Result<(), anyhow::Error> {
let rendered = HANDLEBARS
.read()
.unwrap()
.render("workspace_invite", &param)?;
let email = Message::builder()
.from(lettre::message::Mailbox::new(
Some("AppFlowy Notify".to_string()),
lettre::Address::new("notify", "appflowy.io")?,
))
.to(lettre::message::Mailbox::new(
Some(param.username),
email.parse().unwrap(),
))
.subject("AppFlowy Workpace Invitation")
.header(ContentType::TEXT_HTML)
.body(rendered)?;
AsyncTransport::send(&self.smtp_transport, email).await?;
Ok(())
}
}
#[derive(serde::Serialize)]
pub struct WorkspaceInviteMailerParam {
pub user_icon_url: String,
pub username: String,
pub workspace_name: String,
pub workspace_icon_url: String,
pub workspace_member_count: String,
pub accept_url: String,
}

View file

@ -6,6 +6,7 @@ use crate::biz::collab::storage::CollabAccessControlStorage;
use crate::biz::pg_listener::PgListeners;
use crate::config::config::Config;
use crate::mailer::Mailer;
use access_control::access::AccessControl;
use access_control::metrics::AccessControlMetrics;
use app_error::AppError;
@ -41,6 +42,7 @@ pub struct AppState {
pub access_control: AccessControl,
pub metrics: AppMetrics,
pub gotrue_admin: GoTrueAdmin,
pub mailer: Mailer,
#[cfg(feature = "ai_enable")]
pub appflowy_ai_client: appflowy_ai::client::AppFlowyAIClient,
#[cfg(feature = "history")]