mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-06-13 15:25:36 +00:00
Add Emergency contact feature
Signed-off-by: thelittlefireman <thelittlefireman@users.noreply.github.com>
This commit is contained in:
@ -77,6 +77,14 @@
|
||||
## Cron schedule of the job that checks for trashed items to delete permanently.
|
||||
## Defaults to daily (5 minutes after midnight). Set blank to disable this job.
|
||||
# TRASH_PURGE_SCHEDULE="0 5 0 * * *"
|
||||
##
|
||||
## Cron schedule of the job that sends expiration reminders to emergency request grantors.
|
||||
## Defaults to hourly (10 minutes after the hour). Set blank to disable this job.
|
||||
# EMERGENCY_NOTIFICATION_REMINDER_SCHEDULE="0 10 * * * *"
|
||||
##
|
||||
## Cron schedule of the job that checks for expired (i.e granted by timeout) emergency requests.
|
||||
## Defaults to hourly (15 minutes after the hour). Set blank to disable this job.
|
||||
# EMERGENCY_REQUEST_TIMEOUT_SCHEDULE="0 15 * * * *"
|
||||
|
||||
## Enable extended logging, which shows timestamps and targets in the logs
|
||||
# EXTENDED_LOGGING=true
|
||||
@ -312,6 +320,9 @@
|
||||
## If sending the email fails the login attempt will fail!!
|
||||
# REQUIRE_DEVICE_EMAIL=false
|
||||
|
||||
## Emergency access enable. Enable or disable the emergency access feature for all users
|
||||
# EMERGENCY_ACCESS_ALLOWED=false
|
||||
|
||||
## HIBP Api Key
|
||||
## HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
|
||||
# HIBP_API_KEY=
|
||||
|
@ -0,0 +1 @@
|
||||
DROP TABLE emergency_access;
|
@ -0,0 +1,14 @@
|
||||
CREATE TABLE emergency_access (
|
||||
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||
grantor_uuid CHAR(36) REFERENCES users (uuid),
|
||||
grantee_uuid CHAR(36) REFERENCES users (uuid),
|
||||
email VARCHAR(255),
|
||||
key_encrypted TEXT,
|
||||
atype INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
wait_time_days INTEGER NOT NULL,
|
||||
recovery_initiated_at DATETIME,
|
||||
last_notification_at DATETIME,
|
||||
updated_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
@ -0,0 +1 @@
|
||||
DROP TABLE emergency_access;
|
@ -0,0 +1,14 @@
|
||||
CREATE TABLE emergency_access (
|
||||
uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||
grantor_uuid CHAR(36) REFERENCES users (uuid),
|
||||
grantee_uuid CHAR(36) REFERENCES users (uuid),
|
||||
email VARCHAR(255),
|
||||
key_encrypted TEXT,
|
||||
atype INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
wait_time_days INTEGER NOT NULL,
|
||||
recovery_initiated_at TIMESTAMP,
|
||||
last_notification_at TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
@ -0,0 +1 @@
|
||||
DROP TABLE emergency_access;
|
@ -0,0 +1,14 @@
|
||||
CREATE TABLE emergency_access (
|
||||
uuid TEXT NOT NULL PRIMARY KEY,
|
||||
grantor_uuid TEXT REFERENCES users (uuid),
|
||||
grantee_uuid TEXT REFERENCES users (uuid),
|
||||
email TEXT,
|
||||
key_encrypted TEXT,
|
||||
atype INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
wait_time_days INTEGER NOT NULL,
|
||||
recovery_initiated_at DATETIME,
|
||||
last_notification_at DATETIME,
|
||||
updated_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
@ -89,7 +89,12 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
|
||||
|
||||
user
|
||||
} else if CONFIG.is_signup_allowed(&email) {
|
||||
err!("Account with this email already exists")
|
||||
// check if it's invited by emergency contact
|
||||
if EmergencyAccess::find_invited_by_grantee_email(&data.Email, &conn).is_some() {
|
||||
user
|
||||
} else {
|
||||
err!("Account with this email already exists")
|
||||
}
|
||||
} else {
|
||||
err!("Registration not allowed or user already exists")
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@ mod sends;
|
||||
pub mod two_factor;
|
||||
|
||||
pub use ciphers::purge_trashed_ciphers;
|
||||
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
|
||||
pub use sends::purge_sends;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
|
@ -13,6 +13,7 @@ pub use crate::api::{
|
||||
core::purge_sends,
|
||||
core::purge_trashed_ciphers,
|
||||
core::routes as core_routes,
|
||||
core::{emergency_notification_reminder_job, emergency_request_timeout_job},
|
||||
icons::routes as icons_routes,
|
||||
identity::routes as identity_routes,
|
||||
notifications::routes as notifications_routes,
|
||||
|
44
src/auth.rs
44
src/auth.rs
@ -22,6 +22,8 @@ static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM));
|
||||
|
||||
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin()));
|
||||
static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin()));
|
||||
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy<String> =
|
||||
Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin()));
|
||||
static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin()));
|
||||
static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
|
||||
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
|
||||
@ -75,6 +77,10 @@ pub fn decode_invite(token: &str) -> Result<InviteJwtClaims, Error> {
|
||||
decode_jwt(token, JWT_INVITE_ISSUER.to_string())
|
||||
}
|
||||
|
||||
pub fn decode_emergency_access_invite(token: &str) -> Result<EmergencyAccessInviteJwtClaims, Error> {
|
||||
decode_jwt(token, JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string())
|
||||
}
|
||||
|
||||
pub fn decode_delete(token: &str) -> Result<BasicJwtClaims, Error> {
|
||||
decode_jwt(token, JWT_DELETE_ISSUER.to_string())
|
||||
}
|
||||
@ -159,6 +165,44 @@ pub fn generate_invite_claims(
|
||||
}
|
||||
}
|
||||
|
||||
// var token = _dataProtector.Protect($"EmergencyAccessInvite {emergencyAccess.Id} {emergencyAccess.Email} {nowMillis}");
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EmergencyAccessInviteJwtClaims {
|
||||
// Not before
|
||||
pub nbf: i64,
|
||||
// Expiration time
|
||||
pub exp: i64,
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
|
||||
pub email: String,
|
||||
pub emer_id: Option<String>,
|
||||
pub grantor_name: Option<String>,
|
||||
pub grantor_email: Option<String>,
|
||||
}
|
||||
|
||||
pub fn generate_emergency_access_invite_claims(
|
||||
uuid: String,
|
||||
email: String,
|
||||
emer_id: Option<String>,
|
||||
grantor_name: Option<String>,
|
||||
grantor_email: Option<String>,
|
||||
) -> EmergencyAccessInviteJwtClaims {
|
||||
let time_now = Utc::now().naive_utc();
|
||||
EmergencyAccessInviteJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + Duration::days(5)).timestamp(),
|
||||
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
||||
sub: uuid,
|
||||
email,
|
||||
emer_id,
|
||||
grantor_name,
|
||||
grantor_email,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BasicJwtClaims {
|
||||
// Not before
|
||||
|
@ -333,6 +333,12 @@ make_config! {
|
||||
/// Trash purge schedule |> Cron schedule of the job that checks for trashed items to delete permanently.
|
||||
/// Defaults to daily. Set blank to disable this job.
|
||||
trash_purge_schedule: String, false, def, "0 5 0 * * *".to_string();
|
||||
/// Emergency notification reminder schedule |> Cron schedule of the job that sends expiration reminders to emergency request grantors.
|
||||
/// Defaults to hourly. Set blank to disable this job.
|
||||
emergency_notification_reminder_schedule: String, false, def, "0 10 * * * *".to_string();
|
||||
/// Emergency request timeout schedule |> Cron schedule of the job that checks for expired (i.e granted by timeout) emergency requests.
|
||||
/// Defaults to hourly. Set blank to disable this job.
|
||||
emergency_request_timeout_schedule: String, false, def, "0 15 * * * *".to_string();
|
||||
},
|
||||
|
||||
/// General settings
|
||||
@ -385,6 +391,8 @@ make_config! {
|
||||
org_creation_users: String, true, def, "".to_string();
|
||||
/// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are otherwise disabled
|
||||
invitations_allowed: bool, true, def, true;
|
||||
/// Allow emergency access |> Controls whether users can enable emergency access to their accounts
|
||||
emergency_access_allowed: bool, true, def, true;
|
||||
/// Password iterations |> Number of server-side passwords hashing iterations.
|
||||
/// The changes only apply when a user changes their password. Not recommended to lower the value
|
||||
password_iterations: i32, true, def, 100_000;
|
||||
@ -855,11 +863,19 @@ where
|
||||
reg!("email/delete_account", ".html");
|
||||
reg!("email/invite_accepted", ".html");
|
||||
reg!("email/invite_confirmed", ".html");
|
||||
reg!("email/emergency_access_invite_accepted", ".html");
|
||||
reg!("email/emergency_access_invite_confirmed", ".html");
|
||||
reg!("email/emergency_access_recovery_approved", ".html");
|
||||
reg!("email/emergency_access_recovery_initiated", ".html");
|
||||
reg!("email/emergency_access_recovery_rejected", ".html");
|
||||
reg!("email/emergency_access_recovery_reminder", ".html");
|
||||
reg!("email/emergency_access_recovery_timed_out", ".html");
|
||||
reg!("email/new_device_logged_in", ".html");
|
||||
reg!("email/pw_hint_none", ".html");
|
||||
reg!("email/pw_hint_some", ".html");
|
||||
reg!("email/send_2fa_removed_from_org", ".html");
|
||||
reg!("email/send_org_invite", ".html");
|
||||
reg!("email/send_emergency_access_invite", ".html");
|
||||
reg!("email/twofactor_email", ".html");
|
||||
reg!("email/verify_email", ".html");
|
||||
reg!("email/welcome", ".html");
|
||||
|
288
src/db/models/emergency_access.rs
Normal file
288
src/db/models/emergency_access.rs
Normal file
@ -0,0 +1,288 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::User;
|
||||
|
||||
db_object! {
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, Associations, AsChangeset)]
|
||||
#[table_name = "emergency_access"]
|
||||
#[changeset_options(treat_none_as_null="true")]
|
||||
#[belongs_to(User, foreign_key = "grantor_uuid")]
|
||||
#[primary_key(uuid)]
|
||||
pub struct EmergencyAccess {
|
||||
pub uuid: String,
|
||||
pub grantor_uuid: String,
|
||||
pub grantee_uuid: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub key_encrypted: Option<String>,
|
||||
pub atype: i32, //EmergencyAccessType
|
||||
pub status: i32, //EmergencyAccessStatus
|
||||
pub wait_time_days: i32,
|
||||
pub recovery_initiated_at: Option<NaiveDateTime>,
|
||||
pub last_notification_at: Option<NaiveDateTime>,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
|
||||
impl EmergencyAccess {
|
||||
pub fn new(grantor_uuid: String, email: Option<String>, status: i32, atype: i32, wait_time_days: i32) -> Self {
|
||||
Self {
|
||||
uuid: crate::util::get_uuid(),
|
||||
grantor_uuid,
|
||||
grantee_uuid: None,
|
||||
email,
|
||||
status,
|
||||
atype,
|
||||
wait_time_days,
|
||||
recovery_initiated_at: None,
|
||||
created_at: Utc::now().naive_utc(),
|
||||
updated_at: Utc::now().naive_utc(),
|
||||
key_encrypted: None,
|
||||
last_notification_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_atype_as_str(&self) -> &'static str {
|
||||
if self.atype == EmergencyAccessType::View as i32 {
|
||||
"View"
|
||||
} else {
|
||||
"Takeover"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Status": self.status,
|
||||
"Type": self.atype,
|
||||
"WaitTimeDays": self.wait_time_days,
|
||||
"Object": "emergencyAccess",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_json_grantor_details(&self, conn: &DbConn) -> Value {
|
||||
// find grantor
|
||||
let grantor_user = User::find_by_uuid(&self.grantor_uuid, conn).unwrap();
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Status": self.status,
|
||||
"Type": self.atype,
|
||||
"WaitTimeDays": self.wait_time_days,
|
||||
"GrantorId": grantor_user.uuid,
|
||||
"Email": grantor_user.email,
|
||||
"Name": grantor_user.name,
|
||||
"Object": "emergencyAccessGrantorDetails",})
|
||||
}
|
||||
|
||||
pub fn to_json_grantee_details(&self, conn: &DbConn) -> Value {
|
||||
if self.grantee_uuid.is_some() {
|
||||
let grantee_user =
|
||||
User::find_by_uuid(&self.grantee_uuid.clone().unwrap(), conn).expect("Grantee user not found.");
|
||||
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Status": self.status,
|
||||
"Type": self.atype,
|
||||
"WaitTimeDays": self.wait_time_days,
|
||||
"GranteeId": grantee_user.uuid,
|
||||
"Email": grantee_user.email,
|
||||
"Name": grantee_user.name,
|
||||
"Object": "emergencyAccessGranteeDetails",})
|
||||
} else if self.email.is_some() {
|
||||
let grantee_user = User::find_by_mail(&self.email.clone().unwrap(), conn).expect("Grantee user not found.");
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Status": self.status,
|
||||
"Type": self.atype,
|
||||
"WaitTimeDays": self.wait_time_days,
|
||||
"GranteeId": grantee_user.uuid,
|
||||
"Email": grantee_user.email,
|
||||
"Name": grantee_user.name,
|
||||
"Object": "emergencyAccessGranteeDetails",})
|
||||
} else {
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Status": self.status,
|
||||
"Type": self.atype,
|
||||
"WaitTimeDays": self.wait_time_days,
|
||||
"GranteeId": "",
|
||||
"Email": "",
|
||||
"Name": "",
|
||||
"Object": "emergencyAccessGranteeDetails",})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
|
||||
pub enum EmergencyAccessType {
|
||||
View = 0,
|
||||
Takeover = 1,
|
||||
}
|
||||
|
||||
impl EmergencyAccessType {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"0" | "View" => Some(EmergencyAccessType::View),
|
||||
"1" | "Takeover" => Some(EmergencyAccessType::Takeover),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<i32> for EmergencyAccessType {
|
||||
fn eq(&self, other: &i32) -> bool {
|
||||
*other == *self as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<EmergencyAccessType> for i32 {
|
||||
fn eq(&self, other: &EmergencyAccessType) -> bool {
|
||||
*self == *other as i32
|
||||
}
|
||||
}
|
||||
|
||||
pub enum EmergencyAccessStatus {
|
||||
Invited = 0,
|
||||
Accepted = 1,
|
||||
Confirmed = 2,
|
||||
RecoveryInitiated = 3,
|
||||
RecoveryApproved = 4,
|
||||
}
|
||||
|
||||
// region Database methods
|
||||
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::error::MapResult;
|
||||
|
||||
impl EmergencyAccess {
|
||||
pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||
User::update_uuid_revision(&self.grantor_uuid, conn);
|
||||
self.updated_at = Utc::now().naive_utc();
|
||||
|
||||
db_run! { conn:
|
||||
sqlite, mysql {
|
||||
match diesel::replace_into(emergency_access::table)
|
||||
.values(EmergencyAccessDb::to_db(self))
|
||||
.execute(conn)
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
|
||||
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
|
||||
diesel::update(emergency_access::table)
|
||||
.filter(emergency_access::uuid.eq(&self.uuid))
|
||||
.set(EmergencyAccessDb::to_db(self))
|
||||
.execute(conn)
|
||||
.map_res("Error updating emergency access")
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}.map_res("Error saving emergency access")
|
||||
}
|
||||
postgresql {
|
||||
let value = EmergencyAccessDb::to_db(self);
|
||||
diesel::insert_into(emergency_access::table)
|
||||
.values(&value)
|
||||
.on_conflict(emergency_access::uuid)
|
||||
.do_update()
|
||||
.set(&value)
|
||||
.execute(conn)
|
||||
.map_res("Error saving emergency access")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
|
||||
for user_org in Self::find_all_by_grantor_uuid(user_uuid, conn) {
|
||||
user_org.delete(conn)?;
|
||||
}
|
||||
for user_org in Self::find_all_by_grantee_uuid(user_uuid, conn) {
|
||||
user_org.delete(conn)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||
User::update_uuid_revision(&self.grantor_uuid, conn);
|
||||
|
||||
db_run! { conn: {
|
||||
diesel::delete(emergency_access::table.filter(emergency_access::uuid.eq(self.uuid)))
|
||||
.execute(conn)
|
||||
.map_res("Error removing user from organization")
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(uuid))
|
||||
.first::<EmergencyAccessDb>(conn)
|
||||
.ok().from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_by_grantor_uuid_and_grantee_uuid_or_email(
|
||||
grantor_uuid: &str,
|
||||
grantee_uuid: &str,
|
||||
email: &str,
|
||||
conn: &DbConn,
|
||||
) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid).or(emergency_access::email.eq(email)))
|
||||
.first::<EmergencyAccessDb>(conn)
|
||||
.ok().from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_all_recoveries(conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::status.eq(EmergencyAccessStatus::RecoveryInitiated as i32))
|
||||
.load::<EmergencyAccessDb>(conn).expect("Error loading emergency_access").from_db()
|
||||
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_by_uuid_and_grantor_uuid(uuid: &str, grantor_uuid: &str, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::uuid.eq(uuid))
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
.first::<EmergencyAccessDb>(conn)
|
||||
.ok().from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_all_by_grantee_uuid(grantee_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
|
||||
.load::<EmergencyAccessDb>(conn).expect("Error loading emergency_access").from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::email.eq(grantee_email))
|
||||
.filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32))
|
||||
.first::<EmergencyAccessDb>(conn)
|
||||
.ok().from_db()
|
||||
}}
|
||||
}
|
||||
|
||||
pub fn find_all_by_grantor_uuid(grantor_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
emergency_access::table
|
||||
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
|
||||
.load::<EmergencyAccessDb>(conn).expect("Error loading emergency_access").from_db()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
@ -2,6 +2,7 @@ mod attachment;
|
||||
mod cipher;
|
||||
mod collection;
|
||||
mod device;
|
||||
mod emergency_access;
|
||||
mod favorite;
|
||||
mod folder;
|
||||
mod org_policy;
|
||||
@ -14,6 +15,7 @@ pub use self::attachment::Attachment;
|
||||
pub use self::cipher::Cipher;
|
||||
pub use self::collection::{Collection, CollectionCipher, CollectionUser};
|
||||
pub use self::device::Device;
|
||||
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessStatus, EmergencyAccessType};
|
||||
pub use self::favorite::Favorite;
|
||||
pub use self::folder::{Folder, FolderCipher};
|
||||
pub use self::org_policy::{OrgPolicy, OrgPolicyType};
|
||||
|
@ -176,7 +176,7 @@ impl User {
|
||||
}
|
||||
}
|
||||
|
||||
use super::{Cipher, Device, Favorite, Folder, Send, TwoFactor, UserOrgType, UserOrganization};
|
||||
use super::{Cipher, Device, EmergencyAccess, Favorite, Folder, Send, TwoFactor, UserOrgType, UserOrganization};
|
||||
use crate::db::DbConn;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
@ -266,6 +266,7 @@ impl User {
|
||||
}
|
||||
|
||||
Send::delete_all_by_user(&self.uuid, conn)?;
|
||||
EmergencyAccess::delete_all_by_user(&self.uuid, conn)?;
|
||||
UserOrganization::delete_all_by_user(&self.uuid, conn)?;
|
||||
Cipher::delete_all_by_user(&self.uuid, conn)?;
|
||||
Favorite::delete_all_by_user(&self.uuid, conn)?;
|
||||
|
@ -192,6 +192,23 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
grantor_uuid -> Text,
|
||||
grantee_uuid -> Nullable<Text>,
|
||||
email -> Nullable<Text>,
|
||||
key_encrypted -> Nullable<Text>,
|
||||
atype -> Integer,
|
||||
status -> Integer,
|
||||
wait_time_days -> Integer,
|
||||
recovery_initiated_at -> Nullable<Timestamp>,
|
||||
last_notification_at -> Nullable<Timestamp>,
|
||||
updated_at -> Timestamp,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
joinable!(attachments -> ciphers (cipher_uuid));
|
||||
joinable!(ciphers -> organizations (organization_uuid));
|
||||
joinable!(ciphers -> users (user_uuid));
|
||||
@ -210,6 +227,7 @@ joinable!(users_collections -> collections (collection_uuid));
|
||||
joinable!(users_collections -> users (user_uuid));
|
||||
joinable!(users_organizations -> organizations (org_uuid));
|
||||
joinable!(users_organizations -> users (user_uuid));
|
||||
joinable!(emergency_access -> users (grantor_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
attachments,
|
||||
@ -227,4 +245,5 @@ allow_tables_to_appear_in_same_query!(
|
||||
users,
|
||||
users_collections,
|
||||
users_organizations,
|
||||
emergency_access,
|
||||
);
|
||||
|
@ -192,6 +192,23 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
grantor_uuid -> Text,
|
||||
grantee_uuid -> Nullable<Text>,
|
||||
email -> Nullable<Text>,
|
||||
key_encrypted -> Nullable<Text>,
|
||||
atype -> Integer,
|
||||
status -> Integer,
|
||||
wait_time_days -> Integer,
|
||||
recovery_initiated_at -> Nullable<Timestamp>,
|
||||
last_notification_at -> Nullable<Timestamp>,
|
||||
updated_at -> Timestamp,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
joinable!(attachments -> ciphers (cipher_uuid));
|
||||
joinable!(ciphers -> organizations (organization_uuid));
|
||||
joinable!(ciphers -> users (user_uuid));
|
||||
@ -210,6 +227,7 @@ joinable!(users_collections -> collections (collection_uuid));
|
||||
joinable!(users_collections -> users (user_uuid));
|
||||
joinable!(users_organizations -> organizations (org_uuid));
|
||||
joinable!(users_organizations -> users (user_uuid));
|
||||
joinable!(emergency_access -> users (grantor_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
attachments,
|
||||
@ -227,4 +245,5 @@ allow_tables_to_appear_in_same_query!(
|
||||
users,
|
||||
users_collections,
|
||||
users_organizations,
|
||||
emergency_access,
|
||||
);
|
||||
|
@ -192,6 +192,23 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
grantor_uuid -> Text,
|
||||
grantee_uuid -> Nullable<Text>,
|
||||
email -> Nullable<Text>,
|
||||
key_encrypted -> Nullable<Text>,
|
||||
atype -> Integer,
|
||||
status -> Integer,
|
||||
wait_time_days -> Integer,
|
||||
recovery_initiated_at -> Nullable<Timestamp>,
|
||||
last_notification_at -> Nullable<Timestamp>,
|
||||
updated_at -> Timestamp,
|
||||
created_at -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
joinable!(attachments -> ciphers (cipher_uuid));
|
||||
joinable!(ciphers -> organizations (organization_uuid));
|
||||
joinable!(ciphers -> users (user_uuid));
|
||||
@ -210,6 +227,7 @@ joinable!(users_collections -> collections (collection_uuid));
|
||||
joinable!(users_collections -> users (user_uuid));
|
||||
joinable!(users_organizations -> organizations (org_uuid));
|
||||
joinable!(users_organizations -> users (user_uuid));
|
||||
joinable!(emergency_access -> users (grantor_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
attachments,
|
||||
@ -227,4 +245,5 @@ allow_tables_to_appear_in_same_query!(
|
||||
users,
|
||||
users_collections,
|
||||
users_organizations,
|
||||
emergency_access,
|
||||
);
|
||||
|
135
src/mail.rs
135
src/mail.rs
@ -13,7 +13,10 @@ use lettre::{
|
||||
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
auth::{encode_jwt, generate_delete_claims, generate_invite_claims, generate_verify_email_claims},
|
||||
auth::{
|
||||
encode_jwt, generate_delete_claims, generate_emergency_access_invite_claims, generate_invite_claims,
|
||||
generate_verify_email_claims,
|
||||
},
|
||||
error::Error,
|
||||
CONFIG,
|
||||
};
|
||||
@ -224,6 +227,136 @@ pub fn send_invite(
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_invite(
|
||||
address: &str,
|
||||
uuid: &str,
|
||||
emer_id: Option<String>,
|
||||
grantor_name: Option<String>,
|
||||
grantor_email: Option<String>,
|
||||
) -> EmptyResult {
|
||||
let claims = generate_emergency_access_invite_claims(
|
||||
uuid.to_string(),
|
||||
String::from(address),
|
||||
emer_id.clone(),
|
||||
grantor_name.clone(),
|
||||
grantor_email,
|
||||
);
|
||||
|
||||
let invite_token = encode_jwt(&claims);
|
||||
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/send_emergency_access_invite",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"emer_id": emer_id.unwrap_or_else(|| "_".to_string()),
|
||||
"email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(),
|
||||
"grantor_name": grantor_name,
|
||||
"token": invite_token,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_invite_accepted(address: &str, grantee_email: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_invite_accepted",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantee_email": grantee_email,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_invite_confirmed(address: &str, grantor_name: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_invite_confirmed",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantor_name": grantor_name,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_recovery_approved(address: &str, grantor_name: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_recovery_approved",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantor_name": grantor_name,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_recovery_initiated(
|
||||
address: &str,
|
||||
grantee_name: &str,
|
||||
atype: &str,
|
||||
wait_time_days: &str,
|
||||
) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_recovery_initiated",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantee_name": grantee_name,
|
||||
"atype": atype,
|
||||
"wait_time_days": wait_time_days,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_recovery_reminder(
|
||||
address: &str,
|
||||
grantee_name: &str,
|
||||
atype: &str,
|
||||
wait_time_days: &str,
|
||||
) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_recovery_reminder",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantee_name": grantee_name,
|
||||
"atype": atype,
|
||||
"wait_time_days": wait_time_days,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_recovery_rejected(address: &str, grantor_name: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_recovery_rejected",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantor_name": grantor_name,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_emergency_access_recovery_timed_out(address: &str, grantee_name: &str, atype: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/emergency_access_recovery_timed_out",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"grantee_name": grantee_name,
|
||||
"atype": atype,
|
||||
}),
|
||||
)?;
|
||||
|
||||
send_email(address, &subject, body_html, body_text)
|
||||
}
|
||||
|
||||
pub fn send_invite_accepted(new_user_email: &str, address: &str, org_name: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/invite_accepted",
|
||||
|
12
src/main.rs
12
src/main.rs
@ -345,6 +345,18 @@ fn schedule_jobs(pool: db::DbPool) {
|
||||
}));
|
||||
}
|
||||
|
||||
if !CONFIG.emergency_request_timeout_schedule().is_empty() {
|
||||
sched.add(Job::new(CONFIG.emergency_request_timeout_schedule().parse().unwrap(), || {
|
||||
api::emergency_request_timeout_job(pool.clone());
|
||||
}));
|
||||
}
|
||||
|
||||
if !CONFIG.emergency_notification_reminder_schedule().is_empty() {
|
||||
sched.add(Job::new(CONFIG.emergency_notification_reminder_schedule().parse().unwrap(), || {
|
||||
api::emergency_notification_reminder_job(pool.clone());
|
||||
}));
|
||||
}
|
||||
|
||||
// Periodically check for jobs to run. We probably won't need any
|
||||
// jobs that run more often than once a minute, so a default poll
|
||||
// interval of 30 seconds should be sufficient. Users who want to
|
||||
|
@ -0,0 +1,8 @@
|
||||
Emergency contact {{{grantee_email}}} accepted
|
||||
<!---------------->
|
||||
This email is to notify you that {{grantee_email}} has accepted your invitation to become an emergency access contact.
|
||||
|
||||
To confirm this user, Log into {{url}} the Bitwarden web vault, go to settings and confirm the user.
|
||||
|
||||
If you do not wish to confirm this user, you can also remove them on the same page.
|
||||
{{> email/email_footer_text }}
|
@ -0,0 +1,21 @@
|
||||
Emergency contact {{{grantee_email}}} accepted
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
This email is to notify you that {{grantee_email}} has accepted your invitation to become an emergency access contact.
|
||||
</td>
|
||||
</tr>
|
||||
<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">
|
||||
To confirm this user, <a href="{{url}}/">log into</a> the vaultwarden web vault, go to settings and confirm the user.
|
||||
</td>
|
||||
</tr>
|
||||
<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 last" 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; -webkit-text-size-adjust: none;" valign="top">
|
||||
If you do not wish to confirm this user, you can also remove them on the same page.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
@ -0,0 +1,6 @@
|
||||
Emergency contact for {{{grantor_name}}} confirmed
|
||||
<!---------------->
|
||||
This email is to notify you that you have been confirmed as an emergency access contact for *{{grantor_name}}* was confirmed.
|
||||
|
||||
You can now initiate emergency access requests from the web vault. Log in {{url}}.
|
||||
{{> email/email_footer_text }}
|
@ -0,0 +1,17 @@
|
||||
Emergency contact for {{{grantor_name}}} confirmed
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
This email is to notify you that you have been confirmed as an emergency access contact 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;">{{grantor_name}}</b> was confirmed.
|
||||
</td>
|
||||
</tr>
|
||||
<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 last" 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; -webkit-text-size-adjust: none;" valign="top">
|
||||
You can now initiate emergency access requests from the web vault. <br>
|
||||
<a href="{{url}}/">Log in</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
@ -0,0 +1,4 @@
|
||||
Emergency contact request for {{{grantor_name}}} approved
|
||||
<!---------------->
|
||||
{{grantor_name}} has approved your emergency request. You may now login {{url}} on the web vault and access their account.
|
||||
{{> email/email_footer_text }}
|
@ -0,0 +1,11 @@
|
||||
Emergency contact for {{{grantor_name}}} approved
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
<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;">{{grantor_name}}</b> has approved your emergency request. You may now <a href="{{url}}/">login</a> on the web vault and access their account.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
@ -0,0 +1,6 @@
|
||||
Emergency access request by {{{grantee_name}}} initiated
|
||||
<!---------------->
|
||||
{{grantee_name}} has initiated an emergency request to *{{atype}}* your account. You may login on the web vault and manually approve or reject this request.
|
||||
|
||||
If you do nothing, the request will automatically be approved after {{wait_time_days}} day(s).
|
||||
{{> email/email_footer_text }}
|
@ -0,0 +1,16 @@
|
||||
Emergency access request by {{{grantee_name}}} initiated
|
||||
<!---------------->
|
||||
{{> 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">
|
||||
<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;">{{grantee_name}}</b> has initiated an emergency request to <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;">{{atype}}</b> your account. You may login on the web vault and manually approve or reject this request.
|
||||
</td>
|
||||
</tr>
|
||||
<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 last" 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; -webkit-text-size-adjust: none;" valign="top">
|
||||
If you do nothing, the request will automatically be approved after <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;">{{wait_time_days}}</b> day(s).
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
@ -0,0 +1,4 @@
|
||||
Emergency access request to {{{grantor_name}}} rejected
|
||||
<!---------------->
|
||||
{{grantor_name}} has rejected your emergency request.
|
||||
{{> email/email_footer_text }}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user