Spiegel von
https://github.com/dani-garcia/vaultwarden.git
synchronisiert 2024-11-22 05:10:29 +01:00
Merge branch 'main' into cap_net_bind_service
Dieser Commit ist enthalten in:
Commit
a6dd4f1206
23 geänderte Dateien mit 356 neuen und 91 gelöschten Zeilen
|
@ -8,7 +8,7 @@ resolver = "2"
|
||||||
|
|
||||||
repository = "https://github.com/dani-garcia/vaultwarden"
|
repository = "https://github.com/dani-garcia/vaultwarden"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "GPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
publish = false
|
publish = false
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|
143
LICENSE.txt
143
LICENSE.txt
|
@ -1,5 +1,5 @@
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
@ -7,17 +7,15 @@
|
||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
|
@ -72,7 +60,7 @@ modification follow.
|
||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
|
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
[![Docker Pulls](https://img.shields.io/docker/pulls/vaultwarden/server.svg)](https://hub.docker.com/r/vaultwarden/server)
|
[![Docker Pulls](https://img.shields.io/docker/pulls/vaultwarden/server.svg)](https://hub.docker.com/r/vaultwarden/server)
|
||||||
[![Dependency Status](https://deps.rs/repo/github/dani-garcia/vaultwarden/status.svg)](https://deps.rs/repo/github/dani-garcia/vaultwarden)
|
[![Dependency Status](https://deps.rs/repo/github/dani-garcia/vaultwarden/status.svg)](https://deps.rs/repo/github/dani-garcia/vaultwarden)
|
||||||
[![GitHub Release](https://img.shields.io/github/release/dani-garcia/vaultwarden.svg)](https://github.com/dani-garcia/vaultwarden/releases/latest)
|
[![GitHub Release](https://img.shields.io/github/release/dani-garcia/vaultwarden.svg)](https://github.com/dani-garcia/vaultwarden/releases/latest)
|
||||||
[![GPL-3.0 Licensed](https://img.shields.io/github/license/dani-garcia/vaultwarden.svg)](https://github.com/dani-garcia/vaultwarden/blob/main/LICENSE.txt)
|
[![AGPL-3.0 Licensed](https://img.shields.io/github/license/dani-garcia/vaultwarden.svg)](https://github.com/dani-garcia/vaultwarden/blob/main/LICENSE.txt)
|
||||||
[![Matrix Chat](https://img.shields.io/matrix/vaultwarden:matrix.org.svg?logo=matrix)](https://matrix.to/#/#vaultwarden:matrix.org)
|
[![Matrix Chat](https://img.shields.io/matrix/vaultwarden:matrix.org.svg?logo=matrix)](https://matrix.to/#/#vaultwarden:matrix.org)
|
||||||
|
|
||||||
Image is based on [Rust implementation of Bitwarden API](https://github.com/dani-garcia/vaultwarden).
|
Image is based on [Rust implementation of Bitwarden API](https://github.com/dani-garcia/vaultwarden).
|
||||||
|
@ -39,7 +39,7 @@ docker run -d --name vaultwarden -v /vw-data/:/data/ -p 80:80 vaultwarden/server
|
||||||
```
|
```
|
||||||
This will preserve any persistent data under /vw-data/, you can adapt the path to whatever suits you.
|
This will preserve any persistent data under /vw-data/, you can adapt the path to whatever suits you.
|
||||||
|
|
||||||
**IMPORTANT**: Some web browsers, like Chrome, disallow the use of Web Crypto APIs in insecure contexts. In this case, you might get an error like `Cannot read property 'importKey'`. To solve this problem, you need to access the web vault from HTTPS.
|
**IMPORTANT**: Some web browsers, like Chrome, disallow the use of Web Crypto APIs in insecure contexts. In this case, you might get an error like `Cannot read property 'importKey'`. To solve this problem, you need to access the web vault from HTTPS.
|
||||||
|
|
||||||
This can be configured in [vaultwarden directly](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS) or using a third-party reverse proxy ([some examples](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples)).
|
This can be configured in [vaultwarden directly](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS) or using a third-party reverse proxy ([some examples](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples)).
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ LABELS=(
|
||||||
# https://github.com/opencontainers/image-spec/blob/master/annotations.md
|
# https://github.com/opencontainers/image-spec/blob/master/annotations.md
|
||||||
org.opencontainers.image.created="$(date --utc --iso-8601=seconds)"
|
org.opencontainers.image.created="$(date --utc --iso-8601=seconds)"
|
||||||
org.opencontainers.image.documentation="https://github.com/dani-garcia/vaultwarden/wiki"
|
org.opencontainers.image.documentation="https://github.com/dani-garcia/vaultwarden/wiki"
|
||||||
org.opencontainers.image.licenses="GPL-3.0-only"
|
org.opencontainers.image.licenses="AGPL-3.0-only"
|
||||||
org.opencontainers.image.revision="${SOURCE_COMMIT}"
|
org.opencontainers.image.revision="${SOURCE_COMMIT}"
|
||||||
org.opencontainers.image.source="${SOURCE_REPOSITORY_URL}"
|
org.opencontainers.image.source="${SOURCE_REPOSITORY_URL}"
|
||||||
org.opencontainers.image.url="https://hub.docker.com/r/${DOCKER_REPO#*/}"
|
org.opencontainers.image.url="https://hub.docker.com/r/${DOCKER_REPO#*/}"
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE users_organizations
|
||||||
|
ADD COLUMN reset_password_key TEXT;
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE users_organizations
|
||||||
|
ADD COLUMN reset_password_key TEXT;
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE users_organizations
|
||||||
|
ADD COLUMN reset_password_key TEXT;
|
|
@ -62,6 +62,7 @@ pub fn routes() -> Vec<Route> {
|
||||||
get_plans_tax_rates,
|
get_plans_tax_rates,
|
||||||
import,
|
import,
|
||||||
post_org_keys,
|
post_org_keys,
|
||||||
|
get_organization_keys,
|
||||||
bulk_public_keys,
|
bulk_public_keys,
|
||||||
deactivate_organization_user,
|
deactivate_organization_user,
|
||||||
bulk_deactivate_organization_user,
|
bulk_deactivate_organization_user,
|
||||||
|
@ -86,6 +87,9 @@ pub fn routes() -> Vec<Route> {
|
||||||
put_user_groups,
|
put_user_groups,
|
||||||
delete_group_user,
|
delete_group_user,
|
||||||
post_delete_group_user,
|
post_delete_group_user,
|
||||||
|
put_reset_password_enrollment,
|
||||||
|
get_reset_password_details,
|
||||||
|
put_reset_password,
|
||||||
get_org_export
|
get_org_export
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -882,6 +886,7 @@ async fn _reinvite_user(org_id: &str, user_org: &str, invited_by_email: &str, co
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
struct AcceptData {
|
struct AcceptData {
|
||||||
Token: String,
|
Token: String,
|
||||||
|
ResetPasswordKey: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/organizations/<org_id>/users/<_org_user_id>/accept", data = "<data>")]
|
#[post("/organizations/<org_id>/users/<_org_user_id>/accept", data = "<data>")]
|
||||||
|
@ -909,6 +914,11 @@ async fn accept_invite(
|
||||||
err!("User already accepted the invitation")
|
err!("User already accepted the invitation")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let master_password_required = OrgPolicy::org_is_reset_password_auto_enroll(org, &mut conn).await;
|
||||||
|
if data.ResetPasswordKey.is_none() && master_password_required {
|
||||||
|
err!("Reset password key is required, but not provided.");
|
||||||
|
}
|
||||||
|
|
||||||
// This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type
|
// This check is also done at accept_invite(), _confirm_invite, _activate_user(), edit_user(), admin::update_user_org_type
|
||||||
// It returns different error messages per function.
|
// It returns different error messages per function.
|
||||||
if user_org.atype < UserOrgType::Admin {
|
if user_org.atype < UserOrgType::Admin {
|
||||||
|
@ -924,6 +934,11 @@ async fn accept_invite(
|
||||||
}
|
}
|
||||||
|
|
||||||
user_org.status = UserOrgStatus::Accepted as i32;
|
user_org.status = UserOrgStatus::Accepted as i32;
|
||||||
|
|
||||||
|
if master_password_required {
|
||||||
|
user_org.reset_password_key = data.ResetPasswordKey;
|
||||||
|
}
|
||||||
|
|
||||||
user_org.save(&mut conn).await?;
|
user_org.save(&mut conn).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2460,6 +2475,204 @@ async fn delete_group_user(
|
||||||
GroupUser::delete_by_group_id_and_user_id(&group_id, &org_user_id, &mut conn).await
|
GroupUser::delete_by_group_id_and_user_id(&group_id, &org_user_id, &mut conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
struct OrganizationUserResetPasswordEnrollmentRequest {
|
||||||
|
ResetPasswordKey: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
struct OrganizationUserResetPasswordRequest {
|
||||||
|
NewMasterPasswordHash: String,
|
||||||
|
Key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/organizations/<org_id>/keys")]
|
||||||
|
async fn get_organization_keys(org_id: String, mut conn: DbConn) -> JsonResult {
|
||||||
|
let org = match Organization::find_by_uuid(&org_id, &mut conn).await {
|
||||||
|
Some(organization) => organization,
|
||||||
|
None => err!("Organization not found"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"Object": "organizationKeys",
|
||||||
|
"PublicKey": org.public_key,
|
||||||
|
"PrivateKey": org.private_key,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[put("/organizations/<org_id>/users/<org_user_id>/reset-password", data = "<data>")]
|
||||||
|
async fn put_reset_password(
|
||||||
|
org_id: String,
|
||||||
|
org_user_id: String,
|
||||||
|
headers: AdminHeaders,
|
||||||
|
data: JsonUpcase<OrganizationUserResetPasswordRequest>,
|
||||||
|
mut conn: DbConn,
|
||||||
|
ip: ClientIp,
|
||||||
|
nt: Notify<'_>,
|
||||||
|
) -> EmptyResult {
|
||||||
|
let org = match Organization::find_by_uuid(&org_id, &mut conn).await {
|
||||||
|
Some(org) => org,
|
||||||
|
None => err!("Required organization not found"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let org_user = match UserOrganization::find_by_uuid_and_org(&org_user_id, &org.uuid, &mut conn).await {
|
||||||
|
Some(user) => user,
|
||||||
|
None => err!("User to reset isn't member of required organization"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut user = match User::find_by_uuid(&org_user.user_uuid, &mut conn).await {
|
||||||
|
Some(user) => user,
|
||||||
|
None => err!("User not found"),
|
||||||
|
};
|
||||||
|
|
||||||
|
check_reset_password_applicable_and_permissions(&org_id, &org_user_id, &headers, &mut conn).await?;
|
||||||
|
|
||||||
|
if org_user.reset_password_key.is_none() {
|
||||||
|
err!("Password reset not or not correctly enrolled");
|
||||||
|
}
|
||||||
|
if org_user.status != (UserOrgStatus::Confirmed as i32) {
|
||||||
|
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.name, &org.name).await {
|
||||||
|
error!("Error sending user reset password email: {:#?}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let reset_request = data.into_inner().data;
|
||||||
|
|
||||||
|
user.set_password(reset_request.NewMasterPasswordHash.as_str(), Some(reset_request.Key), true, None);
|
||||||
|
user.save(&mut conn).await?;
|
||||||
|
|
||||||
|
nt.send_logout(&user, None).await;
|
||||||
|
|
||||||
|
log_event(
|
||||||
|
EventType::OrganizationUserAdminResetPassword as i32,
|
||||||
|
&org_user_id,
|
||||||
|
org.uuid.clone(),
|
||||||
|
headers.user.uuid.clone(),
|
||||||
|
headers.device.atype,
|
||||||
|
&ip.ip,
|
||||||
|
&mut conn,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/organizations/<org_id>/users/<org_user_id>/reset-password-details")]
|
||||||
|
async fn get_reset_password_details(
|
||||||
|
org_id: String,
|
||||||
|
org_user_id: String,
|
||||||
|
headers: AdminHeaders,
|
||||||
|
mut conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
|
let org = match Organization::find_by_uuid(&org_id, &mut conn).await {
|
||||||
|
Some(org) => org,
|
||||||
|
None => err!("Required organization not found"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let org_user = match UserOrganization::find_by_uuid_and_org(&org_user_id, &org_id, &mut conn).await {
|
||||||
|
Some(user) => user,
|
||||||
|
None => err!("User to reset isn't member of required organization"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = match User::find_by_uuid(&org_user.user_uuid, &mut conn).await {
|
||||||
|
Some(user) => user,
|
||||||
|
None => err!("User not found"),
|
||||||
|
};
|
||||||
|
|
||||||
|
check_reset_password_applicable_and_permissions(&org_id, &org_user_id, &headers, &mut conn).await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"Object": "organizationUserResetPasswordDetails",
|
||||||
|
"Kdf":user.client_kdf_type,
|
||||||
|
"KdfIterations":user.client_kdf_iter,
|
||||||
|
"ResetPasswordKey":org_user.reset_password_key,
|
||||||
|
"EncryptedPrivateKey":org.private_key ,
|
||||||
|
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_reset_password_applicable_and_permissions(
|
||||||
|
org_id: &str,
|
||||||
|
org_user_id: &str,
|
||||||
|
headers: &AdminHeaders,
|
||||||
|
conn: &mut DbConn,
|
||||||
|
) -> EmptyResult {
|
||||||
|
check_reset_password_applicable(org_id, conn).await?;
|
||||||
|
|
||||||
|
let target_user = match UserOrganization::find_by_uuid_and_org(org_user_id, org_id, conn).await {
|
||||||
|
Some(user) => user,
|
||||||
|
None => err!("Reset target user not found"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resetting user must be higher/equal to user to reset
|
||||||
|
match headers.org_user_type {
|
||||||
|
UserOrgType::Owner => Ok(()),
|
||||||
|
UserOrgType::Admin if target_user.atype <= UserOrgType::Admin => Ok(()),
|
||||||
|
_ => err!("No permission to reset this user's password"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_reset_password_applicable(org_id: &str, conn: &mut DbConn) -> EmptyResult {
|
||||||
|
if !CONFIG.mail_enabled() {
|
||||||
|
err!("Password reset is not supported on an email-disabled instance.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let policy = match OrgPolicy::find_by_org_and_type(org_id, OrgPolicyType::ResetPassword, conn).await {
|
||||||
|
Some(p) => p,
|
||||||
|
None => err!("Policy not found"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !policy.enabled {
|
||||||
|
err!("Reset password policy not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[put("/organizations/<org_id>/users/<org_user_id>/reset-password-enrollment", data = "<data>")]
|
||||||
|
async fn put_reset_password_enrollment(
|
||||||
|
org_id: String,
|
||||||
|
org_user_id: String,
|
||||||
|
headers: Headers,
|
||||||
|
data: JsonUpcase<OrganizationUserResetPasswordEnrollmentRequest>,
|
||||||
|
mut conn: DbConn,
|
||||||
|
ip: ClientIp,
|
||||||
|
) -> EmptyResult {
|
||||||
|
let mut org_user = match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &mut conn).await {
|
||||||
|
Some(u) => u,
|
||||||
|
None => err!("User to enroll isn't member of required organization"),
|
||||||
|
};
|
||||||
|
|
||||||
|
check_reset_password_applicable(&org_id, &mut conn).await?;
|
||||||
|
|
||||||
|
let reset_request = data.into_inner().data;
|
||||||
|
|
||||||
|
if reset_request.ResetPasswordKey.is_none()
|
||||||
|
&& OrgPolicy::org_is_reset_password_auto_enroll(&org_id, &mut conn).await
|
||||||
|
{
|
||||||
|
err!("Reset password can't be withdrawed due to an enterprise policy");
|
||||||
|
}
|
||||||
|
|
||||||
|
org_user.reset_password_key = reset_request.ResetPasswordKey;
|
||||||
|
org_user.save(&mut conn).await?;
|
||||||
|
|
||||||
|
let log_id = if org_user.reset_password_key.is_some() {
|
||||||
|
EventType::OrganizationUserResetPasswordEnroll as i32
|
||||||
|
} else {
|
||||||
|
EventType::OrganizationUserResetPasswordWithdraw as i32
|
||||||
|
};
|
||||||
|
|
||||||
|
log_event(log_id, &org_user_id, org_id, headers.user.uuid.clone(), headers.device.atype, &ip.ip, &mut conn).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// This is a new function active since the v2022.9.x clients.
|
// This is a new function active since the v2022.9.x clients.
|
||||||
// It combines the previous two calls done before.
|
// It combines the previous two calls done before.
|
||||||
// We call those two functions here and combine them our selfs.
|
// We call those two functions here and combine them our selfs.
|
||||||
|
|
|
@ -1136,6 +1136,7 @@ where
|
||||||
reg!("email/email_footer");
|
reg!("email/email_footer");
|
||||||
reg!("email/email_footer_text");
|
reg!("email/email_footer_text");
|
||||||
|
|
||||||
|
reg!("email/admin_reset_password", ".html");
|
||||||
reg!("email/change_email", ".html");
|
reg!("email/change_email", ".html");
|
||||||
reg!("email/delete_account", ".html");
|
reg!("email/delete_account", ".html");
|
||||||
reg!("email/emergency_access_invite_accepted", ".html");
|
reg!("email/emergency_access_invite_accepted", ".html");
|
||||||
|
|
|
@ -87,9 +87,9 @@ pub enum EventType {
|
||||||
OrganizationUserRemoved = 1503,
|
OrganizationUserRemoved = 1503,
|
||||||
OrganizationUserUpdatedGroups = 1504,
|
OrganizationUserUpdatedGroups = 1504,
|
||||||
// OrganizationUserUnlinkedSso = 1505, // Not supported
|
// OrganizationUserUnlinkedSso = 1505, // Not supported
|
||||||
// OrganizationUserResetPasswordEnroll = 1506, // Not supported
|
OrganizationUserResetPasswordEnroll = 1506,
|
||||||
// OrganizationUserResetPasswordWithdraw = 1507, // Not supported
|
OrganizationUserResetPasswordWithdraw = 1507,
|
||||||
// OrganizationUserAdminResetPassword = 1508, // Not supported
|
OrganizationUserAdminResetPassword = 1508,
|
||||||
// OrganizationUserResetSsoLink = 1509, // Not supported
|
// OrganizationUserResetSsoLink = 1509, // Not supported
|
||||||
// OrganizationUserFirstSsoLogin = 1510, // Not supported
|
// OrganizationUserFirstSsoLogin = 1510, // Not supported
|
||||||
OrganizationUserRevoked = 1511,
|
OrganizationUserRevoked = 1511,
|
||||||
|
|
|
@ -32,7 +32,7 @@ pub enum OrgPolicyType {
|
||||||
PersonalOwnership = 5,
|
PersonalOwnership = 5,
|
||||||
DisableSend = 6,
|
DisableSend = 6,
|
||||||
SendOptions = 7,
|
SendOptions = 7,
|
||||||
// ResetPassword = 8, // Not supported
|
ResetPassword = 8,
|
||||||
// MaximumVaultTimeout = 9, // Not supported (Not AGPLv3 Licensed)
|
// MaximumVaultTimeout = 9, // Not supported (Not AGPLv3 Licensed)
|
||||||
// DisablePersonalVaultExport = 10, // Not supported (Not AGPLv3 Licensed)
|
// DisablePersonalVaultExport = 10, // Not supported (Not AGPLv3 Licensed)
|
||||||
}
|
}
|
||||||
|
@ -44,6 +44,13 @@ pub struct SendOptionsPolicyData {
|
||||||
pub DisableHideEmail: bool,
|
pub DisableHideEmail: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://github.com/bitwarden/server/blob/5cbdee137921a19b1f722920f0fa3cd45af2ef0f/src/Core/Models/Data/Organizations/Policies/ResetPasswordDataModel.cs
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
pub struct ResetPasswordDataModel {
|
||||||
|
pub AutoEnrollEnabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub type OrgPolicyResult = Result<(), OrgPolicyErr>;
|
pub type OrgPolicyResult = Result<(), OrgPolicyErr>;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -298,6 +305,20 @@ impl OrgPolicy {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn org_is_reset_password_auto_enroll(org_uuid: &str, conn: &mut DbConn) -> bool {
|
||||||
|
match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await {
|
||||||
|
Some(policy) => match serde_json::from_str::<UpCase<ResetPasswordDataModel>>(&policy.data) {
|
||||||
|
Ok(opts) => {
|
||||||
|
return opts.data.AutoEnrollEnabled;
|
||||||
|
}
|
||||||
|
_ => error!("Failed to deserialize ResetPasswordDataModel: {}", policy.data),
|
||||||
|
},
|
||||||
|
None => return false,
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if the user belongs to an org that has enabled the `DisableHideEmail`
|
/// Returns true if the user belongs to an org that has enabled the `DisableHideEmail`
|
||||||
/// option of the `Send Options` policy, and the user is not an owner or admin of that org.
|
/// option of the `Send Options` policy, and the user is not an owner or admin of that org.
|
||||||
pub async fn is_hide_email_disabled(user_uuid: &str, conn: &mut DbConn) -> bool {
|
pub async fn is_hide_email_disabled(user_uuid: &str, conn: &mut DbConn) -> bool {
|
||||||
|
|
|
@ -29,6 +29,7 @@ db_object! {
|
||||||
pub akey: String,
|
pub akey: String,
|
||||||
pub status: i32,
|
pub status: i32,
|
||||||
pub atype: i32,
|
pub atype: i32,
|
||||||
|
pub reset_password_key: Option<String>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -158,7 +159,7 @@ impl Organization {
|
||||||
"SelfHost": true,
|
"SelfHost": true,
|
||||||
"UseApi": false, // Not supported
|
"UseApi": false, // Not supported
|
||||||
"HasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(),
|
"HasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(),
|
||||||
"UseResetPassword": false, // Not supported
|
"UseResetPassword": CONFIG.mail_enabled(),
|
||||||
|
|
||||||
"BusinessName": null,
|
"BusinessName": null,
|
||||||
"BusinessAddress1": null,
|
"BusinessAddress1": null,
|
||||||
|
@ -194,6 +195,7 @@ impl UserOrganization {
|
||||||
akey: String::new(),
|
akey: String::new(),
|
||||||
status: UserOrgStatus::Accepted as i32,
|
status: UserOrgStatus::Accepted as i32,
|
||||||
atype: UserOrgType::User as i32,
|
atype: UserOrgType::User as i32,
|
||||||
|
reset_password_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -311,7 +313,8 @@ impl UserOrganization {
|
||||||
"UseApi": false, // Not supported
|
"UseApi": false, // Not supported
|
||||||
"SelfHost": true,
|
"SelfHost": true,
|
||||||
"HasPublicAndPrivateKeys": org.private_key.is_some() && org.public_key.is_some(),
|
"HasPublicAndPrivateKeys": org.private_key.is_some() && org.public_key.is_some(),
|
||||||
"ResetPasswordEnrolled": false, // Not supported
|
"ResetPasswordEnrolled": self.reset_password_key.is_some(),
|
||||||
|
"UseResetPassword": CONFIG.mail_enabled(),
|
||||||
"SsoBound": false, // Not supported
|
"SsoBound": false, // Not supported
|
||||||
"UseSso": false, // Not supported
|
"UseSso": false, // Not supported
|
||||||
"ProviderId": null,
|
"ProviderId": null,
|
||||||
|
@ -377,6 +380,7 @@ impl UserOrganization {
|
||||||
"Type": self.atype,
|
"Type": self.atype,
|
||||||
"AccessAll": self.access_all,
|
"AccessAll": self.access_all,
|
||||||
"TwoFactorEnabled": twofactor_enabled,
|
"TwoFactorEnabled": twofactor_enabled,
|
||||||
|
"ResetPasswordEnrolled":self.reset_password_key.is_some(),
|
||||||
|
|
||||||
"Object": "organizationUserUserDetails",
|
"Object": "organizationUserUserDetails",
|
||||||
})
|
})
|
||||||
|
|
|
@ -222,6 +222,7 @@ table! {
|
||||||
akey -> Text,
|
akey -> Text,
|
||||||
status -> Integer,
|
status -> Integer,
|
||||||
atype -> Integer,
|
atype -> Integer,
|
||||||
|
reset_password_key -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -222,6 +222,7 @@ table! {
|
||||||
akey -> Text,
|
akey -> Text,
|
||||||
status -> Integer,
|
status -> Integer,
|
||||||
atype -> Integer,
|
atype -> Integer,
|
||||||
|
reset_password_key -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -222,6 +222,7 @@ table! {
|
||||||
akey -> Text,
|
akey -> Text,
|
||||||
status -> Integer,
|
status -> Integer,
|
||||||
atype -> Integer,
|
atype -> Integer,
|
||||||
|
reset_password_key -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
13
src/mail.rs
13
src/mail.rs
|
@ -496,6 +496,19 @@ pub async fn send_test(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_admin_reset_password(address: &str, user_name: &str, org_name: &str) -> EmptyResult {
|
||||||
|
let (subject, body_html, body_text) = get_text(
|
||||||
|
"email/admin_reset_password",
|
||||||
|
json!({
|
||||||
|
"url": CONFIG.domain(),
|
||||||
|
"img_src": CONFIG._smtp_img_src(),
|
||||||
|
"user_name": user_name,
|
||||||
|
"org_name": org_name,
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
send_email(address, &subject, body_html, body_text).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult {
|
async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult {
|
||||||
let smtp_from = &CONFIG.smtp_from();
|
let smtp_from = &CONFIG.smtp_from();
|
||||||
|
|
||||||
|
|
|
@ -47,7 +47,7 @@
|
||||||
<div class="row my-2 align-items-center pt-3 border-top" title="Send a test email to given email address">
|
<div class="row my-2 align-items-center pt-3 border-top" title="Send a test email to given email address">
|
||||||
<label for="smtp-test-email" class="col-sm-3 col-form-label">Test SMTP</label>
|
<label for="smtp-test-email" class="col-sm-3 col-form-label">Test SMTP</label>
|
||||||
<div class="col-sm-8 input-group">
|
<div class="col-sm-8 input-group">
|
||||||
<input class="form-control" id="smtp-test-email" type="email" placeholder="Enter test email" required>
|
<input class="form-control" id="smtp-test-email" type="email" placeholder="Enter test email" required spellcheck="false">
|
||||||
<button type="button" class="btn btn-outline-primary input-group-text" id="smtpTest">Send test email</button>
|
<button type="button" class="btn btn-outline-primary input-group-text" id="smtpTest">Send test email</button>
|
||||||
<div class="invalid-tooltip">Please provide a valid email address</div>
|
<div class="invalid-tooltip">Please provide a valid email address</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -85,7 +85,7 @@
|
||||||
<input readonly class="form-control" id="input_{{name}}" type="password" value="{{value}}" {{#if default}} placeholder="Default: {{default}}" {{/if}}>
|
<input readonly class="form-control" id="input_{{name}}" type="password" value="{{value}}" {{#if default}} placeholder="Default: {{default}}" {{/if}}>
|
||||||
<button class="btn btn-outline-secondary" type="button" data-vw-pw-toggle="input_{{name}}">Show/hide</button>
|
<button class="btn btn-outline-secondary" type="button" data-vw-pw-toggle="input_{{name}}">Show/hide</button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<input readonly class="form-control" id="input_{{name}}" type="{{type}}" value="{{value}}" {{#if default}} placeholder="Default: {{default}}" {{/if}}>
|
<input readonly class="form-control" id="input_{{name}}" type="{{type}}" value="{{value}}" {{#if default}} placeholder="Default: {{default}}" {{/if}} spellcheck="false">
|
||||||
{{#case type "password"}}
|
{{#case type "password"}}
|
||||||
<button class="btn btn-outline-secondary" type="button" data-vw-pw-toggle="input_{{name}}">Show/hide</button>
|
<button class="btn btn-outline-secondary" type="button" data-vw-pw-toggle="input_{{name}}">Show/hide</button>
|
||||||
{{/case}}
|
{{/case}}
|
||||||
|
|
|
@ -96,7 +96,7 @@
|
||||||
<small>Email:</small>
|
<small>Email:</small>
|
||||||
|
|
||||||
<form class="form-inline input-group w-50" id="inviteUserForm">
|
<form class="form-inline input-group w-50" id="inviteUserForm">
|
||||||
<input type="email" class="form-control me-2" id="inviteEmail" placeholder="Enter email" required>
|
<input type="email" class="form-control me-2" id="inviteEmail" placeholder="Enter email" required spellcheck="false">
|
||||||
<button type="submit" class="btn btn-primary">Invite</button>
|
<button type="submit" class="btn btn-primary">Invite</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
6
src/static/templates/email/admin_reset_password.hbs
Normale Datei
6
src/static/templates/email/admin_reset_password.hbs
Normale Datei
|
@ -0,0 +1,6 @@
|
||||||
|
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.
|
||||||
|
|
||||||
|
===
|
||||||
|
Github: https://github.com/dani-garcia/vaultwarden
|
11
src/static/templates/email/admin_reset_password.html.hbs
Normale Datei
11
src/static/templates/email/admin_reset_password.html.hbs
Normale Datei
|
@ -0,0 +1,11 @@
|
||||||
|
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 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.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{{> email/email_footer }}
|
Laden …
In neuem Issue referenzieren