mirror of
https://github.com/bitwarden/browser
synced 2026-02-06 03:33:30 +00:00
Merge branch 'main' into beeep/dev-container
This commit is contained in:
@@ -3035,10 +3035,6 @@
|
||||
"custom": {
|
||||
"message": "Custom"
|
||||
},
|
||||
"sendPasswordDescV3": {
|
||||
"message": "Add an optional password for recipients to access this Send.",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"createSend": {
|
||||
"message": "New Send",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
@@ -6144,5 +6140,9 @@
|
||||
},
|
||||
"emailPlaceholder": {
|
||||
"message": "user@bitwarden.com , user@acme.com"
|
||||
},
|
||||
"sendPasswordHelperText": {
|
||||
"message": "Individuals will need to enter the password to view this Send",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -28,7 +26,7 @@ export default class WebRequestBackground {
|
||||
this.webRequest.onAuthRequired.addListener(
|
||||
(async (
|
||||
details: chrome.webRequest.OnAuthRequiredDetails,
|
||||
callback: (response: chrome.webRequest.BlockingResponse) => void,
|
||||
callback: (response: chrome.webRequest.BlockingResponse | null) => void,
|
||||
) => {
|
||||
if (!details.url || this.pendingAuthRequests.has(details.requestId)) {
|
||||
if (callback) {
|
||||
@@ -51,16 +49,16 @@ export default class WebRequestBackground {
|
||||
);
|
||||
|
||||
this.webRequest.onCompleted.addListener((details) => this.completeAuthRequest(details), {
|
||||
urls: ["http://*/*"],
|
||||
urls: ["http://*/*", "https://*/*"],
|
||||
});
|
||||
this.webRequest.onErrorOccurred.addListener((details) => this.completeAuthRequest(details), {
|
||||
urls: ["http://*/*"],
|
||||
urls: ["http://*/*", "https://*/*"],
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveAuthCredentials(
|
||||
domain: string,
|
||||
success: (response: chrome.webRequest.BlockingResponse) => void,
|
||||
success: (response: chrome.webRequest.BlockingResponse | null) => void,
|
||||
// eslint-disable-next-line
|
||||
error: Function,
|
||||
) {
|
||||
@@ -82,7 +80,7 @@ export default class WebRequestBackground {
|
||||
const ciphers = await this.cipherService.getAllDecryptedForUrl(
|
||||
domain,
|
||||
activeUserId,
|
||||
null,
|
||||
undefined,
|
||||
UriMatchStrategy.Host,
|
||||
);
|
||||
if (ciphers == null || ciphers.length !== 1) {
|
||||
@@ -90,10 +88,17 @@ export default class WebRequestBackground {
|
||||
return;
|
||||
}
|
||||
|
||||
const username = ciphers[0].login?.username;
|
||||
const password = ciphers[0].login?.password;
|
||||
if (username == null || password == null) {
|
||||
error();
|
||||
return;
|
||||
}
|
||||
|
||||
success({
|
||||
authCredentials: {
|
||||
username: ciphers[0].login.username,
|
||||
password: ciphers[0].login.password,
|
||||
username,
|
||||
password,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
|
||||
4
apps/desktop/desktop_native/Cargo.lock
generated
4
apps/desktop/desktop_native/Cargo.lock
generated
@@ -512,9 +512,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.0"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "camino"
|
||||
|
||||
@@ -27,7 +27,7 @@ ashpd = "=0.12.0"
|
||||
base64 = "=0.22.1"
|
||||
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" }
|
||||
byteorder = "=1.5.0"
|
||||
bytes = "=1.11.0"
|
||||
bytes = "=1.11.1"
|
||||
cbc = "=0.1.2"
|
||||
chacha20poly1305 = "=0.10.1"
|
||||
core-foundation = "=0.10.1"
|
||||
|
||||
@@ -19,20 +19,18 @@ use tracing::{debug, warn};
|
||||
use zbus::Connection;
|
||||
use zbus_polkit::policykit1::{AuthorityProxy, CheckAuthorizationFlags, Subject};
|
||||
|
||||
use crate::secure_memory::*;
|
||||
use crate::secure_memory::{encrypted_memory_store::EncryptedMemoryStore, SecureMemoryStore as _};
|
||||
|
||||
pub struct BiometricLockSystem {
|
||||
// The userkeys that are held in memory MUST be protected from memory dumping attacks, to
|
||||
// ensure locked vaults cannot be unlocked
|
||||
secure_memory: Arc<Mutex<crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore>>,
|
||||
secure_memory: Arc<Mutex<EncryptedMemoryStore<String>>>,
|
||||
}
|
||||
|
||||
impl BiometricLockSystem {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
secure_memory: Arc::new(Mutex::new(
|
||||
crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore::new(),
|
||||
)),
|
||||
secure_memory: Arc::new(Mutex::new(EncryptedMemoryStore::default())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +62,7 @@ impl super::BiometricTrait for BiometricLockSystem {
|
||||
.put(user_id.to_string(), key);
|
||||
}
|
||||
|
||||
async fn unlock(&self, user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
|
||||
async fn unlock(&self, user_id: &String, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
|
||||
if !polkit_authenticate_bitwarden_policy().await? {
|
||||
return Err(anyhow!("Authentication failed"));
|
||||
}
|
||||
@@ -72,11 +70,11 @@ impl super::BiometricTrait for BiometricLockSystem {
|
||||
self.secure_memory
|
||||
.lock()
|
||||
.await
|
||||
.get(user_id)
|
||||
.get(user_id)?
|
||||
.ok_or(anyhow!("No key found"))
|
||||
}
|
||||
|
||||
async fn unlock_available(&self, user_id: &str) -> Result<bool> {
|
||||
async fn unlock_available(&self, user_id: &String) -> Result<bool> {
|
||||
Ok(self.secure_memory.lock().await.has(user_id))
|
||||
}
|
||||
|
||||
@@ -84,7 +82,7 @@ impl super::BiometricTrait for BiometricLockSystem {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn unenroll(&self, user_id: &str) -> Result<(), anyhow::Error> {
|
||||
async fn unenroll(&self, user_id: &String) -> Result<(), anyhow::Error> {
|
||||
self.secure_memory.lock().await.remove(user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -21,14 +21,17 @@ pub trait BiometricTrait: Send + Sync {
|
||||
/// enrollment, this function should do nothing.
|
||||
async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()>;
|
||||
/// Clear the persistent and ephemeral keys
|
||||
async fn unenroll(&self, user_id: &str) -> Result<()>;
|
||||
#[allow(clippy::ptr_arg)] // to allow using user_id as map key type
|
||||
async fn unenroll(&self, user_id: &String) -> Result<()>;
|
||||
/// Check if a persistent (survives app restarts and reboots) key is set for a user
|
||||
async fn has_persistent(&self, user_id: &str) -> Result<bool>;
|
||||
/// Provide a key to be ephemerally held. This should be called on every unlock.
|
||||
async fn provide_key(&self, user_id: &str, key: &[u8]);
|
||||
/// Perform biometric unlock and return the key
|
||||
async fn unlock(&self, user_id: &str, hwnd: Vec<u8>) -> Result<Vec<u8>>;
|
||||
#[allow(clippy::ptr_arg)] // to allow using user_id as map key type
|
||||
async fn unlock(&self, user_id: &String, hwnd: Vec<u8>) -> Result<Vec<u8>>;
|
||||
/// Check if biometric unlock is available based on whether a key is present and whether
|
||||
/// authentication is possible
|
||||
async fn unlock_available(&self, user_id: &str) -> Result<bool>;
|
||||
#[allow(clippy::ptr_arg)] // to allow using user_id as map key type
|
||||
async fn unlock_available(&self, user_id: &String) -> Result<bool>;
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ impl super::BiometricTrait for BiometricLockSystem {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn unlock(&self, _user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>, anyhow::Error> {
|
||||
async fn unlock(&self, _user_id: &String, _hwnd: Vec<u8>) -> Result<Vec<u8>, anyhow::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn unlock_available(&self, _user_id: &str) -> Result<bool, anyhow::Error> {
|
||||
async fn unlock_available(&self, _user_id: &String) -> Result<bool, anyhow::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ impl super::BiometricTrait for BiometricLockSystem {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn unenroll(&self, _user_id: &str) -> Result<(), anyhow::Error> {
|
||||
async fn unenroll(&self, _user_id: &String) -> Result<(), anyhow::Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ impl super::BiometricTrait for BiometricLockSystem {
|
||||
}
|
||||
}
|
||||
|
||||
async fn unenroll(&self, user_id: &str) -> Result<()> {
|
||||
async fn unenroll(&self, user_id: &String) -> Result<()> {
|
||||
self.secure_memory.lock().await.remove(user_id);
|
||||
delete_keychain_entry(user_id).await
|
||||
}
|
||||
@@ -148,7 +148,7 @@ impl super::BiometricTrait for BiometricLockSystem {
|
||||
.put(user_id.to_string(), key);
|
||||
}
|
||||
|
||||
async fn unlock(&self, user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
|
||||
async fn unlock(&self, user_id: &String, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
|
||||
// Allow restoring focus to the previous window (browser)
|
||||
let previous_active_window = super::windows_focus::get_active_window();
|
||||
let _focus_scopeguard = scopeguard::guard((), |_| {
|
||||
@@ -164,8 +164,7 @@ impl super::BiometricTrait for BiometricLockSystem {
|
||||
if secure_memory.has(user_id) {
|
||||
if windows_hello_authenticate("Unlock your vault".to_string()).await? {
|
||||
secure_memory
|
||||
.get(user_id)
|
||||
.clone()
|
||||
.get(user_id)?
|
||||
.ok_or_else(|| anyhow!("No key found for user"))
|
||||
} else {
|
||||
Err(anyhow!("Authentication failed"))
|
||||
@@ -186,7 +185,7 @@ impl super::BiometricTrait for BiometricLockSystem {
|
||||
}
|
||||
}
|
||||
|
||||
async fn unlock_available(&self, user_id: &str) -> Result<bool> {
|
||||
async fn unlock_available(&self, user_id: &String) -> Result<bool> {
|
||||
let secure_memory = self.secure_memory.lock().await;
|
||||
let has_key =
|
||||
secure_memory.has(user_id) || has_keychain_entry(user_id).await.unwrap_or(false);
|
||||
@@ -435,7 +434,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_double_unenroll() {
|
||||
let user_id = "test_user";
|
||||
let user_id = String::from("test_user");
|
||||
let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH];
|
||||
rand::fill(&mut key);
|
||||
|
||||
@@ -443,34 +442,34 @@ mod tests {
|
||||
|
||||
println!("Enrolling user");
|
||||
windows_hello_lock_system
|
||||
.enroll_persistent(user_id, &key)
|
||||
.enroll_persistent(&user_id, &key)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(windows_hello_lock_system
|
||||
.has_persistent(user_id)
|
||||
.has_persistent(&user_id)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
println!("Unlocking user");
|
||||
let key_after_unlock = windows_hello_lock_system
|
||||
.unlock(user_id, Vec::new())
|
||||
.unlock(&user_id, Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(key_after_unlock, key);
|
||||
|
||||
println!("Unenrolling user");
|
||||
windows_hello_lock_system.unenroll(user_id).await.unwrap();
|
||||
windows_hello_lock_system.unenroll(&user_id).await.unwrap();
|
||||
assert!(!windows_hello_lock_system
|
||||
.has_persistent(user_id)
|
||||
.has_persistent(&user_id)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
println!("Unenrolling user again");
|
||||
|
||||
// This throws PASSWORD_NOT_FOUND but our code should handle that and not throw.
|
||||
windows_hello_lock_system.unenroll(user_id).await.unwrap();
|
||||
windows_hello_lock_system.unenroll(&user_id).await.unwrap();
|
||||
assert!(!windows_hello_lock_system
|
||||
.has_persistent(user_id)
|
||||
.has_persistent(&user_id)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
@@ -478,7 +477,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_enroll_unlock_unenroll() {
|
||||
let user_id = "test_user";
|
||||
let user_id = String::from("test_user");
|
||||
let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH];
|
||||
rand::fill(&mut key);
|
||||
|
||||
@@ -486,25 +485,25 @@ mod tests {
|
||||
|
||||
println!("Enrolling user");
|
||||
windows_hello_lock_system
|
||||
.enroll_persistent(user_id, &key)
|
||||
.enroll_persistent(&user_id, &key)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(windows_hello_lock_system
|
||||
.has_persistent(user_id)
|
||||
.has_persistent(&user_id)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
println!("Unlocking user");
|
||||
let key_after_unlock = windows_hello_lock_system
|
||||
.unlock(user_id, Vec::new())
|
||||
.unlock(&user_id, Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(key_after_unlock, key);
|
||||
|
||||
println!("Unenrolling user");
|
||||
windows_hello_lock_system.unenroll(user_id).await.unwrap();
|
||||
windows_hello_lock_system.unenroll(&user_id).await.unwrap();
|
||||
assert!(!windows_hello_lock_system
|
||||
.has_persistent(user_id)
|
||||
.has_persistent(&user_id)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ pub mod ipc;
|
||||
pub mod password;
|
||||
pub mod powermonitor;
|
||||
pub mod process_isolation;
|
||||
pub(crate) mod secure_memory;
|
||||
pub mod secure_memory;
|
||||
pub mod ssh_agent;
|
||||
|
||||
use zeroizing_alloc::ZeroAlloc;
|
||||
|
||||
@@ -5,7 +5,7 @@ use windows::Win32::Security::Cryptography::{
|
||||
CRYPTPROTECTMEMORY_SAME_PROCESS,
|
||||
};
|
||||
|
||||
use crate::secure_memory::SecureMemoryStore;
|
||||
use crate::secure_memory::{DecryptionError, SecureMemoryStore};
|
||||
|
||||
/// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata
|
||||
/// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound
|
||||
@@ -26,7 +26,9 @@ impl DpapiSecretKVStore {
|
||||
}
|
||||
|
||||
impl SecureMemoryStore for DpapiSecretKVStore {
|
||||
fn put(&mut self, key: String, value: &[u8]) {
|
||||
type KeyType = String;
|
||||
|
||||
fn put(&mut self, key: Self::KeyType, value: &[u8]) {
|
||||
let length_header_len = std::mem::size_of::<usize>();
|
||||
|
||||
// The allocated data has to be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, so we pad it
|
||||
@@ -55,8 +57,8 @@ impl SecureMemoryStore for DpapiSecretKVStore {
|
||||
self.map.insert(key, padded_data);
|
||||
}
|
||||
|
||||
fn get(&mut self, key: &str) -> Option<Vec<u8>> {
|
||||
self.map.get(key).map(|data| {
|
||||
fn get(&mut self, key: &Self::KeyType) -> Result<Option<Vec<u8>>, DecryptionError> {
|
||||
if let Some(data) = self.map.get(key) {
|
||||
// A copy is created, that is then mutated by the DPAPI unprotect function.
|
||||
let mut data = data.clone();
|
||||
unsafe {
|
||||
@@ -77,15 +79,19 @@ impl SecureMemoryStore for DpapiSecretKVStore {
|
||||
.expect("length header should be usize"),
|
||||
);
|
||||
|
||||
data[length_header_size..length_header_size + data_length].to_vec()
|
||||
})
|
||||
Ok(Some(
|
||||
data[length_header_size..length_header_size + data_length].to_vec(),
|
||||
))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn has(&self, key: &str) -> bool {
|
||||
fn has(&self, key: &Self::KeyType) -> bool {
|
||||
self.map.contains_key(key)
|
||||
}
|
||||
|
||||
fn remove(&mut self, key: &str) {
|
||||
fn remove(&mut self, key: &Self::KeyType) {
|
||||
self.map.remove(key);
|
||||
}
|
||||
|
||||
@@ -113,7 +119,7 @@ mod tests {
|
||||
store.put(key.clone(), &value);
|
||||
assert!(store.has(&key), "Store should have key for size {}", size);
|
||||
assert_eq!(
|
||||
store.get(&key),
|
||||
store.get(&key).expect("entry in map for key"),
|
||||
Some(value),
|
||||
"Value mismatch for size {}",
|
||||
size
|
||||
@@ -128,7 +134,7 @@ mod tests {
|
||||
let value = vec![1, 2, 3, 4, 5];
|
||||
store.put(key.clone(), &value);
|
||||
assert!(store.has(&key));
|
||||
assert_eq!(store.get(&key), Some(value));
|
||||
assert_eq!(store.get(&key).expect("entry in map for key"), Some(value));
|
||||
store.remove(&key);
|
||||
assert!(!store.has(&key));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use tracing::error;
|
||||
|
||||
use crate::secure_memory::{
|
||||
secure_key::{EncryptedMemory, SecureMemoryEncryptionKey},
|
||||
secure_key::{DecryptionError, EncryptedMemory, SecureMemoryEncryptionKey},
|
||||
SecureMemoryStore,
|
||||
};
|
||||
|
||||
@@ -12,50 +14,87 @@ use crate::secure_memory::{
|
||||
///
|
||||
/// The key is briefly in process memory during encryption and decryption, in memory that is
|
||||
/// protected from swapping to disk via mlock, and then zeroed out immediately after use.
|
||||
#[allow(unused)]
|
||||
pub(crate) struct EncryptedMemoryStore {
|
||||
map: std::collections::HashMap<String, EncryptedMemory>,
|
||||
/// # Type Parameters
|
||||
///
|
||||
/// * `K` - The type of the key.
|
||||
pub struct EncryptedMemoryStore<K>
|
||||
where
|
||||
K: std::cmp::Ord + std::fmt::Display + std::clone::Clone,
|
||||
{
|
||||
map: BTreeMap<K, EncryptedMemory>,
|
||||
memory_encryption_key: SecureMemoryEncryptionKey,
|
||||
}
|
||||
|
||||
impl EncryptedMemoryStore {
|
||||
#[allow(unused)]
|
||||
pub(crate) fn new() -> Self {
|
||||
impl<K> EncryptedMemoryStore<K>
|
||||
where
|
||||
K: std::cmp::Ord + std::fmt::Display + std::clone::Clone,
|
||||
{
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
EncryptedMemoryStore {
|
||||
map: std::collections::HashMap::new(),
|
||||
map: BTreeMap::new(),
|
||||
memory_encryption_key: SecureMemoryEncryptionKey::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// # Returns
|
||||
///
|
||||
/// An array of all decrypted values.
|
||||
/// Due to the usage of `BtreeMap`, the order is deterministic.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `DecryptionError` if an error occured during decryption
|
||||
pub fn to_vec(&mut self) -> Result<Vec<Vec<u8>>, DecryptionError> {
|
||||
let mut result = vec![];
|
||||
let keys: Vec<_> = self.map.keys().cloned().collect();
|
||||
|
||||
for key in &keys {
|
||||
let bytes = self.get(key)?.expect("All keys to still be in map.");
|
||||
result.push(bytes);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl SecureMemoryStore for EncryptedMemoryStore {
|
||||
fn put(&mut self, key: String, value: &[u8]) {
|
||||
impl<K> Default for EncryptedMemoryStore<K>
|
||||
where
|
||||
K: std::cmp::Ord + std::fmt::Display + std::clone::Clone,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> SecureMemoryStore for EncryptedMemoryStore<K>
|
||||
where
|
||||
K: std::cmp::Ord + std::fmt::Display + std::clone::Clone,
|
||||
{
|
||||
type KeyType = K;
|
||||
|
||||
fn put(&mut self, key: Self::KeyType, value: &[u8]) {
|
||||
let encrypted_value = self.memory_encryption_key.encrypt(value);
|
||||
self.map.insert(key, encrypted_value);
|
||||
}
|
||||
|
||||
fn get(&mut self, key: &str) -> Option<Vec<u8>> {
|
||||
let encrypted_memory = self.map.get(key);
|
||||
if let Some(encrypted_memory) = encrypted_memory {
|
||||
match self.memory_encryption_key.decrypt(encrypted_memory) {
|
||||
Ok(plaintext) => Some(plaintext),
|
||||
Err(_) => {
|
||||
error!("In memory store, decryption failed for key {}. The memory may have been tampered with. re-keying.", key);
|
||||
self.memory_encryption_key = SecureMemoryEncryptionKey::new();
|
||||
self.clear();
|
||||
None
|
||||
}
|
||||
}
|
||||
fn get(&mut self, key: &Self::KeyType) -> Result<Option<Vec<u8>>, DecryptionError> {
|
||||
if let Some(encrypted) = self.map.get(key) {
|
||||
self.memory_encryption_key.decrypt(encrypted).map_err(|error| {
|
||||
error!(?error, %key, "In memory store, decryption failed. The memory may have been tampered with. Re-keying.");
|
||||
self.memory_encryption_key = SecureMemoryEncryptionKey::new();
|
||||
self.clear();
|
||||
error
|
||||
}).map(Some)
|
||||
} else {
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn has(&self, key: &str) -> bool {
|
||||
fn has(&self, key: &Self::KeyType) -> bool {
|
||||
self.map.contains_key(key)
|
||||
}
|
||||
|
||||
fn remove(&mut self, key: &str) {
|
||||
fn remove(&mut self, key: &Self::KeyType) {
|
||||
self.map.remove(key);
|
||||
}
|
||||
|
||||
@@ -64,7 +103,10 @@ impl SecureMemoryStore for EncryptedMemoryStore {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EncryptedMemoryStore {
|
||||
impl<K> Drop for EncryptedMemoryStore<K>
|
||||
where
|
||||
K: std::cmp::Ord + std::fmt::Display + std::clone::Clone,
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
self.clear();
|
||||
}
|
||||
@@ -76,30 +118,98 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_secret_kv_store_various_sizes() {
|
||||
let mut store = EncryptedMemoryStore::new();
|
||||
let mut store = EncryptedMemoryStore::default();
|
||||
for size in 0..=2048 {
|
||||
let key = format!("test_key_{}", size);
|
||||
let key = format!("test_key_{size}");
|
||||
let value: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
|
||||
store.put(key.clone(), &value);
|
||||
assert!(store.has(&key), "Store should have key for size {}", size);
|
||||
assert!(store.has(&key), "Store should have key for size {size}");
|
||||
assert_eq!(
|
||||
store.get(&key),
|
||||
store.get(&key).expect("entry in map for key"),
|
||||
Some(value),
|
||||
"Value mismatch for size {}",
|
||||
size
|
||||
"Value mismatch for size {size}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crud() {
|
||||
let mut store = EncryptedMemoryStore::new();
|
||||
let mut store = EncryptedMemoryStore::default();
|
||||
let key = "test_key".to_string();
|
||||
let value = vec![1, 2, 3, 4, 5];
|
||||
store.put(key.clone(), &value);
|
||||
assert!(store.has(&key));
|
||||
assert_eq!(store.get(&key), Some(value));
|
||||
assert_eq!(store.get(&key).expect("entry in map for key"), Some(value));
|
||||
store.remove(&key);
|
||||
assert!(!store.has(&key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_vec_contains_all() {
|
||||
let mut store = EncryptedMemoryStore::default();
|
||||
|
||||
for size in 0..=2048 {
|
||||
let key = format!("test_key_{size}");
|
||||
let value: Vec<u8> = (0..size).map(|i| (i % 256) as u8).collect();
|
||||
store.put(key.clone(), &value);
|
||||
}
|
||||
let vec_values = store.to_vec().expect("decryption to not fail");
|
||||
|
||||
// to_vec() should contain same number of values as inserted
|
||||
assert_eq!(vec_values.len(), 2049);
|
||||
|
||||
// the value from the store should match the value in the vec
|
||||
let keys: Vec<_> = store.map.keys().cloned().collect();
|
||||
for (store_key, vec_value) in keys.iter().zip(vec_values.iter()) {
|
||||
let store_value = store.get(store_key).expect("entry in map for key").unwrap();
|
||||
assert_eq!(&store_value, vec_value);
|
||||
store.remove(store_key);
|
||||
}
|
||||
|
||||
// all values were present
|
||||
assert!(store.map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_vec_preserves_sorted_key_order() {
|
||||
let mut store = EncryptedMemoryStore::new();
|
||||
|
||||
// insert in non-sorted order
|
||||
store.put("morpheus", &[4, 5, 6]);
|
||||
store.put("trinity", &[1, 2, 3]);
|
||||
store.put("dozer", &[7, 8, 9]);
|
||||
store.put("neo", &[10, 11, 12]);
|
||||
|
||||
let vec = store.to_vec().expect("decryption to not fail");
|
||||
|
||||
assert_eq!(
|
||||
vec,
|
||||
vec![
|
||||
vec![7, 8, 9], // dozer
|
||||
vec![4, 5, 6], // morpheus
|
||||
vec![10, 11, 12], // neo
|
||||
vec![1, 2, 3], // trinity
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_vec_order_after_remove() {
|
||||
let mut store = EncryptedMemoryStore::new();
|
||||
|
||||
// insert in non-sorted order
|
||||
store.put("trinity", &[3]);
|
||||
store.put("morpheus", &[1]);
|
||||
store.put("neo", &[2]);
|
||||
|
||||
let vec = store.to_vec().expect("decryption to not fail");
|
||||
|
||||
assert_eq!(vec, vec![vec![1], vec![2], vec![3]]);
|
||||
|
||||
store.remove(&"neo");
|
||||
|
||||
let vec = store.to_vec().expect("decryption to not fail");
|
||||
|
||||
assert_eq!(vec, vec![vec![1], vec![3]]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,35 @@ pub(crate) mod dpapi;
|
||||
pub(crate) mod encrypted_memory_store;
|
||||
mod secure_key;
|
||||
|
||||
pub use encrypted_memory_store::EncryptedMemoryStore;
|
||||
|
||||
use crate::secure_memory::secure_key::DecryptionError;
|
||||
|
||||
/// The secure memory store provides an ephemeral key-value store for sensitive data.
|
||||
/// Data stored in this store is prevented from being swapped to disk and zeroed out. Additionally,
|
||||
/// platform-specific protections are applied to prevent memory dumps or debugger access from
|
||||
/// reading the stored values.
|
||||
#[allow(unused)]
|
||||
pub(crate) trait SecureMemoryStore {
|
||||
pub trait SecureMemoryStore {
|
||||
type KeyType;
|
||||
|
||||
/// Stores a copy of the provided value in secure memory.
|
||||
fn put(&mut self, key: String, value: &[u8]);
|
||||
fn put(&mut self, key: Self::KeyType, value: &[u8]);
|
||||
|
||||
/// Retrieves a copy of the value associated with the given key from secure memory.
|
||||
/// This copy does not have additional memory protections applied, and should be zeroed when no
|
||||
/// longer needed.
|
||||
///
|
||||
/// Note: If memory was tampered with, this will re-key the store and return None.
|
||||
fn get(&mut self, key: &str) -> Option<Vec<u8>>;
|
||||
/// # Errors
|
||||
///
|
||||
/// `DecryptionError` if memory is tampered with. This also re-keys the memory store.
|
||||
fn get(&mut self, key: &Self::KeyType) -> Result<Option<Vec<u8>>, DecryptionError>;
|
||||
|
||||
/// Checks if a value is stored under the given key.
|
||||
fn has(&self, key: &str) -> bool;
|
||||
fn has(&self, key: &Self::KeyType) -> bool;
|
||||
|
||||
/// Removes the value associated with the given key from secure memory.
|
||||
fn remove(&mut self, key: &str);
|
||||
fn remove(&mut self, key: &Self::KeyType);
|
||||
|
||||
/// Clears all values stored in secure memory.
|
||||
fn clear(&mut self);
|
||||
}
|
||||
|
||||
@@ -77,10 +77,20 @@ impl AsRef<[u8]> for MemoryEncryptionKey {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum DecryptionError {
|
||||
pub enum DecryptionError {
|
||||
CouldNotDecrypt,
|
||||
}
|
||||
|
||||
impl std::error::Error for DecryptionError {}
|
||||
|
||||
impl std::fmt::Display for DecryptionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DecryptionError::CouldNotDecrypt => write!(f, "Could not decrypt"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -19,9 +19,7 @@ mod keyctl;
|
||||
mod memfd_secret;
|
||||
mod mlock;
|
||||
|
||||
pub use crypto::EncryptedMemory;
|
||||
|
||||
use crate::secure_memory::secure_key::crypto::DecryptionError;
|
||||
pub use crypto::{DecryptionError, EncryptedMemory};
|
||||
|
||||
/// An ephemeral key that is protected using a platform mechanism. It is generated on construction
|
||||
/// freshly, and can be used to encrypt and decrypt segments of memory. Since the key is ephemeral,
|
||||
|
||||
@@ -137,10 +137,6 @@
|
||||
"message": "Send details",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"sendPasswordDescV3": {
|
||||
"message": "Add an optional password for recipients to access this Send.",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"sendTypeTextToShare": {
|
||||
"message": "Text to share"
|
||||
},
|
||||
@@ -4587,5 +4583,9 @@
|
||||
},
|
||||
"whyAmISeeingThis": {
|
||||
"message": "Why am I seeing this?"
|
||||
},
|
||||
"sendPasswordHelperText": {
|
||||
"message": "Individuals will need to enter the password to view this Send",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,65 +6,80 @@
|
||||
(onCipherClicked)="viewCipher($event)"
|
||||
(onCipherRightClicked)="viewCipherMenu($event)"
|
||||
(onAddCipher)="addCipher($event)"
|
||||
(onAddFolder)="addFolder()"
|
||||
[showPremiumCallout]="showPremiumCallout$ | async"
|
||||
>
|
||||
</app-vault-items-v2>
|
||||
<div class="details" *ngIf="!!action">
|
||||
<app-vault-item-footer
|
||||
id="footer"
|
||||
#footer
|
||||
[cipher]="cipher"
|
||||
[action]="action"
|
||||
(onEdit)="editCipher($event)"
|
||||
(onRestore)="restoreCipher()"
|
||||
(onClone)="cloneCipher($event)"
|
||||
(onDelete)="deleteCipher()"
|
||||
(onCancel)="cancelCipher($event)"
|
||||
(onArchiveToggle)="refreshCurrentCipher()"
|
||||
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
|
||||
></app-vault-item-footer>
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<div class="box">
|
||||
<app-cipher-view *ngIf="action === 'view'" [cipher]="cipher" [collections]="collections">
|
||||
</app-cipher-view>
|
||||
<vault-cipher-form
|
||||
#vaultForm
|
||||
*ngIf="action === 'add' || action === 'edit' || action === 'clone'"
|
||||
formId="cipherForm"
|
||||
[config]="config"
|
||||
(cipherSaved)="savedCipher($event)"
|
||||
[submitBtn]="footer?.submitBtn"
|
||||
(formStatusChange$)="formStatusChanged($event)"
|
||||
>
|
||||
<bit-item slot="attachment-button">
|
||||
<button
|
||||
bit-item-content
|
||||
type="button"
|
||||
(click)="openAttachmentsDialog()"
|
||||
[disabled]="formDisabled"
|
||||
@if (!!action) {
|
||||
<div class="details">
|
||||
<app-vault-item-footer
|
||||
id="footer"
|
||||
#footer
|
||||
[cipher]="cipher"
|
||||
[action]="action"
|
||||
(onEdit)="editCipher($event)"
|
||||
(onRestore)="restoreCipher()"
|
||||
(onClone)="cloneCipher($event)"
|
||||
(onDelete)="deleteCipher()"
|
||||
(onCancel)="cancelCipher($event)"
|
||||
(onArchiveToggle)="refreshCurrentCipher()"
|
||||
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
|
||||
></app-vault-item-footer>
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<div class="box">
|
||||
@if (action === "view") {
|
||||
<app-cipher-view [cipher]="cipher" [collections]="collections"> </app-cipher-view>
|
||||
}
|
||||
@if (action === "add" || action === "edit" || action === "clone") {
|
||||
<vault-cipher-form
|
||||
#vaultForm
|
||||
formId="cipherForm"
|
||||
[config]="config"
|
||||
(cipherSaved)="savedCipher($event)"
|
||||
[submitBtn]="footer?.submitBtn"
|
||||
(formStatusChange$)="formStatusChanged($event)"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ "attachments" | i18n }}
|
||||
<app-premium-badge></app-premium-badge>
|
||||
</div>
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</bit-item>
|
||||
</vault-cipher-form>
|
||||
<bit-item slot="attachment-button">
|
||||
<button
|
||||
bit-item-content
|
||||
type="button"
|
||||
(click)="openAttachmentsDialog()"
|
||||
[disabled]="formDisabled"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ "attachments" | i18n }}
|
||||
<app-premium-badge></app-premium-badge>
|
||||
</div>
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</bit-item>
|
||||
</vault-cipher-form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="logo"
|
||||
class="logo"
|
||||
*ngIf="action !== 'add' && action !== 'edit' && action !== 'view' && action !== 'clone'"
|
||||
>
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
|
||||
}
|
||||
@if (!["add", "edit", "view", "clone"].includes(action)) {
|
||||
<div id="logo" class="logo">
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
@if (activeFilter.isArchived && !(hasArchivedCiphers$ | async)) {
|
||||
<bit-no-items [icon]="itemTypesIcon">
|
||||
<div slot="title">
|
||||
{{ "noItemsInArchive" | i18n }}
|
||||
</div>
|
||||
<p slot="description" bitTypography="body2" class="tw-max-w-md tw-text-center">
|
||||
{{ "noItemsInArchiveDesc" | i18n }}
|
||||
</p>
|
||||
</bit-no-items>
|
||||
} @else {
|
||||
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<ng-template #folderAddEdit></ng-template>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { filter, map, take } from "rxjs/operators";
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
|
||||
import { ItemTypes } from "@bitwarden/assets/svg";
|
||||
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
ToastService,
|
||||
CopyClickListener,
|
||||
COPY_CLICK_LISTENER,
|
||||
NoItemsModule,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import {
|
||||
@@ -112,6 +114,7 @@ const BroadcasterSubscriptionId = "VaultComponent";
|
||||
ButtonModule,
|
||||
PremiumBadgeComponent,
|
||||
VaultItemsV2Component,
|
||||
NoItemsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -154,7 +157,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
|
||||
type: CipherType | null = null;
|
||||
folderId: string | null | undefined = null;
|
||||
collectionId: string | null = null;
|
||||
organizationId: string | null = null;
|
||||
organizationId: OrganizationId | null = null;
|
||||
myVaultOnly = false;
|
||||
addType: CipherType | undefined = undefined;
|
||||
addOrganizationId: string | null = null;
|
||||
@@ -168,9 +171,19 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
|
||||
cipher: CipherView | null = new CipherView();
|
||||
collections: CollectionView[] | null = null;
|
||||
config: CipherFormConfig | null = null;
|
||||
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
|
||||
showPremiumCallout$: Observable<boolean> = this.userId$.pipe(
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
this.routedVaultFilterBridgeService.activeFilter$,
|
||||
this.cipherArchiveService.showSubscriptionEndedMessaging$(userId),
|
||||
]).pipe(map(([activeFilter, showMessaging]) => activeFilter.isArchived && showMessaging)),
|
||||
),
|
||||
);
|
||||
|
||||
/** Tracks the disabled status of the edit cipher form */
|
||||
protected formDisabled: boolean = false;
|
||||
protected itemTypesIcon = ItemTypes;
|
||||
|
||||
private organizations$: Observable<Organization[]> = this.accountService.activeAccount$.pipe(
|
||||
map((a) => a?.id),
|
||||
@@ -178,10 +191,9 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
|
||||
switchMap((id) => this.organizationService.organizations$(id)),
|
||||
);
|
||||
|
||||
protected canAccessAttachments$ = this.accountService.activeAccount$.pipe(
|
||||
filter((account): account is Account => !!account),
|
||||
switchMap((account) =>
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
|
||||
protected hasArchivedCiphers$ = this.userId$.pipe(
|
||||
switchMap((userId) =>
|
||||
this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -95,5 +95,13 @@
|
||||
{{ itemTypes.labelKey | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (desktopMigrationMilestone1()) {
|
||||
<bit-menu-divider />
|
||||
|
||||
<button type="button" bitMenuItem (click)="onAddFolder.emit()">
|
||||
<i class="bwi bwi-folder tw-mr-1" aria-hidden="true"></i>
|
||||
{{ "folder" | i18n }}
|
||||
</button>
|
||||
}
|
||||
</bit-menu>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ScrollingModule } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, input } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Component, input, output } from "@angular/core";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { distinctUntilChanged, debounceTime } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
@@ -32,7 +32,12 @@ import { SearchBarService } from "../../../app/layout/search/search-bar.service"
|
||||
})
|
||||
export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultItemsComponent<C> {
|
||||
readonly showPremiumCallout = input<boolean>(false);
|
||||
readonly organizationId = input<OrganizationId | undefined>(undefined);
|
||||
|
||||
readonly onAddFolder = output<void>();
|
||||
|
||||
protected readonly desktopMigrationMilestone1 = toSignal(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone1),
|
||||
);
|
||||
|
||||
protected CipherViewLikeUtils = CipherViewLikeUtils;
|
||||
|
||||
@@ -55,7 +60,7 @@ export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultIt
|
||||
}
|
||||
|
||||
async navigateToGetPremium() {
|
||||
await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
|
||||
await this.premiumUpgradePromptService.promptForPremium();
|
||||
}
|
||||
|
||||
trackByFn(index: number, c: C): string {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
(onCipherRightClicked)="viewCipherMenu($event)"
|
||||
(onAddCipher)="addCipher($event)"
|
||||
[showPremiumCallout]="showPremiumCallout$ | async"
|
||||
[organizationId]="organizationId"
|
||||
>
|
||||
</app-vault-items-v2>
|
||||
@if (!!action) {
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
|
||||
import {
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
|
||||
// @ts-strict-ignore
|
||||
export class EmergencyAccessPasswordRequest {
|
||||
newMasterPasswordHash: string;
|
||||
key: string;
|
||||
|
||||
// This will eventually be changed to be an actual constructor, once all callers are updated.
|
||||
// The body of this request will be changed to carry the authentication data and unlock data.
|
||||
// https://bitwarden.atlassian.net/browse/PM-23234
|
||||
static newConstructor(
|
||||
authenticationData: MasterPasswordAuthenticationData,
|
||||
unlockData: MasterPasswordUnlockData,
|
||||
): EmergencyAccessPasswordRequest {
|
||||
const request = new EmergencyAccessPasswordRequest();
|
||||
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
|
||||
request.key = unlockData.masterKeyWrappedUserKey;
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,17 @@ import { of } from "rxjs";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordAuthenticationHash,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -18,7 +27,13 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import {
|
||||
Argon2KdfConfig,
|
||||
DEFAULT_KDF_CONFIG,
|
||||
KdfType,
|
||||
KeyService,
|
||||
PBKDF2KdfConfig,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
|
||||
import { EmergencyAccessType } from "../enums/emergency-access-type";
|
||||
@@ -42,6 +57,8 @@ describe("EmergencyAccessService", () => {
|
||||
let cipherService: MockProxy<CipherService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let emergencyAccessService: EmergencyAccessService;
|
||||
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
|
||||
const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")];
|
||||
@@ -54,6 +71,8 @@ describe("EmergencyAccessService", () => {
|
||||
encryptService = mock<EncryptService>();
|
||||
cipherService = mock<CipherService>();
|
||||
logService = mock<LogService>();
|
||||
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
emergencyAccessService = new EmergencyAccessService(
|
||||
emergencyAccessApiService,
|
||||
@@ -62,6 +81,8 @@ describe("EmergencyAccessService", () => {
|
||||
encryptService,
|
||||
cipherService,
|
||||
logService,
|
||||
masterPasswordService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -215,7 +236,13 @@ describe("EmergencyAccessService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeover", () => {
|
||||
/**
|
||||
* @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are any imports/properties
|
||||
* in the test setup above that are now un-used and can also be removed.
|
||||
*/
|
||||
describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => {
|
||||
const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = false;
|
||||
|
||||
const params = {
|
||||
id: "emergencyAccessId",
|
||||
masterPassword: "mockPassword",
|
||||
@@ -242,6 +269,10 @@ describe("EmergencyAccessService", () => {
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(
|
||||
PM27086_UpdateAuthenticationApisForInputPasswordEnabled,
|
||||
);
|
||||
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse);
|
||||
keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey));
|
||||
|
||||
@@ -450,6 +481,180 @@ describe("EmergencyAccessService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => {
|
||||
// Mock feature flag value
|
||||
const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = true;
|
||||
|
||||
// Mock sut method params
|
||||
const id = "emergency-access-id";
|
||||
const masterPassword = "mockPassword";
|
||||
const email = "user@example.com";
|
||||
const activeUserId = newGuid() as UserId;
|
||||
|
||||
// Mock method data
|
||||
const kdfConfig = DEFAULT_KDF_CONFIG;
|
||||
|
||||
const takeoverResponse = {
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: kdfConfig.kdfType,
|
||||
kdfIterations: kdfConfig.iterations,
|
||||
} as EmergencyAccessTakeoverResponse;
|
||||
|
||||
const activeUserPrivateKey = new Uint8Array(64) as UserPrivateKey;
|
||||
let mockGrantorUserKey: UserKey;
|
||||
let salt: MasterPasswordSalt;
|
||||
let authenticationData: MasterPasswordAuthenticationData;
|
||||
let unlockData: MasterPasswordUnlockData;
|
||||
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(
|
||||
PM27086_UpdateAuthenticationApisForInputPasswordEnabled,
|
||||
);
|
||||
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(takeoverResponse);
|
||||
keyService.userPrivateKey$.mockReturnValue(of(activeUserPrivateKey));
|
||||
|
||||
const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockDecryptedGrantorUserKey);
|
||||
mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey;
|
||||
|
||||
salt = email as MasterPasswordSalt;
|
||||
masterPasswordService.emailToSalt.mockReturnValue(salt);
|
||||
|
||||
authenticationData = {
|
||||
salt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
|
||||
unlockData = {
|
||||
salt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue(
|
||||
authenticationData,
|
||||
);
|
||||
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
|
||||
});
|
||||
|
||||
it("should throw if active user private key is not found", async () => {
|
||||
// Arrange
|
||||
keyService.userPrivateKey$.mockReturnValue(of(null));
|
||||
|
||||
// Act
|
||||
const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Active user does not have a private key, cannot complete a takeover.",
|
||||
);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw if the grantor user key cannot be decrypted via the active user private key", async () => {
|
||||
// Arrange
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValue(null);
|
||||
|
||||
// Act
|
||||
const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("Failed to decrypt grantor key");
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use PBKDF2 if takeover response contains KdfType.PBKDF2_SHA256", async () => {
|
||||
// Act
|
||||
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
kdfConfig, // default config (PBKDF2)
|
||||
salt,
|
||||
);
|
||||
});
|
||||
|
||||
it("should use Argon2 if takeover response contains KdfType.Argon2id", async () => {
|
||||
// Arrange
|
||||
const argon2TakeoverResponse = {
|
||||
keyEncrypted: "EncryptedKey",
|
||||
kdf: KdfType.Argon2id,
|
||||
kdfIterations: 3,
|
||||
kdfMemory: 64,
|
||||
kdfParallelism: 4,
|
||||
} as EmergencyAccessTakeoverResponse;
|
||||
|
||||
emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(
|
||||
argon2TakeoverResponse,
|
||||
);
|
||||
|
||||
const expectedKdfConfig = new Argon2KdfConfig(
|
||||
argon2TakeoverResponse.kdfIterations,
|
||||
argon2TakeoverResponse.kdfMemory,
|
||||
argon2TakeoverResponse.kdfParallelism,
|
||||
);
|
||||
|
||||
// Act
|
||||
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
expectedKdfConfig,
|
||||
salt,
|
||||
);
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).not.toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
kdfConfig, // default config (PBKDF2)
|
||||
salt,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
|
||||
// Act
|
||||
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
|
||||
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
);
|
||||
|
||||
expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
masterPassword,
|
||||
kdfConfig,
|
||||
salt,
|
||||
mockGrantorUserKey,
|
||||
);
|
||||
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
|
||||
id,
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call the API method to change the grantor's master password", async () => {
|
||||
// Act
|
||||
await emergencyAccessService.takeover(id, masterPassword, email, activeUserId);
|
||||
|
||||
// Assert
|
||||
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
|
||||
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledTimes(1);
|
||||
expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith(
|
||||
id,
|
||||
request,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRotatedData", () => {
|
||||
const allowedStatuses = [
|
||||
EmergencyAccessStatusType.Confirmed,
|
||||
|
||||
@@ -4,11 +4,19 @@ import { firstValueFrom } from "rxjs";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -56,6 +64,8 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
|
||||
private encryptService: EncryptService,
|
||||
private cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -270,7 +280,7 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
|
||||
* Intended for grantee.
|
||||
* @param id emergency access id
|
||||
* @param masterPassword new master password
|
||||
* @param email email address of grantee (must be consistent or login will fail)
|
||||
* @param email email address of grantor (must be consistent or login will fail)
|
||||
* @param activeUserId the user id of the active user
|
||||
*/
|
||||
async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) {
|
||||
@@ -309,6 +319,36 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide
|
||||
break;
|
||||
}
|
||||
|
||||
// When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used.
|
||||
const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword,
|
||||
);
|
||||
|
||||
if (newApisWithInputPasswordFlagEnabled) {
|
||||
const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email);
|
||||
|
||||
const authenticationData: MasterPasswordAuthenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
masterPassword,
|
||||
config,
|
||||
salt,
|
||||
);
|
||||
|
||||
const unlockData: MasterPasswordUnlockData =
|
||||
await this.masterPasswordService.makeMasterPasswordUnlockData(
|
||||
masterPassword,
|
||||
config,
|
||||
salt,
|
||||
grantorUserKey,
|
||||
);
|
||||
|
||||
const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData);
|
||||
|
||||
await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
|
||||
|
||||
return; // EARLY RETURN for flagged logic
|
||||
}
|
||||
|
||||
const masterKey = await this.keyService.makeMasterKey(masterPassword, email, config);
|
||||
const masterKeyHash = await this.keyService.hashMasterKey(masterPassword, masterKey);
|
||||
|
||||
|
||||
@@ -5645,10 +5645,6 @@
|
||||
"sendTypeText": {
|
||||
"message": "Text"
|
||||
},
|
||||
"sendPasswordDescV3": {
|
||||
"message": "Add an optional password for recipients to access this Send.",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"createSend": {
|
||||
"message": "New Send",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
@@ -12782,5 +12778,12 @@
|
||||
},
|
||||
"invalidSendPassword": {
|
||||
"message": "Invalid Send password"
|
||||
},
|
||||
"sendPasswordHelperText": {
|
||||
"message": "Individuals will need to enter the password to view this Send",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"perUser": {
|
||||
"message": "per user"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { ButtonType } from "@bitwarden/components";
|
||||
import { BitwardenIcon, ButtonType } from "@bitwarden/components";
|
||||
|
||||
export type SubscriptionPricingCardDetails = {
|
||||
title: string;
|
||||
tagline: string;
|
||||
price?: { amount: number; cadence: SubscriptionCadence };
|
||||
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
|
||||
button: {
|
||||
text: string;
|
||||
type: ButtonType;
|
||||
icon?: { type: BitwardenIcon; position: "before" | "after" };
|
||||
};
|
||||
features: string[];
|
||||
};
|
||||
|
||||
@@ -94,7 +94,7 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
|
||||
protected cipherService: CipherService,
|
||||
protected accountService: AccountService,
|
||||
protected restrictedItemTypesService: RestrictedItemTypesService,
|
||||
private configService: ConfigService,
|
||||
protected configService: ConfigService,
|
||||
) {
|
||||
this.subscribeToCiphers();
|
||||
|
||||
|
||||
@@ -1092,7 +1092,7 @@ describe("Cipher Service", () => {
|
||||
|
||||
await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId);
|
||||
|
||||
expect(apiSpy).toHaveBeenCalled();
|
||||
expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId });
|
||||
});
|
||||
|
||||
it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => {
|
||||
|
||||
@@ -1422,7 +1422,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new CipherBulkDeleteRequest(ids);
|
||||
const request = new CipherBulkDeleteRequest(ids, orgId);
|
||||
if (asAdmin) {
|
||||
await this.apiService.deleteManyCiphersAdmin(request);
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction";
|
||||
export { BitwardenIcon } from "./shared/icon";
|
||||
export * from "./a11y";
|
||||
export * from "./anon-layout";
|
||||
export * from "./async-actions";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||
import * as sdkInternal from "@bitwarden/sdk-internal";
|
||||
|
||||
import { APICredentialsData } from "../spec-data/onepassword-1pux/api-credentials";
|
||||
import { BankAccountData } from "../spec-data/onepassword-1pux/bank-account";
|
||||
@@ -25,11 +26,14 @@ import { SanitizedExport } from "../spec-data/onepassword-1pux/sanitized-export"
|
||||
import { SecureNoteData } from "../spec-data/onepassword-1pux/secure-note";
|
||||
import { ServerData } from "../spec-data/onepassword-1pux/server";
|
||||
import { SoftwareLicenseData } from "../spec-data/onepassword-1pux/software-license";
|
||||
import { SSH_KeyData } from "../spec-data/onepassword-1pux/ssh-key";
|
||||
import { SSNData } from "../spec-data/onepassword-1pux/ssn";
|
||||
import { WirelessRouterData } from "../spec-data/onepassword-1pux/wireless-router";
|
||||
|
||||
import { OnePassword1PuxImporter } from "./onepassword-1pux-importer";
|
||||
|
||||
jest.mock("@bitwarden/sdk-internal");
|
||||
|
||||
function validateCustomField(fields: FieldView[], fieldName: string, expectedValue: any) {
|
||||
expect(fields).toBeDefined();
|
||||
const customField = fields.find((f) => f.name === fieldName);
|
||||
@@ -669,6 +673,37 @@ describe("1Password 1Pux Importer", () => {
|
||||
validateCustomField(cipher.fields, "medication notes", "multiple times a day");
|
||||
});
|
||||
|
||||
it("should parse category 114 - SSH Key", async () => {
|
||||
// Mock the SDK import_ssh_key function to return converted OpenSSH format
|
||||
const mockConvertedKey = {
|
||||
privateKey:
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACCWsp3FFVVCMGZ23hscRkDPfGzKZ8z1V/ZB9nzbdDFRswAAAJh8F3bYfBd2\n2AAAAAtzc2gtZWQyNTUxOQAAACCWsp3FFVVCMGZ23hscRkDPfGzKZ8z1V/ZB9nzbdDFRsw\nAAAEA59QYE22f+VFHhiyH1Vfqiwz7xLEt1zCuk8M8Ng5LpKpayncUVVUKwZ3beGxxGQM98\nbMpnzPVX9kH2fNt0MVGzAAAAE3Rlc3RAZXhhbXBsZS5jb20BAgMEBQ==\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||
publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJayncUVVUKwZ3beGxxGQM98bMpnzPVX9kH2fNt0MVGz",
|
||||
fingerprint: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
|
||||
} as sdkInternal.SshKeyView;
|
||||
|
||||
jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(mockConvertedKey);
|
||||
|
||||
const importer = new OnePassword1PuxImporter();
|
||||
const jsonString = JSON.stringify(SSH_KeyData);
|
||||
const result = await importer.parse(jsonString);
|
||||
expect(result != null).toBe(true);
|
||||
const cipher = result.ciphers.shift();
|
||||
expect(cipher.type).toEqual(CipherType.SshKey);
|
||||
expect(cipher.name).toEqual("Some SSH Key");
|
||||
expect(cipher.notes).toEqual("SSH Key Note");
|
||||
|
||||
// Verify that import_ssh_key was called with the PKCS#8 key from 1Password
|
||||
expect(sdkInternal.import_ssh_key).toHaveBeenCalledWith(
|
||||
"-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
|
||||
);
|
||||
|
||||
// Verify the key was converted to OpenSSH format
|
||||
expect(cipher.sshKey.privateKey).toEqual(mockConvertedKey.privateKey);
|
||||
expect(cipher.sshKey.publicKey).toEqual(mockConvertedKey.publicKey);
|
||||
expect(cipher.sshKey.keyFingerprint).toEqual(mockConvertedKey.fingerprint);
|
||||
});
|
||||
|
||||
it("should create folders", async () => {
|
||||
const importer = new OnePassword1PuxImporter();
|
||||
const result = await importer.parse(SanitizedExportJson);
|
||||
|
||||
@@ -8,6 +8,8 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view";
|
||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
|
||||
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
|
||||
import { import_ssh_key } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { ImportResult } from "../../models/import-result";
|
||||
import { BaseImporter } from "../base-importer";
|
||||
@@ -80,6 +82,10 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
cipher.type = CipherType.Identity;
|
||||
cipher.identity = new IdentityView();
|
||||
break;
|
||||
case Category.SSH_Key:
|
||||
cipher.type = CipherType.SshKey;
|
||||
cipher.sshKey = new SshKeyView();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -316,6 +322,19 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else if (cipher.type === CipherType.SshKey) {
|
||||
if (valueKey === "sshKey") {
|
||||
// Use sshKey.metadata.privateKey instead of the sshKey.privateKey field.
|
||||
// The sshKey.privateKey field doesn't have a consistent format for every item.
|
||||
const { privateKey } = field.value.sshKey.metadata;
|
||||
// Convert SSH key from PKCS#8 (1Password format) to OpenSSH format using SDK
|
||||
// Note: 1Password does not store password-protected SSH keys, so no password handling needed for now
|
||||
const parsedKey = import_ssh_key(privateKey);
|
||||
cipher.sshKey.privateKey = parsedKey.privateKey;
|
||||
cipher.sshKey.publicKey = parsedKey.publicKey;
|
||||
cipher.sshKey.keyFingerprint = parsedKey.fingerprint;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (valueKey === "email") {
|
||||
|
||||
@@ -49,6 +49,7 @@ export const Category = Object.freeze({
|
||||
EmailAccount: "111",
|
||||
API_Credential: "112",
|
||||
MedicalRecord: "113",
|
||||
SSH_Key: "114",
|
||||
} as const);
|
||||
|
||||
/**
|
||||
@@ -133,6 +134,7 @@ export interface Value {
|
||||
creditCardType?: string | null;
|
||||
creditCardNumber?: string | null;
|
||||
reference?: string | null;
|
||||
sshKey?: SSHKey | null;
|
||||
}
|
||||
|
||||
export interface Email {
|
||||
@@ -147,6 +149,19 @@ export interface Address {
|
||||
zip: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface SSHKey {
|
||||
privateKey: string;
|
||||
metadata: SSHKeyMetadata;
|
||||
}
|
||||
|
||||
export interface SSHKeyMetadata {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
keyType: string;
|
||||
}
|
||||
|
||||
export interface InputTraits {
|
||||
keyboard: string;
|
||||
correction: string;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ExportData } from "../../onepassword/types/onepassword-1pux-importer-types";
|
||||
|
||||
export const SSH_KeyData: ExportData = {
|
||||
accounts: [
|
||||
{
|
||||
attrs: {
|
||||
accountName: "1Password Customer",
|
||||
name: "1Password Customer",
|
||||
avatar: "",
|
||||
email: "username123123123@gmail.com",
|
||||
uuid: "TRIZ3XV4JJFRXJ3BARILLTUA6E",
|
||||
domain: "https://my.1password.com/",
|
||||
},
|
||||
vaults: [
|
||||
{
|
||||
attrs: {
|
||||
uuid: "pqcgbqjxr4tng2hsqt5ffrgwju",
|
||||
desc: "Just test entries",
|
||||
avatar: "ke7i5rxnjrh3tj6uesstcosspu.png",
|
||||
name: "T's Test Vault",
|
||||
type: "U",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
uuid: "kf7wevmfiqmbgyao42plvgrasy",
|
||||
favIndex: 0,
|
||||
createdAt: 1724868152,
|
||||
updatedAt: 1724868152,
|
||||
state: "active",
|
||||
categoryUuid: "114",
|
||||
details: {
|
||||
loginFields: [],
|
||||
notesPlain: "SSH Key Note",
|
||||
sections: [
|
||||
{
|
||||
title: "SSH Key Section",
|
||||
fields: [
|
||||
{
|
||||
title: "private key",
|
||||
id: "private_key",
|
||||
value: {
|
||||
sshKey: {
|
||||
privateKey:
|
||||
"-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
|
||||
metadata: {
|
||||
privateKey:
|
||||
"-----BEGIN PRIVATE KEY-----\nMFECAQEwBQYDK2VwBCIEIDn1BgTbZ/5UUeGLIfVV+qLBOvEsS3XMK6Twzw2Dkukq\ngSEAlrKdxRVVQrBndt4bHEZAz3xsymfM9Vf2QfZ823QxUbM=\n-----END PRIVATE KEY-----\n",
|
||||
publicKey:
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJayncUVVUKwZ3beGxxGQM98bMpnzPVX9kH2fNt0MVGz",
|
||||
fingerprint: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
|
||||
keyType: "ed25519",
|
||||
},
|
||||
},
|
||||
},
|
||||
guarded: true,
|
||||
multiline: false,
|
||||
dontGenerate: false,
|
||||
inputTraits: {
|
||||
keyboard: "default",
|
||||
correction: "default",
|
||||
capitalization: "default",
|
||||
},
|
||||
},
|
||||
],
|
||||
hideAddAnotherField: true,
|
||||
},
|
||||
],
|
||||
passwordHistory: [],
|
||||
},
|
||||
overview: {
|
||||
subtitle: "SHA256:/9qSxXuic8kaVBhwv3c8PuetiEpaOgIp7xHNCbcSuN8",
|
||||
icons: null,
|
||||
title: "Some SSH Key",
|
||||
url: "",
|
||||
watchtowerExclusions: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -16,7 +16,7 @@
|
||||
{{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD
|
||||
</h2>
|
||||
<span bitTypography="h3"> </span>
|
||||
<span bitTypography="body1" class="tw-text-main">/ {{ term }}</span>
|
||||
<span bitTypography="body1" class="tw-text-main tw-font-normal">/ {{ term }}</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
@@ -38,21 +38,36 @@
|
||||
<!-- Password Manager Section -->
|
||||
<div id="password-manager" class="tw-border-b tw-border-secondary-100 tw-pb-2">
|
||||
<div class="tw-flex tw-justify-between tw-mb-1">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "passwordManager" | i18n }}</h3>
|
||||
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">
|
||||
{{ "passwordManager" | i18n }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Password Manager Members -->
|
||||
<div id="password-manager-members" class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
@let passwordManagerSeats = cart.passwordManager.seats;
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x
|
||||
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ term }}
|
||||
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
|
||||
{{ passwordManagerSeats.quantity }}
|
||||
{{
|
||||
translateWithParams(
|
||||
passwordManagerSeats.translationKey,
|
||||
passwordManagerSeats.translationParams
|
||||
)
|
||||
}}
|
||||
@if (!passwordManagerSeats.hideBreakdown) {
|
||||
x
|
||||
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ term }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="password-manager-total">
|
||||
<div
|
||||
bitTypography="body1"
|
||||
class="tw-text-muted tw-font-normal"
|
||||
data-testid="password-manager-total"
|
||||
>
|
||||
{{ passwordManagerSeatsTotal() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,13 +77,25 @@
|
||||
@if (additionalStorage) {
|
||||
<div id="additional-storage" class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalStorage.quantity }} {{ additionalStorage.translationKey | i18n }} x
|
||||
{{ additionalStorage.cost | currency: "USD" : "symbol" }} /
|
||||
{{ term }}
|
||||
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
|
||||
{{ additionalStorage.quantity }}
|
||||
{{
|
||||
translateWithParams(
|
||||
additionalStorage.translationKey,
|
||||
additionalStorage.translationParams
|
||||
)
|
||||
}}
|
||||
@if (!additionalStorage.hideBreakdown) {
|
||||
x {{ additionalStorage.cost | currency: "USD" : "symbol" }} /
|
||||
{{ term }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="additional-storage-total">
|
||||
<div
|
||||
bitTypography="body1"
|
||||
class="tw-text-muted tw-font-normal"
|
||||
data-testid="additional-storage-total"
|
||||
>
|
||||
{{ additionalStorageTotal() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,19 +107,30 @@
|
||||
@if (secretsManagerSeats) {
|
||||
<div id="secrets-manager" class="tw-border-b tw-border-secondary-100 tw-py-2">
|
||||
<div class="tw-flex tw-justify-between">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "secretsManager" | i18n }}</h3>
|
||||
<div bitTypography="h5" class="tw-text-muted tw-font-semibold">
|
||||
{{ "secretsManager" | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secrets Manager Members -->
|
||||
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x
|
||||
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
|
||||
/ {{ term }}
|
||||
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
|
||||
{{ secretsManagerSeats.quantity }}
|
||||
{{
|
||||
translateWithParams(
|
||||
secretsManagerSeats.translationKey,
|
||||
secretsManagerSeats.translationParams
|
||||
)
|
||||
}}
|
||||
@if (!secretsManagerSeats.hideBreakdown) {
|
||||
x
|
||||
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
|
||||
/ {{ term }}
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
bitTypography="body1"
|
||||
class="tw-text-muted"
|
||||
class="tw-text-muted tw-font-normal"
|
||||
data-testid="secrets-manager-seats-total"
|
||||
>
|
||||
{{ secretsManagerSeatsTotal() | currency: "USD" : "symbol" }}
|
||||
@@ -103,12 +141,20 @@
|
||||
@let additionalServiceAccounts = cart.secretsManager?.additionalServiceAccounts;
|
||||
@if (additionalServiceAccounts) {
|
||||
<div id="additional-service-accounts" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
|
||||
{{ additionalServiceAccounts.quantity }}
|
||||
{{ additionalServiceAccounts.translationKey | i18n }} x
|
||||
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ term }}
|
||||
{{
|
||||
translateWithParams(
|
||||
additionalServiceAccounts.translationKey,
|
||||
additionalServiceAccounts.translationParams
|
||||
)
|
||||
}}
|
||||
@if (!additionalServiceAccounts.hideBreakdown) {
|
||||
x
|
||||
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ term }}
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
bitTypography="body1"
|
||||
@@ -129,19 +175,41 @@
|
||||
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2"
|
||||
data-testid="discount-section"
|
||||
>
|
||||
<h3 bitTypography="h5" class="tw-text-success-600">{{ discountLabel() }}</h3>
|
||||
<div bitTypography="body1" class="tw-text-success-600">{{ discountLabel() }}</div>
|
||||
<div bitTypography="body1" class="tw-text-success-600" data-testid="discount-amount">
|
||||
-{{ discountAmount() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Credit -->
|
||||
@if (creditAmount() > 0) {
|
||||
<div
|
||||
id="credit-section"
|
||||
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-py-2"
|
||||
data-testid="credit-section"
|
||||
>
|
||||
<div bitTypography="body1" class="tw-text-muted tw-font-normal">
|
||||
{{ translateWithParams(cart.credit!.translationKey, cart.credit!.translationParams) }}
|
||||
</div>
|
||||
<div
|
||||
bitTypography="body1"
|
||||
class="tw-text-muted tw-font-normal"
|
||||
data-testid="credit-amount"
|
||||
>
|
||||
-{{ creditAmount() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Estimated Tax -->
|
||||
<div
|
||||
id="estimated-tax-section"
|
||||
class="tw-flex tw-justify-between tw-border-b tw-border-secondary-100 tw-pt-2 tw-pb-0.5"
|
||||
>
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "estimatedTax" | i18n }}</h3>
|
||||
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">
|
||||
{{ "estimatedTax" | i18n }}
|
||||
</h3>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="estimated-tax">
|
||||
{{ estimatedTax() | currency: "USD" : "symbol" }}
|
||||
</div>
|
||||
@@ -149,7 +217,7 @@
|
||||
|
||||
<!-- Total -->
|
||||
<div id="total-section" class="tw-flex tw-justify-between tw-items-center tw-pt-2">
|
||||
<h3 bitTypography="h5" class="tw-text-muted">{{ "total" | i18n }}</h3>
|
||||
<h3 bitTypography="h5" class="tw-text-muted tw-font-semibold">{{ "total" | i18n }}</h3>
|
||||
<div bitTypography="body1" class="tw-text-muted" data-testid="final-total">
|
||||
{{ total() | currency: "USD" : "symbol" }} / {{ term | i18n }}
|
||||
</div>
|
||||
|
||||
@@ -25,8 +25,10 @@ behavior across Bitwarden applications.
|
||||
- [With Secrets Manager](#with-secrets-manager)
|
||||
- [With Secrets Manager and Additional Service Accounts](#with-secrets-manager-and-additional-service-accounts)
|
||||
- [All Products](#all-products)
|
||||
- [With Account Credit](#with-account-credit)
|
||||
- [With Percent Discount](#with-percent-discount)
|
||||
- [With Amount Discount](#with-amount-discount)
|
||||
- [With Discount and Credit](#with-discount-and-credit)
|
||||
- [Custom Header Template](#custom-header-template)
|
||||
- [Premium Plan](#premium-plan)
|
||||
- [Families Plan](#families-plan)
|
||||
@@ -85,9 +87,16 @@ export type Cart = {
|
||||
};
|
||||
cadence: "annually" | "monthly"; // Billing period for entire cart
|
||||
discount?: Discount; // Optional cart-level discount
|
||||
credit?: Credit; // Optional account credit
|
||||
estimatedTax: number; // Tax amount
|
||||
};
|
||||
|
||||
export type Credit = {
|
||||
translationKey: string; // Translation key for credit label
|
||||
translationParams?: Array<string | number>; // Optional params for translation
|
||||
value: number; // Credit amount to subtract from subtotal
|
||||
};
|
||||
|
||||
import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
|
||||
|
||||
export type Discount = {
|
||||
@@ -330,6 +339,33 @@ Show a cart with all available products:
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### With Account Credit
|
||||
|
||||
Show cart with account credit applied:
|
||||
|
||||
<Canvas of={CartSummaryStories.WithCredit} />
|
||||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
credit: {
|
||||
translationKey: 'accountCredit',
|
||||
value: 25.00
|
||||
},
|
||||
estimatedTax: 10.00
|
||||
}"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### With Percent Discount
|
||||
|
||||
Show cart with percentage-based discount:
|
||||
@@ -396,6 +432,42 @@ Show cart with fixed amount discount:
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### With Discount and Credit
|
||||
|
||||
Show cart with both discount and credit applied:
|
||||
|
||||
<Canvas of={CartSummaryStories.WithDiscountAndCredit} />
|
||||
|
||||
```html
|
||||
<billing-cart-summary
|
||||
[cart]="{
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
translationKey: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
cadence: 'annually',
|
||||
discount: {
|
||||
type: 'percent-off',
|
||||
value: 15
|
||||
},
|
||||
credit: {
|
||||
translationKey: 'accountCredit',
|
||||
value: 50.00
|
||||
},
|
||||
estimatedTax: 15.00
|
||||
}"
|
||||
>
|
||||
</billing-cart-summary>
|
||||
```
|
||||
|
||||
### Custom Header Template
|
||||
|
||||
Show cart with custom header template:
|
||||
@@ -466,10 +538,12 @@ Show cart with families plan:
|
||||
- **Collapsible Interface**: Users can toggle between a summary view showing only the total and a
|
||||
detailed view showing all line items
|
||||
- **Line Item Grouping**: Organizes items by product category (Password Manager, Secrets Manager)
|
||||
- **Dynamic Calculations**: Automatically calculates subtotals, discounts, taxes, and totals using
|
||||
Angular signals and computed values
|
||||
- **Dynamic Calculations**: Automatically calculates subtotals, discounts, credits, taxes, and
|
||||
totals using Angular signals and computed values
|
||||
- **Discount Support**: Displays both percentage-based and fixed-amount discounts with green success
|
||||
styling
|
||||
- **Credit Support**: Shows account credit deductions with clear labeling using i18n translation
|
||||
keys
|
||||
- **Custom Header Templates**: Optional header input allows for custom header designs while
|
||||
maintaining cart functionality
|
||||
- **Flexible Structure**: Accommodates different combinations of products, add-ons, and discounts
|
||||
|
||||
@@ -89,6 +89,8 @@ describe("CartSummaryComponent", () => {
|
||||
return "Premium membership";
|
||||
case "discount":
|
||||
return "discount";
|
||||
case "accountCredit":
|
||||
return "accountCredit";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
@@ -253,6 +255,126 @@ describe("CartSummaryComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hideBreakdown Property", () => {
|
||||
it("should hide cost breakdown when hideBreakdown is true for password manager seats", () => {
|
||||
// Arrange
|
||||
const cartWithHiddenBreakdown: Cart = {
|
||||
...mockCart,
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
translationKey: "members",
|
||||
cost: 50,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
|
||||
fixture.detectChanges();
|
||||
|
||||
const pmLineItem = fixture.debugElement.query(
|
||||
By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'),
|
||||
);
|
||||
|
||||
// Act / Assert
|
||||
expect(pmLineItem.nativeElement.textContent).toContain("5 Members");
|
||||
});
|
||||
|
||||
it("should show cost breakdown when hideBreakdown is false for password manager seats", () => {
|
||||
// Arrange / Act
|
||||
const pmLineItem = fixture.debugElement.query(
|
||||
By.css('[id="password-manager-members"] .tw-flex-1 .tw-text-muted'),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(pmLineItem.nativeElement.textContent).toContain("5 Members x $50.00 / month");
|
||||
});
|
||||
|
||||
it("should hide cost breakdown for additional storage when hideBreakdown is true", () => {
|
||||
// Arrange
|
||||
const cartWithHiddenBreakdown: Cart = {
|
||||
...mockCart,
|
||||
passwordManager: {
|
||||
...mockCart.passwordManager,
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
|
||||
fixture.detectChanges();
|
||||
|
||||
const storageItem = fixture.debugElement.query(By.css("[id='additional-storage']"));
|
||||
const storageLineItem = storageItem.query(By.css(".tw-flex-1 .tw-text-muted"));
|
||||
const storageTotal = storageItem.query(By.css("[data-testid='additional-storage-total']"));
|
||||
|
||||
// Act / Assert
|
||||
expect(storageLineItem.nativeElement.textContent).toContain("2 Additional storage GB");
|
||||
expect(storageTotal.nativeElement.textContent).toContain("$20.00");
|
||||
});
|
||||
|
||||
it("should hide cost breakdown for secrets manager seats when hideBreakdown is true", () => {
|
||||
// Arrange
|
||||
const cartWithHiddenBreakdown: Cart = {
|
||||
...mockCart,
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
translationKey: "secretsManagerSeats",
|
||||
cost: 30,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
additionalServiceAccounts: mockCart.secretsManager!.additionalServiceAccounts,
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
|
||||
fixture.detectChanges();
|
||||
|
||||
const smLineItem = fixture.debugElement.query(
|
||||
By.css('[id="secrets-manager-members"] .tw-text-muted'),
|
||||
);
|
||||
const smTotal = fixture.debugElement.query(
|
||||
By.css('[data-testid="secrets-manager-seats-total"]'),
|
||||
);
|
||||
|
||||
// Act / Assert
|
||||
expect(smLineItem.nativeElement.textContent).toContain("3 Secrets Manager seats");
|
||||
expect(smTotal.nativeElement.textContent).toContain("$90.00");
|
||||
});
|
||||
|
||||
it("should hide cost breakdown for additional service accounts when hideBreakdown is true", () => {
|
||||
// Arrange
|
||||
const cartWithHiddenBreakdown: Cart = {
|
||||
...mockCart,
|
||||
secretsManager: {
|
||||
seats: mockCart.secretsManager!.seats,
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 6,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithHiddenBreakdown);
|
||||
fixture.detectChanges();
|
||||
|
||||
const saLineItem = fixture.debugElement.query(
|
||||
By.css('[id="additional-service-accounts"] .tw-text-muted'),
|
||||
);
|
||||
const saTotal = fixture.debugElement.query(
|
||||
By.css('[data-testid="additional-service-accounts-total"]'),
|
||||
);
|
||||
|
||||
// Act / Assert
|
||||
expect(saLineItem.nativeElement.textContent).toContain("2 Additional machine accounts");
|
||||
expect(saTotal.nativeElement.textContent).toContain("$12.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Discount Display", () => {
|
||||
it("should not display discount section when no discount is present", () => {
|
||||
// Arrange / Act
|
||||
@@ -336,6 +458,94 @@ describe("CartSummaryComponent", () => {
|
||||
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Credit Display", () => {
|
||||
it("should not display credit section when no credit is present", () => {
|
||||
// Arrange / Act
|
||||
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
|
||||
|
||||
// Assert
|
||||
expect(creditSection).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should display credit correctly", () => {
|
||||
// Arrange
|
||||
const cartWithCredit: Cart = {
|
||||
...mockCart,
|
||||
credit: {
|
||||
translationKey: "accountCredit",
|
||||
value: 25.0,
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithCredit);
|
||||
fixture.detectChanges();
|
||||
|
||||
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
|
||||
const creditLabel = creditSection.query(By.css("h3"));
|
||||
const creditAmount = creditSection.query(By.css('[data-testid="credit-amount"]'));
|
||||
|
||||
// Act / Assert
|
||||
expect(creditSection).toBeTruthy();
|
||||
expect(creditLabel.nativeElement.textContent.trim()).toBe("accountCredit");
|
||||
expect(creditAmount.nativeElement.textContent).toContain("-$25.00");
|
||||
});
|
||||
|
||||
it("should apply credit to total calculation", () => {
|
||||
// Arrange
|
||||
const cartWithCredit: Cart = {
|
||||
...mockCart,
|
||||
credit: {
|
||||
translationKey: "accountCredit",
|
||||
value: 50.0,
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithCredit);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Subtotal = 372, credit = 50, tax = 9.6
|
||||
// Total = 372 - 50 + 9.6 = 331.6
|
||||
const expectedTotal = "$331.60";
|
||||
const topTotal = fixture.debugElement.query(By.css("h2"));
|
||||
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
|
||||
|
||||
// Act / Assert
|
||||
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
|
||||
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
|
||||
});
|
||||
|
||||
it("should display and apply both discount and credit correctly", () => {
|
||||
// Arrange
|
||||
const cartWithBoth: Cart = {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
value: 10,
|
||||
},
|
||||
credit: {
|
||||
translationKey: "accountCredit",
|
||||
value: 30.0,
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithBoth);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Subtotal = 372, discount = 37.2 (10%), credit = 30, tax = 9.6
|
||||
// Total = 372 - 37.2 - 30 + 9.6 = 314.4
|
||||
const expectedTotal = "$314.40";
|
||||
const discountSection = fixture.debugElement.query(
|
||||
By.css('[data-testid="discount-section"]'),
|
||||
);
|
||||
const creditSection = fixture.debugElement.query(By.css('[data-testid="credit-section"]'));
|
||||
const topTotal = fixture.debugElement.query(By.css("h2"));
|
||||
const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']"));
|
||||
|
||||
// Act / Assert
|
||||
expect(discountSection).toBeTruthy();
|
||||
expect(creditSection).toBeTruthy();
|
||||
expect(topTotal.nativeElement.textContent).toContain(expectedTotal);
|
||||
expect(bottomTotal.nativeElement.textContent).toContain(expectedTotal);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CartSummaryComponent - Custom Header Template", () => {
|
||||
@@ -424,6 +634,8 @@ describe("CartSummaryComponent - Custom Header Template", () => {
|
||||
return "Collapse purchase details";
|
||||
case "discount":
|
||||
return "discount";
|
||||
case "accountCredit":
|
||||
return "accountCredit";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ export default {
|
||||
return "Your next charge is for";
|
||||
case "dueOn":
|
||||
return "due on";
|
||||
case "premiumSubscriptionCredit":
|
||||
return "Premium subscription credit";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
@@ -341,3 +343,92 @@ export const WithAmountDiscount: Story = {
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHiddenBreakdown: Story = {
|
||||
name: "Hidden Cost Breakdown",
|
||||
args: {
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
translationKey: "members",
|
||||
cost: 30.0,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 6.0,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
},
|
||||
cadence: "monthly",
|
||||
estimatedTax: 19.2,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCredit: Story = {
|
||||
name: "With Account Credit",
|
||||
args: {
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
cadence: "monthly",
|
||||
credit: {
|
||||
translationKey: "premiumSubscriptionCredit",
|
||||
value: 25.0,
|
||||
},
|
||||
estimatedTax: 10.0,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDiscountAndCredit: Story = {
|
||||
name: "With Both Discount and Credit",
|
||||
args: {
|
||||
cart: {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
cadence: "annually",
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
value: 15,
|
||||
},
|
||||
credit: {
|
||||
translationKey: "premiumSubscriptionCredit",
|
||||
value: 50.0,
|
||||
},
|
||||
estimatedTax: 15.0,
|
||||
} satisfies Cart,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -142,11 +142,22 @@ export class CartSummaryComponent {
|
||||
return getLabel(this.i18nService, discount);
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates the credit amount from the cart credit
|
||||
*/
|
||||
readonly creditAmount = computed<number>(() => {
|
||||
const { credit } = this.cart();
|
||||
if (!credit) {
|
||||
return 0;
|
||||
}
|
||||
return credit.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates the total of all line items including discount and tax
|
||||
*/
|
||||
readonly total = computed<number>(
|
||||
() => this.subtotal() - this.discountAmount() + this.estimatedTax(),
|
||||
() => this.subtotal() - this.discountAmount() - this.creditAmount() + this.estimatedTax(),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -154,6 +165,16 @@ export class CartSummaryComponent {
|
||||
*/
|
||||
readonly total$ = toObservable(this.total);
|
||||
|
||||
/**
|
||||
* Translates a key with optional parameters
|
||||
*/
|
||||
translateWithParams(key: string, params?: Array<string | number>): string {
|
||||
if (!params || params.length === 0) {
|
||||
return this.i18nService.t(key);
|
||||
}
|
||||
return this.i18nService.t(key, ...params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the expanded/collapsed state of the cart items
|
||||
*/
|
||||
|
||||
@@ -22,22 +22,24 @@
|
||||
@if (price(); as priceValue) {
|
||||
<div class="tw-mb-6">
|
||||
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
|
||||
<!-- Show no decimals for whole numbers (e.g. $5), but always show 2 decimals when present (e.g. $120.50) -->
|
||||
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
|
||||
priceValue.amount | currency: "$"
|
||||
priceValue.amount
|
||||
| currency: "$" : true : (priceValue.amount % 1 === 0 ? "1.0-0" : "1.2-2")
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ priceValue.cadence }}
|
||||
/ {{ priceValue.cadence | i18n }}
|
||||
@if (priceValue.showPerUser) {
|
||||
per user
|
||||
{{ "perUser" | i18n }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Button space (always reserved) -->
|
||||
<div class="tw-mb-6 tw-h-12">
|
||||
@if (button(); as buttonConfig) {
|
||||
<!-- Button -->
|
||||
@if (button(); as buttonConfig) {
|
||||
<div class="tw-mb-6 tw-h-12">
|
||||
<button
|
||||
bitButton
|
||||
[buttonType]="buttonConfig.type"
|
||||
@@ -46,19 +48,19 @@
|
||||
(click)="buttonClick.emit()"
|
||||
type="button"
|
||||
>
|
||||
@if (buttonConfig.icon?.position === "before") {
|
||||
<i class="bwi {{ buttonConfig.icon.type }} tw-me-2" aria-hidden="true"></i>
|
||||
@if (buttonConfig.icon && buttonConfig.icon.position === "before") {
|
||||
<bit-icon [name]="buttonConfig.icon.type" class="tw-me-2" aria-hidden="true"></bit-icon>
|
||||
}
|
||||
{{ buttonConfig.text }}
|
||||
@if (
|
||||
buttonConfig.icon &&
|
||||
(buttonConfig.icon.position === "after" || !buttonConfig.icon.position)
|
||||
) {
|
||||
<i class="bwi {{ buttonConfig.icon.type }} tw-ms-2" aria-hidden="true"></i>
|
||||
<bit-icon [name]="buttonConfig.icon.type" class="tw-ms-2" aria-hidden="true"></bit-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Features List -->
|
||||
<div class="tw-flex-grow">
|
||||
@@ -67,10 +69,12 @@
|
||||
<ul class="tw-list-none tw-p-0 tw-m-0">
|
||||
@for (feature of featureList; track feature) {
|
||||
<li class="tw-flex tw-items-start tw-gap-2 tw-mb-2 last:tw-mb-0">
|
||||
<i
|
||||
class="bwi bwi-check tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
|
||||
<bit-icon
|
||||
name="bwi-check"
|
||||
class="tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
>
|
||||
</bit-icon>
|
||||
<span bitTypography="helper" class="tw-text-muted tw-leading-relaxed">{{
|
||||
feature
|
||||
}}</span>
|
||||
|
||||
@@ -39,7 +39,7 @@ import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
| Input | Type | Description |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `tagline` | `string` | **Required.** Descriptive text below title (max 2 lines) |
|
||||
| `price` | `{ amount: number; cadence: "monthly" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown |
|
||||
| `price` | `{ amount: number; cadence: "month" \| "monthly" \| "year" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown |
|
||||
| `button` | `{ type: ButtonType; text: string; disabled?: boolean; icon?: { type: string; position: "before" \| "after" } }` | **Optional.** Button configuration with optional icon. If omitted, no button is shown. Icon uses `bwi-*` classes, position defaults to "after" |
|
||||
| `features` | `string[]` | **Optional.** List of features with checkmarks |
|
||||
| `activeBadge` | `{ text: string; variant?: BadgeVariant }` | **Optional.** Active plan badge using proper Badge component, positioned on the same line as title, aligned to the right. If omitted, no badge is shown |
|
||||
@@ -182,6 +182,58 @@ For coming soon or unavailable plans:
|
||||
</billing-pricing-card>
|
||||
```
|
||||
|
||||
### With Button Icons
|
||||
|
||||
Add icons to buttons for enhanced visual communication:
|
||||
|
||||
<Canvas of={PricingCardStories.WithButtonIcon} />
|
||||
|
||||
```html
|
||||
<!-- Icon after text (default) -->
|
||||
<billing-pricing-card
|
||||
title="Premium Plan"
|
||||
tagline="Upgrade for advanced features"
|
||||
[price]="{ amount: 10, cadence: 'monthly' }"
|
||||
[button]="{
|
||||
text: 'Upgrade Now',
|
||||
type: 'primary',
|
||||
icon: { type: 'bwi-external-link', position: 'after' }
|
||||
}"
|
||||
[features]="premiumFeatures"
|
||||
>
|
||||
</billing-pricing-card>
|
||||
|
||||
<!-- Icon before text -->
|
||||
<billing-pricing-card
|
||||
title="Business Plan"
|
||||
tagline="Add more features to your plan"
|
||||
[price]="{ amount: 5, cadence: 'monthly', showPerUser: true }"
|
||||
[button]="{
|
||||
text: 'Add Features',
|
||||
type: 'secondary',
|
||||
icon: { type: 'bwi-plus', position: 'before' }
|
||||
}"
|
||||
[features]="businessFeatures"
|
||||
>
|
||||
</billing-pricing-card>
|
||||
```
|
||||
|
||||
### Active Plan Badge
|
||||
|
||||
Show which plan is currently active:
|
||||
|
||||
<Canvas of={PricingCardStories.ActivePlan} />
|
||||
|
||||
```html
|
||||
<billing-pricing-card
|
||||
title="Free Plan"
|
||||
tagline="Your current plan with essential features"
|
||||
[features]="freeFeatures"
|
||||
[activeBadge]="{ text: 'Active plan' }"
|
||||
>
|
||||
</billing-pricing-card>
|
||||
```
|
||||
|
||||
### Pricing Grid Layout
|
||||
|
||||
Multiple cards displayed together:
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { BadgeVariant, ButtonType, SvgModule, TypographyModule } from "@bitwarden/components";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
@@ -69,6 +70,29 @@ describe("PricingCardComponent", () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PricingCardComponent, TestHostComponent, SvgModule, TypographyModule, CommonModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "month":
|
||||
return "month";
|
||||
case "monthly":
|
||||
return "monthly";
|
||||
case "year":
|
||||
return "year";
|
||||
case "annually":
|
||||
return "annually";
|
||||
case "perUser":
|
||||
return "per user";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
// For signal inputs, we need to set required inputs through the host component
|
||||
@@ -151,7 +175,7 @@ describe("PricingCardComponent", () => {
|
||||
it("should display bwi-check icons for features", () => {
|
||||
hostFixture.detectChanges();
|
||||
const compiled = hostFixture.nativeElement;
|
||||
const icons = compiled.querySelectorAll("i.bwi-check");
|
||||
const icons = compiled.querySelectorAll("bit-icon[name='bwi-check']");
|
||||
|
||||
expect(icons.length).toBe(3); // One for each feature
|
||||
});
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import { Meta, StoryObj } from "@storybook/angular";
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { TypographyModule } from "@bitwarden/components";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SvgModule, TypographyModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { PricingCardComponent } from "./pricing-card.component";
|
||||
|
||||
export default {
|
||||
title: "Billing/Pricing Card",
|
||||
component: PricingCardComponent,
|
||||
moduleMetadata: {
|
||||
imports: [TypographyModule],
|
||||
},
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [PricingCardComponent, SvgModule, TypographyModule, I18nPipe],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "month":
|
||||
return "month";
|
||||
case "monthly":
|
||||
return "monthly";
|
||||
case "year":
|
||||
return "year";
|
||||
case "annually":
|
||||
return "annually";
|
||||
case "perUser":
|
||||
return "per user";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
tagline: "Everything you need for secure password management across all your devices",
|
||||
},
|
||||
@@ -83,7 +110,7 @@ export const WithoutFeatures: Story = {
|
||||
}),
|
||||
args: {
|
||||
tagline: "Advanced security and management for your organization",
|
||||
price: { amount: 3, cadence: "monthly" },
|
||||
price: { amount: 3, cadence: "month" },
|
||||
button: { text: "Contact Sales", type: "primary" },
|
||||
},
|
||||
};
|
||||
@@ -150,7 +177,7 @@ export const LongTagline: Story = {
|
||||
args: {
|
||||
tagline:
|
||||
"Comprehensive password management solution for teams and organizations that need advanced security features, detailed reporting, and enterprise-grade administration tools that scale with your business",
|
||||
price: { amount: 5, cadence: "monthly", showPerUser: true },
|
||||
price: { amount: 5, cadence: "month", showPerUser: true },
|
||||
button: { text: "Start Business Trial", type: "primary" },
|
||||
features: [
|
||||
"Everything in Premium",
|
||||
@@ -274,7 +301,7 @@ export const WithoutButton: Story = {
|
||||
}),
|
||||
args: {
|
||||
tagline: "This plan will be available soon with exciting new features",
|
||||
price: { amount: 15, cadence: "monthly" },
|
||||
price: { amount: 15, cadence: "month" },
|
||||
features: ["Advanced security features", "Enhanced collaboration tools", "Premium support"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,12 +4,15 @@ import { ChangeDetectionStrategy, Component, input, output } from "@angular/core
|
||||
import {
|
||||
BadgeModule,
|
||||
BadgeVariant,
|
||||
BitwardenIcon,
|
||||
ButtonModule,
|
||||
ButtonType,
|
||||
CardComponent,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
/**
|
||||
* A reusable UI-only component that displays pricing information in a card format.
|
||||
@@ -20,20 +23,29 @@ import {
|
||||
selector: "billing-pricing-card",
|
||||
templateUrl: "./pricing-card.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [BadgeModule, ButtonModule, SvgModule, TypographyModule, CurrencyPipe, CardComponent],
|
||||
imports: [
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
SvgModule,
|
||||
IconModule,
|
||||
TypographyModule,
|
||||
CurrencyPipe,
|
||||
CardComponent,
|
||||
I18nPipe,
|
||||
],
|
||||
})
|
||||
export class PricingCardComponent {
|
||||
readonly tagline = input.required<string>();
|
||||
readonly price = input<{
|
||||
amount: number;
|
||||
cadence: "monthly" | "annually";
|
||||
cadence: "month" | "monthly" | "year" | "annually";
|
||||
showPerUser?: boolean;
|
||||
}>();
|
||||
readonly button = input<{
|
||||
type: ButtonType;
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
icon?: { type: string; position: "before" | "after" };
|
||||
icon?: { type: BitwardenIcon; position: "before" | "after" };
|
||||
}>();
|
||||
readonly features = input<string[]>();
|
||||
readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>();
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Discount } from "@bitwarden/pricing";
|
||||
|
||||
import { Credit } from "./credit";
|
||||
|
||||
export type CartItem = {
|
||||
translationKey: string;
|
||||
translationParams?: Array<string | number>;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
discount?: Discount;
|
||||
hideBreakdown?: boolean;
|
||||
};
|
||||
|
||||
export type Cart = {
|
||||
@@ -18,5 +22,6 @@ export type Cart = {
|
||||
};
|
||||
cadence: "annually" | "monthly";
|
||||
discount?: Discount;
|
||||
credit?: Credit;
|
||||
estimatedTax: number;
|
||||
};
|
||||
|
||||
5
libs/pricing/src/types/credit.ts
Normal file
5
libs/pricing/src/types/credit.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Credit = {
|
||||
translationKey: string;
|
||||
translationParams?: Array<string | number>;
|
||||
value: number;
|
||||
};
|
||||
@@ -61,6 +61,9 @@
|
||||
@if (sendDetailsForm.get("authType").value === AuthType.Email) {
|
||||
<bit-hint class="tw-mt-2">{{ "emailVerificationDesc" | i18n }}</bit-hint>
|
||||
}
|
||||
@if (sendDetailsForm.get("authType").value === AuthType.Password) {
|
||||
<bit-hint class="tw-mt-2">{{ "sendPasswordHelperText" | i18n }}</bit-hint>
|
||||
}
|
||||
</bit-form-field>
|
||||
|
||||
@if (sendDetailsForm.get("authType").value === AuthType.Password) {
|
||||
@@ -108,7 +111,6 @@
|
||||
></button>
|
||||
}
|
||||
</div>
|
||||
<bit-hint>{{ "sendPasswordDescV3" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
}
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ export class SendDetailsComponent implements OnInit {
|
||||
const emails = control.value.split(",").map((e: string) => e.trim());
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const invalidEmails = emails.filter((e: string) => e.length > 0 && !emailRegex.test(e));
|
||||
return invalidEmails.length > 0 ? { email: true } : null;
|
||||
return invalidEmails.length > 0 ? { multipleEmails: true } : null;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -160,7 +160,7 @@
|
||||
"path-browserify": "1.0.1",
|
||||
"postcss": "8.5.6",
|
||||
"postcss-loader": "8.2.0",
|
||||
"prettier": "3.7.3",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-tailwindcss": "0.7.1",
|
||||
"process": "0.11.10",
|
||||
"remark-gfm": "4.0.1",
|
||||
@@ -36683,9 +36683,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.7.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz",
|
||||
"integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==",
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
"path-browserify": "1.0.1",
|
||||
"postcss": "8.5.6",
|
||||
"postcss-loader": "8.2.0",
|
||||
"prettier": "3.7.3",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-tailwindcss": "0.7.1",
|
||||
"process": "0.11.10",
|
||||
"remark-gfm": "4.0.1",
|
||||
|
||||
Reference in New Issue
Block a user