Spiegel von
https://github.com/dani-garcia/vaultwarden.git
synchronisiert 2025-03-13 16:57:01 +01:00
Process org enrollment in accounts::post_set_password
Dieser Commit ist enthalten in:
Ursprung
8d2d9f8d1a
Commit
7649ce8a3c
9 geänderte Dateien mit 174 neuen und 107 gelöschten Zeilen
|
@ -7,7 +7,7 @@ use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
core::{log_user_event, two_factor::email},
|
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||||
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
|
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
|
||||||
JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||||
},
|
},
|
||||||
|
@ -96,7 +96,6 @@ pub struct SetPasswordData {
|
||||||
keys: Option<KeysData>,
|
keys: Option<KeysData>,
|
||||||
master_password_hash: String,
|
master_password_hash: String,
|
||||||
master_password_hint: Option<String>,
|
master_password_hint: Option<String>,
|
||||||
#[allow(dead_code)]
|
|
||||||
org_identifier: Option<String>,
|
org_identifier: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -297,8 +296,24 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, mut co
|
||||||
user.public_key = Some(keys.public_key);
|
user.public_key = Some(keys.public_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(identifier) = data.org_identifier {
|
||||||
|
if identifier != crate::sso::FAKE_IDENTIFIER {
|
||||||
|
let org = match Organization::find_by_name(&identifier, &mut conn).await {
|
||||||
|
None => err!("Failed to retrieve the associated organization"),
|
||||||
|
Some(org) => org,
|
||||||
|
};
|
||||||
|
|
||||||
|
let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &mut conn).await {
|
||||||
|
None => err!("Failed to retrieve the invitation"),
|
||||||
|
Some(org) => org,
|
||||||
|
};
|
||||||
|
|
||||||
|
accept_org_invite(&user, membership, None, &mut conn).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
mail::send_set_password(&user.email.to_lowercase(), &user.name).await?;
|
mail::send_welcome(&user.email.to_lowercase()).await?;
|
||||||
} else {
|
} else {
|
||||||
Membership::accept_user_invitations(&user.uuid, &mut conn).await?;
|
Membership::accept_user_invitations(&user.uuid, &mut conn).await?;
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,11 +50,12 @@ pub fn events_routes() -> Vec<Route> {
|
||||||
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
|
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{JsonResult, Notify, UpdateType},
|
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||||
auth::Headers,
|
auth::Headers,
|
||||||
db::DbConn,
|
db::{models::*, DbConn},
|
||||||
error::Error,
|
error::Error,
|
||||||
http_client::make_http_request,
|
http_client::make_http_request,
|
||||||
|
mail,
|
||||||
util::parse_experimental_client_feature_flags,
|
util::parse_experimental_client_feature_flags,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -246,3 +247,49 @@ fn api_not_found() -> Json<Value> {
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn accept_org_invite(
|
||||||
|
user: &User,
|
||||||
|
mut member: Membership,
|
||||||
|
reset_password_key: Option<String>,
|
||||||
|
conn: &mut DbConn,
|
||||||
|
) -> EmptyResult {
|
||||||
|
if member.status != MembershipStatus::Invited as i32 {
|
||||||
|
err!("User already accepted the invitation");
|
||||||
|
}
|
||||||
|
|
||||||
|
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
||||||
|
// It returns different error messages per function.
|
||||||
|
if member.atype < MembershipType::Admin {
|
||||||
|
match OrgPolicy::is_user_allowed(&member.user_uuid, &member.org_uuid, false, conn).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(OrgPolicyErr::TwoFactorMissing) => {
|
||||||
|
if crate::CONFIG.email_2fa_auto_fallback() {
|
||||||
|
two_factor::email::activate_email_2fa(user, conn).await?;
|
||||||
|
} else {
|
||||||
|
err!("You cannot join this organization until you enable two-step login on your user account");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(OrgPolicyErr::SingleOrgEnforced) => {
|
||||||
|
err!("You cannot join this organization because you are a member of an organization which forbids it");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
member.status = MembershipStatus::Accepted as i32;
|
||||||
|
member.reset_password_key = reset_password_key;
|
||||||
|
|
||||||
|
member.save(conn).await?;
|
||||||
|
|
||||||
|
if crate::CONFIG.mail_enabled() {
|
||||||
|
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
|
||||||
|
Some(org) => org,
|
||||||
|
None => err!("Organization not found."),
|
||||||
|
};
|
||||||
|
// User was invited to an organization, so they must be confirmed manually after acceptance
|
||||||
|
mail::send_invite_accepted(&user.email, &member.invited_by_email.unwrap_or(org.billing_email), &org.name)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ use std::collections::{HashMap, HashSet};
|
||||||
use crate::api::admin::FAKE_ADMIN_UUID;
|
use crate::api::admin::FAKE_ADMIN_UUID;
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
core::{log_event, two_factor, CipherSyncData, CipherSyncType},
|
core::{accept_org_invite, log_event, two_factor, CipherSyncData, CipherSyncType},
|
||||||
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||||
},
|
},
|
||||||
auth::{
|
auth::{
|
||||||
|
@ -342,13 +342,30 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json<Value>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called during the SSO enrollment
|
// Called during the SSO enrollment
|
||||||
// The `_identifier` should be the harcoded value returned by `get_org_domain_sso_details`
|
// The `identifier` should be the value returned by `get_org_domain_sso_details`
|
||||||
// The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it
|
// The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it
|
||||||
#[get("/organizations/<_identifier>/auto-enroll-status")]
|
#[get("/organizations/<identifier>/auto-enroll-status")]
|
||||||
fn get_auto_enroll_status(_identifier: &str) -> JsonResult {
|
async fn get_auto_enroll_status(identifier: &str, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||||
|
let org = if identifier == crate::sso::FAKE_IDENTIFIER {
|
||||||
|
match Membership::find_main_user_org(&headers.user.uuid, &mut conn).await {
|
||||||
|
Some(member) => Organization::find_by_uuid(&member.org_uuid, &mut conn).await,
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Organization::find_by_name(identifier, &mut conn).await
|
||||||
|
};
|
||||||
|
|
||||||
|
let (id, identifier, rp_auto_enroll) = match org {
|
||||||
|
None => (get_uuid(), identifier.to_string(), false),
|
||||||
|
Some(org) => {
|
||||||
|
(org.uuid.to_string(), org.name, OrgPolicy::org_is_reset_password_auto_enroll(&org.uuid, &mut conn).await)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"Id": get_uuid(),
|
"Id": id,
|
||||||
"ResetPasswordEnabled": false, // Not implemented
|
"Identifier": identifier,
|
||||||
|
"ResetPasswordEnabled": rp_auto_enroll,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -932,13 +949,26 @@ async fn _get_org_details(org_id: &OrganizationId, host: &str, user_id: &UserId,
|
||||||
json!(ciphers_json)
|
json!(ciphers_json)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Endpoint called when the user select SSO login (body: `{ "email": "" }`).
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct OrgDomainDetails {
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
||||||
// VaultWarden sso login is not linked to Org so we set a dummy value.
|
// So we either return an Org name associated to the user or a dummy value.
|
||||||
#[post("/organizations/domain/sso/details")]
|
// The `verifiedDate` is required but the value ATM is ignored.
|
||||||
fn get_org_domain_sso_details() -> JsonResult {
|
#[post("/organizations/domain/sso/details", data = "<data>")]
|
||||||
|
async fn get_org_domain_sso_details(data: Json<OrgDomainDetails>, mut conn: DbConn) -> JsonResult {
|
||||||
|
let data: OrgDomainDetails = data.into_inner();
|
||||||
|
|
||||||
|
let identifier = match Organization::find_main_org_user_email(&data.email, &mut conn).await {
|
||||||
|
Some(org) => org.name,
|
||||||
|
None => crate::sso::FAKE_IDENTIFIER.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"organizationIdentifier": "vaultwarden",
|
"organizationIdentifier": identifier,
|
||||||
"ssoAvailable": CONFIG.sso_enabled(),
|
"ssoAvailable": CONFIG.sso_enabled(),
|
||||||
"verifiedDate": crate::util::format_date(&chrono::Utc::now().naive_utc()),
|
"verifiedDate": crate::util::format_date(&chrono::Utc::now().naive_utc()),
|
||||||
})))
|
})))
|
||||||
|
@ -1283,71 +1313,36 @@ async fn accept_invite(
|
||||||
err!("Invitation was issued to a different account", "Claim does not match user_id")
|
err!("Invitation was issued to a different account", "Claim does not match user_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a claim org_id does not match the one in from the URI, something is wrong.
|
||||||
|
if !claims.org_id.eq(&org_id) {
|
||||||
|
err!("Error accepting the invitation", "Claim does not match the org_id")
|
||||||
|
}
|
||||||
|
|
||||||
// If a claim does not have a member_id or it does not match the one in from the URI, something is wrong.
|
// If a claim does not have a member_id or it does not match the one in from the URI, something is wrong.
|
||||||
if !claims.member_id.eq(&member_id) {
|
if !claims.member_id.eq(&member_id) {
|
||||||
err!("Error accepting the invitation", "Claim does not match the member_id")
|
err!("Error accepting the invitation", "Claim does not match the member_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
let member = &claims.member_id;
|
let member_id = &claims.member_id;
|
||||||
let org = &claims.org_id;
|
|
||||||
|
|
||||||
Invitation::take(&claims.email, &mut conn).await;
|
Invitation::take(&claims.email, &mut conn).await;
|
||||||
|
|
||||||
// skip invitation logic when we were invited via the /admin panel
|
// skip invitation logic when we were invited via the /admin panel
|
||||||
if **member != FAKE_ADMIN_UUID {
|
if **member_id != FAKE_ADMIN_UUID {
|
||||||
let Some(mut member) = Membership::find_by_uuid_and_org(member, org, &mut conn).await else {
|
let Some(member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &mut conn).await else {
|
||||||
err!("Error accepting the invitation")
|
err!("Error accepting the invitation")
|
||||||
};
|
};
|
||||||
|
|
||||||
if member.status != MembershipStatus::Invited as i32 {
|
let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &mut conn).await {
|
||||||
err!("User already accepted the invitation")
|
true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."),
|
||||||
}
|
true => data.reset_password_key,
|
||||||
|
false => None,
|
||||||
|
};
|
||||||
|
|
||||||
let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await;
|
accept_org_invite(&headers.user, member, reset_password_key, &mut conn).await?;
|
||||||
if data.reset_password_key.is_none() && master_password_required {
|
} else if CONFIG.mail_enabled() {
|
||||||
err!("Reset password key is required, but not provided.");
|
// User was invited from /admin, so they are automatically confirmed
|
||||||
}
|
let org_name = CONFIG.invitation_org_name();
|
||||||
|
mail::send_invite_confirmed(&claims.email, &org_name).await?;
|
||||||
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type
|
|
||||||
// It returns different error messages per function.
|
|
||||||
if member.atype < MembershipType::Admin {
|
|
||||||
match OrgPolicy::is_user_allowed(&member.user_uuid, &org_id, false, &mut conn).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(OrgPolicyErr::TwoFactorMissing) => {
|
|
||||||
if CONFIG.email_2fa_auto_fallback() {
|
|
||||||
two_factor::email::activate_email_2fa(&headers.user, &mut conn).await?;
|
|
||||||
} else {
|
|
||||||
err!("You cannot join this organization until you enable two-step login on your user account");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(OrgPolicyErr::SingleOrgEnforced) => {
|
|
||||||
err!("You cannot join this organization because you are a member of an organization which forbids it");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
member.status = MembershipStatus::Accepted as i32;
|
|
||||||
|
|
||||||
if master_password_required {
|
|
||||||
member.reset_password_key = data.reset_password_key;
|
|
||||||
}
|
|
||||||
|
|
||||||
member.save(&mut conn).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
|
||||||
if let Some(invited_by_email) = &claims.invited_by_email {
|
|
||||||
let org_name = match Organization::find_by_uuid(&claims.org_id, &mut conn).await {
|
|
||||||
Some(org) => org.name,
|
|
||||||
None => err!("Organization not found."),
|
|
||||||
};
|
|
||||||
// User was invited to an organization, so they must be confirmed manually after acceptance
|
|
||||||
mail::send_invite_accepted(&claims.email, invited_by_email, &org_name).await?;
|
|
||||||
} else {
|
|
||||||
// User was invited from /admin, so they are automatically confirmed
|
|
||||||
let org_name = CONFIG.invitation_org_name();
|
|
||||||
mail::send_invite_confirmed(&claims.email, &org_name).await?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -2012,16 +2007,18 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, mut conn: DbCo
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called during the SSO enrollment.
|
// Called during the SSO enrollment.
|
||||||
// Cannot use the OrganizationId guard since the Org does not exists.
|
// Return the org policy if it exists, otherwise use the default one.
|
||||||
#[get("/organizations/<org_id>/policies/master-password", rank = 1)]
|
#[get("/organizations/<org_id>/policies/master-password", rank = 1)]
|
||||||
fn get_master_password_policy(org_id: OrganizationId, _headers: Headers) -> JsonResult {
|
async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||||
let data = match CONFIG.sso_master_password_policy() {
|
|
||||||
Some(policy) => policy,
|
|
||||||
None => "null".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let policy =
|
let policy =
|
||||||
OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data);
|
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &mut conn).await.unwrap_or_else(|| {
|
||||||
|
let data = match CONFIG.sso_master_password_policy() {
|
||||||
|
Some(policy) => policy,
|
||||||
|
None => "null".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
OrgPolicy::new(org_id, OrgPolicyType::MasterPassword, CONFIG.sso_master_password_policy().is_some(), data)
|
||||||
|
});
|
||||||
|
|
||||||
Ok(Json(policy.to_json()))
|
Ok(Json(policy.to_json()))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1509,7 +1509,6 @@ where
|
||||||
reg!("email/send_emergency_access_invite", ".html");
|
reg!("email/send_emergency_access_invite", ".html");
|
||||||
reg!("email/send_org_invite", ".html");
|
reg!("email/send_org_invite", ".html");
|
||||||
reg!("email/send_single_org_removed_from_org", ".html");
|
reg!("email/send_single_org_removed_from_org", ".html");
|
||||||
reg!("email/set_password", ".html");
|
|
||||||
reg!("email/smtp_test", ".html");
|
reg!("email/smtp_test", ".html");
|
||||||
reg!("email/sso_change_email", ".html");
|
reg!("email/sso_change_email", ".html");
|
||||||
reg!("email/twofactor_email", ".html");
|
reg!("email/twofactor_email", ".html");
|
||||||
|
|
|
@ -391,11 +391,36 @@ impl Organization {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_name(name: &str, conn: &mut DbConn) -> Option<Self> {
|
||||||
|
db_run! { conn: {
|
||||||
|
organizations::table
|
||||||
|
.filter(organizations::name.eq(name))
|
||||||
|
.first::<OrganizationDb>(conn)
|
||||||
|
.ok().from_db()
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_all(conn: &mut DbConn) -> Vec<Self> {
|
pub async fn get_all(conn: &mut DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
organizations::table.load::<OrganizationDb>(conn).expect("Error loading organizations").from_db()
|
organizations::table.load::<OrganizationDb>(conn).expect("Error loading organizations").from_db()
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn find_main_org_user_email(user_email: &str, conn: &mut DbConn) -> Option<Organization> {
|
||||||
|
let lower_mail = user_email.to_lowercase();
|
||||||
|
|
||||||
|
db_run! { conn: {
|
||||||
|
organizations::table
|
||||||
|
.inner_join(users_organizations::table.on(users_organizations::org_uuid.eq(organizations::uuid)))
|
||||||
|
.inner_join(users::table.on(users::uuid.eq(users_organizations::user_uuid)))
|
||||||
|
.filter(users::email.eq(lower_mail))
|
||||||
|
.filter(users_organizations::status.ne(MembershipStatus::Revoked as i32))
|
||||||
|
.order(users_organizations::atype.asc())
|
||||||
|
.select(organizations::all_columns)
|
||||||
|
.first::<OrganizationDb>(conn)
|
||||||
|
.ok().from_db()
|
||||||
|
}}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Membership {
|
impl Membership {
|
||||||
|
@ -1099,6 +1124,17 @@ impl Membership {
|
||||||
.first::<MembershipDb>(conn).ok().from_db()
|
.first::<MembershipDb>(conn).ok().from_db()
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn find_main_user_org(user_uuid: &str, conn: &mut DbConn) -> Option<Self> {
|
||||||
|
db_run! { conn: {
|
||||||
|
users_organizations::table
|
||||||
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
|
.filter(users_organizations::status.ne(MembershipStatus::Revoked as i32))
|
||||||
|
.order(users_organizations::atype.asc())
|
||||||
|
.first::<MembershipDb>(conn)
|
||||||
|
.ok().from_db()
|
||||||
|
}}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OrganizationApiKey {
|
impl OrganizationApiKey {
|
||||||
|
|
12
src/mail.rs
12
src/mail.rs
|
@ -565,18 +565,6 @@ pub async fn send_sso_change_email(address: &str) -> EmptyResult {
|
||||||
send_email(address, &subject, body_html, body_text).await
|
send_email(address, &subject, body_html, body_text).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_set_password(address: &str, user_name: &str) -> EmptyResult {
|
|
||||||
let (subject, body_html, body_text) = get_text(
|
|
||||||
"email/set_password",
|
|
||||||
json!({
|
|
||||||
"url": CONFIG.domain(),
|
|
||||||
"img_src": CONFIG._smtp_img_src(),
|
|
||||||
"user_name": user_name,
|
|
||||||
}),
|
|
||||||
)?;
|
|
||||||
send_email(address, &subject, body_html, body_text).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send_test(address: &str) -> EmptyResult {
|
pub async fn send_test(address: &str) -> EmptyResult {
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
"email/smtp_test",
|
"email/smtp_test",
|
||||||
|
|
|
@ -28,6 +28,8 @@ use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub static FAKE_IDENTIFIER: &str = "Vaultwarden";
|
||||||
|
|
||||||
static AC_CACHE: Lazy<Cache<OIDCState, AuthenticatedUser>> =
|
static AC_CACHE: Lazy<Cache<OIDCState, AuthenticatedUser>> =
|
||||||
Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build());
|
Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build());
|
||||||
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
Master Password Has Been Changed
|
|
||||||
<!---------------->
|
|
||||||
The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately.
|
|
||||||
|
|
||||||
===
|
|
||||||
{{> email/email_footer_text }}
|
|
|
@ -1,11 +0,0 @@
|
||||||
Master Password Has Been Changed
|
|
||||||
<!---------------->
|
|
||||||
{{> email/email_header }}
|
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
|
||||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
|
||||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
|
||||||
The master password for <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{user_name}}</b> has been changed. If you did not initiate this request, please reach out to your administrator immediately.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
{{> email/email_footer }}
|
|
Laden …
Tabelle hinzufügen
In neuem Issue referenzieren