mirror of
https://github.com/AppFlowy-IO/AppFlowy-Cloud.git
synced 2025-04-19 03:24:42 -04:00
feat: accept workspace invite email
This commit is contained in:
parent
418341f49f
commit
6e74449ab1
29 changed files with 758 additions and 76 deletions
2
.github/workflows/integration_test.yml
vendored
2
.github/workflows/integration_test.yml
vendored
|
@ -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: |
|
||||
|
|
|
@ -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"
|
||||
}
|
22
.sqlx/query-073a6c450bfc161ba7551509831c59018897a49ac61ae894a71d31be1cca3591.json
generated
Normal file
22
.sqlx/query-073a6c450bfc161ba7551509831c59018897a49ac61ae894a71d31be1cca3591.json
generated
Normal 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"
|
||||
}
|
18
.sqlx/query-52b936c6adf43ec5c7e777ad9379dec30b750fefad73684e552481f709006d04.json
generated
Normal file
18
.sqlx/query-52b936c6adf43ec5c7e777ad9379dec30b750fefad73684e552481f709006d04.json
generated
Normal 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"
|
||||
}
|
22
.sqlx/query-6821f1e02da2c71cdf0566a163c85ff185bf0ba89c770254c9c15880ba76a553.json
generated
Normal file
22
.sqlx/query-6821f1e02da2c71cdf0566a163c85ff185bf0ba89c770254c9c15880ba76a553.json
generated
Normal 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"
|
||||
}
|
22
.sqlx/query-7f6b1db5fd7b4e235f1e04d9d990fa2d47edfed23e692fbab778d387b2861a22.json
generated
Normal file
22
.sqlx/query-7f6b1db5fd7b4e235f1e04d9d990fa2d47edfed23e692fbab778d387b2861a22.json
generated
Normal 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
176
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
15
admin_frontend/templates/pages/login_callback.html
Normal file
15
admin_frontend/templates/pages/login_callback.html
Normal 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>
|
|
@ -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>
|
11
admin_frontend/templates/pages/redirect.html
Normal file
11
admin_frontend/templates/pages/redirect.html
Normal 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>
|
|
@ -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
|
||||
|
|
6
dev.env
6
dev.env
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
85
src/mailer.rs
Normal 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", ¶m)?;
|
||||
|
||||
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,
|
||||
}
|
|
@ -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")]
|
||||
|
|
Loading…
Add table
Reference in a new issue