diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ea6894dab8e..8327093441c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -115,7 +115,7 @@ jobs: run: rustup --version - name: Cache cargo registry - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - name: Run cargo fmt working-directory: ./apps/desktop/desktop_native diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index bfea785fc63..46008206299 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index f94b0bfc501..fa28a709056 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Element arxivə göndərildi" }, + "itemWasUnarchived": { + "message": "Element arxivdən çıxarıldı" + }, "itemUnarchived": { "message": "Element arxivdən çıxarıldı" }, @@ -586,7 +589,7 @@ "message": "Arxivi istifadə etmək üçün premium üzvlük tələb olunur." }, "itemRestored": { - "message": "Item has been restored" + "message": "Element bərpa edildi" }, "edit": { "message": "Düzəliş et" @@ -4815,19 +4818,34 @@ "message": "Admin" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "Avtomatik istifadəçi təsdiqi" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "Bu cihazın kilidi açıq olduqda gözləyən istifadəçiləri avtomatik təsdiqlə" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "Avtomatik istifadəçi təsdiqi ilə vaxta qənaət edin" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "Bu, təşkilatınızın veri təhlükəsizliyinə təsir edə bilər. " }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "Risklər barədə öyrən" + }, + "autoConfirmSetup": { + "message": "Yeni istifadəçiləri avtomatik təsdiqlə" + }, + "autoConfirmSetupDesc": { + "message": "Bu cihazın kilidi açıq olduqda yeni istifadəçilər avtomatik təsdiqlənəcək." + }, + "autoConfirmSetupHint": { + "message": "Potensial təhlükəsizlik riskləri nələrdir?" + }, + "autoConfirmEnabled": { + "message": "Avtomatik təsdiq işə salındı" + }, + "availableNow": { + "message": "İndi mmövcuddur" }, "accountSecurity": { "message": "Hesab güvənliyi" @@ -5686,10 +5704,10 @@ "message": "Bu giriş risk altındadır və bir veb sayt əskikdir. Daha güclü təhlükəsizlik üçün bir veb sayt əlavə edin və parolu dəyişdirin." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Zəifliyi olan parol." }, "changeNow": { - "message": "Change now" + "message": "İndi dəyişdir" }, "missingWebsite": { "message": "Əskik veb sayt" diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 81f7fefdd9f..53ce24563e0 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Бяспеке акаўнта" }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 4fcca092639..751c7fe12df 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Елементът беше преместен в архива" }, + "itemWasUnarchived": { + "message": "Елементът беше изваден от архива" + }, "itemUnarchived": { "message": "Елементът беше изваден от архива" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Научете повече за рисковете" }, + "autoConfirmSetup": { + "message": "Автоматично потвърждаване на новите потребители" + }, + "autoConfirmSetupDesc": { + "message": "Новите потребители ще бъдат потвърждавани автоматично, докато това устройство е отключено." + }, + "autoConfirmSetupHint": { + "message": "Какви са възможните рискове за сигурността?" + }, + "autoConfirmEnabled": { + "message": "Автоматичното потвърждаване е включено" + }, + "availableNow": { + "message": "Налично сега" + }, "accountSecurity": { "message": "Защита на регистрацията" }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 753418ab603..d690cf29878 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index a9a6c77e7ae..75ab751df5f 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index c195fd8c0b2..53cb057c188 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguretat del compte" }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 1087cf67c7c..6905ba5f922 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Položka byla přesunuta do archivu" }, + "itemWasUnarchived": { + "message": "Položka byla odebrána z archivu" + }, "itemUnarchived": { "message": "Položka byla odebrána z archivu" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Více o rizicích" }, + "autoConfirmSetup": { + "message": "Automaticky potvrdit nové uživatele" + }, + "autoConfirmSetupDesc": { + "message": "Noví uživatelé budou automaticky potvrzeni, když bude toto zařízení odemčeno." + }, + "autoConfirmSetupHint": { + "message": "Jaká jsou možná bezpečnostní rizika?" + }, + "autoConfirmEnabled": { + "message": "Zapnuto automatické potvrzení" + }, + "availableNow": { + "message": "Nyní k dispozici" + }, "accountSecurity": { "message": "Zabezpečení účtu" }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 0b8d45e4042..2e8f20f51ac 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -344,16 +344,16 @@ "message": "Bitwarden for Business" }, "bitwardenAuthenticator": { - "message": "Dilyswr Bitwarden" + "message": "Dilysydd Bitwarden" }, "continueToAuthenticatorPageDesc": { "message": "Bitwarden Authenticator allows you to store authenticator keys and generate TOTP codes for 2-step verification flows. Learn more on the bitwarden.com website" }, "bitwardenSecretsManager": { - "message": "Bitwarden Secrets Manager" + "message": "Rheolydd Cyfrinachau Bitwarden" }, "continueToSecretsManagerPageDesc": { - "message": "Securely store, manage, and share developer secrets with Bitwarden Secrets Manager. Learn more on the bitwarden.com website." + "message": "Gallwch storio, rheoli a rhannu cyfrinachau datblygwyr yn ddiogel gyda Rheolydd Cyfrinachau Bitwarden. Dysgwch fwy ar wefan bitwarden.com." }, "passwordlessDotDev": { "message": "Passwordless.dev" @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4155,7 +4158,7 @@ "description": "Text to display in overlay when the account is locked." }, "unlockYourAccountToViewAutofillSuggestions": { - "message": "Unlock your account to view autofill suggestions", + "message": "Datglowch eich cyfrif i weld argymhellion llenwi awtomatig", "description": "Text to display in overlay when the account is locked." }, "unlockAccount": { @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Diogelwch eich cyfrif" }, @@ -4957,7 +4975,7 @@ "message": "Download Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Download Bitwarden on all devices" + "message": "Lawrlwytho Bitwarden ar bob dyfais" }, "getTheMobileApp": { "message": "Get the mobile app" @@ -4987,7 +5005,7 @@ "message": "Premium" }, "unlockFeaturesWithPremium": { - "message": "Unlock reporting, emergency access, and more security features with Premium." + "message": "Datglowch nodweddion diogelwch megis adroddiadau, mynediad mewn argyfwng, a mwy drwy gyfrif Premium." }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" @@ -4996,7 +5014,7 @@ "message": "Filters" }, "filterVault": { - "message": "Filter vault" + "message": "Hidlo'r gell" }, "filterApplied": { "message": "One filter applied" @@ -5882,7 +5900,7 @@ "message": "Great job securing your at-risk logins!" }, "upgradeNow": { - "message": "Upgrade now" + "message": "Uwchraddio nawr" }, "builtInAuthenticator": { "message": "Built-in authenticator" diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 8b35b2049ca..6d06071480b 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Kontosikkerhed" }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 8363d97c5e2..c2b5b1d1ef2 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Eintrag wurde archiviert" }, + "itemWasUnarchived": { + "message": "Eintrag wird nicht mehr archiviert" + }, "itemUnarchived": { "message": "Eintrag wird nicht mehr archiviert" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Erfahre mehr über die Risiken" }, + "autoConfirmSetup": { + "message": "Neue Benutzer automatisch bestätigen" + }, + "autoConfirmSetupDesc": { + "message": "Neue Benutzer werden automatisch bestätigt, während dieses Gerät entsperrt ist." + }, + "autoConfirmSetupHint": { + "message": "Was sind die möglichen Sicherheitsrisiken?" + }, + "autoConfirmEnabled": { + "message": "Automatische Bestätigung aktiviert" + }, + "availableNow": { + "message": "Jetzt verfügbar" + }, "accountSecurity": { "message": "Kontosicherheit" }, diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index ea86a8137a8..2cf5650bafd 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Ασφάλεια λογαριασμού" }, diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 29b39863bc6..d3a393ecc37 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 4b773754d24..1940070310e 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index d841974a386..fcc7725f3fc 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index fab3e5f5bd9..3ee3d31d56b 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguridad de la cuenta" }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 674fc7f85e9..e39f87b6b86 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 175dff36dce..81905e2ee20 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 770c83f1d0e..057db9f0290 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "امنیت حساب کاربری" }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 3ad41bc0b78..62785164c08 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Tilin suojaus" }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index d6d34948cf4..b485c7f26f5 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 56604fa3a10..238a5ca5e68 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "L'élément a été envoyé à l'archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "L'élément a été désarchivé" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Sécurité du compte" }, diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index efd1396eac9..1544e6b822c 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguridade da conta" }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 2b466187b70..6f8b6f235a7 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "הפריט נשלח לארכיון" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "הפריט הוסר מהארכיון" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "אבטחת החשבון" }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 5458ff6f671..81202f05b58 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index d0a46669fd9..536529d1995 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Stavka poslana u arhivu" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Stavka vraćena iz arhive" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Sigurnost računa" }, diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 38cf27cfdc2..049f2f776f1 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Az elem az archivumba került." }, + "itemWasUnarchived": { + "message": "Az elem visszavételre került az archivumból." + }, "itemUnarchived": { "message": "Az elemek visszavéelre kerültek az archivumból." }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "További információ a kockázatokról" }, + "autoConfirmSetup": { + "message": "Új felhasználók automatikus megerősítése" + }, + "autoConfirmSetupDesc": { + "message": "Az új felhasználók automatikusan megerősítésre kerülnek, amíg ez az eszköz fel van oldva." + }, + "autoConfirmSetupHint": { + "message": "Melyek a lehetséges biztonsági kockázatok?" + }, + "autoConfirmEnabled": { + "message": "Az automatikus megerősítés bekapcsolásra került." + }, + "availableNow": { + "message": "Elérhető most" + }, "accountSecurity": { "message": "Fiókbiztonság" }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 9d50cc7f048..1a628e4e765 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Keamanan akun" }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 2fd85c2096e..3f662938cd7 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Elemento archiviato" }, + "itemWasUnarchived": { + "message": "Elemento rimosso dall'archivio" + }, "itemUnarchived": { "message": "Elemento rimosso dall'archivio" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Scopri quali sono i rischi" }, + "autoConfirmSetup": { + "message": "Conferma automaticamente i nuovi utenti" + }, + "autoConfirmSetupDesc": { + "message": "I nuovi utenti saranno automaticamente confermati mentre questo dispositivo è sbloccato." + }, + "autoConfirmSetupHint": { + "message": "Quali sono i rischi potenziali per la sicurezza?" + }, + "autoConfirmEnabled": { + "message": "Conferma automatica attivata" + }, + "availableNow": { + "message": "Disponibile ora" + }, "accountSecurity": { "message": "Sicurezza dell'account" }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index ed377335590..b114ac090c0 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "アイテムはアーカイブに送信されました" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "アイテムはアーカイブから解除されました" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "アカウントのセキュリティ" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index b54ca90d920..feb90164977 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "ანგარიშის უსაფრთხოება" }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 26184ebf9c1..e0bce7c9224 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index ec287d84d9d..46f73f568cb 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 14c97b086fe..10cf245290d 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "항목이 보관함으로 이동되었습니다" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "항목 보관 해제됨" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "계정 보안" }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 44574d5bae8..f93790244cf 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Paskyros saugumas" }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index eca6a7f92af..596b4dfeaa0 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Vienums tika ievietots arhīvā" }, + "itemWasUnarchived": { + "message": "Vienums tika izņemts no arhīva" + }, "itemUnarchived": { "message": "Vienums tika izņemts no arhīva" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Uzzināt par riskiem" }, + "autoConfirmSetup": { + "message": "Automātiski apstiprināt jaunus lietotājus" + }, + "autoConfirmSetupDesc": { + "message": "Jauni lietotāji tiks automātiski apstiprināti, kamēr šī ierīce ir atslēgta." + }, + "autoConfirmSetupHint": { + "message": "Kādi ir iespējamie drošības riski?" + }, + "autoConfirmEnabled": { + "message": "Automātiska apstiprināšana ieslēgta" + }, + "availableNow": { + "message": "Pieejams tagad" + }, "accountSecurity": { "message": "Konta drošība" }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 49afe2b7db5..cc286de0c01 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index db264f4d571..546c03c8bfb 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 26184ebf9c1..e0bce7c9224 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index c588aa0ef39..d5015c3a87d 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Kontosikkerhet" }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 26184ebf9c1..e0bce7c9224 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index d4bb4a8f6d9..ecb9fc7a297 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item naar archief verzonden" }, + "itemWasUnarchived": { + "message": "Item uit het archief gehaald" + }, "itemUnarchived": { "message": "Item uit het archief gehaald" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Meer informatie over de risico's" }, + "autoConfirmSetup": { + "message": "Automatisch nieuwe gebruikers bevestigen" + }, + "autoConfirmSetupDesc": { + "message": "Nieuwe gebruikers worden automatisch bevestigd wanneer dit apparaat is ontgrendeld." + }, + "autoConfirmSetupHint": { + "message": "Wat zijn de mogelijke veiligheidsrisico's?" + }, + "autoConfirmEnabled": { + "message": "Automatische bevestigen ingeschakeld" + }, + "availableNow": { + "message": "Nu beschikbaar" + }, "accountSecurity": { "message": "Accountbeveiliging" }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 26184ebf9c1..e0bce7c9224 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 26184ebf9c1..e0bce7c9224 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 4392b744e97..72912399978 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Element został przeniesiony do archiwum" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Element został usunięty z archiwum" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Bezpieczeństwo konta" }, diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index a21fefd54a5..5534c2b6ed7 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "O item foi enviado para o arquivo" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "O item foi desarquivado" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Saiba mais sobre os riscos" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Segurança da conta" }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 69c97e5ad78..8643ac017f6 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "O item foi movido para o arquivo" }, + "itemWasUnarchived": { + "message": "O item foi desarquivado" + }, "itemUnarchived": { "message": "O item foi desarquivado" }, @@ -4818,16 +4821,31 @@ "message": "Confirmação automática do utilizador" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "Confirmar automaticamente os utilizadores pendentes enquanto este dispositivo estiver desbloqueado" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "Poupe tempo com a confirmação automática de utilizadores" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "Isto pode afetar a segurança dos dados da sua organização. " }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "Saiba mais sobre os riscos" + }, + "autoConfirmSetup": { + "message": "Confirmar automaticamente novos utilizadores" + }, + "autoConfirmSetupDesc": { + "message": "Os novos utilizadores serão automaticamente confirmados enquanto este dispositivo estiver desbloqueado." + }, + "autoConfirmSetupHint": { + "message": "Quais são os riscos potenciais de segurança?" + }, + "autoConfirmEnabled": { + "message": "Confirmação automática ativada" + }, + "availableNow": { + "message": "Já disponível" }, "accountSecurity": { "message": "Segurança da conta" diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 40faec2782a..5cbab8b51b0 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 64e9c877c48..0ca930ed9f3 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Элемент был отправлен в архив" }, + "itemWasUnarchived": { + "message": "Элемент был разархивирован" + }, "itemUnarchived": { "message": "Элемент был разархивирован" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Узнайте о рисках" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Безопасность аккаунта" }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 79ff6de2618..bed0cd73187 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 6b8d752a7fc..5f5a1113fef 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Položka bola archivovaná" }, + "itemWasUnarchived": { + "message": "Položka bola odobraná z archívu" + }, "itemUnarchived": { "message": "Položka bola odobraná z archívu" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Dozvedieť sa viac o rizikách" }, + "autoConfirmSetup": { + "message": "Automaticky potvrdzovať nových používateľov" + }, + "autoConfirmSetupDesc": { + "message": "Noví používatelia budú automaticky potvrdení, keď je toto zariadenie odomknuté." + }, + "autoConfirmSetupHint": { + "message": "Aké sú potenciálne bezpečnostné riziká?" + }, + "autoConfirmEnabled": { + "message": "Zapnuté automatické potvrdzovanie" + }, + "availableNow": { + "message": "Teraz dostupné" + }, "accountSecurity": { "message": "Zabezpečenie účtu" }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 3f82082adf2..bc5ed382df5 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 59e1ce9335e..48ef707f942 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Ставка је послата у архиву" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Ставка враћена из архиве" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Безбедност налога" }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 81a7fda2e39..e208186b408 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Objektet skickades till arkivet" }, + "itemWasUnarchived": { + "message": "Objektet har avarkiverats" + }, "itemUnarchived": { "message": "Objektet har avarkiverats" }, @@ -4812,22 +4815,37 @@ "message": "Adminkonsol" }, "admin": { - "message": "Admin" + "message": "Administratör" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "Automatisk bekräftelse av användare" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "Bekräfta automatiskt väntande användare medan enheten är olåst" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "Spara tid med automatisk användarbekräftelse" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "Detta kan påverka din organisations datasäkerhet. " }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "Läs mer om riskerna" + }, + "autoConfirmSetup": { + "message": "Bekräfta nya användare automatiskt" + }, + "autoConfirmSetupDesc": { + "message": "Nya användare kommer automatiskt att bekräftas när denna enhet är upplåst." + }, + "autoConfirmSetupHint": { + "message": "Vilka är de potentiella säkerhetsriskerna?" + }, + "autoConfirmEnabled": { + "message": "Aktiverade automatisk bekräftelse" + }, + "availableNow": { + "message": "Tillgänglig nu" }, "accountSecurity": { "message": "Kontosäkerhet" diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index db9e2f519af..3b1c93584b4 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "ஆவணம் காப்பகத்திற்கு அனுப்பப்பட்டது" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "காப்பகம் மீட்டெடுக்கப்பட்டது" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "கணக்கு பாதுகாப்பு" }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 26184ebf9c1..e0bce7c9224 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 53a2c56ae94..320d94d746a 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "ย้ายรายการไปที่จัดเก็บถาวรแล้ว" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "เลิกจัดเก็บถาวรรายการแล้ว" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "ความปลอดภัยของบัญชี" }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index d2356eb133f..35844d74041 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Kayıt arşive gönderildi" }, + "itemWasUnarchived": { + "message": "Kayıt arşivden çıkarıldı" + }, "itemUnarchived": { "message": "Kayıt arşivden çıkarıldı" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Yeni kullanıcıları otomatik onayla" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Hesap güvenliği" }, diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 854c43872e5..9b5b04dd7ec 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Запис архівовано" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Запис розархівовано" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Безпека облікового запису" }, diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index df7d6c00d7b..83e8af63982 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Mục đã được chuyển vào kho lưu trữ" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Mục đã được bỏ lưu trữ" }, @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Bảo mật tài khoản" }, @@ -4957,7 +4975,7 @@ "message": "Tải xuống Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Tải xuống Bitwarden trên tất cả các thiết bị" + "message": "Tải xuống Bitwarden trên tất cả thiết bị" }, "getTheMobileApp": { "message": "Tải ứng dụng di động" diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 1658ce23944..756b3a995c3 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "项目已发送到归档" }, + "itemWasUnarchived": { + "message": "项目已取消归档" + }, "itemUnarchived": { "message": "项目已取消归档" }, @@ -3225,7 +3228,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为「$ACTION$」。", "placeholders": { "hours": { "content": "$1", @@ -4829,6 +4832,21 @@ "autoConfirmWarningLink": { "message": "了解此风险" }, + "autoConfirmSetup": { + "message": "自动确认新用户" + }, + "autoConfirmSetupDesc": { + "message": "当此设备已解锁时,新用户将被自动确认。" + }, + "autoConfirmSetupHint": { + "message": "有哪些潜在的安全风险?" + }, + "autoConfirmEnabled": { + "message": "启用了自动确认" + }, + "availableNow": { + "message": "目前可用" + }, "accountSecurity": { "message": "账户安全" }, diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 5a772b12476..bcbc4db394e 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "項目已移至封存" }, + "itemWasUnarchived": { + "message": "已取消封存項目" + }, "itemUnarchived": { "message": "項目取消封存" }, @@ -586,7 +589,7 @@ "message": "需要進階版會員才能使用封存功能。" }, "itemRestored": { - "message": "Item has been restored" + "message": "已還原項目" }, "edit": { "message": "編輯" @@ -4815,7 +4818,7 @@ "message": "Admin" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "自動使用者確認" }, "automaticUserConfirmationHint": { "message": "Automatically confirm pending users while this device is unlocked" @@ -4824,10 +4827,25 @@ "message": "Save time with automatic user confirmation" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "可能影響您的組織資料安全性。" }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "了解風險" + }, + "autoConfirmSetup": { + "message": "自動確認新使用者" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "潛在的安全性風險有哪些?" + }, + "autoConfirmEnabled": { + "message": "開啟自動確認" + }, + "availableNow": { + "message": "Available now" }, "accountSecurity": { "message": "帳戶安全性" @@ -5686,10 +5704,10 @@ "message": "此登入資訊存在風險,且缺少網站。請新增網站並變更密碼以提升安全性。" }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "有安全疑慮的密碼。" }, "changeNow": { - "message": "Change now" + "message": "立即變更" }, "missingWebsite": { "message": "缺少網站" diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts index 21fe74f1873..4bc31f8ea60 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -4,13 +4,11 @@ import { first, firstValueFrom, map, - retry, share, startWith, Subject, switchMap, tap, - timer, } from "rxjs"; import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; @@ -65,39 +63,16 @@ export class PhishingDataService { private _triggerUpdate$ = new Subject(); update$ = this._triggerUpdate$.pipe( startWith(undefined), // Always emit once - tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)), switchMap(() => this._cachedState.state$.pipe( first(), // Only take the first value to avoid an infinite loop when updating the cache below - switchMap(async (cachedState) => { - const next = await this.getNextWebAddresses(cachedState); - if (next) { - await this._cachedState.update(() => next); - this.logService.info(`[PhishingDataService] cache updated`); - } + tap((cachedState) => { + void this._backgroundUpdate(cachedState); }), - retry({ - count: 3, - delay: (err, count) => { - this.logService.error( - `[PhishingDataService] Unable to update web addresses. Attempt ${count}.`, - err, - ); - return timer(5 * 60 * 1000); // 5 minutes - }, - resetOnSuccess: true, + catchError((err: unknown) => { + this.logService.error("[PhishingDataService] Background update failed to start.", err); + return EMPTY; }), - catchError( - ( - err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */, - ) => { - this.logService.error( - "[PhishingDataService] Retries unsuccessful. Unable to update web addresses.", - err, - ); - return EMPTY; - }, - ), ), ), share(), @@ -225,4 +200,47 @@ export class PhishingDataService { } return []; } + + // Runs the update flow in the background and retries up to 3 times on failure + private async _backgroundUpdate(prev: PhishingData | null): Promise { + this.logService.info(`[PhishingDataService] Update triggered...`); + const phishingData = prev ?? { + webAddresses: [], + timestamp: 0, + checksum: "", + applicationVersion: "", + }; + // Start time for logging performance of update + const startTime = Date.now(); + const maxAttempts = 3; + const delayMs = 5 * 60 * 1000; // 5 minutes + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const next = await this.getNextWebAddresses(phishingData); + if (next) { + await this._cachedState.update(() => next); + + // Performance logging + const elapsed = Date.now() - startTime; + this.logService.info(`[PhishingDataService] cache updated in ${elapsed}ms`); + } + return; + } catch (err) { + this.logService.error( + `[PhishingDataService] Unable to update web addresses. Attempt ${attempt}.`, + err, + ); + if (attempt < maxAttempts) { + await new Promise((res) => setTimeout(res, delayMs)); + } else { + const elapsed = Date.now() - startTime; + this.logService.error( + `[PhishingDataService] Retries unsuccessful after ${elapsed}ms. Unable to update web addresses.`, + err, + ); + } + } + } + } } diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index 8f30d00cc31..f180564b912 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -9,9 +9,9 @@ import { map, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index 1a3df238543..521d72bba0c 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -11,9 +11,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts index 1f0d9f2a0c9..ddf50eb39bf 100644 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, input, OnInit } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { CenterPositionStrategy, DialogService } from "@bitwarden/components"; import { SendFormConfig } from "@bitwarden/send-ui"; diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index 6e73d9811f2..dfbfabf8d5e 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -17,10 +17,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { ButtonModule, NoItemsModule } from "@bitwarden/components"; import { diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index 89769bdd1ce..f36a475a805 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -13,7 +13,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator"; @@ -139,7 +139,7 @@ export class SendV2Component implements OnDestroy { .pipe(takeUntilDestroyed()) .subscribe(([emptyList, noFilteredResults, currentFilter]) => { if (currentFilter?.sendType !== null) { - this.title = this.sendTypeTitles[currentFilter.sendType] ?? "allSends"; + this.title = this.sendTypeTitles[currentFilter.sendType as SendType] ?? "allSends"; } else { this.title = "allSends"; } diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html index 8f184c6a0c1..7230c565a48 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html @@ -31,14 +31,34 @@ {{ "cancel" | i18n }} - + + @if (isEditMode) { + @if ((archiveFlagEnabled$ | async) && isCipherArchived) { + + } + @if ((userCanArchive$ | async) && canCipherBeArchived) { + + } + } + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts index f2c9d470816..4ffe44133d7 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts @@ -1,7 +1,8 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -12,13 +13,16 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info"; +import { DialogService } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, CipherFormConfig, CipherFormConfigService, CipherFormMode, @@ -45,15 +49,15 @@ describe("AddEditV2Component", () => { let cipherServiceMock: MockProxy; const buildConfigResponse = { originalCipher: {} } as CipherFormConfig; - const buildConfig = jest.fn((mode: CipherFormMode) => - Promise.resolve({ ...buildConfigResponse, mode }), - ); + const buildConfig = jest.fn((mode) => Promise.resolve({ ...buildConfigResponse, mode })); const queryParams$ = new BehaviorSubject({}); const disable = jest.fn(); const navigate = jest.fn(); const back = jest.fn().mockResolvedValue(null); const setHistory = jest.fn(); const collect = jest.fn().mockResolvedValue(null); + const openSimpleDialog = jest.fn().mockResolvedValue(true); + const cipherArchiveService = mock(); beforeEach(async () => { buildConfig.mockClear(); @@ -61,6 +65,10 @@ describe("AddEditV2Component", () => { navigate.mockClear(); back.mockClear(); collect.mockClear(); + openSimpleDialog.mockClear(); + + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); addEditCipherInfo$ = new BehaviorSubject(null); cipherServiceMock = mock({ @@ -83,10 +91,21 @@ describe("AddEditV2Component", () => { { provide: CipherAuthorizationService, useValue: { - canDeleteCipher$: jest.fn().mockReturnValue(true), + canDeleteCipher$: jest.fn().mockReturnValue(of(true)), }, }, { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, ], }) .overrideProvider(CipherFormConfigService, { @@ -94,6 +113,11 @@ describe("AddEditV2Component", () => { buildConfig, }, }) + .overrideProvider(DialogService, { + useValue: { + openSimpleDialog, + }, + }) .compileComponents(); fixture = TestBed.createComponent(AddEditV2Component); @@ -356,6 +380,111 @@ describe("AddEditV2Component", () => { }); }); + describe("archive", () => { + it("calls archiveCipherUtilsService service to archive the cipher", async () => { + buildConfigResponse.originalCipher = { id: "222-333-444-5555", edit: true } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + await fixture.whenStable(); + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "222-333-444-5555" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archiveCipherUtilsService service to unarchive the cipher", async () => { + buildConfigResponse.originalCipher = { + id: "222-333-444-5555", + archivedDate: new Date(), + edit: true, + } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "222-333-444-5555" }), + ); + }); + }); + + describe("archive button", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + buildConfigResponse.originalCipher = { archivedDate: undefined, edit: true } as Cipher; + }); + + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + queryParams$.next({ cipherId: "222-333-444-5555" }); + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + buildConfigResponse.originalCipher = { edit: true } as Cipher; + }); + + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + describe("delete", () => { it("dialogService openSimpleDialog called when deleteBtn is hit", async () => { const dialogSpy = jest diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index 22aad854dd0..8704694fd53 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { Component, OnInit, OnDestroy, viewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Params, Router } from "@angular/router"; @@ -16,6 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; @@ -31,6 +32,8 @@ import { ToastService, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, + CipherFormComponent, CipherFormConfig, CipherFormConfigService, CipherFormGenerationService, @@ -159,6 +162,7 @@ export type AddEditQueryParams = Partial>; ], }) export class AddEditV2Component implements OnInit, OnDestroy { + readonly cipherFormComponent = viewChild(CipherFormComponent); headerText: string; config: CipherFormConfig; canDeleteCipher$: Observable; @@ -171,6 +175,18 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.config?.originalCipher?.id as CipherId; } + get cipher(): CipherView { + return new CipherView(this.config?.originalCipher); + } + + get canCipherBeArchived(): boolean { + return this.cipher?.canBeArchived; + } + + get isCipherArchived(): boolean { + return this.cipher?.isArchived; + } + private fido2PopoutSessionData$ = fido2PopoutSessionData$(); private fido2PopoutSessionData: Fido2SessionData; @@ -182,6 +198,16 @@ export class AddEditV2Component implements OnInit, OnDestroy { return BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.addEditVaultItem); } + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; + + /** + * Flag to indicate if the user can archive items. + * @protected + */ + protected userCanArchive$ = this.accountService.activeAccount$.pipe( + switchMap((account) => this.archiveService.userCanArchive$(account.id)), + ); + constructor( private route: ActivatedRoute, private i18nService: I18nService, @@ -196,6 +222,8 @@ export class AddEditV2Component implements OnInit, OnDestroy { private dialogService: DialogService, protected cipherAuthorizationService: CipherAuthorizationService, private accountService: AccountService, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -322,6 +350,10 @@ export class AddEditV2Component implements OnInit, OnDestroy { await BrowserApi.sendMessage("addEditCipherSubmitted"); } + get isEditMode(): boolean { + return ["edit", "partial-edit"].includes(this.config?.mode); + } + subscribeToParams(): void { this.route.queryParams .pipe( @@ -430,6 +462,40 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.i18nService.t(translation[type]); } + /** + * Update the cipher in the form after archiving/unarchiving. + * @param revisionDate The new revision date. + * @param archivedDate The new archived date (null if unarchived). + **/ + updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => { + this.cipherFormComponent().patchCipher((current) => { + current.revisionDate = revisionDate; + current.archivedDate = archivedDate; + return current; + }); + }; + + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.updateCipherFromArchive( + new Date(cipherResponse.revisionDate), + new Date(cipherResponse.archivedDate), + ); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null); + }; + delete = async () => { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "deleteItem" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index b86ec24fd20..04b59d0ee0e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -4,61 +4,62 @@ bitIconButton="bwi-ellipsis-v" size="small" [label]="'moreOptionsLabel' | i18n: cipher.name" - [disabled]="decryptionFailure" [bitMenuTriggerFor]="moreOptions" > - - - + + + + - - - - - - @if (canEdit) { - - } - - - {{ "clone" | i18n }} - - - {{ "assignToCollections" | i18n }} - - - @if (showArchive$ | async) { - @if (canArchive$ | async) { - - } @else { - + } @else { + + + } } } @if (canDelete$ | async) { diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index bd9ce108522..6728249b788 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -158,14 +158,6 @@ describe("ItemMoreOptionsComponent", () => { expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); }); - it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => { - uriMatchStrategy$.next(UriMatchStrategy.Exact); - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - describe("autofill confirmation dialog", () => { beforeEach(() => { uriMatchStrategy$.next(UriMatchStrategy.Domain); @@ -236,22 +228,30 @@ describe("ItemMoreOptionsComponent", () => { }); describe("URI match strategy handling", () => { + it("calls the passwordService to passwordRepromptCheck", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); + }); + describe("when the default URI match strategy is Exact", () => { beforeEach(() => { uriMatchStrategy$.next(UriMatchStrategy.Exact); }); - it("calls the passwordService to passwordRepromptCheck", async () => { - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); - - await component.doAutofill(); - - expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); - }); - - it("shows the exact match dialog", async () => { + it("shows the exact match dialog when the cipher has no saved URIs", async () => { autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [], + }, + })); await component.doAutofill(); @@ -266,6 +266,53 @@ describe("ItemMoreOptionsComponent", () => { expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); }); + + it("does not show the exact match dialog when the cipher has at least one non-exact match uri", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com", match: UriMatchStrategy.Domain }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher uris all have a match strategy of Exact", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com/a", match: UriMatchStrategy.Exact }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); }); describe("when the default URI match strategy is not Exact", () => { @@ -273,7 +320,45 @@ describe("ItemMoreOptionsComponent", () => { mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); uriMatchStrategy$.next(UriMatchStrategy.Domain); }); - it("does not show the exact match dialog", async () => { + + it("does not show the exact match dialog when the cipher has no saved URIs", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher has only exact match saved URIs", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com/a", match: UriMatchStrategy.Exact }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("does not show the exact match dialog when the cipher has at least one uri without a match strategy of Exact", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c, @@ -292,70 +377,6 @@ describe("ItemMoreOptionsComponent", () => { expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); }); - - it("shows the exact match dialog when the cipher has a single uri with a match strategy of Exact", async () => { - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [{ uri: "https://one.example.com", match: UriMatchStrategy.Exact }], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.objectContaining({ key: "cannotAutofill" }), - content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), - type: "info", - }), - ); - expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); - expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); - }); - }); - - it("does not show the exact match dialog when the cipher has no uris", async () => { - mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - - it("does not show the exact match dialog when the cipher has a uri with a match strategy of Exact and a uri with a match strategy of Domain", async () => { - mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [ - { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, - { uri: "https://page.example.com", match: UriMatchStrategy.Domain }, - ], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index c4353e17bef..ce797d9755e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -204,12 +204,15 @@ export class ItemMoreOptionsComponent { } const uris = cipher.login?.uris ?? []; - const cipherHasAllExactMatchLoginUris = - uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact); - const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); - if (cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact) { + const showExactMatchDialog = + uris.length === 0 + ? uriMatchStrategy === UriMatchStrategy.Exact + : // all saved URIs are exact match + uris.every((u) => (u.match ?? uriMatchStrategy) === UriMatchStrategy.Exact); + + if (showExactMatchDialog) { await this.dialogService.openSimpleDialog({ title: { key: "cannotAutofill" }, content: { key: "cannotAutofillExactMatch" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index 883d17b61c3..e6dffdaff08 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -10,6 +10,10 @@ import { BehaviorSubject, Observable, Subject, of } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { NudgeType, NudgesService } from "@bitwarden/angular/vault"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { + AutoConfirmExtensionSetupDialogComponent, + AutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { CurrentAccountComponent } from "@bitwarden/browser/auth/popup/account-switching/current-account.component"; import AutofillService from "@bitwarden/browser/autofill/services/autofill.service"; import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component"; @@ -136,6 +140,7 @@ class VaultListItemsContainerStubComponent { const mockDialogRef = { close: jest.fn(), afterClosed: jest.fn().mockReturnValue(of(undefined)), + closed: of(undefined), } as unknown as import("@bitwarden/components").DialogRef; jest @@ -145,6 +150,11 @@ jest jest .spyOn(DecryptionFailureDialogComponent, "open") .mockImplementation((_: DialogService, _params: any) => mockDialogRef as any); + +const autoConfirmDialogSpy = jest + .spyOn(AutoConfirmExtensionSetupDialogComponent, "open") + .mockImplementation((_: DialogService) => mockDialogRef as any); + jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false); jest.spyOn(BrowserPopupUtils, "openCurrentPagePopout").mockResolvedValue(); @@ -222,6 +232,13 @@ describe("VaultV2Component", () => { getFeatureFlag$: jest.fn().mockImplementation((_flag: string) => of(false)), }; + const autoConfirmSvc = { + configuration$: jest.fn().mockReturnValue(of({})), + canManageAutoConfirm$: jest.fn().mockReturnValue(of(false)), + upsert: jest.fn().mockResolvedValue(undefined), + autoConfirmUser: jest.fn().mockResolvedValue(undefined), + }; + beforeEach(async () => { jest.clearAllMocks(); await TestBed.configureTestingModule({ @@ -275,6 +292,10 @@ describe("VaultV2Component", () => { provide: SearchService, useValue: { isCipherSearching$: of(false) }, }, + { + provide: AutomaticUserConfirmationService, + useValue: autoConfirmSvc, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); @@ -588,4 +609,86 @@ describe("VaultV2Component", () => { const spotlights = queryAllSpotlights(fixture); expect(spotlights.length).toBe(0); })); + + describe("AutoConfirmExtensionSetupDialog", () => { + beforeEach(() => { + autoConfirmDialogSpy.mockClear(); + }); + + it("opens dialog when canManage is true and showBrowserNotification is undefined", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: undefined, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).toHaveBeenCalledWith(expect.any(Object)); + })); + + it("does not open dialog when showBrowserNotification is false", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: false, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + + it("does not open dialog when showBrowserNotification is true", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: true, + showSetupDialog: true, + showBrowserNotification: true, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + + it("does not open dialog when canManage is false even if showBrowserNotification is undefined", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(false)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: undefined, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 30d1d21abfb..761b366bcd2 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -15,6 +15,7 @@ import { shareReplay, switchMap, take, + withLatestFrom, tap, BehaviorSubject, } from "rxjs"; @@ -25,6 +26,11 @@ import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg"; +import { + AutoConfirmExtensionSetupDialogComponent, + AutoConfirmState, + AutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -41,6 +47,7 @@ import { ButtonModule, DialogService, NoItemsModule, + ToastService, TypographyModule, } from "@bitwarden/components"; import { @@ -267,6 +274,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { private introCarouselService: IntroCarouselService, private nudgesService: NudgesService, private router: Router, + private autoConfirmService: AutomaticUserConfirmationService, + private toastService: ToastService, private vaultProfileService: VaultProfileService, private billingAccountService: BillingAccountProfileStateService, private liveAnnouncer: LiveAnnouncer, @@ -329,6 +338,36 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { }); }); + const autoConfirmState$ = this.autoConfirmService.configuration$(this.activeUserId); + + combineLatest([ + this.autoConfirmService.canManageAutoConfirm$(this.activeUserId), + autoConfirmState$, + ]) + .pipe( + filter(([canManage, state]) => canManage && state.showBrowserNotification === undefined), + take(1), + switchMap(() => AutoConfirmExtensionSetupDialogComponent.open(this.dialogService).closed), + withLatestFrom(autoConfirmState$, this.accountService.activeAccount$.pipe(getUserId)), + switchMap(([result, state, userId]) => { + const newState: AutoConfirmState = { + ...state, + enabled: result ?? false, + showBrowserNotification: !result, + }; + + if (result) { + this.toastService.showToast({ + message: this.i18nService.t("autoConfirmEnabled"), + variant: "success", + }); + } + + return this.autoConfirmService.upsert(userId, newState); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); await this.vaultItemsTransferService.enforceOrganizationDataOwnership(this.activeUserId); this.readySubject.next(true); diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index 9b8380a4214..d2a4aaab3f0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -3,37 +3,47 @@ - + @if (cipher) { + + } - - - - - + @if (!cipher.isDeleted) { + + } + @if (cipher.isDeleted && cipher.permissions.restore) { + + } + + @if ((archiveFlagEnabled$ | async) && cipher.isArchived) { + + } + @if ((userCanArchive$ | async) && cipher.canBeArchived) { + + } + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts index 3d4fdb2e9f9..9c536a7e85a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts @@ -1,9 +1,13 @@ -import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, flush, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of, Subject } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AUTOFILL_ID, @@ -11,20 +15,32 @@ import { COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, } from "@bitwarden/common/autofill/constants"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EventType } from "@bitwarden/common/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CopyCipherFieldService, PasswordRepromptService } from "@bitwarden/vault"; +import { + ArchiveCipherUtilitiesService, + CopyCipherFieldService, + PasswordRepromptService, +} from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; @@ -62,7 +78,9 @@ describe("ViewV2Component", () => { username: "test-username", password: "test-password", totp: "123", + uris: [], }, + card: {}, } as unknown as CipherView; const mockPasswordRepromptService = { @@ -84,6 +102,8 @@ describe("ViewV2Component", () => { softDeleteWithServer: jest.fn().mockResolvedValue(undefined), }; + const cipherArchiveService = mock(); + beforeEach(async () => { mockCipherService.cipherViews$.mockClear(); mockCipherService.deleteWithServer.mockClear(); @@ -97,6 +117,10 @@ describe("ViewV2Component", () => { back.mockClear(); showToast.mockClear(); showPasswordPrompt.mockClear(); + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); await TestBed.configureTestingModule({ imports: [ViewV2Component], @@ -142,6 +166,61 @@ describe("ViewV2Component", () => { provide: PasswordRepromptService, useValue: mockPasswordRepromptService, }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: OrganizationService, + useValue: mock(), + }, + { + provide: CollectionService, + useValue: mock(), + }, + { + provide: FolderService, + useValue: mock(), + }, + { + provide: TaskService, + useValue: mock(), + }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getIconsUrl: () => "https://example.com", + }), + }, + }, + { + provide: DomainSettingsService, + useValue: { + showFavicons$: of(true), + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + }, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, + { + provide: CipherRiskService, + useValue: mock(), + }, ], }) .overrideProvider(DialogService, { @@ -154,6 +233,7 @@ describe("ViewV2Component", () => { fixture = TestBed.createComponent(ViewV2Component); component = fixture.componentInstance; fixture.detectChanges(); + (component as any).showFooter$ = of(true); }); describe("queryParams", () => { @@ -352,6 +432,93 @@ describe("ViewV2Component", () => { })); }); + describe("archive button", () => { + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(false)); + component.cipher = { ...mockCipher, canBeArchived: true, isDeleted: false } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, archivedDate: new Date(), edit: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + component.cipher = { ...mockCipher, archivedDate: undefined } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + + describe("archive", () => { + beforeEach(() => { + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + }); + + it("calls archive service to archive the cipher", async () => { + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archive service to unarchive the cipher", async () => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + ); + }); + }); + describe("delete", () => { beforeEach(() => { component.cipher = mockCipher; diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 1dea91c0b9f..64fa42bb2ba 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -25,6 +25,7 @@ import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; @@ -42,6 +43,7 @@ import { ToastService, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, ChangeLoginPasswordService, CipherViewComponent, CopyCipherFieldService, @@ -114,6 +116,10 @@ export class ViewV2Component { senderTabId?: number; protected showFooter$: Observable; + protected userCanArchive$ = this.accountService.activeAccount$ + .pipe(getUserId) + .pipe(switchMap((userId) => this.archiveService.userCanArchive$(userId))); + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; constructor( private passwordRepromptService: PasswordRepromptService, @@ -131,6 +137,8 @@ export class ViewV2Component { protected cipherAuthorizationService: CipherAuthorizationService, private copyCipherFieldService: CopyCipherFieldService, private popupScrollPositionService: VaultPopupScrollPositionService, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -272,6 +280,24 @@ export class ViewV2Component { }); }; + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = new Date(cipherResponse.archivedDate); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = null; + }; + protected deleteCipher() { return this.cipher.isDeleted ? this.cipherService.deleteWithServer(this.cipher.id, this.activeUserId) diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index b8e3c801310..bd83c2d6806 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -9,17 +9,17 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NodeUtils } from "@bitwarden/node/node-utils"; import { Response } from "../../../models/response"; import { CliUtils } from "../../../utils"; import { SendTextResponse } from "../models/send-text.response"; import { SendResponse } from "../models/send.response"; - +`` export class SendCreateCommand { constructor( private sendService: SendService, diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index e06cbab39b4..a9e9f8ab604 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -3,12 +3,12 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; import { CliUtils } from "../../../utils"; diff --git a/apps/cli/src/tools/send/commands/receive.command.ts b/apps/cli/src/tools/send/commands/receive.command.ts index a412f7c1667..5cbf458c87f 100644 --- a/apps/cli/src/tools/send/commands/receive.command.ts +++ b/apps/cli/src/tools/send/commands/receive.command.ts @@ -13,11 +13,11 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { KeyService } from "@bitwarden/key-management"; import { NodeUtils } from "@bitwarden/node/node-utils"; diff --git a/apps/cli/src/tools/send/commands/template.command.ts b/apps/cli/src/tools/send/commands/template.command.ts index c1c2c97b03d..09213ac5fa8 100644 --- a/apps/cli/src/tools/send/commands/template.command.ts +++ b/apps/cli/src/tools/send/commands/template.command.ts @@ -1,4 +1,4 @@ -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; import { TemplateResponse } from "../../../models/response/template.response"; diff --git a/apps/cli/src/tools/send/models/send-access.response.ts b/apps/cli/src/tools/send/models/send-access.response.ts index 07877bfb548..7bd54801307 100644 --- a/apps/cli/src/tools/send/models/send-access.response.ts +++ b/apps/cli/src/tools/send/models/send-access.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index a76f7bcd820..44664f4ad3a 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -1,9 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 900131b9e2d..f6b75be4b12 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -7,7 +7,7 @@ import * as chalk from "chalk"; import { program, Command, Option, OptionValues } from "commander"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseProgram } from "../../base-program"; import { Response } from "../../models/response"; diff --git a/apps/desktop/custom-appx-manifest.xml b/apps/desktop/custom-appx-manifest.xml new file mode 100644 index 00000000000..2f7796c97cf --- /dev/null +++ b/apps/desktop/custom-appx-manifest.xml @@ -0,0 +1,111 @@ + + + + + + + + ${displayName} + ${publisherDisplayName} + A secure and free password manager for all of your devices. + assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 54a6dba8326..b20aa7e5af8 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -20,47 +20,79 @@ fs.mkdirSync(path.join(__dirname, "dist"), { recursive: true }); const args = process.argv.slice(2); // Get arguments passed to the script const mode = args.includes("--release") ? "release" : "debug"; +const isRelease = mode === "release"; const targetArg = args.find(arg => arg.startsWith("--target=")); const target = targetArg ? targetArg.split("=")[1] : null; let crossPlatform = process.argv.length > 2 && process.argv[2] === "cross-platform"; +/** + * Execute a command. + * @param {string} bin Executable to run. + * @param {string[]} args Arguments for executable. + * @param {string} [workingDirectory] Path to working directory, relative to the script directory. Defaults to the script directory. + * @param {string} [useShell] Whether to use a shell to execute the command. Defaults to false. + */ +function runCommand(bin, args, workingDirectory = "", useShell = false) { + const options = { stdio: 'inherit', cwd: path.resolve(__dirname, workingDirectory), shell: useShell } + console.debug("Running command:", bin, args, options) + child_process.execFileSync(bin, args, options) +} + function buildNapiModule(target, release = true) { - const targetArg = target ? `--target ${target}` : ""; + const targetArg = target ? `--target=${target}` : ""; const releaseArg = release ? "--release" : ""; - child_process.execSync(`npm run build -- ${releaseArg} ${targetArg}`, { stdio: 'inherit', cwd: path.join(__dirname, "napi") }); + const crossCompileArg = effectivePlatform(target) !== process.platform ? "--cross-compile" : ""; + runCommand("npm", ["run", "build", "--", crossCompileArg, releaseArg, targetArg].filter(s => s != ''), "./napi", true); +} + +/** + * Build a Rust binary with Cargo. + * + * If {@link target} is specified, cross-compilation helpers are used to build if necessary, and the resulting + * binary is copied to the `dist` folder. + * @param {string} bin Name of cargo binary package in `desktop_native` workspace. + * @param {string?} target Rust compiler target, e.g. `aarch64-pc-windows-msvc`. + * @param {boolean} release Whether to build in release mode. + */ +function cargoBuild(bin, target, release) { + const targetArg = target ? `--target=${target}` : ""; + const releaseArg = release ? "--release" : ""; + const args = ["build", "--bin", bin, releaseArg, targetArg] + // Use cross-compilation helper if necessary + if (effectivePlatform(target) === "win32" && process.platform !== "win32") { + args.unshift("xwin") + } + runCommand("cargo", args.filter(s => s != '')) + + // Infer the architecture and platform if not passed explicitly + let nodeArch, platform; + if (target) { + nodeArch = rustTargetsMap[target].nodeArch; + platform = rustTargetsMap[target].platform; + } + else { + nodeArch = process.arch; + platform = process.platform; + } + + // Copy the resulting binary to the dist folder + const profileFolder = isRelease ? "release" : "debug"; + const ext = platform === "win32" ? ".exe" : ""; + const src = path.join(__dirname, "target", target ? target : "", profileFolder, `${bin}${ext}`) + const dst = path.join(__dirname, "dist", `${bin}.${platform}-${nodeArch}${ext}`) + console.log(`Copying ${src} to ${dst}`); + fs.copyFileSync(src, dst); } function buildProxyBin(target, release = true) { - const targetArg = target ? `--target ${target}` : ""; - const releaseArg = release ? "--release" : ""; - child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")}); - - if (target) { - // Copy the resulting binary to the dist folder - const targetFolder = release ? "release" : "debug"; - const ext = process.platform === "win32" ? ".exe" : ""; - const nodeArch = rustTargetsMap[target].nodeArch; - fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${process.platform}-${nodeArch}${ext}`)); - } + cargoBuild("desktop_proxy", target, release) } function buildImporterBinaries(target, release = true) { // These binaries are only built for Windows, so we can skip them on other platforms - if (process.platform !== "win32") { - return; - } - - const bin = "bitwarden_chromium_import_helper"; - const targetArg = target ? `--target ${target}` : ""; - const releaseArg = release ? "--release" : ""; - child_process.execSync(`cargo build --bin ${bin} ${releaseArg} ${targetArg}`); - - if (target) { - // Copy the resulting binary to the dist folder - const targetFolder = release ? "release" : "debug"; - const nodeArch = rustTargetsMap[target].nodeArch; - fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `${bin}.exe`), path.join(__dirname, "dist", `${bin}.${process.platform}-${nodeArch}.exe`)); + if (effectivePlatform(target) == "win32") { + cargoBuild("bitwarden_chromium_import_helper", target, release) } } @@ -69,17 +101,29 @@ function buildProcessIsolation() { return; } - child_process.execSync(`cargo build --release`, { - stdio: 'inherit', - cwd: path.join(__dirname, "process_isolation") - }); + runCommand("cargo", ["build", "--package", "process_isolation", "--release"]); console.log("Copying process isolation library to dist folder"); fs.copyFileSync(path.join(__dirname, "target", "release", "libprocess_isolation.so"), path.join(__dirname, "dist", `libprocess_isolation.so`)); } function installTarget(target) { - child_process.execSync(`rustup target add ${target}`, { stdio: 'inherit', cwd: __dirname }); + runCommand("rustup", ["target", "add", target]); + // Install cargo-xwin for cross-platform builds targeting Windows + if (target.includes('windows') && process.platform !== 'win32') { + runCommand("cargo", ["install", "--version", "0.20.2", "--locked", "cargo-xwin"]); + // install tools needed for packaging Appx, only supported on macOS for now. + if (process.platform === "darwin") { + runCommand("brew", ["install", "iinuwa/msix-packaging-tap/msix-packaging", "osslsigncode"]); + } + } +} + +function effectivePlatform(target) { + if (target) { + return rustTargetsMap[target].platform + } + return process.platform } if (!crossPlatform && !target) { @@ -94,9 +138,9 @@ if (!crossPlatform && !target) { if (target) { console.log(`Building for target: ${target} in ${mode} mode`); installTarget(target); - buildNapiModule(target, mode === "release"); - buildProxyBin(target, mode === "release"); - buildImporterBinaries(false, mode === "release"); + buildNapiModule(target, isRelease); + buildProxyBin(target, isRelease); + buildImporterBinaries(target, isRelease); buildProcessIsolation(); return; } @@ -113,8 +157,8 @@ if (process.platform === "linux") { platformTargets.forEach(([target, _]) => { installTarget(target); - buildNapiModule(target, mode === "release"); - buildProxyBin(target, mode === "release"); - buildImporterBinaries(target, mode === "release"); + buildNapiModule(target, isRelease); + buildProxyBin(target, isRelease); + buildImporterBinaries(target, isRelease); buildProcessIsolation(); }); diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 0c95c7f01a6..2d7d76827f1 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -13,14 +13,15 @@ }, "afterSign": "scripts/after-sign.js", "afterPack": "scripts/after-pack.js", - "asarUnpack": ["**/*.node"], + "beforePack": "scripts/before-pack.js", "files": [ - "**/*", - "!**/node_modules/@bitwarden/desktop-napi/**/*", - "**/node_modules/@bitwarden/desktop-napi/index.js", - "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + "!node_modules/@bitwarden/desktop-napi/scripts", + "!node_modules/@bitwarden/desktop-napi/src", + "!node_modules/@bitwarden/desktop-napi/Cargo.toml", + "!node_modules/@bitwarden/desktop-napi/build.rs", + "!node_modules/@bitwarden/desktop-napi/package.json" ], - "electronVersion": "36.8.1", + "electronVersion": "37.7.0", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", @@ -34,11 +35,11 @@ }, "extraFiles": [ { - "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "from": "desktop_native/dist/desktop_proxy.win32-${arch}.exe", "to": "desktop_proxy.exe" }, { - "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "from": "desktop_native/dist/bitwarden_chromium_import_helper.win32-${arch}.exe", "to": "bitwarden_chromium_import_helper.exe" } ] @@ -58,6 +59,7 @@ "appx": { "artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", + "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "BitwardenBeta", "identityName": "8bitSolutionsLLC.BitwardenBeta", "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index a4e1c44dc5b..c42c3cc4202 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -13,12 +13,13 @@ }, "afterSign": "scripts/after-sign.js", "afterPack": "scripts/after-pack.js", - "asarUnpack": ["**/*.node"], + "beforePack": "scripts/before-pack.js", "files": [ - "**/*", - "!**/node_modules/@bitwarden/desktop-napi/**/*", - "**/node_modules/@bitwarden/desktop-napi/index.js", - "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + "!node_modules/@bitwarden/desktop-napi/scripts", + "!node_modules/@bitwarden/desktop-napi/src", + "!node_modules/@bitwarden/desktop-napi/Cargo.toml", + "!node_modules/@bitwarden/desktop-napi/build.rs", + "!node_modules/@bitwarden/desktop-napi/package.json" ], "electronVersion": "39.2.6", "generateUpdatesFilesForAllChannels": true, @@ -94,11 +95,11 @@ }, "extraFiles": [ { - "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "from": "desktop_native/dist/desktop_proxy.win32-${arch}.exe", "to": "desktop_proxy.exe" }, { - "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "from": "desktop_native/dist/bitwarden_chromium_import_helper.win32-${arch}.exe", "to": "bitwarden_chromium_import_helper.exe" } ] @@ -172,6 +173,7 @@ "appx": { "artifactName": "${productName}-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", + "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "bitwardendesktop", "identityName": "8bitSolutionsLLC.bitwardendesktop", "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 17322c42a84..93d016f8791 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,7 +29,7 @@ "build:macos-extension:mas": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas", "build:macos-extension:masdev": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas-dev", "build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main", - "build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", + "build:main:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", "build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch", "build:renderer": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer", "build:renderer:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer", diff --git a/apps/desktop/scripts/after-pack.js b/apps/desktop/scripts/after-pack.js index 5fc42f31ac3..34378ee092b 100644 --- a/apps/desktop/scripts/after-pack.js +++ b/apps/desktop/scripts/after-pack.js @@ -6,9 +6,12 @@ const path = require("path"); const { flipFuses, FuseVersion, FuseV1Options } = require("@electron/fuses"); const builder = require("electron-builder"); const fse = require("fs-extra"); - exports.default = run; +/** + * + * @param {builder.AfterPackContext} context + */ async function run(context) { console.log("## After pack"); // console.log(context); diff --git a/apps/desktop/scripts/appx-cross-build.ps1 b/apps/desktop/scripts/appx-cross-build.ps1 new file mode 100755 index 00000000000..62619d5ea37 --- /dev/null +++ b/apps/desktop/scripts/appx-cross-build.ps1 @@ -0,0 +1,226 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS +Script to build, package and sign the Bitwarden desktop client as a Windows Appx +package. + +.DESCRIPTION +This script provides cross-platform support for packaging and signing the +Bitwarden desktop client as a Windows Appx package. + +Currently, only macOS -> Windows Appx is supported, but Linux -> Windows Appx +could be added in the future by providing Linux binaries for the msix-packaging +project. + +.NOTES +The reason this script exists is because electron-builder does not currently +support cross-platform Appx packaging without proprietary tools (Parallels +Windows VM). This script uses third-party tools (makemsix from msix-packaging +and osslsigncode) to package and sign the Appx. + +The signing certificate must have the same subject as the publisher name. This +can be generated on the Windows target using PowerShell 5.1 and copied to the +host, or directly on the host with OpenSSL. + +Using Windows PowerShell 5.1: +```powershell +$publisher = "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US" +$certificate = New-SelfSignedCertificate -Type Custom -KeyUsage DigitalSignature -CertStoreLocation "Cert:\CurrentUser\My" -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") -Subject $publisher -FriendlyName "Bitwarden Developer Signing Certificate" +$password = Read-Host -AsSecureString +Export-PfxCertificate -cert "Cert:\CurrentUser\My\${$certificate.Thumbprint}" -FilePath "C:\path/to/pfx" -Password $password +``` + +Using OpenSSL: +```sh +subject="jurisdictionCountryName=US/jurisdictionStateOrProvinceName=Delaware/businessCategory=Private Organization/serialNumber=7654941, C=US, ST=California, L=Santa Barbara, O=Bitwarden Inc., CN=Bitwarden Inc." +keyfile="/tmp/mysigning.rsa.pem" +certfile="/tmp/mysigning.cert.pem" +p12file="/tmp/mysigning.p12" +openssl req -x509 -keyout "$keyfile" -out "$certfile" -subj "$subject" \ + -newkey rsa:2048 -days 3650 -nodes \ + -addext 'keyUsage=critical,digitalSignature' \ + -addext 'extendedKeyUsage=critical,codeSigning' \ + -addext 'basicConstraints=critical,CA:FALSE' +openssl pkcs12 -inkey "$keyfile" -in "$certfile" -export -out "$p12file" +rm $keyfile +``` + +.EXAMPLE +./scripts/cross-build.ps1 -Architecture arm64 -CertificatePath ~/Development/code-signing.pfx -CertificatePassword (Read-Host -AsSecureString) -Release -Beta + +Reads the signing certificate password from user input, then builds, packages +and signs the Appx. + +Alternatively, you can specify the CERTIFICATE_PASSWORD environment variable. +#> +param( + [Parameter(Mandatory=$true)] + [ValidateSet("X64", "ARM64")]$Architecture, + [string] + # Path to PKCS12 certificate file. If not specified, the Appx will not be signed. + $CertificatePath, + [SecureString] + # Password for PKCS12 certificate. Alternatively, may be specified in + # CERTIFICATE_PASSWORD environment variable. If not specified, the Appx will + # not be signed. + $CertificatePassword, + [Switch] + # Whether to build the Beta version of the app. + $Beta=$false, + [Switch] + # Whether to build in release mode. + $Release=$false +) +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true +$startTime = Get-Date +$originalLocation = Get-Location +if (!(Get-Command makemsix -ErrorAction SilentlyContinue)) { + Write-Error "The `makemsix` tool from the msix-packaging project is required to construct Appx package." + Write-Error "On macOS, you can install with Homebrew:" + Write-Error " brew install iinuwa/msix-packaging-tap/msix-packaging" + Exit 1 +} + +if (!(Get-Command osslsigncode -ErrorAction SilentlyContinue)) { + Write-Error "The `osslsigncode` tool is required to sign the Appx package." + Write-Error "On macOS, you can install with Homebrew:" + Write-Error " brew install osslsigncode" + Exit 1 +} + +if (!(Get-Command cargo-xwin -ErrorAction SilentlyContinue)) { + Write-Error "The `cargo-xwin` tool is required to cross-compile Windows native code." + Write-Error "You can install with cargo:" + Write-Error " cargo install --version 0.20.2 --locked cargo-xwin" + Exit 1 +} + +try { + +# Resolve certificate file before we change directories. +$CertificateFile = Get-Item $CertificatePath -ErrorAction SilentlyContinue + +cd $PSScriptRoot/.. + +if ($Beta) { + $electronConfigFile = Get-Item "./electron-builder.beta.json" +} +else { + $electronConfigFile = Get-Item "./electron-builder.json" +} + +$builderConfig = Get-Content $electronConfigFile | ConvertFrom-Json +$packageConfig = Get-Content package.json | ConvertFrom-Json +$manifestTemplate = Get-Content $builderConfig.appx.customManifestPath + +$srcDir = Get-Location +$assetsDir = Get-Item $builderConfig.directories.buildResources +$buildDir = Get-Item $builderConfig.directories.app +$outDir = Join-Path (Get-Location) ($builderConfig.directories.output ?? "dist") + +if ($Release) { + $buildConfiguration = "--release" +} +$arch = "$Architecture".ToLower() +$ext = "appx" +$version = Get-Date -Format "yyyy.M.d.1HHmm" +$productName = $builderConfig.productName +$artifactName = "${productName}-$($packageConfig.version)-${arch}.$ext" + +Write-Host "Building native code" +$rustTarget = switch ($arch) { + x64 { "x86_64-pc-windows-msvc" } + arm64 { "aarch64-pc-windows-msvc" } + default { + Write-Error "Unsupported architecture: $Architecture. Supported architectures are x64 and arm64" + Exit(1) + } +} +npm run build-native -- cross-platform $buildConfiguration "--target=$rustTarget" + +Write-Host "Building Javascript code" +if ($Release) { + npm run build +} +else { + npm run build:dev +} + +Write-Host "Cleaning output folder" +Remove-Item -Recurse -Force $outDir -ErrorAction Ignore + +Write-Host "Packaging Electron executable" +& npx electron-builder --config $electronConfigFile --publish never --dir --win --$arch + +cd $outDir +New-Item -Type Directory (Join-Path $outDir "appx") + +Write-Host "Building Appx directory structure" +$appxDir = (Join-Path $outDir appx/app) +if ($arch -eq "x64") { + Move-Item (Join-Path $outDir "win-unpacked") $appxDir +} +else { + Move-Item (Join-Path $outDir "win-${arch}-unpacked") $appxDir +} + +Write-Host "Copying Assets" +New-Item -Type Directory (Join-Path $outDir appx/assets) +Copy-Item $srcDir/resources/appx/* $outDir/appx/assets/ + +Write-Host "Building Appx manifest" +$translationMap = @{ + 'arch' = $arch + 'applicationId' = $builderConfig.appx.applicationId + 'displayName' = $productName + 'executable' = "app\${productName}.exe" + 'publisher' = $builderConfig.appx.publisher + 'publisherDisplayName' = $builderConfig.appx.publisherDisplayName + 'version' = $version +} + +$manifest = $manifestTemplate +$translationMap.Keys | ForEach-Object { + $manifest = $manifest.Replace("`${$_}", $translationMap[$_]) +} +$manifest | Out-File appx/AppxManifest.xml +$unsignedArtifactpath = [System.IO.Path]::GetFileNameWithoutExtension($artifactName) + "-unsigned.$ext" +Write-Host "Creating unsigned Appx" +makemsix pack -d appx -p $unsignedArtifactpath + +$outfile = Join-Path $outDir $unsignedArtifactPath +if ($null -eq $CertificatePath) { + Write-Warning "No Certificate specified. Not signing Appx." +} +elseif ($null -eq $CertificatePassword -and $null -eq $env:CERTIFICATE_PASSWORD) { + Write-Warning "No certificate password specified in CertificatePassword argument nor CERTIFICATE_PASSWORD environment variable. Not signing Appx." +} +else { + $cert = $CertificateFile + $pw = $null + if ($null -ne $CertificatePassword) { + $pw = ConvertFrom-SecureString -SecureString $CertificatePassword -AsPlainText + } else { + $pw = $env:CERTIFICATE_PASSWORD + } + $unsigned = $outfile + $outfile = (Join-Path $outDir $artifactName) + Write-Host "Signing $artifactName with $cert" + osslsigncode sign ` + -pkcs12 "$cert" ` + -pass "$pw" ` + -in $unsigned ` + -out $outfile + Remove-Item $unsigned +} + +$endTime = Get-Date +$elapsed = $endTime - $startTime +Write-Host "Successfully packaged $(Get-Item $outfile)" +Write-Host ("Finished at $($endTime.ToString('HH:mm:ss')) in $($elapsed.ToString('mm')) minutes and $($elapsed.ToString('ss')).$($elapsed.ToString('fff')) seconds") +} +finally { + Set-Location -Path $originalLocation +} diff --git a/apps/desktop/scripts/before-pack.js b/apps/desktop/scripts/before-pack.js new file mode 100644 index 00000000000..ca9bf924b2d --- /dev/null +++ b/apps/desktop/scripts/before-pack.js @@ -0,0 +1,31 @@ +/* eslint-disable no-console */ +/** @import { BeforePackContext } from 'app-builder-lib' */ +exports.default = run; + +/** + * @param {BeforePackContext} context + */ +async function run(context) { + console.log("## before pack"); + console.log("Stripping .node files that don't belong to this platform..."); + removeExtraNodeFiles(context); +} + +/** + * Removes Node files for platforms besides the current platform being packaged. + * + * @param {BeforePackContext} context + */ +function removeExtraNodeFiles(context) { + // When doing cross-platform builds, due to electron-builder limitiations, + // .node files for other platforms may be generated and unpacked, so we + // remove them manually here before signing and distributing. + const packagerPlatform = context.packager.platform.nodeName; + const platforms = ["darwin", "linux", "win32"]; + const fileFilter = context.packager.info._configuration.files[0].filter; + for (const platform of platforms) { + if (platform != packagerPlatform) { + fileFilter.push(`!node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-*.node`); + } + } +} diff --git a/apps/desktop/sign.js b/apps/desktop/sign.js index 6a42666c46f..b8da98a882b 100644 --- a/apps/desktop/sign.js +++ b/apps/desktop/sign.js @@ -1,22 +1,60 @@ /* eslint-disable @typescript-eslint/no-require-imports, no-console */ +const child_process = require("child_process"); exports.default = async function (configuration) { - if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && configuration.path.slice(-4) == ".exe") { + const ext = configuration.path.split(".").at(-1); + if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ext == "exe") { console.log(`[*] Signing file: ${configuration.path}`); - require("child_process").execSync( - `azuresigntool sign -v ` + - `-kvu ${process.env.SIGNING_VAULT_URL} ` + - `-kvi ${process.env.SIGNING_CLIENT_ID} ` + - `-kvt ${process.env.SIGNING_TENANT_ID} ` + - `-kvs ${process.env.SIGNING_CLIENT_SECRET} ` + - `-kvc ${process.env.SIGNING_CERT_NAME} ` + - `-fd ${configuration.hash} ` + - `-du ${configuration.site} ` + - `-tr http://timestamp.digicert.com ` + - `"${configuration.path}"`, + child_process.execFileSync( + "azuresigntool", + // prettier-ignore + [ + "sign", + "-v", + "-kvu", process.env.SIGNING_VAULT_URL, + "-kvi", process.env.SIGNING_CLIENT_ID, + "-kvt", process.env.SIGNING_TENANT_ID, + "-kvs", process.env.SIGNING_CLIENT_SECRET, + "-kvc", process.env.SIGNING_CERT_NAME, + "-fd", configuration.hash, + "-du", configuration.site, + "-tr", "http://timestamp.digicert.com", + configuration.path, + ], { stdio: "inherit", }, ); + } else if (process.env.ELECTRON_BUILDER_SIGN_CERT && ["exe", "appx"].includes(ext)) { + console.log(`[*] Signing file: ${configuration.path}`); + if (process.platform !== "win32") { + console.warn( + "Signing Windows executables on non-Windows platforms is not supported. Not signing.", + ); + return; + } + const certFile = process.env.ELECTRON_BUILDER_SIGN_CERT; + const certPw = process.env.ELECTRON_BUILDER_SIGN_CERT_PW; + if (!certPw) { + throw new Error( + "The certificate file password must be set in ELECTRON_BUILDER_SIGN_CERT_PW in order to sign files.", + ); + } + try { + child_process.execFileSync( + "signtool.exe", + ["sign", "/fd", "SHA256", "/a", "/f", certFile, "/p", certPw, configuration.path], + { + stdio: "inherit", + }, + ); + console.info(`Signed ${configuration.path} successfully.`); + } catch (error) { + throw new Error( + `Failed to sign ${configuration.path}: ${error.message}\n` + + `Check that ELECTRON_BUILDER_SIGN_CERT points to a valid PKCS12 file ` + + `and ELECTRON_BUILDER_SIGN_CERT_PW is correct.`, + ); + } } }; diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts index ab881e5b57b..f22b94974d1 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts @@ -6,7 +6,7 @@ import { BehaviorSubject } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; import { GlobalStateProvider } from "@bitwarden/state"; diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts index 28004f475e5..0dfdc1ee7c5 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts @@ -4,7 +4,7 @@ import { toSignal } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; import { filter, map, startWith } from "rxjs"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; import { I18nPipe } from "@bitwarden/ui-common"; diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts index 713915e3cf7..8a6e22cc402 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts @@ -16,10 +16,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { SendItemsService, SendListFiltersService } from "@bitwarden/send-ui"; diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts index 6a44713d309..be49e6593e4 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -22,9 +22,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, DialogService, ToastService } from "@bitwarden/components"; import { diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index d3cdbba9e23..4562253e12c 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -101,7 +101,7 @@ } }, "new": { - "message": "New", + "message": "Yeni", "description": "for adding new items" }, "newUri": { @@ -2416,7 +2416,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLink": { - "message": "Copy Send link", + "message": "\"Send\" keçidini kopyala", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLinkToClipboard": { @@ -4040,14 +4040,14 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsTitleNoSearchResults": { - "message": "No search results returned" + "message": "Heç bir axtarış nəticəsi qayıtmadı" }, "sendsBodyNoItems": { "message": "İstənilən platformada faylları və veriləri hər kəslə güvənli şəkildə paylaşın. Məlumatlarınız, ifşa olunmamaq üçün ucdan-uca şifrələnmiş qalacaq.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoSearchResults": { - "message": "Clear filters or try another search term" + "message": "Filtrləri təmizləyin və ya başqa bir axtarış terminini sınayın" }, "generatorNudgeTitle": { "message": "Cəld parol yaradın" @@ -4268,7 +4268,7 @@ "message": "Yazma qısayolu" }, "editAutotypeKeyboardModifiersDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." + "message": "Aşağıdakı dəyişdiricilərdən birini və ya ikisini daxil edin: Ctrl, Alt, Win və bir hərf." }, "invalidShortcut": { "message": "Yararsız qısayol" @@ -4307,7 +4307,7 @@ "message": "Arxivdən çıxart" }, "archived": { - "message": "Archived" + "message": "Arxivləndi" }, "itemsInArchive": { "message": "Arxivdəki elementlər" @@ -4331,19 +4331,19 @@ "message": "Arxivlənmiş elementlər ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək. Bu elementi arxivləmək istədiyinizə əminsiniz?" }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "Arxivdən çıxart və saxla" }, "restartPremium": { - "message": "Restart Premium" + "message": "\"Premium\"u yenidən başlat" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Premium abunəliyiniz bitdi" }, "premiumSubscriptionEndedDesc": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + "message": "Arxivinizə təkrar erişmək üçün Premium abunəliyinizi yenidən başladın. Təkrar başlatmazdan əvvəl arxivlənmiş elementin detallarına düzəliş etsəniz, həmin element seyfinizə daşınacaq." }, "itemRestored": { - "message": "Item has been restored" + "message": "Element bərpa edildi" }, "zipPostalCodeLabel": { "message": "ZIP / Poçt kodu" diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index b00233457ec..33de901c06b 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4010,6 +4010,12 @@ }, "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" }, "missingWebsite": { "message": "Missing website" diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index d27efd0789a..e4492d051b3 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -4268,7 +4268,7 @@ "message": "Premi i tasti da impostare per la scorciatoia" }, "editAutotypeKeyboardModifiersDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." + "message": "Includi uno o due tasti modificatori a scelta tra Ctrl, Alt e Win." }, "invalidShortcut": { "message": "Scorciatoia non valida" diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index f96fff1153c..8c61f598efe 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -4307,7 +4307,7 @@ "message": "Avarkivera" }, "archived": { - "message": "Archived" + "message": "Arkiverade" }, "itemsInArchive": { "message": "Objekt i arkivet" @@ -4331,16 +4331,16 @@ "message": "Arkiverade objekt är uteslutna från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?" }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "Avarkivera och spara" }, "restartPremium": { "message": "Starta om Premium" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Ditt Premium-abonnemang avslutades" }, "premiumSubscriptionEndedDesc": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + "message": "För att återfå åtkomst till ditt arkiv, starta om Premium-abonnemanget. Om du redigerar detaljer för ett arkiverat objekt innan du startar om kommer det att flyttas tillbaka till ditt valv." }, "itemRestored": { "message": "Objektet har återställts" diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 028e7836c41..5adc841351f 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -2574,7 +2574,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为「$ACTION$」。", "placeholders": { "hours": { "content": "$1", diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index bbdd2ad0a0f..b2008d57bcd 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -4,7 +4,7 @@ import { once } from "node:events"; import * as path from "path"; import * as url from "url"; -import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, screen, session } from "electron"; import { concatMap, firstValueFrom, pairwise } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -122,6 +122,7 @@ export class WindowMain { if (!isMacAppStore()) { const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { + dialog.showErrorBox("Error", "An instance of Bitwarden Desktop is already running."); app.quit(); return; } else { diff --git a/apps/web/src/app/billing/clients/account-billing.client.ts b/apps/web/src/app/billing/clients/account-billing.client.ts index 256a06b3ead..e520e70bf70 100644 --- a/apps/web/src/app/billing/clients/account-billing.client.ts +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -1,6 +1,8 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response"; +import { BitwardenSubscription } from "@bitwarden/subscription"; import { BillingAddress, @@ -11,13 +13,22 @@ import { @Injectable() export class AccountBillingClient { private endpoint = "/account/billing/vnext"; - private apiService: ApiService; - constructor(apiService: ApiService) { - this.apiService = apiService; - } + constructor(private apiService: ApiService) {} - purchasePremiumSubscription = async ( + getLicense = async (): Promise => { + const path = `${this.endpoint}/license`; + return this.apiService.send("GET", path, null, true, true); + }; + + getSubscription = async (): Promise => { + const path = `${this.endpoint}/subscription`; + const json = await this.apiService.send("GET", path, null, true, true); + const response = new BitwardenSubscriptionResponse(json); + return response.toDomain(); + }; + + purchaseSubscription = async ( paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, billingAddress: Pick, ): Promise => { @@ -29,6 +40,17 @@ export class AccountBillingClient { const request = isTokenizedPayment ? { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress } : { nonTokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }; + await this.apiService.send("POST", path, request, true, true); }; + + reinstateSubscription = async (): Promise => { + const path = `${this.endpoint}/subscription/reinstate`; + await this.apiService.send("POST", path, null, true, false); + }; + + updateSubscriptionStorage = async (additionalStorageGb: number): Promise => { + const path = `${this.endpoint}/subscription/storage`; + await this.apiService.send("PUT", path, { additionalStorageGb }, true, false); + }; } diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index fbaf65d1839..f85dab54fe7 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -1,9 +1,12 @@ import { inject, NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component"; +import { AccountSubscriptionComponent } from "@bitwarden/web-vault/app/billing/individual/subscription/account-subscription.component"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component"; @@ -17,11 +20,15 @@ const routes: Routes = [ data: { titleId: "subscription" }, children: [ { path: "", pathMatch: "full", redirectTo: "premium" }, - { - path: "user-subscription", - component: UserSubscriptionComponent, - data: { titleId: "premiumMembership" }, - }, + ...featureFlaggedRoute({ + defaultComponent: UserSubscriptionComponent, + flaggedComponent: AccountSubscriptionComponent, + featureFlag: FeatureFlag.PM29594_UpdateIndividualSubscriptionPage, + routeOptions: { + path: "user-subscription", + data: { titleId: "premiumMembership" }, + }, + }), /** * Two-Route Matching Strategy for /premium: * diff --git a/apps/web/src/app/billing/individual/subscription/account-subscription.component.html b/apps/web/src/app/billing/individual/subscription/account-subscription.component.html new file mode 100644 index 00000000000..9bb788c1f36 --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/account-subscription.component.html @@ -0,0 +1,50 @@ +@if (subscriptionLoading()) { + + + {{ "loading" | i18n }} + +} @else { + @if (subscription.value(); as subscription) { + +
+

{{ "youHavePremium" | i18n }}

+

+ {{ "viewAndManagePremiumSubscription" | i18n }} +

+
+ + +
+ + + + + @if (subscription.storage; as storage) { + + } + + + +
+ } +} diff --git a/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts new file mode 100644 index 00000000000..183f4f82666 --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts @@ -0,0 +1,291 @@ +import { ChangeDetectionStrategy, Component, computed, inject, resource } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom, lastValueFrom, map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService, TypographyModule } from "@bitwarden/components"; +import { Maybe } from "@bitwarden/pricing"; +import { + AdditionalOptionsCardAction, + AdditionalOptionsCardActions, + AdditionalOptionsCardComponent, + MAX_STORAGE_GB, + Storage, + StorageCardAction, + StorageCardActions, + StorageCardComponent, + SubscriptionCardAction, + SubscriptionCardActions, + SubscriptionCardComponent, + SubscriptionStatuses, +} from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + AdjustAccountSubscriptionStorageDialogComponent, + AdjustAccountSubscriptionStorageDialogParams, +} from "@bitwarden/web-vault/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component"; +import { + OffboardingSurveyDialogResultType, + openOffboardingSurvey, +} from "@bitwarden/web-vault/app/billing/shared/offboarding-survey.component"; + +@Component({ + templateUrl: "./account-subscription.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AdditionalOptionsCardComponent, + I18nPipe, + JslibModule, + StorageCardComponent, + SubscriptionCardComponent, + TypographyModule, + ], + providers: [AccountBillingClient], +}) +export class AccountSubscriptionComponent { + private accountService = inject(AccountService); + private activatedRoute = inject(ActivatedRoute); + private accountBillingClient = inject(AccountBillingClient); + private billingAccountProfileStateService = inject(BillingAccountProfileStateService); + private configService = inject(ConfigService); + private dialogService = inject(DialogService); + private fileDownloadService = inject(FileDownloadService); + private i18nService = inject(I18nService); + private router = inject(Router); + private subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction); + private toastService = inject(ToastService); + + readonly subscription = resource({ + loader: async () => { + const redirectToPremiumPage = async (): Promise => { + await this.router.navigate(["/settings/subscription/premium"]); + return null; + }; + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return await redirectToPremiumPage(); + } + const hasPremiumPersonally = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumPersonally$(account.id), + ); + if (!hasPremiumPersonally) { + return await redirectToPremiumPage(); + } + return await this.accountBillingClient.getSubscription(); + }, + }); + + readonly subscriptionLoading = computed(() => this.subscription.isLoading()); + + readonly subscriptionTerminal = computed>(() => { + const subscription = this.subscription.value(); + if (subscription) { + return ( + subscription.status === SubscriptionStatuses.IncompleteExpired || + subscription.status === SubscriptionStatuses.Canceled || + subscription.status === SubscriptionStatuses.Unpaid + ); + } + }); + + readonly subscriptionPendingCancellation = computed>(() => { + const subscription = this.subscription.value(); + if (subscription) { + return ( + (subscription.status === SubscriptionStatuses.Trialing || + subscription.status === SubscriptionStatuses.Active) && + !!subscription.cancelAt + ); + } + }); + + readonly storage = computed>(() => { + const subscription = this.subscription.value(); + return subscription?.storage; + }); + + readonly purchasedStorage = computed(() => { + const subscription = this.subscription.value(); + return subscription?.cart.passwordManager.additionalStorage?.quantity; + }); + + readonly premiumPlan = toSignal( + this.subscriptionPricingService + .getPersonalSubscriptionPricingTiers$() + .pipe( + map((tiers) => + tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium), + ), + ), + ); + + readonly premiumStoragePrice = computed>(() => { + const premiumPlan = this.premiumPlan(); + return premiumPlan?.passwordManager.annualPricePerAdditionalStorageGB; + }); + + readonly premiumProvidedStorage = computed>(() => { + const premiumPlan = this.premiumPlan(); + return premiumPlan?.passwordManager.providedStorageGB; + }); + + readonly canAddStorage = computed>(() => { + if (this.subscriptionTerminal()) { + return false; + } + const storage = this.storage(); + const premiumProvidedStorage = this.premiumProvidedStorage(); + if (storage && premiumProvidedStorage) { + const maxAttainableStorage = MAX_STORAGE_GB - premiumProvidedStorage; + return storage.available < maxAttainableStorage; + } + }); + + readonly canRemoveStorage = computed>(() => { + if (this.subscriptionTerminal()) { + return false; + } + const purchasedStorage = this.purchasedStorage(); + if (!purchasedStorage || purchasedStorage === 0) { + return false; + } + const storage = this.storage(); + if (storage) { + return storage.available > storage.used; + } + }); + + readonly canCancelSubscription = computed>(() => { + if (this.subscriptionTerminal()) { + return false; + } + return !this.subscriptionPendingCancellation(); + }); + + readonly premiumToOrganizationUpgradeEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM29593_PremiumToOrganizationUpgrade), + { initialValue: false }, + ); + + onSubscriptionCardAction = async (action: SubscriptionCardAction) => { + switch (action) { + case SubscriptionCardActions.ContactSupport: + window.open("https://bitwarden.com/contact/", "_blank"); + break; + case SubscriptionCardActions.ManageInvoices: + await this.router.navigate(["../billing-history"], { relativeTo: this.activatedRoute }); + break; + case SubscriptionCardActions.ReinstateSubscription: { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "reinstateSubscription" }, + content: { key: "reinstateConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + await this.accountBillingClient.reinstateSubscription(); + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("reinstated"), + }); + this.subscription.reload(); + break; + } + case SubscriptionCardActions.UpdatePayment: + await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }); + break; + case SubscriptionCardActions.UpgradePlan: + // TODO: Implement upgrade plan navigation + break; + } + }; + + onStorageCardAction = async (action: StorageCardAction) => { + const data = this.getAdjustStorageDialogParams(action); + const dialogReference = AdjustAccountSubscriptionStorageDialogComponent.open( + this.dialogService, + { + data, + }, + ); + const result = await lastValueFrom(dialogReference.closed); + if (result === "submitted") { + this.subscription.reload(); + } + }; + + onAdditionalOptionsCardAction = async (action: AdditionalOptionsCardAction) => { + switch (action) { + case AdditionalOptionsCardActions.DownloadLicense: { + const license = await this.accountBillingClient.getLicense(); + const json = JSON.stringify(license, null, 2); + this.fileDownloadService.download({ + fileName: "bitwarden_premium_license.json", + blobData: json, + }); + break; + } + case AdditionalOptionsCardActions.CancelSubscription: { + const dialogReference = openOffboardingSurvey(this.dialogService, { + data: { + type: "User", + }, + }); + + const result = await lastValueFrom(dialogReference.closed); + + if (result === OffboardingSurveyDialogResultType.Closed) { + return; + } + + this.subscription.reload(); + } + } + }; + + getAdjustStorageDialogParams = ( + action: StorageCardAction, + ): Maybe => { + const purchasedStorage = this.purchasedStorage(); + const storagePrice = this.premiumStoragePrice(); + const providedStorage = this.premiumProvidedStorage(); + + switch (action) { + case StorageCardActions.AddStorage: { + if (storagePrice && providedStorage) { + return { + type: "add", + price: storagePrice, + provided: providedStorage, + cadence: "annually", + existing: purchasedStorage, + }; + } + break; + } + case StorageCardActions.RemoveStorage: { + if (purchasedStorage) { + return { + type: "remove", + existing: purchasedStorage, + }; + } + break; + } + } + }; +} diff --git a/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.html b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.html new file mode 100644 index 00000000000..1d3172837ad --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.html @@ -0,0 +1,43 @@ +@let content = this.content(); +
+ + +

{{ content.body }}

+
+ + {{ content.label }} + + @if (action() === "add") { + + + {{ "total" | i18n }} + {{ formGroup.value.amount }} GB × {{ price() | currency: "$" }} = + {{ price() * formGroup.value.amount | currency: "$" }} / + {{ term() | i18n }} + + } + +
+
+ + + + +
+
diff --git a/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts new file mode 100644 index 00000000000..f1350cda49e --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts @@ -0,0 +1,182 @@ +import { CurrencyPipe } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + AsyncActionsModule, + ButtonModule, + DIALOG_DATA, + DialogConfig, + DialogModule, + DialogRef, + DialogService, + FormFieldModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { Maybe } from "@bitwarden/pricing"; +import { MAX_STORAGE_GB } from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients"; + +type RemoveStorage = { + type: "remove"; + existing: number; +}; + +type AddStorage = { + type: "add"; + price: number; + provided: number; + cadence: SubscriptionCadence; + existing?: number; +}; + +export type AdjustAccountSubscriptionStorageDialogParams = RemoveStorage | AddStorage; + +type AdjustAccountSubscriptionStorageDialogResult = "closed" | "submitted"; + +@Component({ + templateUrl: "./adjust-account-subscription-storage-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [AccountBillingClient], + imports: [ + AsyncActionsModule, + ButtonModule, + CurrencyPipe, + DialogModule, + FormFieldModule, + I18nPipe, + ReactiveFormsModule, + TypographyModule, + ], +}) +export class AdjustAccountSubscriptionStorageDialogComponent { + private readonly accountBillingClient = inject(AccountBillingClient); + private readonly dialogParams = inject(DIALOG_DATA); + private readonly dialogRef = inject(DialogRef); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + + readonly action = computed<"add" | "remove">(() => this.dialogParams.type); + + readonly price = computed>(() => { + if (this.dialogParams.type === "add") { + return this.dialogParams.price; + } + }); + + readonly provided = computed>(() => { + if (this.dialogParams.type === "add") { + return this.dialogParams.provided; + } + }); + + readonly term = computed>(() => { + if (this.dialogParams.type === "add") { + switch (this.dialogParams.cadence) { + case "annually": + return this.i18nService.t("year"); + case "monthly": + return this.i18nService.t("month"); + } + } + }); + + readonly existing = computed>(() => this.dialogParams.existing); + + readonly content = computed<{ + title: string; + body: string; + label: string; + }>(() => { + const action = this.action(); + switch (action) { + case "add": + return { + title: this.i18nService.t("addStorage"), + body: this.i18nService.t("storageAddNote"), + label: this.i18nService.t("gbStorageAdd"), + }; + case "remove": + return { + title: this.i18nService.t("removeStorage"), + body: this.i18nService.t("whenYouRemoveStorage"), + label: this.i18nService.t("gbStorageRemove"), + }; + } + }); + + readonly maxPurchasable = computed>(() => { + const provided = this.provided(); + if (provided) { + return MAX_STORAGE_GB - provided; + } + }); + + readonly maxValidatorValue = computed(() => { + const maxPurchasable = this.maxPurchasable() ?? MAX_STORAGE_GB; + const existing = this.existing(); + const action = this.action(); + + switch (action) { + case "add": { + return existing ? maxPurchasable - existing : maxPurchasable; + } + case "remove": { + return existing ? existing : 0; + } + } + }); + + formGroup = new FormGroup({ + amount: new FormControl(1, { + nonNullable: true, + validators: [ + Validators.required, + Validators.min(1), + Validators.max(this.maxValidatorValue()), + ], + }), + }); + + submit = async () => { + this.formGroup.markAllAsTouched(); + if (!this.formGroup.valid || !this.formGroup.value.amount) { + return; + } + + const action = this.action(); + const existing = this.existing(); + const amount = this.formGroup.value.amount; + + switch (action) { + case "add": { + await this.accountBillingClient.updateSubscriptionStorage(amount + (existing ?? 0)); + break; + } + case "remove": { + await this.accountBillingClient.updateSubscriptionStorage(existing! - amount); + } + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("adjustedStorage", amount), + }); + + this.dialogRef.close("submitted"); + }; + + static open = ( + dialogService: DialogService, + dialogConfig: DialogConfig, + ) => + dialogService.open( + AdjustAccountSubscriptionStorageDialogComponent, + dialogConfig, + ); +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 81169d719b6..83440646b48 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -478,13 +478,13 @@ describe("UpgradePaymentService", () => { describe("upgradeToPremium", () => { it("should call accountBillingClient to purchase premium subscription and refresh data", async () => { // Arrange - mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + mockAccountBillingClient.purchaseSubscription.mockResolvedValue(); // Act await sut.upgradeToPremium(mockTokenizedPaymentMethod, mockBillingAddress); // Assert - expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith( mockTokenizedPaymentMethod, mockBillingAddress, ); @@ -496,13 +496,13 @@ describe("UpgradePaymentService", () => { const accountCreditPaymentMethod: NonTokenizedPaymentMethod = { type: NonTokenizablePaymentMethods.accountCredit, }; - mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + mockAccountBillingClient.purchaseSubscription.mockResolvedValue(); // Act await sut.upgradeToPremium(accountCreditPaymentMethod, mockBillingAddress); // Assert - expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith( accountCreditPaymentMethod, mockBillingAddress, ); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index ae18ab4c629..b8d5637e471 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -143,7 +143,7 @@ export class UpgradePaymentService { ): Promise { this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); - await this.accountBillingClient.purchasePremiumSubscription(paymentMethod, billingAddress); + await this.accountBillingClient.purchaseSubscription(paymentMethod, billingAddress); await this.refreshAndSync(); } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 34362b4be3e..77ae3b31837 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -142,7 +142,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { if (!this.selectedPlan()) { return { passwordManager: { - seats: { name: "", cost: 0, quantity: 0 }, + seats: { translationKey: "", cost: 0, quantity: 0 }, }, cadence: "annually", estimatedTax: 0, @@ -152,7 +152,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { return { passwordManager: { seats: { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + translationKey: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", cost: this.selectedPlan()!.details.passwordManager.annualPrice ?? 0, quantity: 1, }, diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index 307f170f116..f060d29b377 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -40,13 +40,13 @@
{{ "status" | i18n }}
-
+
{{ (subscription && subscriptionStatus) || "-" }} {{ "pendingCancellation" | i18n }}
diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 2fc39218cf8..5034b21d03d 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -30,6 +30,7 @@ import { import { UpdateLicenseDialogComponent } from "../shared/update-license-dialog.component"; import { UpdateLicenseDialogResult } from "../shared/update-license-types"; +// TODO: Remove with deletion of pm-29594-update-individual-subscription-page // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -256,8 +257,8 @@ export class UserSubscriptionComponent implements OnInit { return null; } return discount.amountOff - ? { type: DiscountTypes.AmountOff, active: discount.active, value: discount.amountOff } - : { type: DiscountTypes.PercentOff, active: discount.active, value: discount.percentOff }; + ? { type: DiscountTypes.AmountOff, value: discount.amountOff } + : { type: DiscountTypes.PercentOff, value: discount.percentOff }; } get isSubscriptionActive(): boolean { diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts index 9ae0600fb2a..7d6ad41cb00 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts @@ -122,6 +122,41 @@ describe("CipherStep", () => { expect(logger.record).toHaveBeenCalledWith("Cipher ID cipher-3 was undecryptable"); expect(logger.record).toHaveBeenCalledWith("Found 2 undecryptable ciphers"); }); + + it("returns correct results when running diagnostics multiple times", async () => { + const userId = "user-id" as UserId; + const cipher1 = { id: "cipher-1", organizationId: null } as Cipher; + const cipher2 = { id: "cipher-2", organizationId: null } as Cipher; + + const workingData: RecoveryWorkingData = { + userId, + userKey: null, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [cipher1, cipher2], + folders: [], + }; + + // First run: cipher1 succeeds, cipher2 fails + cipherEncryptionService.decrypt + .mockResolvedValueOnce({} as any) + .mockRejectedValueOnce(new Error("Decryption failed")); + + const result1 = await cipherStep.runDiagnostics(workingData, logger); + + expect(result1).toBe(false); + expect(cipherStep.canRecover(workingData)).toBe(true); + + // Second run: all ciphers succeed + cipherEncryptionService.decrypt.mockResolvedValue({} as any); + + const result2 = await cipherStep.runDiagnostics(workingData, logger); + + expect(result2).toBe(true); + expect(cipherStep.canRecover(workingData)).toBe(false); + expect(cipherStep["undecryptableCipherIds"]).toHaveLength(0); + expect(cipherStep["decryptableCipherIds"]).toHaveLength(2); + }); }); describe("canRecover", () => { diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts index 01c2d9bc2a1..47000f8880b 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts @@ -25,6 +25,7 @@ export class CipherStep implements RecoveryStep { } this.undecryptableCipherIds = []; + this.decryptableCipherIds = []; // The tool is currently only implemented to handle ciphers that are corrupt for a user. For an organization, the case of // local user not having access to the organization key is not properly handled here, and should be implemented separately. // For now, this just filters out and does not consider corrupt organization ciphers. diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.spec.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.spec.ts new file mode 100644 index 00000000000..8e59007732e --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.spec.ts @@ -0,0 +1,404 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { UserKey } from "@bitwarden/common/types/key"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; +import { DialogService } from "@bitwarden/components"; +import { PureCrypto } from "@bitwarden/sdk-internal"; +import { UserId } from "@bitwarden/user-core"; + +import { LogRecorder } from "../log-recorder"; + +import { FolderStep } from "./folder-step"; +import { RecoveryWorkingData } from "./recovery-step"; + +// Mock SdkLoadService +jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk-load.service", () => ({ + SdkLoadService: { + Ready: Promise.resolve(), + }, +})); + +jest.mock("@bitwarden/sdk-internal", () => ({ + PureCrypto: { + symmetric_decrypt_string: jest.fn(), + }, +})); + +describe("FolderStep", () => { + let folderStep: FolderStep; + let folderService: MockProxy; + let dialogService: MockProxy; + let logger: MockProxy; + + const mockUserKey = { + toEncoded: jest.fn().mockReturnValue("encoded-user-key"), + } as unknown as UserKey; + + beforeEach(() => { + folderService = mock(); + dialogService = mock(); + logger = mock(); + + folderStep = new FolderStep(folderService, dialogService); + + jest.clearAllMocks(); + }); + + describe("runDiagnostics", () => { + it("returns false and logs error when userKey is missing", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: null, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [], + }; + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(false); + expect(logger.record).toHaveBeenCalledWith("Missing user key"); + }); + + it("returns true when all folders are decryptable", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(true); + expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith( + "encrypted-name-1", + "encoded-user-key", + ); + expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith( + "encrypted-name-2", + "encoded-user-key", + ); + expect(logger.record).toHaveBeenCalledWith("Found 0 undecryptable folders"); + expect(logger.record).toHaveBeenCalledWith("Found 2 decryptable folders"); + }); + + it("returns false and records folders with no name", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: null as null }; + const folder3 = { id: "folder-3", name: { encryptedString: null as null } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2, folder3] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(false); + expect(logger.record).toHaveBeenCalledWith("Folder ID folder-2 has no name"); + expect(logger.record).toHaveBeenCalledWith("Folder ID folder-3 has no name"); + expect(logger.record).toHaveBeenCalledWith("Found 2 undecryptable folders"); + expect(logger.record).toHaveBeenCalledWith("Found 1 decryptable folders"); + }); + + it("returns false and records undecryptable folders", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + const folder3 = { id: "folder-3", name: { encryptedString: "encrypted-name-3" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2, folder3] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock) + .mockReturnValueOnce("decrypted-name") // folder1 succeeds + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }) // folder2 fails + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }); // folder3 fails + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(false); + expect(logger.record).toHaveBeenCalledWith( + "Folder name for folder ID folder-2 was undecryptable", + ); + expect(logger.record).toHaveBeenCalledWith( + "Folder name for folder ID folder-3 was undecryptable", + ); + expect(logger.record).toHaveBeenCalledWith("Found 2 undecryptable folders"); + expect(logger.record).toHaveBeenCalledWith("Found 1 decryptable folders"); + }); + + it("returns correct results when running diagnostics multiple times", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2] as Folder[], + }; + + // First run: folder1 succeeds, folder2 fails + (PureCrypto.symmetric_decrypt_string as jest.Mock) + .mockReturnValueOnce("decrypted-name") + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }); + + const result1 = await folderStep.runDiagnostics(workingData, logger); + + expect(result1).toBe(false); + expect(folderStep.canRecover(workingData)).toBe(true); + + // Second run: all folders succeed + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + const result2 = await folderStep.runDiagnostics(workingData, logger); + + expect(result2).toBe(true); + expect(folderStep.canRecover(workingData)).toBe(false); + expect(folderStep["undecryptableFolderIds"]).toEqual([]); + expect(folderStep["decryptableFolderIds"]).toEqual(["folder-1", "folder-2"]); + }); + }); + + describe("canRecover", () => { + it("returns false when there are no undecryptable folders", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [ + { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }, + { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }, + ] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + await folderStep.runDiagnostics(workingData, logger); + const result = folderStep.canRecover(workingData); + + expect(result).toBe(false); + }); + + it("returns true when there are undecryptable folders but at least one decryptable folder", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [ + { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }, + { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }, + ] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock) + .mockReturnValueOnce("decrypted-name") + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }); + + await folderStep.runDiagnostics(workingData, logger); + const result = folderStep.canRecover(workingData); + + expect(result).toBe(true); + }); + + it("returns false when all folders are undecryptable", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [ + { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }, + { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }, + ] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + + await folderStep.runDiagnostics(workingData, logger); + const result = folderStep.canRecover(workingData); + + expect(result).toBe(false); + }); + }); + + describe("runRecovery", () => { + it("logs and returns early when there are no undecryptable folders", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [], + }; + + await folderStep.runRecovery(workingData, logger); + + expect(logger.record).toHaveBeenCalledWith("No undecryptable folders to recover"); + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(folderService.delete).not.toHaveBeenCalled(); + }); + + it("throws error when userId is missing", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + // Now set userId to null for recovery + workingData.userId = null; + + await expect(folderStep.runRecovery(workingData, logger)).rejects.toThrow("Missing user ID"); + expect(logger.record).toHaveBeenCalledWith("Missing user ID"); + }); + + it("throws error when user cancels deletion", async () => { + const userId = "user-id" as UserId; + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + + const workingData: RecoveryWorkingData = { + userId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + dialogService.openSimpleDialog.mockResolvedValue(false); + + await expect(folderStep.runRecovery(workingData, logger)).rejects.toThrow( + "Folder recovery cancelled by user", + ); + + expect(logger.record).toHaveBeenCalledWith("Showing confirmation dialog for 1 folders"); + expect(logger.record).toHaveBeenCalledWith("User cancelled folder deletion"); + expect(folderService.delete).not.toHaveBeenCalled(); + }); + + it("deletes undecryptable folders when user confirms", async () => { + const userId = "user-id" as UserId; + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + + const workingData: RecoveryWorkingData = { + userId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + dialogService.openSimpleDialog.mockResolvedValue(true); + folderService.delete.mockResolvedValue(undefined); + + await folderStep.runRecovery(workingData, logger); + + expect(logger.record).toHaveBeenCalledWith("Showing confirmation dialog for 2 folders"); + expect(logger.record).toHaveBeenCalledWith("Deleting 2 folders"); + expect(folderService.delete).toHaveBeenCalledWith("folder-1", userId); + expect(folderService.delete).toHaveBeenCalledWith("folder-2", userId); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-1"); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-2"); + expect(logger.record).toHaveBeenCalledWith("Successfully deleted 2 folders"); + }); + + it("continues deleting folders even if some deletions fail", async () => { + const userId = "user-id" as UserId; + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + const folder3 = { id: "folder-3", name: { encryptedString: "encrypted-name-3" } }; + + const workingData: RecoveryWorkingData = { + userId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2, folder3] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + dialogService.openSimpleDialog.mockResolvedValue(true); + folderService.delete + .mockResolvedValueOnce(undefined) // folder1 succeeds + .mockRejectedValueOnce(new Error("Network error")) // folder2 fails + .mockResolvedValueOnce(undefined); // folder3 succeeds + + await folderStep.runRecovery(workingData, logger); + + expect(folderService.delete).toHaveBeenCalledTimes(3); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-1"); + expect(logger.record).toHaveBeenCalledWith( + "Failed to delete folder folder-2: Error: Network error", + ); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-3"); + }); + }); +}); diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts index 90e252ce6c3..2087c360ddc 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts @@ -25,6 +25,8 @@ export class FolderStep implements RecoveryStep { } this.undecryptableFolderIds = []; + this.decryptableFolderIds = []; + for (const folder of workingData.folders) { if (!folder.name?.encryptedString) { logger.record(`Folder ID ${folder.id} has no name`); diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts index 4f5dda1745e..e9ef85867e7 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts @@ -8,9 +8,9 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SendAddEditDialogComponent } from "@bitwarden/send-ui"; @@ -72,6 +72,7 @@ describe("NewSendDropdownComponent", () => { const openSpy = jest.spyOn(SendAddEditDialogComponent, "open"); const openDrawerSpy = jest.spyOn(SendAddEditDialogComponent, "openDrawer"); mockConfigService.getFeatureFlag.mockResolvedValue(false); + openSpy.mockReturnValue({ closed: of({}) } as any); await component.createSend(SendType.Text); @@ -85,6 +86,8 @@ describe("NewSendDropdownComponent", () => { mockConfigService.getFeatureFlag.mockImplementation(async (key) => key === FeatureFlag.SendUIRefresh ? true : false, ); + const mockRef = { closed: of({}) }; + openDrawerSpy.mockReturnValue(mockRef as any); await component.createSend(SendType.Text); diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts index 22f07e4fe92..68c8c188d31 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; -import { firstValueFrom, Observable, of, switchMap } from "rxjs"; +import { firstValueFrom, Observable, of, switchMap, lastValueFrom } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -8,9 +8,15 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components"; -import { DefaultSendFormConfigService, SendAddEditDialogComponent } from "@bitwarden/send-ui"; +import { + DefaultSendFormConfigService, + SendAddEditDialogComponent, + SendItemDialogResult, +} from "@bitwarden/send-ui"; + +import { SendSuccessDrawerDialogComponent } from "../shared"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -60,12 +66,19 @@ export class NewSendDropdownComponent { if (!(await firstValueFrom(this.canAccessPremium$)) && type === SendType.File) { return; } - const formConfig = await this.addEditFormConfigService.buildConfig("add", undefined, type); - const useRefresh = await this.configService.getFeatureFlag(FeatureFlag.SendUIRefresh); + if (useRefresh) { - SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig }); + const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig }); + if (dialogRef) { + const result = await lastValueFrom(dialogRef.closed); + if (result?.result === SendItemDialogResult.Saved && result?.send) { + this.dialogService.openDrawer(SendSuccessDrawerDialogComponent, { + data: result.send, + }); + } + } } else { SendAddEditDialogComponent.open(this.dialogService, { formConfig }); } diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts index 0397575f021..060dc1958b1 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts @@ -11,12 +11,12 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response" import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index 6418744a727..8a6f720bb45 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -17,100 +17,159 @@ {{ "sendDisabledWarning" | i18n }} -
-
-
-
- {{ "filters" | i18n }} -
-
-
- -
-
-
    -
  • - - - -
  • -
-
-
-
-

{{ "types" | i18n }}

-
-
    -
  • - - - -
  • -
  • - - - -
  • -
-
-
-
-
-
- -
- - - {{ "loading" | i18n }} - - - - {{ "sendsTitleNoItems" | i18n }} - {{ "sendsBodyNoItems" | i18n }} - - - +@if (SendUIRefresh$ | async) { +
+ +
+ +
+ + {{ "allSends" | i18n }} + {{ "sendTypeText" | i18n }} + {{ "sendTypeFile" | i18n }} + +
+ +
+
+ + +
+ + + {{ "loading" | i18n }} + + + + {{ "sendsTitleNoItems" | i18n }} + {{ "sendsBodyNoItems" | i18n }} + + + +
-
+} @else { +
+
+
+
+ {{ "filters" | i18n }} +
+
+
+ +
+
+
    +
  • + + + +
  • +
+
+
+
+

{{ "types" | i18n }}

+
+
    +
  • + + + +
  • +
  • + + + +
  • +
+
+
+
+
+
+ + +
+ + + {{ "loading" | i18n }} + + + + {{ "sendsTitleNoItems" | i18n }} + {{ "sendsBodyNoItems" | i18n }} + + + +
+
+
+} diff --git a/apps/web/src/app/tools/send/send.component.ts b/apps/web/src/app/tools/send/send.component.ts index 7c0e03e3e21..db45b104900 100644 --- a/apps/web/src/app/tools/send/send.component.ts +++ b/apps/web/src/app/tools/send/send.component.ts @@ -1,7 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, NgZone, OnInit, OnDestroy } from "@angular/core"; -import { lastValueFrom } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { lastValueFrom, Observable, switchMap, EMPTY } from "rxjs"; import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; import { NoSendsIcon } from "@bitwarden/assets/svg"; @@ -17,6 +19,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendFilterType } from "@bitwarden/common/tools/send/types/send-filter-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { @@ -26,6 +30,7 @@ import { SearchModule, TableDataSource, ToastService, + ToggleGroupModule, } from "@bitwarden/components"; import { DefaultSendFormConfigService, @@ -39,6 +44,7 @@ import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; import { NewSendDropdownComponent } from "./new-send/new-send-dropdown.component"; +import { SendSuccessDrawerDialogComponent } from "./shared"; const BroadcasterSubscriptionId = "SendComponent"; @@ -52,6 +58,7 @@ const BroadcasterSubscriptionId = "SendComponent"; NoItemsModule, HeaderModule, NewSendDropdownComponent, + ToggleGroupModule, SendTableComponent, ], templateUrl: "send.component.html", @@ -60,6 +67,8 @@ const BroadcasterSubscriptionId = "SendComponent"; export class SendComponent extends BaseSendComponent implements OnInit, OnDestroy { private sendItemDialogRef?: DialogRef | undefined; noItemIcon = NoSendsIcon; + selectedToggleValue?: SendFilterType; + SendUIRefresh$: Observable; override set filteredSends(filteredSends: SendView[]) { super.filteredSends = filteredSends; @@ -87,6 +96,8 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro toastService: ToastService, private addEditFormConfigService: DefaultSendFormConfigService, accountService: AccountService, + private route: ActivatedRoute, + private router: Router, private configService: ConfigService, ) { super( @@ -103,10 +114,38 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro toastService, accountService, ); + + this.SendUIRefresh$ = this.configService.getFeatureFlag$(FeatureFlag.SendUIRefresh); + + this.SendUIRefresh$.pipe( + switchMap((sendUiRefreshEnabled) => { + if (sendUiRefreshEnabled) { + return this.route.queryParamMap; + } + return EMPTY; + }), + takeUntilDestroyed(), + ).subscribe((params) => { + const typeParam = params.get("type"); + const value = ( + typeParam === SendFilterType.Text || typeParam === SendFilterType.File + ? typeParam + : SendFilterType.All + ) as SendFilterType; + this.selectedToggleValue = value; + + if (this.loaded) { + this.applyTypeFilter(value); + } + }); } async ngOnInit() { await super.ngOnInit(); + this.onSuccessfulLoad = async () => { + this.applyTypeFilter(this.selectedToggleValue); + }; + await this.load(); // Broadcaster subscription - load if sync completes in the background @@ -172,12 +211,49 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro }); } - const result = await lastValueFrom(this.sendItemDialogRef.closed); + const result: SendItemDialogResult = await lastValueFrom(this.sendItemDialogRef.closed); this.sendItemDialogRef = undefined; // If the dialog was closed by deleting the cipher, refresh the vault. - if (result === SendItemDialogResult.Deleted || result === SendItemDialogResult.Saved) { + if ( + result?.result === SendItemDialogResult.Deleted || + result?.result === SendItemDialogResult.Saved + ) { await this.load(); } + + if ( + result?.result === SendItemDialogResult.Saved && + result?.send && + (await this.configService.getFeatureFlag(FeatureFlag.SendUIRefresh)) + ) { + this.dialogService.openDrawer(SendSuccessDrawerDialogComponent, { + data: result.send, + }); + } + } + + private applyTypeFilter(value: SendFilterType) { + if (value === SendFilterType.All) { + this.selectAll(); + } else if (value === SendFilterType.Text) { + this.selectType(SendType.Text); + } else if (value === SendFilterType.File) { + this.selectType(SendType.File); + } + } + + onToggleChange(value: SendFilterType) { + const queryParams = value === SendFilterType.All ? { type: null } : { type: value }; + + this.router + .navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: "merge", + }) + .catch((err) => { + this.logService.error("Failed to update route query params:", err); + }); } } diff --git a/apps/web/src/app/tools/send/shared/index.ts b/apps/web/src/app/tools/send/shared/index.ts new file mode 100644 index 00000000000..afc507ee464 --- /dev/null +++ b/apps/web/src/app/tools/send/shared/index.ts @@ -0,0 +1 @@ +export { SendSuccessDrawerDialogComponent } from "./send-success-drawer-dialog.component"; diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html new file mode 100644 index 00000000000..b9326ca08ac --- /dev/null +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html @@ -0,0 +1,45 @@ + + + {{ dialogTitle() | i18n }} + + +
+
+
+ +
+
+ +

+ {{ "sendCreatedSuccessfully" | i18n }} +

+ +

+ {{ "sendCreatedDescription" | i18n: formattedExpirationTime }} +

+ + + {{ "sendLink" | i18n }} + + + +
+
+ + + + + +
diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts new file mode 100644 index 00000000000..67e01cd9ff0 --- /dev/null +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts @@ -0,0 +1,75 @@ +import { Component, ChangeDetectionStrategy, Inject, signal, computed } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { ActiveSendIcon } from "@bitwarden/assets/svg"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +@Component({ + imports: [SharedModule, DialogModule, TypographyModule], + templateUrl: "./send-success-drawer-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendSuccessDrawerDialogComponent { + readonly sendLink = signal(""); + activeSendIcon = ActiveSendIcon; + + // Computed property to get the dialog title based on send type + readonly dialogTitle = computed(() => { + return this.send.type === SendType.Text ? "newTextSend" : "newFileSend"; + }); + + constructor( + @Inject(DIALOG_DATA) public send: SendView, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + ) { + void this.initLink(); + } + + async initLink() { + const env = await firstValueFrom(this.environmentService.environment$); + this.sendLink.set(env.getSendUrl() + this.send.accessId + "/" + this.send.urlB64Key); + } + + get formattedExpirationTime(): string { + if (!this.send.deletionDate) { + return ""; + } + const hoursAvailable = this.getHoursAvailable(this.send); + if (hoursAvailable < 24) { + return hoursAvailable === 1 + ? this.i18nService.t("oneHour").toLowerCase() + : this.i18nService.t("durationTimeHours", String(hoursAvailable)).toLowerCase(); + } + const daysAvailable = Math.ceil(hoursAvailable / 24); + return daysAvailable === 1 + ? this.i18nService.t("oneDay").toLowerCase() + : this.i18nService.t("days", String(daysAvailable)).toLowerCase(); + } + + private getHoursAvailable(send: SendView): number { + const now = new Date().getTime(); + const deletionDate = new Date(send.deletionDate).getTime(); + return Math.max(0, Math.ceil((deletionDate - now) / (1000 * 60 * 60))); + } + + copyLink() { + const link = this.sendLink(); + if (!link) { + return; + } + this.platformUtilsService.copyToClipboard(link); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("valueCopied", this.i18nService.t("sendLink")), + }); + } +} diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index 16256ab875a..c863608ba10 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -2,7 +2,8 @@ {{ title }} - @if (cipherIsArchived) { + + @if (isCipherArchived) { {{ "archived" | i18n }} } @@ -83,8 +84,28 @@ } - @if (showDelete) { + @if (showActionButtons) {
+ @if (userCanArchive$ | async) { + @if (isCipherArchived) { + + } + @if (cipher.canBeArchived) { + + } + } + + + + {{ "autoConfirmSetupHint" | i18n }} + + + +
+ + + `, + imports: [ButtonModule, DialogModule, CommonModule, JslibModule, BadgeComponent], +}) +export class AutoConfirmExtensionSetupDialogComponent { + constructor(public dialogRef: DialogRef) {} + + static open(dialogService: DialogService) { + return dialogService.open(AutoConfirmExtensionSetupDialogComponent, { + positionStrategy: new CenterPositionStrategy(), + }); + } +} diff --git a/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts index f126ce3b92c..877a0fe918a 100644 --- a/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts +++ b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts @@ -2,7 +2,12 @@ import { DialogRef } from "@angular/cdk/dialog"; import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component } from "@angular/core"; -import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { + ButtonModule, + CenterPositionStrategy, + DialogModule, + DialogService, +} from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @Component({ @@ -14,6 +19,8 @@ export class AutoConfirmWarningDialogComponent { constructor(public dialogRef: DialogRef) {} static open(dialogService: DialogService) { - return dialogService.open(AutoConfirmWarningDialogComponent); + return dialogService.open(AutoConfirmWarningDialogComponent, { + positionStrategy: new CenterPositionStrategy(), + }); } } diff --git a/libs/auto-confirm/src/components/index.ts b/libs/auto-confirm/src/components/index.ts index a0310e805c6..1cddd1d7e59 100644 --- a/libs/auto-confirm/src/components/index.ts +++ b/libs/auto-confirm/src/components/index.ts @@ -1 +1,2 @@ +export * from "./auto-confirm-extension-dialog.component"; export * from "./auto-confirm-warning-dialog.component"; diff --git a/libs/common/src/billing/models/response/bitwarden-subscription.response.ts b/libs/common/src/billing/models/response/bitwarden-subscription.response.ts new file mode 100644 index 00000000000..870c4de7e3a --- /dev/null +++ b/libs/common/src/billing/models/response/bitwarden-subscription.response.ts @@ -0,0 +1,102 @@ +import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response"; +import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Cart } from "@bitwarden/pricing"; +import { + BitwardenSubscription, + Storage, + SubscriptionStatus, + SubscriptionStatuses, +} from "@bitwarden/subscription"; + +export class BitwardenSubscriptionResponse extends BaseResponse { + status: SubscriptionStatus; + cart: Cart; + storage: Storage; + cancelAt?: Date; + canceled?: Date; + nextCharge?: Date; + suspension?: Date; + gracePeriod?: number; + + constructor(response: any) { + super(response); + + const status = this.getResponseProperty("Status"); + if ( + status !== SubscriptionStatuses.Incomplete && + status !== SubscriptionStatuses.IncompleteExpired && + status !== SubscriptionStatuses.Trialing && + status !== SubscriptionStatuses.Active && + status !== SubscriptionStatuses.PastDue && + status !== SubscriptionStatuses.Canceled && + status !== SubscriptionStatuses.Unpaid + ) { + throw new Error(`Failed to parse invalid subscription status: ${status}`); + } + this.status = status; + + this.cart = new CartResponse(this.getResponseProperty("Cart")); + this.storage = new StorageResponse(this.getResponseProperty("Storage")); + + const suspension = this.getResponseProperty("Suspension"); + if (suspension) { + this.suspension = new Date(suspension); + } + + const gracePeriod = this.getResponseProperty("GracePeriod"); + if (gracePeriod) { + this.gracePeriod = gracePeriod; + } + + const nextCharge = this.getResponseProperty("NextCharge"); + if (nextCharge) { + this.nextCharge = new Date(nextCharge); + } + + const cancelAt = this.getResponseProperty("CancelAt"); + if (cancelAt) { + this.cancelAt = new Date(cancelAt); + } + + const canceled = this.getResponseProperty("Canceled"); + if (canceled) { + this.canceled = new Date(canceled); + } + } + + toDomain = (): BitwardenSubscription => { + switch (this.status) { + case SubscriptionStatuses.Incomplete: + case SubscriptionStatuses.IncompleteExpired: + case SubscriptionStatuses.PastDue: + case SubscriptionStatuses.Unpaid: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + suspension: this.suspension!, + gracePeriod: this.gracePeriod!, + }; + } + case SubscriptionStatuses.Trialing: + case SubscriptionStatuses.Active: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + nextCharge: this.nextCharge!, + cancelAt: this.cancelAt, + }; + } + case SubscriptionStatuses.Canceled: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + canceled: this.canceled!, + }; + } + } + }; +} diff --git a/libs/common/src/billing/models/response/cart.response.ts b/libs/common/src/billing/models/response/cart.response.ts new file mode 100644 index 00000000000..c1a1d17521a --- /dev/null +++ b/libs/common/src/billing/models/response/cart.response.ts @@ -0,0 +1,97 @@ +import { + SubscriptionCadence, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Cart, CartItem, Discount } from "@bitwarden/pricing"; + +import { DiscountResponse } from "./discount.response"; + +export class CartItemResponse extends BaseResponse implements CartItem { + translationKey: string; + quantity: number; + cost: number; + discount?: Discount; + + constructor(response: any) { + super(response); + + this.translationKey = this.getResponseProperty("TranslationKey"); + this.quantity = this.getResponseProperty("Quantity"); + this.cost = this.getResponseProperty("Cost"); + const discount = this.getResponseProperty("Discount"); + if (discount) { + this.discount = discount; + } + } +} + +class PasswordManagerCartItemResponse extends BaseResponse { + seats: CartItem; + additionalStorage?: CartItem; + + constructor(response: any) { + super(response); + + this.seats = new CartItemResponse(this.getResponseProperty("Seats")); + const additionalStorage = this.getResponseProperty("AdditionalStorage"); + if (additionalStorage) { + this.additionalStorage = new CartItemResponse(additionalStorage); + } + } +} + +class SecretsManagerCartItemResponse extends BaseResponse { + seats: CartItem; + additionalServiceAccounts?: CartItem; + + constructor(response: any) { + super(response); + + this.seats = new CartItemResponse(this.getResponseProperty("Seats")); + const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts"); + if (additionalServiceAccounts) { + this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts); + } + } +} + +export class CartResponse extends BaseResponse implements Cart { + passwordManager: { + seats: CartItem; + additionalStorage?: CartItem; + }; + secretsManager?: { + seats: CartItem; + additionalServiceAccounts?: CartItem; + }; + cadence: SubscriptionCadence; + discount?: Discount; + estimatedTax: number; + + constructor(response: any) { + super(response); + + this.passwordManager = new PasswordManagerCartItemResponse( + this.getResponseProperty("PasswordManager"), + ); + + const secretsManager = this.getResponseProperty("SecretsManager"); + if (secretsManager) { + this.secretsManager = new SecretsManagerCartItemResponse(secretsManager); + } + + const cadence = this.getResponseProperty("Cadence"); + if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) { + throw new Error(`Failed to parse invalid cadence: ${cadence}`); + } + this.cadence = cadence; + + const discount = this.getResponseProperty("Discount"); + if (discount) { + this.discount = new DiscountResponse(discount); + } + + this.estimatedTax = this.getResponseProperty("EstimatedTax"); + } +} diff --git a/libs/common/src/billing/models/response/discount.response.ts b/libs/common/src/billing/models/response/discount.response.ts new file mode 100644 index 00000000000..03460a10df8 --- /dev/null +++ b/libs/common/src/billing/models/response/discount.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing"; + +export class DiscountResponse extends BaseResponse implements Discount { + type: DiscountType; + value: number; + + constructor(response: any) { + super(response); + + const type = this.getResponseProperty("Type"); + if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) { + throw new Error(`Failed to parse invalid discount type: ${type}`); + } + this.type = type; + this.value = this.getResponseProperty("Value"); + } +} diff --git a/libs/common/src/billing/models/response/storage.response.ts b/libs/common/src/billing/models/response/storage.response.ts new file mode 100644 index 00000000000..7e270ccc934 --- /dev/null +++ b/libs/common/src/billing/models/response/storage.response.ts @@ -0,0 +1,16 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Storage } from "@bitwarden/subscription"; + +export class StorageResponse extends BaseResponse implements Storage { + available: number; + used: number; + readableUsed: string; + + constructor(response: any) { + super(response); + + this.available = this.getResponseProperty("Available"); + this.used = this.getResponseProperty("Used"); + this.readableUsed = this.getResponseProperty("ReadableUsed"); + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 20da219e8d7..ab8fe5decd8 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -31,6 +31,8 @@ export enum FeatureFlag { PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", PM26462_Milestone_3 = "pm-26462-milestone-3", PM23341_Milestone_2 = "pm-23341-milestone-2", + PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page", + PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -54,7 +56,6 @@ export enum FeatureFlag { /* DIRT */ EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", PhishingDetection = "phishing-detection", - PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab", /* Vault */ PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", @@ -116,7 +117,6 @@ export const DefaultFeatureFlagValue = { /* DIRT */ [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, - [FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE, /* Vault */ [FeatureFlag.CipherKeyEncryption]: FALSE, @@ -139,6 +139,8 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, [FeatureFlag.PM26462_Milestone_3]: FALSE, [FeatureFlag.PM23341_Milestone_2]: FALSE, + [FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE, + [FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts index 5f5da741707..ef1057c51e6 100644 --- a/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts +++ b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts @@ -30,7 +30,7 @@ export class DefaultKeyGenerationService implements KeyGenerationService { ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> { if (salt == null) { const bytes = await this.cryptoFunctionService.randomBytes(32); - salt = Utils.fromBufferToUtf8(bytes); + salt = Utils.fromBufferToUtf8(bytes.buffer as ArrayBuffer); } const material = await this.cryptoFunctionService.aesGenerateKey(bitLength); const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 10f349fbec7..8839ea8ca50 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -330,6 +330,7 @@ export class ApiService implements ApiServiceAbstraction { return new PaymentResponse(r); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page postReinstatePremium(): Promise { return this.send("POST", "/accounts/reinstate-premium", null, true, false); } diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 80f50ab9a40..4245ffa6007 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -1,8 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; - -import { SendType } from "../../enums/send-type"; +import { AuthType } from "../../types/auth-type"; +import { SendType } from "../../types/send-type"; import { SendResponse } from "../response/send.response"; import { SendFileData } from "./send-file.data"; diff --git a/libs/common/src/tools/send/models/domain/send-access.spec.ts b/libs/common/src/tools/send/models/domain/send-access.spec.ts index 686236bff8e..58083d8a4bb 100644 --- a/libs/common/src/tools/send/models/domain/send-access.spec.ts +++ b/libs/common/src/tools/send/models/domain/send-access.spec.ts @@ -1,7 +1,7 @@ import { mock } from "jest-mock-extended"; import { mockContainerService, mockEnc } from "../../../../../spec"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccessResponse } from "../response/send-access.response"; import { SendAccess } from "./send-access"; diff --git a/libs/common/src/tools/send/models/domain/send-access.ts b/libs/common/src/tools/send/models/domain/send-access.ts index 68d1af7b57e..1877a5c1148 100644 --- a/libs/common/src/tools/send/models/domain/send-access.ts +++ b/libs/common/src/tools/send/models/domain/send-access.ts @@ -3,7 +3,7 @@ import { EncString } from "../../../../key-management/crypto/models/enc-string"; import Domain from "../../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccessResponse } from "../response/send-access.response"; import { SendAccessView } from "../view/send-access.view"; diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index 7be29216254..691876a7e31 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -11,7 +11,8 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../../platform/services/container.service"; import { UserKey } from "../../../../types/key"; -import { SendType } from "../../enums/send-type"; +import { AuthType } from "../../types/auth-type"; +import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; import { AuthType, Send } from "./send"; @@ -25,6 +26,7 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, + authType: AuthType.None, name: "encName", notes: "encNotes", text: { @@ -56,6 +58,7 @@ describe("Send", () => { id: null, accessId: null, type: undefined, + authType: undefined, name: null, notes: null, text: undefined, @@ -79,6 +82,7 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, + authType: AuthType.None, name: { encryptedString: "encName", encryptionType: 0 }, notes: { encryptedString: "encNotes", encryptionType: 0 }, text: { @@ -109,6 +113,7 @@ describe("Send", () => { send.id = "id"; send.accessId = "accessId"; send.type = SendType.Text; + send.authType = AuthType.None; send.name = mockEnc("name"); send.notes = mockEnc("notes"); send.text = text; @@ -148,6 +153,7 @@ describe("Send", () => { name: "name", notes: "notes", type: 0, + authType: 2, key: expect.anything(), cryptoKey: "cryptoKey", file: expect.anything(), diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index ea396df477b..ab112dfc4ad 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -8,21 +8,14 @@ import { UserId } from "@bitwarden/common/types/guid"; import { EncString } from "../../../../key-management/crypto/models/enc-string"; import { Utils } from "../../../../platform/misc/utils"; import Domain from "../../../../platform/models/domain/domain-base"; -import { SendType } from "../../enums/send-type"; +import { AuthType } from "../../types/auth-type"; +import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; import { SendView } from "../view/send.view"; import { SendFile } from "./send-file"; import { SendText } from "./send-text"; -export const AuthType = Object.freeze({ - Email: 0, - Password: 1, - None: 2, -} as const); - -export type AuthType = (typeof AuthType)[keyof typeof AuthType]; - export class Send extends Domain { id: string; accessId: string; @@ -63,6 +56,7 @@ export class Send extends Domain { ); this.type = obj.type; + this.authType = obj.authType; this.maxAccessCount = obj.maxAccessCount; this.accessCount = obj.accessCount; this.password = obj.password; diff --git a/libs/common/src/tools/send/models/request/send.request.ts b/libs/common/src/tools/send/models/request/send.request.ts index f7e3ff26d7f..902ca0a2c54 100644 --- a/libs/common/src/tools/send/models/request/send.request.ts +++ b/libs/common/src/tools/send/models/request/send.request.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; import { Send } from "../domain/send"; diff --git a/libs/common/src/tools/send/models/response/send-access.response.ts b/libs/common/src/tools/send/models/response/send-access.response.ts index 65a98e527a4..54107017fcf 100644 --- a/libs/common/src/tools/send/models/response/send-access.response.ts +++ b/libs/common/src/tools/send/models/response/send-access.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { BaseResponse } from "../../../../models/response/base.response"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index e05ea9ddd85..e73a0ecbbd6 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -3,7 +3,8 @@ import { AuthType } from "@bitwarden/common/tools/send/models/domain/send"; import { BaseResponse } from "../../../../models/response/base.response"; -import { SendType } from "../../enums/send-type"; +import { AuthType } from "../../types/auth-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; @@ -11,6 +12,7 @@ export class SendResponse extends BaseResponse { id: string; accessId: string; type: SendType; + authType: AuthType; name: string; notes: string; file: SendFileApi; @@ -32,6 +34,7 @@ export class SendResponse extends BaseResponse { this.id = this.getResponseProperty("Id"); this.accessId = this.getResponseProperty("AccessId"); this.type = this.getResponseProperty("Type"); + this.authType = this.getResponseProperty("AuthType"); this.name = this.getResponseProperty("Name"); this.notes = this.getResponseProperty("Notes"); this.key = this.getResponseProperty("Key"); diff --git a/libs/common/src/tools/send/models/view/send-access.view.ts b/libs/common/src/tools/send/models/view/send-access.view.ts index cb8b29796af..9d1b56d88ec 100644 --- a/libs/common/src/tools/send/models/view/send-access.view.ts +++ b/libs/common/src/tools/send/models/view/send-access.view.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { View } from "../../../../models/view/view"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccess } from "../domain/send-access"; import { SendFileView } from "./send-file.view"; diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index 4dfa21db95f..04abb16990c 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -4,8 +4,9 @@ import { View } from "../../../../models/view/view"; import { Utils } from "../../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { DeepJsonify } from "../../../../types/deep-jsonify"; -import { SendType } from "../../enums/send-type"; -import { AuthType, Send } from "../domain/send"; +import { AuthType } from "../../types/auth-type"; +import { SendType } from "../../types/send-type"; +import { Send } from "../domain/send"; import { SendFileView } from "./send-file.view"; import { SendTextView } from "./send-text.view"; diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index f709553646f..1c931b7ad98 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -6,7 +6,6 @@ import { FileUploadService, } from "../../../platform/abstractions/file-upload/file-upload.service"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; -import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendAccessRequest } from "../models/request/send-access.request"; @@ -16,6 +15,7 @@ import { SendFileDownloadDataResponse } from "../models/response/send-file-downl import { SendFileUploadDataResponse } from "../models/response/send-file-upload-data.response"; import { SendResponse } from "../models/response/send.response"; import { SendAccessView } from "../models/view/send-access.view"; +import { SendType } from "../types/send-type"; import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction"; import { InternalSendService } from "./send.service.abstraction"; diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 397ae905e31..fb99ddbe3bc 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -24,13 +24,13 @@ import { ContainerService } from "../../../platform/services/container.service"; import { SelfHostedEnvironment } from "../../../platform/services/default-environment.service"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; -import { SendType } from "../enums/send-type"; import { SendFileApi } from "../models/api/send-file.api"; import { SendTextApi } from "../models/api/send-text.api"; import { SendFileData } from "../models/data/send-file.data"; import { SendTextData } from "../models/data/send-text.data"; import { SendData } from "../models/data/send.data"; import { SendView } from "../models/view/send.view"; +import { SendType } from "../types/send-type"; import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions"; import { SendStateProvider } from "./send-state.provider"; diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 4d244579271..d1961434e5c 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -16,7 +16,6 @@ import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; -import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendFile } from "../models/domain/send-file"; @@ -24,6 +23,7 @@ import { SendText } from "../models/domain/send-text"; import { SendWithIdRequest } from "../models/request/send-with-id.request"; import { SendView } from "../models/view/send.view"; import { SEND_KDF_ITERATIONS } from "../send-kdf"; +import { SendType } from "../types/send-type"; import { SendStateProvider } from "./send-state.provider.abstraction"; import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction"; diff --git a/libs/common/src/tools/send/services/test-data/send-tests.data.ts b/libs/common/src/tools/send/services/test-data/send-tests.data.ts index 784d54bd71f..c1d04ab2926 100644 --- a/libs/common/src/tools/send/services/test-data/send-tests.data.ts +++ b/libs/common/src/tools/send/services/test-data/send-tests.data.ts @@ -1,12 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { EncString } from "../../../../key-management/crypto/models/enc-string"; -import { SendType } from "../../enums/send-type"; import { SendTextApi } from "../../models/api/send-text.api"; import { SendTextData } from "../../models/data/send-text.data"; import { SendData } from "../../models/data/send.data"; import { Send } from "../../models/domain/send"; import { SendView } from "../../models/view/send.view"; +import { SendType } from "../../types/send-type"; export function testSendViewData(id: string, name: string) { const data = new SendView({} as any); diff --git a/libs/common/src/tools/send/types/auth-type.ts b/libs/common/src/tools/send/types/auth-type.ts new file mode 100644 index 00000000000..5d0243249fd --- /dev/null +++ b/libs/common/src/tools/send/types/auth-type.ts @@ -0,0 +1,12 @@ +/** An type of auth necessary to access a Send */ +export const AuthType = Object.freeze({ + /** Send requires email OTP verification */ + Email: 0, + /** Send requires a password */ + Password: 1, + /** Send requires no auth */ + None: 2, +} as const); + +/** An type of auth necessary to access a Send */ +export type AuthType = (typeof AuthType)[keyof typeof AuthType]; diff --git a/libs/common/src/tools/send/types/send-filter-type.ts b/libs/common/src/tools/send/types/send-filter-type.ts new file mode 100644 index 00000000000..dd26536076a --- /dev/null +++ b/libs/common/src/tools/send/types/send-filter-type.ts @@ -0,0 +1,7 @@ +export const SendFilterType = Object.freeze({ + All: "all", + Text: "text", + File: "file", +} as const); + +export type SendFilterType = (typeof SendFilterType)[keyof typeof SendFilterType]; diff --git a/libs/common/src/tools/send/enums/send-type.ts b/libs/common/src/tools/send/types/send-type.ts similarity index 100% rename from libs/common/src/tools/send/enums/send-type.ts rename to libs/common/src/tools/send/types/send-type.ts diff --git a/libs/common/src/vault/abstractions/cipher-archive.service.ts b/libs/common/src/vault/abstractions/cipher-archive.service.ts index 0969b7de1ac..3a5071dc51a 100644 --- a/libs/common/src/vault/abstractions/cipher-archive.service.ts +++ b/libs/common/src/vault/abstractions/cipher-archive.service.ts @@ -3,12 +3,14 @@ import { Observable } from "rxjs"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { CipherData } from "../models/data/cipher.data"; + export abstract class CipherArchiveService { abstract hasArchiveFlagEnabled$: Observable; abstract archivedCiphers$(userId: UserId): Observable; abstract userCanArchive$(userId: UserId): Observable; abstract userHasPremium$(userId: UserId): Observable; - abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; - abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; abstract showSubscriptionEndedMessaging$(userId: UserId): Observable; } diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index db360f7f991..89f59665681 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -109,6 +109,10 @@ export class CipherView implements View, InitializerMetadata { return this.item?.subTitle; } + get canBeArchived(): boolean { + return !this.isDeleted && !this.isArchived; + } + get hasPasswordHistory(): boolean { return this.passwordHistory && this.passwordHistory.length > 0; } diff --git a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts index 2078d1f29ea..60589ed58db 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts @@ -1,3 +1,7 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ import { mock } from "jest-mock-extended"; import { of, firstValueFrom, BehaviorSubject } from "rxjs"; diff --git a/libs/common/src/vault/services/default-cipher-archive.service.ts b/libs/common/src/vault/services/default-cipher-archive.service.ts index 12d67ab07f9..0f1120b1de8 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.ts @@ -18,6 +18,7 @@ import { } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { CipherArchiveService } from "../abstractions/cipher-archive.service"; +import { CipherData } from "../models/data/cipher.data"; export class DefaultCipherArchiveService implements CipherArchiveService { constructor( @@ -84,15 +85,17 @@ export class DefaultCipherArchiveService implements CipherArchiveService { ); } - async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { + async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]); const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true); const response = new ListResponse(r, CipherResponse); const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); + // prevent mutating ciphers$ state + const localCiphers = structuredClone(currentCiphers); for (const cipher of response.data) { - const localCipher = currentCiphers[cipher.id as CipherId]; + const localCipher = localCiphers[cipher.id as CipherId]; if (localCipher == null) { continue; @@ -102,18 +105,21 @@ export class DefaultCipherArchiveService implements CipherArchiveService { localCipher.revisionDate = cipher.revisionDate; } - await this.cipherService.upsert(Object.values(currentCiphers), userId); + await this.cipherService.upsert(Object.values(localCiphers), userId); + return response.data[0]; } - async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { + async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { const request = new CipherBulkUnarchiveRequest(Array.isArray(ids) ? ids : [ids]); const r = await this.apiService.send("PUT", "/ciphers/unarchive", request, true, true); const response = new ListResponse(r, CipherResponse); const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); + // prevent mutating ciphers$ state + const localCiphers = structuredClone(currentCiphers); for (const cipher of response.data) { - const localCipher = currentCiphers[cipher.id as CipherId]; + const localCipher = localCiphers[cipher.id as CipherId]; if (localCipher == null) { continue; @@ -123,6 +129,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService { localCipher.revisionDate = cipher.revisionDate; } - await this.cipherService.upsert(Object.values(currentCiphers), userId); + await this.cipherService.upsert(Object.values(localCiphers), userId); + return response.data[0]; } } diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 6cf44544422..bc065155fdb 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -128,18 +128,13 @@ export abstract class KeyService { /** * Generates a new user key - * @deprecated Interacting with the master key directly is prohibited. Use {@link makeUserKeyV1} instead. + * @deprecated Interacting with the master key directly is prohibited. + * For new features please use the KM provided SDK methods for user cryptography initialization or reach out to the KM team. * @throws Error when master key is null or undefined. * @param masterKey The user's master key. * @returns A new user key and the master key protected version of it */ abstract makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]>; - /** - * Generates a new user key for a V1 user - * Note: This will be replaced by a higher level function to initialize a whole users cryptographic state in the near future. - * @returns A new user key - */ - abstract makeUserKeyV1(): Promise; /** * Clears the user's stored version of the user key * @param userId The desired user @@ -334,9 +329,9 @@ export abstract class KeyService { abstract getFingerprint(fingerprintMaterial: string, publicKey: Uint8Array): Promise; /** * Generates a new keypair - * @param key A key to encrypt the private key with. If not provided, - * defaults to the user key - * @returns A new keypair: [publicKey in Base64, encrypted privateKey] + * @deprecated New use-cases of this function are prohibited. Low-level cryptographic constructions and initialization should be done in the SDK. + * @param key A symmetric key to wrap the newly created private key with. + * @returns A new keypair: [publicKey in Base64, wrapped privateKey] * @throws If the provided key is a null-ish value. */ abstract makeKeyPair(key: SymmetricCryptoKey): Promise<[string, EncString]>; @@ -361,6 +356,8 @@ export abstract class KeyService { /** * Initialize all necessary crypto keys needed for a new account. * Warning! This completely replaces any existing keys! + * @deprecated New use cases for cryptography initialization should be done in the SDK. + * Current usage is actively being migrated see PM-21771 for details. * @param userId The user id of the target user. * @returns The user's newly created public key, private key, and encrypted private key * @throws An error if the userId is null or undefined. diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 8cb072a4c2a..752a89e5fcd 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -213,11 +213,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { return this.buildProtectedSymmetricKey(masterKey, newUserKey); } - async makeUserKeyV1(): Promise { - const newUserKey = await this.keyGenerationService.createKey(512); - return newUserKey as UserKey; - } - /** * Clears the user key. Clears all stored versions of the user keys as well, such as the biometrics key * @param userId The desired user diff --git a/libs/nx-plugin/src/generators/basic-lib.spec.ts b/libs/nx-plugin/src/generators/basic-lib.spec.ts index 9fd7a702375..2018593046b 100644 --- a/libs/nx-plugin/src/generators/basic-lib.spec.ts +++ b/libs/nx-plugin/src/generators/basic-lib.spec.ts @@ -24,7 +24,7 @@ describe("basic-lib generator", () => { expect(tsconfigContent).not.toBeNull(); const tsconfig = JSON.parse(tsconfigContent?.toString() ?? ""); expect(tsconfig.compilerOptions.paths[`@bitwarden/${options.name}`]).toEqual([ - `libs/test/src/index.ts`, + `./libs/test/src/index.ts`, ]); }); diff --git a/libs/nx-plugin/src/generators/basic-lib.ts b/libs/nx-plugin/src/generators/basic-lib.ts index 4f2f542ac89..c0d8a528841 100644 --- a/libs/nx-plugin/src/generators/basic-lib.ts +++ b/libs/nx-plugin/src/generators/basic-lib.ts @@ -82,7 +82,7 @@ function updateTsConfigPath(tree: Tree, name: string, srcRoot: string) { updateJson(tree, "tsconfig.base.json", (json) => { const paths = json.compilerOptions.paths || {}; - paths[`@bitwarden/${name}`] = [`${srcRoot}/index.ts`]; + paths[`@bitwarden/${name}`] = [`./${srcRoot}/index.ts`]; json.compilerOptions.paths = paths; return json; diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index e2fe7d80dc0..e916de3995d 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -46,7 +46,7 @@
@let passwordManagerSeats = cart.passwordManager.seats;
- {{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.name | i18n }} x + {{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x {{ passwordManagerSeats.cost | currency: "USD" : "symbol" }} / {{ term }} @@ -63,7 +63,7 @@
- {{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x + {{ additionalStorage.quantity }} {{ additionalStorage.translationKey | i18n }} x {{ additionalStorage.cost | currency: "USD" : "symbol" }} / {{ term }}
@@ -86,7 +86,7 @@
- {{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.name | i18n }} x + {{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x {{ secretsManagerSeats.cost | currency: "USD" : "symbol" }} / {{ term }}
@@ -105,7 +105,7 @@
{{ additionalServiceAccounts.quantity }} - {{ additionalServiceAccounts.name | i18n }} x + {{ additionalServiceAccounts.translationKey | i18n }} x {{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }} / {{ term }} diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx index 02e705276bc..d327d5658fe 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx @@ -67,7 +67,7 @@ The component uses the following Cart and CartItem data structures: ```typescript export type CartItem = { - name: string; // Display name for i18n lookup + translationKey: string; // Translation key for i18n lookup quantity: number; // Number of items cost: number; // Cost per item discount?: Discount; // Optional item-level discount @@ -92,7 +92,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing"; export type Discount = { type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff - active: boolean; // Whether discount is currently applied value: number; // Dollar amount or percentage (20 for 20%) }; ``` @@ -108,7 +107,7 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, @@ -124,12 +123,12 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, @@ -145,14 +144,13 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, cadence: 'monthly', discount: { type: 'percent-off', - active: true, value: 20 }, estimatedTax: 8.00 @@ -188,7 +186,7 @@ Show cart with yearly subscription: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 500.00 } }, @@ -211,12 +209,12 @@ Show cart with password manager and additional storage: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, @@ -239,14 +237,14 @@ Show cart with password manager and secrets manager seats only: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 } }, @@ -269,19 +267,19 @@ Show cart with password manager, secrets manager seats, and additional service a passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 }, additionalServiceAccounts: { quantity: 2, - name: 'additionalServiceAccounts', + translationKey: 'additionalServiceAccounts', cost: 6.00 } }, @@ -304,24 +302,24 @@ Show a cart with all available products: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 }, additionalServiceAccounts: { quantity: 2, - name: 'additionalServiceAccounts', + translationKey: 'additionalServiceAccounts', cost: 6.00 } }, @@ -344,19 +342,18 @@ Show cart with percentage-based discount: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, cadence: 'monthly', discount: { type: 'percent-off', - active: true, value: 20 }, estimatedTax: 10.40 @@ -377,21 +374,20 @@ Show cart with fixed amount discount: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 } }, cadence: 'annually', discount: { type: 'amount-off', - active: true, value: 50.00 }, estimatedTax: 95.00 @@ -431,7 +427,7 @@ Show cart with premium plan: passwordManager: { seats: { quantity: 1, - name: 'premiumMembership', + translationKey: 'premiumMembership', cost: 10.00 } }, @@ -454,7 +450,7 @@ Show cart with families plan: passwordManager: { seats: { quantity: 1, - name: 'familiesMembership', + translationKey: 'familiesMembership', cost: 40.00 } }, @@ -488,8 +484,7 @@ Show cart with families plan: - Use consistent naming and formatting for cart items - Include clear quantity and unit pricing information - Ensure tax estimates are accurate and clearly labeled -- Set `active: true` on discounts that should be displayed -- Use localized strings for CartItem names (for i18n lookup) +- Use valid translation keys for CartItem translationKey (for i18n lookup) - Provide complete Cart object with all required fields - Use "annually" or "monthly" for cadence (not "year" or "month") diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts index f019322e4db..10975585899 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts @@ -16,24 +16,24 @@ describe("CartSummaryComponent", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10, }, }, secretsManager: { seats: { quantity: 3, - name: "secretsManagerSeats", + translationKey: "secretsManagerSeats", cost: 30, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6, }, }, @@ -270,7 +270,6 @@ describe("CartSummaryComponent", () => { ...mockCart, discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, }; @@ -296,7 +295,6 @@ describe("CartSummaryComponent", () => { ...mockCart, discount: { type: DiscountTypes.AmountOff, - active: true, value: 50.0, }, }; @@ -315,33 +313,12 @@ describe("CartSummaryComponent", () => { expect(discountAmount.nativeElement.textContent).toContain("-$50.00"); }); - it("should not display discount when discount is inactive", () => { - // Arrange - const cartWithInactiveDiscount: Cart = { - ...mockCart, - discount: { - type: DiscountTypes.PercentOff, - active: false, - value: 20, - }, - }; - fixture.componentRef.setInput("cart", cartWithInactiveDiscount); - fixture.detectChanges(); - - // Act / Assert - const discountSection = fixture.debugElement.query( - By.css('[data-testid="discount-section"]'), - ); - expect(discountSection).toBeFalsy(); - }); - it("should apply discount to total calculation", () => { // Arrange const cartWithDiscount: Cart = { ...mockCart, discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, }; @@ -382,24 +359,24 @@ describe("CartSummaryComponent - Custom Header Template", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10, }, }, secretsManager: { seats: { quantity: 3, - name: "secretsManagerSeats", + translationKey: "secretsManagerSeats", cost: 30, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6, }, }, diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts index aed23c54a30..581e363ab24 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts @@ -71,7 +71,7 @@ export default { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, @@ -98,12 +98,12 @@ export const WithAdditionalStorage: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, @@ -120,7 +120,7 @@ export const PasswordManagerYearlyCadence: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 500.0, }, }, @@ -137,14 +137,14 @@ export const SecretsManagerSeatsOnly: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, }, @@ -161,19 +161,19 @@ export const SecretsManagerSeatsAndServiceAccounts: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6.0, }, }, @@ -190,24 +190,24 @@ export const AllProducts: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6.0, }, }, @@ -223,7 +223,7 @@ export const FamiliesPlan: Story = { passwordManager: { seats: { quantity: 1, - name: "familiesMembership", + translationKey: "familiesMembership", cost: 40.0, }, }, @@ -239,7 +239,7 @@ export const PremiumPlan: Story = { passwordManager: { seats: { quantity: 1, - name: "premiumMembership", + translationKey: "premiumMembership", cost: 10.0, }, }, @@ -255,7 +255,7 @@ export const CustomHeaderTemplate: Story = { passwordManager: { seats: { quantity: 1, - name: "premiumMembership", + translationKey: "premiumMembership", cost: 10.0, }, }, @@ -296,19 +296,18 @@ export const WithPercentDiscount: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, cadence: "monthly", discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, estimatedTax: 10.4, @@ -322,21 +321,20 @@ export const WithAmountDiscount: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, }, cadence: "annually", discount: { type: DiscountTypes.AmountOff, - active: true, value: 50.0, }, estimatedTax: 95.0, diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.ts index b92a465169c..ef35f0ded33 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.ts @@ -116,7 +116,7 @@ export class CartSummaryComponent { */ readonly discountAmount = computed(() => { const { discount } = this.cart(); - if (!discount || !discount.active) { + if (!discount) { return 0; } @@ -136,7 +136,7 @@ export class CartSummaryComponent { */ readonly discountLabel = computed(() => { const { discount } = this.cart(); - if (!discount || !discount.active) { + if (!discount) { return ""; } return getLabel(this.i18nService, discount); diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx index f9b9ba85619..8988f79ea07 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx @@ -38,8 +38,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing"; type Discount = { /** The type of discount */ type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff - /** Whether the discount is currently active */ - active: boolean; /** The discount value (percentage or amount depending on type) */ value: number; }; @@ -47,8 +45,7 @@ type Discount = { ## Behavior -- The badge is only displayed when `discount` is provided, `active` is `true`, and `value` is - greater than 0. +- The badge is only displayed when `discount` is provided and `value` is greater than 0. - For `percent-off` type: percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1 (e.g., `0.2` for 20%). - For `amount-off` type: amount values are formatted as currency (USD) with 2 decimal places. @@ -62,7 +59,3 @@ type Discount = { ### Amount Discount - -### Inactive Discount - - diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts index 6f8e7ab9e74..540ae48adb4 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts @@ -35,30 +35,18 @@ describe("DiscountBadgeComponent", () => { expect(component.display()).toBe(false); }); - it("should return false when discount is inactive", () => { + it("should return true when discount has percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: false, - value: 20, - }); - fixture.detectChanges(); - expect(component.display()).toBe(false); - }); - - it("should return true when discount is active with percent-off", () => { - fixture.componentRef.setInput("discount", { - type: DiscountTypes.PercentOff, - active: true, value: 20, }); fixture.detectChanges(); expect(component.display()).toBe(true); }); - it("should return true when discount is active with amount-off", () => { + it("should return true when discount has amount-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 10.99, }); fixture.detectChanges(); @@ -68,7 +56,6 @@ describe("DiscountBadgeComponent", () => { it("should return false when value is 0 (percent-off)", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 0, }); fixture.detectChanges(); @@ -78,7 +65,6 @@ describe("DiscountBadgeComponent", () => { it("should return false when value is 0 (amount-off)", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 0, }); fixture.detectChanges(); @@ -96,7 +82,6 @@ describe("DiscountBadgeComponent", () => { it("should return percentage text when type is percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 20, }); fixture.detectChanges(); @@ -108,7 +93,6 @@ describe("DiscountBadgeComponent", () => { it("should convert decimal value to percentage for percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 0.15, }); fixture.detectChanges(); @@ -119,7 +103,6 @@ describe("DiscountBadgeComponent", () => { it("should return amount text when type is amount-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 10.99, }); fixture.detectChanges(); diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts index 1d2d15e84c5..610e7b815a8 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts @@ -40,7 +40,6 @@ export const PercentDiscount: Story = { args: { discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, } as Discount, }, @@ -54,7 +53,6 @@ export const PercentDiscountDecimal: Story = { args: { discount: { type: DiscountTypes.PercentOff, - active: true, value: 0.15, // 15% in decimal format } as Discount, }, @@ -68,7 +66,6 @@ export const AmountDiscount: Story = { args: { discount: { type: DiscountTypes.AmountOff, - active: true, value: 10.99, } as Discount, }, @@ -82,26 +79,11 @@ export const LargeAmountDiscount: Story = { args: { discount: { type: DiscountTypes.AmountOff, - active: true, value: 99.99, } as Discount, }, }; -export const InactiveDiscount: Story = { - render: (args) => ({ - props: args, - template: ``, - }), - args: { - discount: { - type: DiscountTypes.PercentOff, - active: false, - value: 20, - } as Discount, - }, -}; - export const NoDiscount: Story = { render: (args) => ({ props: args, diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.ts index 17204be85ff..8937ea274d4 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.ts @@ -23,7 +23,7 @@ export class DiscountBadgeComponent { if (!discount) { return false; } - return discount.active && discount.value > 0; + return discount.value > 0; }); readonly label = computed>(() => { diff --git a/libs/pricing/src/types/cart.ts b/libs/pricing/src/types/cart.ts index d27a867b785..ed5108edee8 100644 --- a/libs/pricing/src/types/cart.ts +++ b/libs/pricing/src/types/cart.ts @@ -1,7 +1,7 @@ import { Discount } from "@bitwarden/pricing"; export type CartItem = { - name: string; + translationKey: string; quantity: number; cost: number; discount?: Discount; diff --git a/libs/pricing/src/types/discount.ts b/libs/pricing/src/types/discount.ts index c12998ef609..afea56fce0a 100644 --- a/libs/pricing/src/types/discount.ts +++ b/libs/pricing/src/types/discount.ts @@ -9,7 +9,6 @@ export type DiscountType = (typeof DiscountTypes)[keyof typeof DiscountTypes]; export type Discount = { type: DiscountType; - active: boolean; value: number; }; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.html b/libs/subscription/src/components/additional-options-card/additional-options-card.component.html index 851ae32ddb3..c4d3d291b26 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.html +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.html @@ -13,8 +13,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('download-license')" + [disabled]="downloadLicenseDisabled()" + (click)="callToActionClicked.emit(actions.DownloadLicense)" > {{ "downloadLicense" | i18n }} @@ -22,8 +22,8 @@ bitButton buttonType="danger" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('cancel-subscription')" + [disabled]="cancelSubscriptionDisabled()" + (click)="callToActionClicked.emit(actions.CancelSubscription)" > {{ "cancelSubscription" | i18n }} diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx index 4519d19a530..3162e740cb0 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx @@ -21,6 +21,8 @@ subscription actions. - [Examples](#examples) - [Default](#default) - [Actions Disabled](#actions-disabled) + - [Download License Disabled](#download-license-disabled) + - [Cancel Subscription Disabled](#cancel-subscription-disabled) - [Features](#features) - [Do's and Don'ts](#dos-and-donts) - [Accessibility](#accessibility) @@ -44,9 +46,10 @@ import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; ### Inputs -| Input | Type | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------- | -| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | +| Input | Type | Description | +| ---------------------------- | --------- | ----------------------------------------------------------------------------- | +| `downloadLicenseDisabled` | `boolean` | Optional. Disables download license button when true. Defaults to `false`. | +| `cancelSubscriptionDisabled` | `boolean` | Optional. Disables cancel subscription button when true. Defaults to `false`. | ### Outputs @@ -109,14 +112,46 @@ Component with action buttons disabled (useful during async operations): ```html ``` -**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like -downloading the license or processing subscription cancellation. +**Note:** Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` independently to control +button states during async operations like downloading the license or processing subscription +cancellation. + +### Download License Disabled + +Component with only the download license button disabled: + + + +```html + + +``` + +### Cancel Subscription Disabled + +Component with only the cancel subscription button disabled: + + + +```html + + +``` ## Features @@ -133,9 +168,11 @@ downloading the license or processing subscription cancellation. - Handle both `download-license` and `cancel-subscription` events in parent components - Show appropriate confirmation dialogs before executing destructive actions (cancel subscription) -- Disable buttons or show loading states during async operations +- Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` to control button states during + operations - Provide clear user feedback after action completion - Consider adding additional safety measures for subscription cancellation +- Control button states independently based on business logic ### ❌ Don't diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts index 345de037fd3..3346c287beb 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts @@ -66,9 +66,32 @@ describe("AdditionalOptionsCardComponent", () => { }); }); - describe("callsToActionDisabled", () => { - it("should disable both buttons when callsToActionDisabled is true", () => { - fixture.componentRef.setInput("callsToActionDisabled", true); + describe("button disabled states", () => { + it("should enable both buttons by default", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should disable download license button when downloadLicenseDisabled is true", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable cancel subscription button when cancelSubscriptionDisabled is true", () => { + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable both buttons independently", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -76,18 +99,23 @@ describe("AdditionalOptionsCardComponent", () => { expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons when callsToActionDisabled is false", () => { - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should allow download enabled while cancel disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", false); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); expect(buttons[0].nativeElement.disabled).toBe(false); - expect(buttons[1].nativeElement.disabled).toBe(false); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons by default", () => { + it("should allow cancel enabled while download disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", false); + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); - expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); expect(buttons[1].nativeElement.disabled).toBe(false); }); }); diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts index 66c151f536f..7dd7a5375fe 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts @@ -44,6 +44,23 @@ export const Default: Story = { export const ActionsDisabled: Story = { name: "Actions Disabled", args: { - callsToActionDisabled: true, + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: true, + }, +}; + +export const DownloadLicenseDisabled: Story = { + name: "Download License Disabled", + args: { + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: false, + }, +}; + +export const CancelSubscriptionDisabled: Story = { + name: "Cancel Subscription Disabled", + args: { + downloadLicenseDisabled: false, + cancelSubscriptionDisabled: true, }, }; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts index a962a167ec6..6c633a43d93 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts @@ -3,7 +3,13 @@ import { Component, ChangeDetectionStrategy, output, input } from "@angular/core import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -export type AdditionalOptionsCardAction = "download-license" | "cancel-subscription"; +export const AdditionalOptionsCardActions = { + DownloadLicense: "download-license", + CancelSubscription: "cancel-subscription", +} as const; + +export type AdditionalOptionsCardAction = + (typeof AdditionalOptionsCardActions)[keyof typeof AdditionalOptionsCardActions]; @Component({ selector: "billing-additional-options-card", @@ -12,6 +18,10 @@ export type AdditionalOptionsCardAction = "download-license" | "cancel-subscript imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], }) export class AdditionalOptionsCardComponent { - readonly callsToActionDisabled = input(false); + readonly downloadLicenseDisabled = input(false); + readonly cancelSubscriptionDisabled = input(false); + readonly callToActionClicked = output(); + + protected readonly actions = AdditionalOptionsCardActions; } diff --git a/libs/subscription/src/components/storage-card/storage-card.component.html b/libs/subscription/src/components/storage-card/storage-card.component.html index c11f1917176..f8ac4b18604 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.html +++ b/libs/subscription/src/components/storage-card/storage-card.component.html @@ -21,8 +21,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('add-storage')" + [disabled]="addStorageDisabled()" + (click)="callToActionClicked.emit(actions.AddStorage)" > {{ "addStorage" | i18n }} @@ -30,8 +30,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled() || !canRemoveStorage()" - (click)="callToActionClicked.emit('remove-storage')" + [disabled]="removeStorageDisabled()" + (click)="callToActionClicked.emit(actions.RemoveStorage)" > {{ "removeStorage" | i18n }} diff --git a/libs/subscription/src/components/storage-card/storage-card.component.mdx b/libs/subscription/src/components/storage-card/storage-card.component.mdx index 43215cb863c..7e06fa23553 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.mdx +++ b/libs/subscription/src/components/storage-card/storage-card.component.mdx @@ -30,6 +30,8 @@ full). - [Large Storage Pool (1TB)](#large-storage-pool-1tb) - [Small Storage Pool (1GB)](#small-storage-pool-1gb) - [Actions Disabled](#actions-disabled) + - [Add Storage Disabled](#add-storage-disabled) + - [Remove Storage Disabled](#remove-storage-disabled) - [Features](#features) - [Do's and Don'ts](#dos-and-donts) - [Accessibility](#accessibility) @@ -53,10 +55,11 @@ import { StorageCardComponent, Storage } from "@bitwarden/subscription"; ### Inputs -| Input | Type | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------- | -| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | -| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | +| Input | Type | Description | +| ----------------------- | --------- | ------------------------------------------------------------------------ | +| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | +| `addStorageDisabled` | `boolean` | Optional. Disables add storage button when true. Defaults to `false`. | +| `removeStorageDisabled` | `boolean` | Optional. Disables remove storage button when true. Defaults to `false`. | ### Outputs @@ -93,7 +96,8 @@ The component automatically adapts its appearance based on storage usage: Key behaviors: - Progress bar color changes from blue (primary) to red (danger) when full -- Remove storage button is disabled when storage is full +- Button disabled states are controlled independently via `addStorageDisabled` and + `removeStorageDisabled` inputs - Title changes to "Storage full" when at capacity - Description provides context-specific messaging @@ -123,7 +127,7 @@ Storage with no files uploaded: [storage]="{ available: 5, used: 0, - readableUsed: '0 GB' + readableUsed: '0 GB', }" (callToActionClicked)="handleAction($event)" > @@ -141,7 +145,7 @@ Storage with partial usage (50%): [storage]="{ available: 5, used: 2.5, - readableUsed: '2.5 GB' + readableUsed: '2.5 GB', }" (callToActionClicked)="handleAction($event)" > @@ -159,15 +163,15 @@ Storage at full capacity with disabled remove button: [storage]="{ available: 5, used: 5, - readableUsed: '5 GB' + readableUsed: '5 GB', }" (callToActionClicked)="handleAction($event)" > ``` -**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns -red. +**Note:** When storage is full, the progress bar turns red. Button disabled states are controlled +independently via the `addStorageDisabled` and `removeStorageDisabled` inputs. ### Low Usage (10%) @@ -180,7 +184,7 @@ Minimal storage usage: [storage]="{ available: 5, used: 0.5, - readableUsed: '500 MB' + readableUsed: '500 MB', }" (callToActionClicked)="handleAction($event)" > @@ -198,7 +202,7 @@ Substantial storage usage: [storage]="{ available: 5, used: 3.75, - readableUsed: '3.75 GB' + readableUsed: '3.75 GB', }" (callToActionClicked)="handleAction($event)" > @@ -216,7 +220,7 @@ Storage approaching capacity: [storage]="{ available: 5, used: 4.75, - readableUsed: '4.75 GB' + readableUsed: '4.75 GB', }" (callToActionClicked)="handleAction($event)" > @@ -234,7 +238,7 @@ Enterprise-level storage allocation: [storage]="{ available: 1000, used: 734, - readableUsed: '734 GB' + readableUsed: '734 GB', }" (callToActionClicked)="handleAction($event)" > @@ -252,7 +256,7 @@ Minimal storage allocation: [storage]="{ available: 1, used: 0.8, - readableUsed: '800 MB' + readableUsed: '800 MB', }" (callToActionClicked)="handleAction($event)" > @@ -270,16 +274,57 @@ Storage card with action buttons disabled (useful during async operations): [storage]="{ available: 5, used: 2.5, - readableUsed: '2.5 GB' + readableUsed: '2.5 GB', }" - [callsToActionDisabled]="true" + [addStorageDisabled]="true" + [removeStorageDisabled]="true" (callToActionClicked)="handleAction($event)" > ``` -**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like -adding or removing storage. +**Note:** Use `addStorageDisabled` and `removeStorageDisabled` independently to control button +states during async operations like adding or removing storage. + +### Add Storage Disabled + +Storage card with only the add button disabled: + + + +```html + + +``` + +### Remove Storage Disabled + +Storage card with only the remove button disabled: + + + +```html + + +``` ## Features @@ -304,13 +349,14 @@ adding or removing storage. - Use human-readable format strings (e.g., "2.5 GB", "500 MB") for `readableUsed` - Keep `used` value less than or equal to `available` under normal circumstances - Update storage data in real-time when user adds or removes storage -- Disable UI interactions when storage operations are in progress +- Use `addStorageDisabled` and `removeStorageDisabled` to control button states during operations - Show loading states during async storage operations +- Control button states independently based on business logic ### ❌ Don't -- Omit the `readableUsed` field - it's required for display -- Use inconsistent units between `available` and `used` (both should be in GB) +- Omit the `readableUsed` field - it's required +- Use inconsistent units between `available` and `used` (all should be in GB) - Allow negative values for storage amounts - Ignore the `callToActionClicked` events - they require handling - Display inaccurate or stale storage information diff --git a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts index ae0d7ad9dcb..fe2223f1449 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts @@ -163,18 +163,6 @@ describe("StorageCardComponent", () => { }); }); - describe("canRemoveStorage", () => { - it("should return true when storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); - expect(component.canRemoveStorage()).toBe(true); - }); - - it("should return false when storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); - expect(component.canRemoveStorage()).toBe(false); - }); - }); - describe("button rendering", () => { it("should render both buttons", () => { setupComponent(baseStorage); @@ -182,25 +170,46 @@ describe("StorageCardComponent", () => { expect(buttons.length).toBe(2); }); - it("should enable remove button when storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + it("should enable add button by default", () => { + setupComponent(baseStorage); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0].nativeElement; + expect(addButton.disabled).toBe(false); + }); + + it("should disable add button when addStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0]; + expect(addButton.attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable remove button by default", () => { + setupComponent(baseStorage); const buttons = fixture.debugElement.queryAll(By.css("button")); const removeButton = buttons[1].nativeElement; expect(removeButton.disabled).toBe(false); }); - it("should disable remove button when storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + it("should disable remove button when removeStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("removeStorageDisabled", true); + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); const removeButton = buttons[1]; expect(removeButton.attributes["aria-disabled"]).toBe("true"); }); }); - describe("callsToActionDisabled", () => { - it("should disable both buttons when callsToActionDisabled is true", () => { + describe("independent button disabled states", () => { + it("should disable both buttons independently", () => { setupComponent(baseStorage); - fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -208,9 +217,10 @@ describe("StorageCardComponent", () => { expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons when callsToActionDisabled is false and storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should enable both buttons when both disabled inputs are false", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", false); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -218,15 +228,27 @@ describe("StorageCardComponent", () => { expect(buttons[1].nativeElement.disabled).toBe(false); }); - it("should keep remove button disabled when callsToActionDisabled is false but storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should allow add button enabled while remove button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); expect(buttons[0].nativeElement.disabled).toBe(false); expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); + + it("should allow remove button enabled while add button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); }); describe("button click events", () => { @@ -243,7 +265,7 @@ describe("StorageCardComponent", () => { }); it("should emit remove-storage action when remove button is clicked", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + setupComponent(baseStorage); const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); diff --git a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts index 8c2070e59f9..2afbaf0d0b1 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts @@ -143,6 +143,33 @@ export const ActionsDisabled: Story = { used: 2.5, readableUsed: "2.5 GB", } satisfies Storage, - callsToActionDisabled: true, + addStorageDisabled: true, + removeStorageDisabled: true, + }, +}; + +export const AddStorageDisabled: Story = { + name: "Add Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: true, + removeStorageDisabled: false, + }, +}; + +export const RemoveStorageDisabled: Story = { + name: "Remove Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: false, + removeStorageDisabled: true, }, }; diff --git a/libs/subscription/src/components/storage-card/storage-card.component.ts b/libs/subscription/src/components/storage-card/storage-card.component.ts index 988f4a0ec60..483649434ff 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.ts @@ -12,7 +12,12 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { Storage } from "../../types/storage"; -export type StorageCardAction = "add-storage" | "remove-storage"; +export const StorageCardActions = { + AddStorage: "add-storage", + RemoveStorage: "remove-storage", +} as const; + +export type StorageCardAction = (typeof StorageCardActions)[keyof typeof StorageCardActions]; @Component({ selector: "billing-storage-card", @@ -25,7 +30,8 @@ export class StorageCardComponent { readonly storage = input.required(); - readonly callsToActionDisabled = input(false); + readonly addStorageDisabled = input(false); + readonly removeStorageDisabled = input(false); readonly callToActionClicked = output(); @@ -64,5 +70,5 @@ export class StorageCardComponent { return this.isFull() ? "danger" : "primary"; }); - readonly canRemoveStorage = computed(() => !this.isFull()); + protected readonly actions = StorageCardActions; } diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx index 0f605f0f05e..c9cc6df7263 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx @@ -67,14 +67,14 @@ import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/sub ### Outputs -| Output | Type | Description | -| --------------------- | ---------------- | ---------------------------------------------------------- | -| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout | +| Output | Type | Description | +| --------------------- | ------------------------ | ---------------------------------------------------------- | +| `callToActionClicked` | `SubscriptionCardAction` | Emitted when a user clicks an action button in the callout | -**PlanCardAction Type:** +**SubscriptionCardAction Type:** ```typescript -type PlanCardAction = +type SubscriptionCardAction = | "contact-support" | "manage-invoices" | "reinstate-subscription" diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts index 3485f2a493a..cdb85360c74 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts @@ -14,7 +14,7 @@ describe("SubscriptionCardComponent", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, }, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts index abe5789382b..32976c89cc2 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts @@ -103,7 +103,7 @@ export const Active: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -131,7 +131,7 @@ export const ActiveWithUpgrade: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -157,7 +157,7 @@ export const Trial: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -185,7 +185,7 @@ export const TrialWithUpgrade: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -212,7 +212,7 @@ export const Incomplete: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -239,7 +239,7 @@ export const IncompleteExpired: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -266,7 +266,7 @@ export const PastDue: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -293,7 +293,7 @@ export const PendingCancellation: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -320,7 +320,7 @@ export const Unpaid: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -346,7 +346,7 @@ export const Canceled: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -372,31 +372,30 @@ export const Enterprise: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 7, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 0.5, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 13, }, additionalServiceAccounts: { quantity: 5, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 1, }, }, discount: { type: DiscountTypes.PercentOff, - active: true, - value: 0.25, + value: 25, }, cadence: "monthly", estimatedTax: 6.4, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.ts index f52127a0104..ebfb41df6c2 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.ts @@ -16,12 +16,16 @@ import { CartSummaryComponent, Maybe } from "@bitwarden/pricing"; import { BitwardenSubscription, SubscriptionStatuses } from "@bitwarden/subscription"; import { I18nPipe } from "@bitwarden/ui-common"; -export type PlanCardAction = - | "contact-support" - | "manage-invoices" - | "reinstate-subscription" - | "update-payment" - | "upgrade-plan"; +export const SubscriptionCardActions = { + ContactSupport: "contact-support", + ManageInvoices: "manage-invoices", + ReinstateSubscription: "reinstate-subscription", + UpdatePayment: "update-payment", + UpgradePlan: "upgrade-plan", +} as const; + +export type SubscriptionCardAction = + (typeof SubscriptionCardActions)[keyof typeof SubscriptionCardActions]; type Badge = { text: string; variant: BadgeVariant }; @@ -33,7 +37,7 @@ type Callout = Maybe<{ callsToAction?: { text: string; buttonType: ButtonType; - action: PlanCardAction; + action: SubscriptionCardAction; }[]; }>; @@ -64,7 +68,7 @@ export class SubscriptionCardComponent { readonly showUpgradeButton = input(false); - readonly callToActionClicked = output(); + readonly callToActionClicked = output(); readonly badge = computed(() => { const subscription = this.subscription(); @@ -136,12 +140,12 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("updatePayment"), buttonType: "unstyled", - action: "update-payment", + action: SubscriptionCardActions.UpdatePayment, }, { text: this.i18nService.t("contactSupportShort"), buttonType: "unstyled", - action: "contact-support", + action: SubscriptionCardActions.ContactSupport, }, ], }; @@ -155,7 +159,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("contactSupportShort"), buttonType: "unstyled", - action: "contact-support", + action: SubscriptionCardActions.ContactSupport, }, ], }; @@ -172,7 +176,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("reinstateSubscription"), buttonType: "unstyled", - action: "reinstate-subscription", + action: SubscriptionCardActions.ReinstateSubscription, }, ], }; @@ -189,7 +193,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("upgradeNow"), buttonType: "unstyled", - action: "upgrade-plan", + action: SubscriptionCardActions.UpgradePlan, }, ], }; @@ -208,7 +212,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("manageInvoices"), buttonType: "unstyled", - action: "manage-invoices", + action: SubscriptionCardActions.ManageInvoices, }, ], }; @@ -225,7 +229,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("manageInvoices"), buttonType: "unstyled", - action: "manage-invoices", + action: SubscriptionCardActions.ManageInvoices, }, ], }; diff --git a/libs/subscription/src/types/bitwarden-subscription.ts b/libs/subscription/src/types/bitwarden-subscription.ts index 15bf64d03aa..5c43ed20590 100644 --- a/libs/subscription/src/types/bitwarden-subscription.ts +++ b/libs/subscription/src/types/bitwarden-subscription.ts @@ -12,6 +12,8 @@ export const SubscriptionStatuses = { Unpaid: "unpaid", } as const; +export type SubscriptionStatus = (typeof SubscriptionStatuses)[keyof typeof SubscriptionStatuses]; + type HasCart = { cart: Cart; }; diff --git a/libs/subscription/src/types/storage.ts b/libs/subscription/src/types/storage.ts index beb187250dd..35df54cb4f2 100644 --- a/libs/subscription/src/types/storage.ts +++ b/libs/subscription/src/types/storage.ts @@ -1,3 +1,5 @@ +export const MAX_STORAGE_GB = 100; + export type Storage = { available: number; readableUsed: string; diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index 33dde9ae51a..7d28d857403 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -525,6 +525,20 @@ describe("VaultExportService", () => { const exportedData = actual as ExportedVaultAsString; expectEqualFolders(UserFolders, exportedData.data); }); + + it("does not export the key property in unencrypted exports", async () => { + // Create a cipher with a key property + const cipherWithKey = generateCipherView(false); + (cipherWithKey as any).key = "shouldBeDeleted"; + cipherService.getAllDecrypted.mockResolvedValue([cipherWithKey]); + + const actual = await exportService.getExport(userId, "json"); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + const parsed = JSON.parse(exportedData.data); + expect(parsed.items.length).toBe(1); + expect(parsed.items[0].key).toBeUndefined(); + }); }); export class FolderResponse { diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index ddda96b21e0..b30f14872ca 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -317,6 +317,7 @@ export class IndividualVaultExportService const cipher = new CipherWithIdExport(); cipher.build(c); cipher.collectionIds = null; + delete cipher.key; jsonDoc.items.push(cipher); }); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index ed3a16516f2..8d5178e0e0c 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -383,6 +383,7 @@ export class OrganizationVaultExportService decCiphers.forEach((c) => { const cipher = new CipherWithIdExport(); cipher.build(c); + delete cipher.key; jsonDoc.items.push(cipher); }); return JSON.stringify(jsonDoc, null, " "); diff --git a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts index 38257df603a..15b50a3809c 100644 --- a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts @@ -6,9 +6,9 @@ import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DIALOG_DATA, DialogRef, @@ -44,8 +44,10 @@ export const SendItemDialogResult = Object.freeze({ } as const); /** A result of the Send add/edit dialog. */ -export type SendItemDialogResult = (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult]; - +export type SendItemDialogResult = { + result: (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult]; + send?: SendView; +}; /** * Component for adding or editing a send item. */ @@ -93,7 +95,7 @@ export class SendAddEditDialogComponent { */ async onSendCreated(send: SendView) { // FIXME Add dialogService.open send-created dialog - this.dialogRef.close(SendItemDialogResult.Saved); + this.dialogRef.close({ result: SendItemDialogResult.Saved, send }); return; } @@ -101,14 +103,14 @@ export class SendAddEditDialogComponent { * Handles the event when the send is updated. */ async onSendUpdated(send: SendView) { - this.dialogRef.close(SendItemDialogResult.Saved); + this.dialogRef.close({ result: SendItemDialogResult.Saved }); } /** * Handles the event when the send is deleted. */ async onSendDeleted() { - this.dialogRef.close(SendItemDialogResult.Deleted); + this.dialogRef.close({ result: SendItemDialogResult.Deleted }); this.toastService.showToast({ variant: "success", diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts index 8f8390a170c..acdb7b56c2b 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts @@ -5,7 +5,7 @@ import { BehaviorSubject, of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { NewSendDropdownV2Component } from "./new-send-dropdown-v2.component"; diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts index 7e7c4a2005b..f586373de70 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts @@ -6,7 +6,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts index e1474175267..b5cbeced209 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts @@ -7,7 +7,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; diff --git a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts index 0859986664a..4f30860b6a6 100644 --- a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts @@ -1,5 +1,5 @@ -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; /** diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts index 6724bb324c3..fa069b92ed2 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts @@ -6,9 +6,9 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DialogService, ToastService } from "@bitwarden/components"; import { CredentialGeneratorService } from "@bitwarden/generator-core"; diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index ec351bee923..e2b50eafc99 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -9,8 +9,8 @@ import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SectionComponent, SectionHeaderComponent, diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts index 4e4900039c7..7b00f17cc9c 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts @@ -4,9 +4,9 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, Validators, ReactiveFormsModule, FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, FormFieldModule, diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index 0471ed90eef..53a9365bf99 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -18,8 +18,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { AsyncActionsModule, BitSubmitDirective, diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts index 343fa880795..9178991a028 100644 --- a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts @@ -7,8 +7,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts index d885f279bc6..63f4b97105a 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts @@ -10,9 +10,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, ButtonModule, diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.html b/libs/tools/send/send-ui/src/send-table/send-table.component.html index 96b9519019e..cc2fca2c41c 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.html +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.html @@ -33,14 +33,16 @@ > {{ "disabled" | i18n }} } - @if (s.password) { + @if (s.authType !== authType.None) { + @let titleKey = + s.authType === authType.Email ? "emailProtected" : "passwordProtected"; - {{ "password" | i18n }} + {{ titleKey | i18n }} } @if (s.maxAccessCountReached) { = {}): SendView send.id = `send-${id}`; send.name = "My Send"; send.type = SendType.Text; + send.authType = AuthType.None; send.deletionDate = new Date("2030-01-01T12:00:00Z"); send.password = null as any; @@ -34,21 +36,29 @@ dataSource.data = [ createMockSend(2, { name: "Password Protected Send", type: SendType.Text, + authType: AuthType.Password, password: "123", }), createMockSend(3, { + name: "Email Protected Send", + type: SendType.Text, + authType: AuthType.Email, + emails: ["ckent@dailyplanet.com"], + }), + createMockSend(4, { name: "Disabled Send", type: SendType.Text, disabled: true, }), - createMockSend(4, { + createMockSend(5, { name: "Expired Send", type: SendType.File, expirationDate: new Date("2025-12-01T00:00:00Z"), }), - createMockSend(5, { + createMockSend(6, { name: "Max Access Reached", type: SendType.Text, + authType: AuthType.Password, maxAccessCount: 5, accessCount: 5, password: "123", @@ -69,7 +79,8 @@ export default { deletionDate: "Deletion Date", options: "Options", disabled: "Disabled", - password: "Password", + passwordProtected: "Password protected", + emailProtected: "Email protected", maxAccessCountReached: "Max access count reached", expired: "Expired", pendingDeletion: "Pending deletion", diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.ts index c912a01f98a..1475d9c65d1 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.ts @@ -2,8 +2,9 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, ButtonModule, @@ -37,6 +38,7 @@ import { }) export class SendTableComponent { protected readonly sendType = SendType; + protected readonly authType = AuthType; /** * The data source containing the Send items to display in the table. diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts index ef38938aba8..096ae95ad66 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts @@ -4,8 +4,8 @@ import { BehaviorSubject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendListFiltersService } from "./send-list-filters.service"; diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts index b266ad08a69..cf84204ba0d 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts @@ -5,8 +5,8 @@ import { FormBuilder } from "@angular/forms"; import { map, Observable, startWith } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ChipSelectOption } from "@bitwarden/components"; diff --git a/libs/vault/src/services/archive-cipher-utilities.service.spec.ts b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts index 76a4073325e..5df1bff9a56 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.spec.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts @@ -5,6 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -24,6 +25,7 @@ describe("ArchiveCipherUtilitiesService", () => { const mockCipher = new CipherView(); mockCipher.id = "cipher-id" as CipherId; const mockUserId = "user-id"; + const mockCipherData = { id: mockCipher.id } as CipherData; beforeEach(() => { cipherArchiveService = mock(); @@ -37,8 +39,8 @@ describe("ArchiveCipherUtilitiesService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true); - cipherArchiveService.archiveWithServer.mockResolvedValue(undefined); - cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined); + cipherArchiveService.archiveWithServer.mockResolvedValue(mockCipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue(mockCipherData); i18nService.t.mockImplementation((key) => key); service = new ArchiveCipherUtilitiesService( diff --git a/libs/vault/src/services/archive-cipher-utilities.service.ts b/libs/vault/src/services/archive-cipher-utilities.service.ts index bbe7dba6715..5d3c5c33236 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.ts @@ -25,11 +25,18 @@ export class ArchiveCipherUtilitiesService { private accountService: AccountService, ) {} - /** Archive a cipher, with confirmation dialog and password reprompt checks. */ - async archiveCipher(cipher: CipherView) { - const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); - if (!repromptPassed) { - return; + /** Archive a cipher, with confirmation dialog and password reprompt checks. + * + * @param cipher The cipher to archive + * @param skipReprompt Whether to skip the password reprompt check + * @returns The archived CipherData on success, or undefined on failure or cancellation + */ + async archiveCipher(cipher: CipherView, skipReprompt = false) { + if (!skipReprompt) { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); + if (!repromptPassed) { + return; + } } const confirmed = await this.dialogService.openSimpleDialog({ @@ -43,38 +50,47 @@ export class ArchiveCipherUtilitiesService { } const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherArchiveService - .archiveWithServer(cipher.id as CipherId, userId) - .then(() => { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("itemWasSentToArchive"), - }); - }) - .catch(() => { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("errorOccurred"), - }); + try { + const cipherResponse = await this.cipherArchiveService.archiveWithServer( + cipher.id as CipherId, + userId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasSentToArchive"), }); + return cipherResponse; + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } } - /** Unarchives a cipher */ + /** Unarchives a cipher + * @param cipher The cipher to unarchive + * @returns The unarchived cipher on success, or undefined on failure + */ async unarchiveCipher(cipher: CipherView) { const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherArchiveService - .unarchiveWithServer(cipher.id as CipherId, userId) - .then(() => { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("itemWasUnarchived"), - }); - }) - .catch(() => { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("errorOccurred"), - }); + try { + const cipherResponse = await this.cipherArchiveService.unarchiveWithServer( + cipher.id as CipherId, + userId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasUnarchived"), }); + return cipherResponse; + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } } } diff --git a/package-lock.json b/package-lock.json index eec3487b6d4..32d5abebb91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "big-integer": "1.6.52", "braintree-web-drop-in": "1.46.0", "buffer": "6.0.3", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "chalk": "4.1.2", "commander": "14.0.0", "core-js": "3.47.0", @@ -19321,9 +19321,9 @@ "license": "MIT" }, "node_modules/bufferutil": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", - "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1cfddb16c42..7aba2035dce 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "big-integer": "1.6.52", "braintree-web-drop-in": "1.46.0", "buffer": "6.0.3", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "chalk": "4.1.2", "commander": "14.0.0", "core-js": "3.47.0",