mirror of
https://github.com/dani-garcia/vaultwarden
synced 2024-12-13 17:22:58 +01:00
Merge pull request #216 from mprasil/superuser
Implement poor man's admin panel
This commit is contained in:
commit
e0614620ef
12 changed files with 209 additions and 71 deletions
16
README.md
16
README.md
|
@ -24,6 +24,7 @@ _*Note, that this project is not associated with the [Bitwarden](https://bitward
|
|||
- [Configuring bitwarden service](#configuring-bitwarden-service)
|
||||
- [Disable registration of new users](#disable-registration-of-new-users)
|
||||
- [Disable invitations](#disable-invitations)
|
||||
- [Configure server administrator](#configure-server-administrator)
|
||||
- [Enabling HTTPS](#enabling-https)
|
||||
- [Enabling WebSocket notifications](#enabling-websocket-notifications)
|
||||
- [Enabling U2F authentication](#enabling-u2f-authentication)
|
||||
|
@ -154,6 +155,21 @@ docker run -d --name bitwarden \
|
|||
-p 80:80 \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
### Configure server administrator
|
||||
|
||||
You can configure one email account to be server administrator via the `SERVER_ADMIN_EMAIL` environment variable:
|
||||
|
||||
```sh
|
||||
docker run -d --name bitwarden \
|
||||
-e SERVER_ADMIN_EMAIL=admin@example.com \
|
||||
-v /bw-data/:/data/ \
|
||||
-p 80:80 \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
|
||||
This will give the user extra functionality and privileges to manage users on the server. In the Vault, the user will see a special (virtual) organization called `bitwarden_rs`. This organization doesn't actually exist and can't be used for most things. (can't have collections or ciphers) Instead it just contains all the users registered on the server. Deleting users from this organization will actually completely delete the user from the server. Inviting users into this organization will just invite the user so they are able to register, but will not grant any organization membership. (unlike inviting user to regular organization)
|
||||
|
||||
You can think of the `bitwarden_rs` organization as sort of Admin interface to manage users on the server. Due to the virtual nature of this organization, it is missing some internal data structures and most of the functionality. It is thus strongly recommended to use dedicated account for `SERVER_ADMIN_EMAIL` and this account shouldn't be used for actually storing passwords. Also keep in mind that deleting user this way removes the user permanently without any way to restore the deleted data just as if user deleted their own account.
|
||||
|
||||
### Enabling HTTPS
|
||||
To enable HTTPS, you need to configure the `ROCKET_TLS`.
|
||||
|
|
|
@ -288,28 +288,11 @@ fn delete_account(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn
|
|||
if !user.check_valid_password(&data.MasterPasswordHash) {
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
// Delete ciphers and their attachments
|
||||
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) {
|
||||
if cipher.delete(&conn).is_err() {
|
||||
err!("Failed deleting cipher")
|
||||
}
|
||||
|
||||
match user.delete(&conn) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => err!("Failed deleting user account, are you the only owner of some organization?")
|
||||
}
|
||||
|
||||
// Delete folders
|
||||
for f in Folder::find_by_user(&user.uuid, &conn) {
|
||||
if f.delete(&conn).is_err() {
|
||||
err!("Failed deleting folder")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete devices
|
||||
for d in Device::find_by_user(&user.uuid, &conn) { d.delete(&conn); }
|
||||
|
||||
// Delete user
|
||||
user.delete(&conn);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/accounts/revision-date")]
|
||||
|
|
|
@ -151,9 +151,10 @@ fn clear_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: D
|
|||
err!("Device not owned by user")
|
||||
}
|
||||
|
||||
device.delete(&conn);
|
||||
|
||||
Ok(())
|
||||
match device.delete(&conn) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => err!("Failed deleting device")
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/devices/identifier/<uuid>/token", data = "<data>")]
|
||||
|
|
|
@ -326,12 +326,7 @@ fn get_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResul
|
|||
}
|
||||
|
||||
#[get("/organizations/<org_id>/users")]
|
||||
fn get_org_users(org_id: String, headers: AdminHeaders, conn: DbConn) -> JsonResult {
|
||||
match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) {
|
||||
Some(_) => (),
|
||||
None => err!("User isn't member of organization")
|
||||
}
|
||||
|
||||
fn get_org_users(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult {
|
||||
let users = UserOrganization::find_by_org(&org_id, &conn);
|
||||
let users_json: Vec<Value> = users.iter().map(|c| c.to_json_user_details(&conn)).collect();
|
||||
|
||||
|
@ -410,27 +405,30 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
|
|||
|
||||
};
|
||||
|
||||
let mut new_user = UserOrganization::new(user.uuid.clone(), org_id.clone());
|
||||
let access_all = data.AccessAll.unwrap_or(false);
|
||||
new_user.access_all = access_all;
|
||||
new_user.type_ = new_type;
|
||||
new_user.status = user_org_status;
|
||||
// Don't create UserOrganization in virtual organization
|
||||
if org_id != Organization::VIRTUAL_ID {
|
||||
let mut new_user = UserOrganization::new(user.uuid.clone(), org_id.clone());
|
||||
let access_all = data.AccessAll.unwrap_or(false);
|
||||
new_user.access_all = access_all;
|
||||
new_user.type_ = new_type;
|
||||
new_user.status = user_org_status;
|
||||
|
||||
// If no accessAll, add the collections received
|
||||
if !access_all {
|
||||
for col in &data.Collections {
|
||||
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
|
||||
None => err!("Collection not found in Organization"),
|
||||
Some(collection) => {
|
||||
if CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, &conn).is_err() {
|
||||
err!("Failed saving collection access for user")
|
||||
// If no accessAll, add the collections received
|
||||
if !access_all {
|
||||
for col in &data.Collections {
|
||||
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
|
||||
None => err!("Collection not found in Organization"),
|
||||
Some(collection) => {
|
||||
if CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, &conn).is_err() {
|
||||
err!("Failed saving collection access for user")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new_user.save(&conn);
|
||||
new_user.save(&conn);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
@ -560,6 +558,23 @@ fn edit_user(org_id: String, org_user_id: String, data: JsonUpcase<EditUserData>
|
|||
|
||||
#[delete("/organizations/<org_id>/users/<org_user_id>")]
|
||||
fn delete_user(org_id: String, org_user_id: String, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
|
||||
// We're deleting user in virtual Organization. Delete User, not UserOrganization
|
||||
if org_id == Organization::VIRTUAL_ID {
|
||||
match User::find_by_uuid(&org_user_id, &conn) {
|
||||
Some(user_to_delete) => {
|
||||
if user_to_delete.uuid == headers.user.uuid {
|
||||
err!("Delete your account in the account settings")
|
||||
} else {
|
||||
match user_to_delete.delete(&conn) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(_) => err!("Failed to delete user - likely because it's the only owner of organization")
|
||||
}
|
||||
}
|
||||
},
|
||||
None => err!("User not found")
|
||||
}
|
||||
}
|
||||
|
||||
let user_to_delete = match UserOrganization::find_by_uuid_and_org(&org_user_id, &org_id, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("User to delete isn't member of the organization")
|
||||
|
|
|
@ -107,11 +107,13 @@ fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn, re
|
|||
Some(device) => {
|
||||
// Check if valid device
|
||||
if device.user_uuid != user.uuid {
|
||||
device.delete(&conn);
|
||||
err!("Device is not owned by user")
|
||||
match device.delete(&conn) {
|
||||
Ok(()) => Device::new(device_id, user.uuid.clone(), device_name, device_type_num),
|
||||
Err(_) => err!("Tried to delete device not owned by user, but failed")
|
||||
}
|
||||
} else {
|
||||
device
|
||||
}
|
||||
|
||||
device
|
||||
}
|
||||
None => {
|
||||
// Create new device
|
||||
|
|
10
src/auth.rs
10
src/auth.rs
|
@ -95,7 +95,7 @@ use rocket::Outcome;
|
|||
use rocket::request::{self, Request, FromRequest};
|
||||
|
||||
use db::DbConn;
|
||||
use db::models::{User, UserOrganization, UserOrgType, UserOrgStatus, Device};
|
||||
use db::models::{User, Organization, UserOrganization, UserOrgType, UserOrgStatus, Device};
|
||||
|
||||
pub struct Headers {
|
||||
pub host: String,
|
||||
|
@ -212,7 +212,13 @@ impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders {
|
|||
err_handler!("The current user isn't confirmed member of the organization")
|
||||
}
|
||||
}
|
||||
None => err_handler!("The current user isn't member of the organization")
|
||||
None => {
|
||||
if headers.user.is_server_admin() && org_id == Organization::VIRTUAL_ID {
|
||||
UserOrganization::new_virtual(headers.user.uuid.clone(), UserOrgType::Owner, UserOrgStatus::Confirmed)
|
||||
} else {
|
||||
err_handler!("The current user isn't member of the organization")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Outcome::Success(Self{
|
||||
|
|
|
@ -182,6 +182,13 @@ impl Cipher {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> QueryResult<()> {
|
||||
for cipher in Self::find_owned_by_user(user_uuid, &conn) {
|
||||
cipher.delete(&conn)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn move_to_folder(&self, folder_uuid: Option<String>, user_uuid: &str, conn: &DbConn) -> Result<(), &str> {
|
||||
match self.get_folder_uuid(&user_uuid, &conn) {
|
||||
None => {
|
||||
|
|
|
@ -123,13 +123,17 @@ impl Device {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn delete(self, conn: &DbConn) -> bool {
|
||||
match diesel::delete(devices::table.filter(
|
||||
devices::uuid.eq(self.uuid)))
|
||||
.execute(&**conn) {
|
||||
Ok(1) => true, // One row deleted
|
||||
_ => false,
|
||||
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
|
||||
diesel::delete(devices::table.filter(
|
||||
devices::uuid.eq(self.uuid)
|
||||
)).execute(&**conn).and(Ok(()))
|
||||
}
|
||||
|
||||
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> QueryResult<()> {
|
||||
for device in Self::find_by_user(user_uuid, &conn) {
|
||||
device.delete(&conn)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
|
||||
|
|
|
@ -93,6 +93,13 @@ impl Folder {
|
|||
).execute(&**conn).and(Ok(()))
|
||||
}
|
||||
|
||||
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> QueryResult<()> {
|
||||
for folder in Self::find_by_user(user_uuid, &conn) {
|
||||
folder.delete(&conn)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
|
||||
folders::table
|
||||
.filter(folders::uuid.eq(uuid))
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use serde_json::Value as JsonValue;
|
||||
|
||||
use uuid::Uuid;
|
||||
use super::{User, CollectionUser};
|
||||
use super::{User, CollectionUser, Invitation};
|
||||
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable)]
|
||||
#[table_name = "organizations"]
|
||||
|
@ -51,6 +51,8 @@ impl UserOrgType {
|
|||
|
||||
/// Local methods
|
||||
impl Organization {
|
||||
pub const VIRTUAL_ID: &'static str = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
pub fn new(name: String, billing_email: String) -> Self {
|
||||
Self {
|
||||
uuid: Uuid::new_v4().to_string(),
|
||||
|
@ -60,6 +62,14 @@ impl Organization {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn new_virtual() -> Self {
|
||||
Self {
|
||||
uuid: String::from(Organization::VIRTUAL_ID),
|
||||
name: String::from("bitwarden_rs"),
|
||||
billing_email: String::from("none@none.none")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> JsonValue {
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
|
@ -103,6 +113,20 @@ impl UserOrganization {
|
|||
type_: UserOrgType::User as i32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_virtual(user_uuid: String, type_: UserOrgType, status: UserOrgStatus) -> Self {
|
||||
Self {
|
||||
uuid: user_uuid.clone(),
|
||||
|
||||
user_uuid,
|
||||
org_uuid: String::from(Organization::VIRTUAL_ID),
|
||||
|
||||
access_all: true,
|
||||
key: String::new(),
|
||||
status: status as i32,
|
||||
type_: type_ as i32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -114,6 +138,10 @@ use db::schema::{organizations, users_organizations, users_collections, ciphers_
|
|||
/// Database methods
|
||||
impl Organization {
|
||||
pub fn save(&mut self, conn: &DbConn) -> bool {
|
||||
if self.uuid == Organization::VIRTUAL_ID {
|
||||
return false
|
||||
}
|
||||
|
||||
UserOrganization::find_by_org(&self.uuid, conn)
|
||||
.iter()
|
||||
.for_each(|user_org| {
|
||||
|
@ -131,6 +159,10 @@ impl Organization {
|
|||
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
|
||||
use super::{Cipher, Collection};
|
||||
|
||||
if self.uuid == Organization::VIRTUAL_ID {
|
||||
return Err(diesel::result::Error::NotFound)
|
||||
}
|
||||
|
||||
Cipher::delete_all_by_organization(&self.uuid, &conn)?;
|
||||
Collection::delete_all_by_organization(&self.uuid, &conn)?;
|
||||
UserOrganization::delete_all_by_organization(&self.uuid, &conn)?;
|
||||
|
@ -143,6 +175,9 @@ impl Organization {
|
|||
}
|
||||
|
||||
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
|
||||
if uuid == Organization::VIRTUAL_ID {
|
||||
return Some(Self::new_virtual())
|
||||
};
|
||||
organizations::table
|
||||
.filter(organizations::uuid.eq(uuid))
|
||||
.first::<Self>(&**conn).ok()
|
||||
|
@ -232,6 +267,9 @@ impl UserOrganization {
|
|||
}
|
||||
|
||||
pub fn save(&mut self, conn: &DbConn) -> bool {
|
||||
if self.org_uuid == Organization::VIRTUAL_ID {
|
||||
return false
|
||||
}
|
||||
User::update_uuid_revision(&self.user_uuid, conn);
|
||||
|
||||
match diesel::replace_into(users_organizations::table)
|
||||
|
@ -243,6 +281,9 @@ impl UserOrganization {
|
|||
}
|
||||
|
||||
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
|
||||
if self.org_uuid == Organization::VIRTUAL_ID {
|
||||
return Err(diesel::result::Error::NotFound)
|
||||
}
|
||||
User::update_uuid_revision(&self.user_uuid, conn);
|
||||
|
||||
CollectionUser::delete_all_by_user(&self.user_uuid, &conn)?;
|
||||
|
@ -261,6 +302,13 @@ impl UserOrganization {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> QueryResult<()> {
|
||||
for user_org in Self::find_any_state_by_user(&user_uuid, &conn) {
|
||||
user_org.delete(&conn)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn has_full_access(self) -> bool {
|
||||
self.access_all || self.type_ < UserOrgType::User as i32
|
||||
}
|
||||
|
@ -292,10 +340,29 @@ impl UserOrganization {
|
|||
.load::<Self>(&**conn).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
pub fn find_any_state_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
users_organizations::table
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
.load::<Self>(&**conn).expect("Error loading user organizations")
|
||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||
.load::<Self>(&**conn).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
if org_uuid == Organization::VIRTUAL_ID {
|
||||
User::get_all(&*conn).iter().map(|user| {
|
||||
Self::new_virtual(
|
||||
user.uuid.clone(),
|
||||
UserOrgType::User,
|
||||
if Invitation::find_by_mail(&user.email, &conn).is_some() {
|
||||
UserOrgStatus::Invited
|
||||
} else {
|
||||
UserOrgStatus::Confirmed
|
||||
})
|
||||
}).collect()
|
||||
} else {
|
||||
users_organizations::table
|
||||
.filter(users_organizations::org_uuid.eq(org_uuid))
|
||||
.load::<Self>(&**conn).expect("Error loading user organizations")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_by_org_and_type(org_uuid: &str, type_: i32, conn: &DbConn) -> Vec<Self> {
|
||||
|
|
|
@ -103,22 +103,31 @@ impl User {
|
|||
pub fn reset_security_stamp(&mut self) {
|
||||
self.security_stamp = Uuid::new_v4().to_string();
|
||||
}
|
||||
|
||||
pub fn is_server_admin(&self) -> bool {
|
||||
match CONFIG.server_admin_email {
|
||||
Some(ref server_admin_email) => &self.email == server_admin_email,
|
||||
None => false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use db::DbConn;
|
||||
use db::schema::{users, invitations};
|
||||
use super::{Cipher, Folder, Device, UserOrganization, UserOrgType};
|
||||
|
||||
/// Database methods
|
||||
impl User {
|
||||
pub fn to_json(&self, conn: &DbConn) -> JsonValue {
|
||||
use super::UserOrganization;
|
||||
use super::TwoFactor;
|
||||
use super::{UserOrganization, UserOrgType, UserOrgStatus, TwoFactor};
|
||||
|
||||
let orgs = UserOrganization::find_by_user(&self.uuid, conn);
|
||||
let mut orgs = UserOrganization::find_by_user(&self.uuid, conn);
|
||||
if self.is_server_admin() {
|
||||
orgs.push(UserOrganization::new_virtual(self.uuid.clone(), UserOrgType::Owner, UserOrgStatus::Confirmed));
|
||||
}
|
||||
let orgs_json: Vec<JsonValue> = orgs.iter().map(|c| c.to_json(&conn)).collect();
|
||||
|
||||
let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).is_empty();
|
||||
|
||||
json!({
|
||||
|
@ -150,13 +159,27 @@ impl User {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn delete(self, conn: &DbConn) -> bool {
|
||||
match diesel::delete(users::table.filter(
|
||||
users::uuid.eq(self.uuid)))
|
||||
.execute(&**conn) {
|
||||
Ok(1) => true, // One row deleted
|
||||
_ => false,
|
||||
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
|
||||
for user_org in UserOrganization::find_by_user(&self.uuid, &*conn) {
|
||||
if user_org.type_ == UserOrgType::Owner as i32 {
|
||||
if UserOrganization::find_by_org_and_type(
|
||||
&user_org.org_uuid,
|
||||
UserOrgType::Owner as i32, &conn
|
||||
).len() <= 1 {
|
||||
return Err(diesel::result::Error::NotFound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UserOrganization::delete_all_by_user(&self.uuid, &*conn)?;
|
||||
Cipher::delete_all_by_user(&self.uuid, &*conn)?;
|
||||
Folder::delete_all_by_user(&self.uuid, &*conn)?;
|
||||
Device::delete_all_by_user(&self.uuid, &*conn)?;
|
||||
Invitation::take(&self.email, &*conn); // Delete invitation if any
|
||||
|
||||
diesel::delete(users::table.filter(
|
||||
users::uuid.eq(self.uuid)))
|
||||
.execute(&**conn).and(Ok(()))
|
||||
}
|
||||
|
||||
pub fn update_uuid_revision(uuid: &str, conn: &DbConn) {
|
||||
|
@ -190,6 +213,11 @@ impl User {
|
|||
.filter(users::uuid.eq(uuid))
|
||||
.first::<Self>(&**conn).ok()
|
||||
}
|
||||
|
||||
pub fn get_all(conn: &DbConn) -> Vec<Self> {
|
||||
users::table
|
||||
.load::<Self>(&**conn).expect("Error loading users")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable)]
|
||||
|
|
|
@ -237,6 +237,7 @@ pub struct Config {
|
|||
local_icon_extractor: bool,
|
||||
signups_allowed: bool,
|
||||
invitations_allowed: bool,
|
||||
server_admin_email: Option<String>,
|
||||
password_iterations: i32,
|
||||
show_password_hint: bool,
|
||||
|
||||
|
@ -272,6 +273,7 @@ impl Config {
|
|||
|
||||
local_icon_extractor: get_env_or("LOCAL_ICON_EXTRACTOR", false),
|
||||
signups_allowed: get_env_or("SIGNUPS_ALLOWED", true),
|
||||
server_admin_email: get_env("SERVER_ADMIN_EMAIL"),
|
||||
invitations_allowed: get_env_or("INVITATIONS_ALLOWED", true),
|
||||
password_iterations: get_env_or("PASSWORD_ITERATIONS", 100_000),
|
||||
show_password_hint: get_env_or("SHOW_PASSWORD_HINT", true),
|
||||
|
|
Loading…
Reference in a new issue