Browse Source

Merge branch 'main' into main

pull/7566/head
kittygaming99 2 weeks ago
committed by GitHub
parent
commit
aca1b04a44
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .dockerignore
  2. 1
      .env.template
  3. 7
      playwright/tests/organization.smtp.spec.ts
  4. 16
      src/api/core/accounts.rs
  5. 103
      src/api/core/organizations.rs
  6. 15
      src/api/core/two_factor/email.rs
  7. 5
      src/api/core/two_factor/mod.rs
  8. 14
      src/api/identity.rs
  9. 12
      src/auth.rs
  10. 3
      src/config.rs
  11. 12
      src/db/models/device.rs
  12. 8
      src/db/models/event.rs
  13. 14
      src/mail.rs
  14. 12
      src/static/templates/email/admin_account_recovery.hbs
  15. 11
      src/static/templates/email/admin_account_recovery.html.hbs
  16. 4
      src/static/templates/email/admin_reset_password.hbs

4
.dockerignore

@ -1,7 +1,7 @@
// Ignore everything
# Ignore everything
*
// Allow what is needed
# Allow what is needed
!.git
!docker/healthcheck.sh
!docker/start.sh

1
.env.template

@ -390,6 +390,7 @@
##
## The following flags are available:
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)

7
playwright/tests/organization.smtp.spec.ts

@ -127,6 +127,9 @@ test('Organization is visible', async ({ page }) => {
});
test('Recover user password', async ({ page }) => {
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
await activateTOTP(test, page, users.user2);
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
let newPassword = "TotoNewPassword";
@ -138,9 +141,10 @@ test('Recover user password', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Recover account' }).click();
await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword);
await page.getByRole('checkbox', { name: 'Reset two-step login' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Account recovery success');
await mail2Buffer.expect((m) => m.subject.includes('Master Password Has Been Changed'));
await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
});
let user2 = {
@ -150,6 +154,7 @@ test('Recover user password', async ({ page }) => {
};
await logUser(test, page, user2, {
mailBuffer: mail2Buffer,
mail2fa: true,
notNewDevice: true,
});
});

16
src/api/core/accounts.rs

@ -1340,11 +1340,13 @@ pub struct PreloginData {
}
#[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
pub async fn prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data: PreloginData = data.into_inner();
let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await {
@ -1352,7 +1354,7 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None),
};
Json(json!({
Ok(Json(json!({
"kdf": kdf_type,
"kdfIterations": kdf_iter,
"kdfMemory": kdf_mem,
@ -1364,7 +1366,7 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
"parallelism": kdf_para
},
"salt": null,
}))
})))
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs
@ -1595,6 +1597,8 @@ async fn post_auth_request(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
let data = data.into_inner();
let Some(user) = User::find_by_mail(&data.email, &conn).await else {
@ -1756,6 +1760,8 @@ async fn get_auth_request_response(
client_headers: ClientHeaders,
conn: DbConn,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?;
let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else {
err!("AuthRequest doesn't exist", "User not found")
};

103
src/api/core/organizations.rs

@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use num_traits::FromPrimitive;
use rocket::{Route, serde::json::Json};
use rocket::{Route, http::Status, serde::json::Json};
use serde_json::Value;
use crate::{
@ -17,7 +17,8 @@ use crate::{
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User,
UserId,
},
},
mail,
@ -132,7 +133,6 @@ struct FullCollectionData {
name: String,
groups: Vec<CollectionGroupData>,
users: Vec<CollectionMembershipData>,
id: Option<CollectionId>,
external_id: Option<String>,
}
@ -391,7 +391,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
Ok(Json(json!({
@ -887,11 +887,11 @@ struct OrgIdData {
#[get("/ciphers/organization-details?<data..>")]
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if data.organization_id != headers.membership.org_uuid {
err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
Ok(Json(json!({
@ -955,7 +955,7 @@ async fn get_members(
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
let mut users_json = Vec::new();
@ -1793,11 +1793,22 @@ async fn bulk_public_keys(
use super::ciphers::CipherData;
use super::ciphers::update_cipher_from_data;
// The import endpoint only ever uses the name/id/external_id of a collection.
// Bitwarden's own server ignores `groups`/`users` here too, so do not make them
// mandatory: clients are free to leave them out.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportCollectionData {
name: String,
id: Option<CollectionId>,
external_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportData {
ciphers: Vec<CipherData>,
collections: Vec<FullCollectionData>,
collections: Vec<ImportCollectionData>,
collection_relationships: Vec<RelationsData>,
}
@ -2476,7 +2487,7 @@ async fn get_groups_data(
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
};
if !allowed {
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
}
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
@ -2927,8 +2938,8 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserRecoverAccountRequest {
new_master_password_hash: String,
key: String,
new_master_password_hash: Option<String>,
key: Option<String>,
#[serde(default)]
reset_master_password: bool,
@ -2972,12 +2983,7 @@ async fn put_recover_account(
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
let req = data.into_inner();
if req.reset_master_password && !req.reset_two_factor {
recover_account(org_id, member_id, headers, req, conn, nt).await
} else {
err!("Unsupported operation")
}
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
}
// Deprecated since `v2026.4.2`
@ -2997,7 +3003,7 @@ async fn recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
reset_request: OrganizationUserRecoverAccountRequest,
req: OrganizationUserRecoverAccountRequest,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
@ -3012,7 +3018,7 @@ async fn recover_account(
err!("User to reset isn't member of required organization")
};
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
let Some(mut user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
err!("User not found")
};
@ -3025,29 +3031,56 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality");
}
// Sending email before resetting password to ensure working email configuration and the resulting
// user notification. Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await {
let fallback_2fa_email = if req.reset_two_factor && CONFIG.email_2fa_auto_fallback() {
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
} else {
false
};
// Sending email first ensure working email configuration and the resulting user notification.
// Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_account_recovery(
&user.email,
user.display_name(),
&org.name,
req.reset_master_password,
req.reset_two_factor,
fallback_2fa_email,
)
.await
{
err!(format!("Error sending user reset password email: {e:#?}"));
}
let mut user = user;
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
.await?;
if req.reset_master_password {
if let Some(key) = req.key
&& let Some(hash) = req.new_master_password_hash
{
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
} else {
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
}
}
if req.reset_two_factor {
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() {
two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await?;
}
}
user.save(&conn).await?;
nt.send_logout(&user, None, &conn).await;
log_event(
EventType::OrganizationUserAdminResetPassword,
&member_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
if req.reset_master_password {
headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await;
}
if req.reset_two_factor {
headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await;
}
Ok(())
}

15
src/api/core/two_factor/email.rs

@ -63,13 +63,19 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
let user = if let Some(email) = email {
let Some(user) = User::find_by_mail(email, &conn).await else {
err!("Username or password is incorrect. Try again.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
)
};
if let Some(master_password_hash) = master_password_hash {
// Check password
if !user.check_valid_password(master_password_hash) {
err!("Username or password is incorrect. Try again.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {email}.", client_headers.ip.ip)
)
}
} else if let Some(auth_request_id) = auth_request_id {
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
@ -96,7 +102,10 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
};
// SSO login only sends device id, so we get the user by the most recently used device
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
err!("Username or password is incorrect. Try again.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
)
};
user

5
src/api/core/two_factor/mod.rs

@ -16,8 +16,8 @@ use crate::{
db::{
DbConn, DbPool,
models::{
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
TwoFactorIncomplete, TwoFactorType, User, UserId,
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
},
},
mail,
@ -151,6 +151,7 @@ async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, c
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
twofactor.delete(&conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
}

14
src/api/identity.rs

@ -905,6 +905,12 @@ async fn twofactor_auth(
// Remove all twofactors from the user
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
// No device may keep skipping 2FA once every second factor is gone.
// `device` is cleared in memory too, since saving it later would restore its token.
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
device.delete_twofactor_remember();
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
@ -1050,13 +1056,13 @@ async fn json_err_twofactor(
}
#[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
#[post("/accounts/prelogin/password", data = "<data>")]
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
#[post("/accounts/register", data = "<data>")]

12
src/auth.rs

@ -23,14 +23,14 @@ use rocket::{
use crate::{
CONFIG,
api::ApiResult,
api::{ApiResult, core::log_event},
config::PathType,
db::{
DbConn,
models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
SendId, User, UserId, UserStampException,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
SendFileId, SendId, User, UserId, UserStampException,
},
},
error::Error,
@ -822,6 +822,12 @@ pub struct AdminHeaders {
pub org_id: OrganizationId,
}
impl AdminHeaders {
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminHeaders {
type Error = &'static str;

3
src/config.rs

@ -1427,6 +1427,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
"desktop-ui-migration-milestone-4",
// Auth Team
"pm-5594-safari-account-switching",
"pm-32413-multi-client-password-management",
// Autofill Team
"ssh-agent",
"ssh-agent-v2",
@ -1746,7 +1747,7 @@ where
reg!("email/email_footer");
reg!("email/email_footer_text");
reg!("email/admin_reset_password", ".html");
reg!("email/admin_account_recovery", ".html");
reg!("email/change_email_existing", ".html");
reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".html");

12
src/db/models/device.rs

@ -266,10 +266,22 @@ impl Device {
let devices = Self::find_by_user(user_uuid, conn).await;
for mut device in devices {
device.refresh_token = Device::generate_refresh_token();
device.twofactor_remember = None;
device.save(false, conn).await?;
}
Ok(())
}
pub async fn clear_twofactor_remember_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::update(devices::table)
.filter(devices::user_uuid.eq(user_uuid))
.set(devices::twofactor_remember.eq::<Option<String>>(None))
.execute(conn)
.map_res("Error removing two factor remember tokens")
})
.await
}
}
#[derive(Display)]

8
src/db/models/event.rs

@ -43,7 +43,7 @@ pub struct Event {
pub provider_org_uuid: Option<String>,
}
// Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs
// Upstream enum: https://github.com/bitwarden/server/blob/v2026.6.2/src/Core/Dirt/Enums/EventType.cs
#[derive(Debug, Copy, Clone)]
pub enum EventType {
// User
@ -108,6 +108,12 @@ pub enum EventType {
OrganizationUserRejectedAuthRequest = 1514,
OrganizationUserDeleted = 1515, // Both user and organization user data were deleted
OrganizationUserLeft = 1516, // User voluntarily left the organization
// OrganizationUserAutomaticallyConfirmed = 1517,
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
OrganizationUserAdminResetTwoFactor = 1519,
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
// OrganizationUserNotificationBannerActionClicked = 1522,
// Organization
OrganizationUpdated = 1600,

14
src/mail.rs

@ -633,14 +633,24 @@ pub async fn send_test(address: &str) -> EmptyResult {
send_email(address, &subject, body_html, body_text).await
}
pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name: &str) -> EmptyResult {
pub async fn send_admin_account_recovery(
address: &str,
user_name: &str,
org_name: &str,
reset_password: bool,
reset_2fa: bool,
fallback_2fa_email: bool,
) -> EmptyResult {
let (subject, body_html, body_text) = get_text(
"email/admin_reset_password",
"email/admin_account_recovery",
json!({
"url": CONFIG.domain(),
"img_src": CONFIG._smtp_img_src(),
"user_name": user_name,
"org_name": org_name,
"reset_password": reset_password,
"reset_2fa": reset_2fa,
"fallback_2fa_email": fallback_2fa_email,
}),
)?;
send_email(address, &subject, body_html, body_text).await

12
src/static/templates/email/admin_account_recovery.hbs

@ -0,0 +1,12 @@
Admin account recovery from {{org_name}} organization
<!---------------->
{{#if reset_password}}
The master password for {{user_name}} has been changed.
{{/if}}
{{#if reset_2fa}}
Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}}
{{/if}}
If you did not initiate this request, please reach out to your administrator immediately.
{{> email/email_footer_text }}

11
src/static/templates/email/admin_reset_password.html.hbs → src/static/templates/email/admin_account_recovery.html.hbs

@ -1,10 +1,17 @@
Master Password Has Been Changed
Admin account recovery from {{org_name}} organization
<!---------------->
{{> 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 by an administrator in your <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;">{{org_name}}</b> organization. If you did not initiate this request, please reach out to your administrator immediately.
{{#if reset_password}}
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}}
{{#if reset_2fa}}
Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}}
{{/if}}
<br>
If you did not initiate this request, please reach out to your administrator immediately.
</td>
</tr>
</table>

4
src/static/templates/email/admin_reset_password.hbs

@ -1,4 +0,0 @@
Master Password Has Been Changed
<!---------------->
The master password for {{user_name}} has been changed by an administrator in your {{org_name}} organization. If you did not initiate this request, please reach out to your administrator immediately.
{{> email/email_footer_text }}
Loading…
Cancel
Save