diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml
index f706bba610d..12748a47748 100644
--- a/.github/workflows/build-web.yml
+++ b/.github/workflows/build-web.yml
@@ -82,7 +82,7 @@ jobs:
build-containers:
- name: Build artifacts and container images
+ name: "Build [${{matrix.artifact_name}}], image tag: [${{matrix.image_name}}]"
runs-on: ubuntu-24.04
permissions:
security-events: write
@@ -158,6 +158,17 @@ jobs:
mv package.json.tmp package.json
########## Set up Docker ##########
+ - name: Set up Docker
+ uses: docker/setup-docker-action@b60f85385d03ac8acfca6d9996982511d8620a19 # v4.3.0
+ with:
+ daemon-config: |
+ {
+ "debug": true,
+ "features": {
+ "containerd-snapshotter": true
+ }
+ }
+
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
@@ -175,20 +186,6 @@ jobs:
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
run: az acr login -n ${_AZ_REGISTRY%.azurecr.io}
- - name: Login to Azure - CI Subscription
- if: ${{ needs.setup.outputs.has_secrets == 'true' }}
- uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- with:
- creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
-
- - name: Retrieve github PAT secrets
- if: ${{ needs.setup.outputs.has_secrets == 'true' }}
- id: retrieve-secret-pat
- uses: bitwarden/gh-actions/get-keyvault-secrets@main
- with:
- keyvault: "bitwarden-ci"
- secrets: "github-pat-bitwarden-devops-bot-repo-scope"
-
########## Generate image tag and build Docker image ##########
- name: Generate container image tag
id: tag
@@ -220,7 +217,6 @@ jobs:
run: echo "name=$_AZ_REGISTRY/${PROJECT_NAME}:${IMAGE_TAG}" >> $GITHUB_OUTPUT
- name: Build Docker image
- if: ${{ needs.setup.outputs.has_secrets == 'true' }}
id: build-container
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
with:
@@ -229,13 +225,20 @@ jobs:
NPM_COMMAND=${{ matrix.npm_command }}
context: .
file: apps/web/Dockerfile
+ load: true
platforms: |
linux/amd64,
linux/arm/v7,
linux/arm64
- push: true
+ push: false
tags: ${{ steps.image-name.outputs.name }}
+ - name: Push images
+ if: ${{ needs.setup.outputs.has_secrets == 'true' }}
+ env:
+ IMAGE_NAME: ${{ steps.image-name.outputs.name }}
+ run: docker push $IMAGE_NAME
+
- name: Zip project
working-directory: apps/web
env:
diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json
index 55a9dbca20a..0a8884951bb 100644
--- a/apps/browser/src/_locales/ar/messages.json
+++ b/apps/browser/src/_locales/ar/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json
index a6d885898a2..358a7596f56 100644
--- a/apps/browser/src/_locales/az/messages.json
+++ b/apps/browser/src/_locales/az/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Riskli parolları dəyişdir"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json
index fcf9786a210..ab2889ec99a 100644
--- a/apps/browser/src/_locales/be/messages.json
+++ b/apps/browser/src/_locales/be/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json
index bdba04b0f46..347fba7e93f 100644
--- a/apps/browser/src/_locales/bg/messages.json
+++ b/apps/browser/src/_locales/bg/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Промяна на парола в риск"
+ },
+ "introCarouselLabel": {
+ "message": "Добре дошли в Битуорден"
+ },
+ "securityPrioritized": {
+ "message": "Защитата е от първостепенна важност"
+ },
+ "securityPrioritizedBody": {
+ "message": "Съхранявайте данни за вписване, карти и самоличности в своя защитен трезор. Битуорден използва шифроване от край до край за да пази това, което е важно за Вас."
+ },
+ "quickLogin": {
+ "message": "Бързо и лесно вписване"
+ },
+ "quickLoginBody": {
+ "message": "Настройте отключването чрез биометрични данни и автоматичното попълване, за да се вписвате в регистрациите си без да натискате и един клавиш."
+ },
+ "secureUser": {
+ "message": "Подобрете данните си за вписване"
+ },
+ "secureUserBody": {
+ "message": "Използвайте генератора, за да създавате и запазвате сложни, уникални пароли за всичките си регистрации."
+ },
+ "secureDevices": {
+ "message": "Вашите данни – когато и където Ви потрябват"
+ },
+ "secureDevicesBody": {
+ "message": "Съхранявайте неограничен брой пароли на множество устройства – с приложенията на Битуорден за мобилни телефони, браузър и компютър."
}
}
diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json
index e7ac4f6b729..0806f936677 100644
--- a/apps/browser/src/_locales/bn/messages.json
+++ b/apps/browser/src/_locales/bn/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json
index e277f2cee8f..3d17255e801 100644
--- a/apps/browser/src/_locales/bs/messages.json
+++ b/apps/browser/src/_locales/bs/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json
index a6b3820971f..59631bc1670 100644
--- a/apps/browser/src/_locales/ca/messages.json
+++ b/apps/browser/src/_locales/ca/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json
index 7f03e3f0598..be338aee089 100644
--- a/apps/browser/src/_locales/cs/messages.json
+++ b/apps/browser/src/_locales/cs/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Změnit ohrožené heslo"
+ },
+ "introCarouselLabel": {
+ "message": "Vítejte v Bitwardenu"
+ },
+ "securityPrioritized": {
+ "message": "Bezpečnost, priorita"
+ },
+ "securityPrioritizedBody": {
+ "message": "Uložte si přihlašovací údaje, karty a identity do zabezpečeného trezoru. Bitwarden používá šifrování end-to-end s nulovou znalostí, aby ochránil to, co je pro Vás důležité."
+ },
+ "quickLogin": {
+ "message": "Rychlé a snadné přihlášení"
+ },
+ "quickLoginBody": {
+ "message": "Nastavte biometrické odemknutí a automatické vyplňování pro přihlášení k Vašim účtům bez zadání jediného písmene."
+ },
+ "secureUser": {
+ "message": "Zvýšit úroveň přihlašování"
+ },
+ "secureUserBody": {
+ "message": "Použijte generátor k vytvoření a uložení silných, jedinečných hesel pro všechny Vaše účty."
+ },
+ "secureDevices": {
+ "message": "Vaše data, kdy a kde je potřebujete"
+ },
+ "secureDevicesBody": {
+ "message": "Uložte neomezená hesla na neomezených zařízeních s Bitwardenem na mobilu, prohlížeči a desktopové aplikaci."
}
}
diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json
index 3a59bca3f71..c6a59456c46 100644
--- a/apps/browser/src/_locales/cy/messages.json
+++ b/apps/browser/src/_locales/cy/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json
index 55e68031f03..55d6cad20e4 100644
--- a/apps/browser/src/_locales/da/messages.json
+++ b/apps/browser/src/_locales/da/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json
index ba129609937..fa77ca964ad 100644
--- a/apps/browser/src/_locales/de/messages.json
+++ b/apps/browser/src/_locales/de/messages.json
@@ -1150,7 +1150,7 @@
"description": "Default URI match detection for autofill."
},
"defaultUriMatchDetectionDesc": {
- "message": "Wähle die Standardmethode, mit der die URI-Match-Erkennung für Anmeldungen bei Aktionen wie dem automatischen Ausfüllen behandelt wird."
+ "message": "Wähle die Standardmethode, mit der die URI-Übereinstimmungserkennung für Anmeldungen bei Aktionen wie dem automatischen Ausfüllen behandelt wird."
},
"theme": {
"message": "Design"
@@ -1584,7 +1584,7 @@
"message": "Erfahre mehr über Auto-Ausfüllen"
},
"defaultAutoFillOnPageLoad": {
- "message": "Standard Auto-Ausfüllen Einstellung für Login-Einträge"
+ "message": "Standard Auto-Ausfüllen Einstellung für Zugangsdaten-Einträge"
},
"defaultAutoFillOnPageLoadDesc": {
"message": "Du kannst Auto-Ausfüllen beim Laden der Seite für einzelne Zugangsdaten-Einträge in der Bearbeiten-Ansicht des Eintrags deaktivieren."
@@ -1965,7 +1965,7 @@
"description": "URI match detection for autofill."
},
"defaultMatchDetection": {
- "message": "Standard-Match-Erkennung",
+ "message": "Standard Übereinstimmungserkennung",
"description": "Default URI match detection for autofill."
},
"toggleOptions": {
@@ -2505,14 +2505,14 @@
"description": "Description of the review at-risk login slide on the at-risk password page carousel"
},
"reviewAtRiskLoginSlideImgAltPeriod": {
- "message": "Illustration of a list of logins that are at-risk."
+ "message": "Illustration einer Liste gefährdeter Zugangsdaten."
},
"generatePasswordSlideDesc": {
"message": "Generiere schnell ein starkes, einzigartiges Passwort mit dem Bitwarden Auto-Ausfüllen-Menü auf der gefährdeten Website.",
"description": "Description of the generate password slide on the at-risk password page carousel"
},
"generatePasswordSlideImgAltPeriod": {
- "message": "Illustration of the Bitwarden autofill menu displaying a generated password."
+ "message": "Illustration des Bitwarden Auto-Ausfüllen-Menüs, das ein generiertes Passwort anzeigt."
},
"updateInBitwarden": {
"message": "In Bitwarden aktualisieren"
@@ -2522,7 +2522,7 @@
"description": "Description of the update in Bitwarden slide on the at-risk password page carousel"
},
"updateInBitwardenSlideImgAltPeriod": {
- "message": "Illustration of a Bitwarden’s notification prompting the user to update the login."
+ "message": "Illustration einer Bitwarden-Benachrichtigung, die den Benutzer dazu auffordert, die Zugangsdaten zu aktualisieren."
},
"turnOnAutofill": {
"message": "Auto-Ausfüllen aktivieren"
@@ -2978,7 +2978,7 @@
}
},
"exportingIndividualVaultWithAttachmentsDescription": {
- "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included",
+ "message": "Nur die einzelnen Tresor-Einträge einschließlich Anhängen von $EMAIL$ werden exportiert. Tresor-Einträge der Organisation werden nicht berücksichtigt",
"placeholders": {
"email": {
"content": "$1",
@@ -4244,7 +4244,7 @@
}
},
"viewItemTitleWithField": {
- "message": "View item - $ITEMNAME$ - $FIELD$",
+ "message": "Eintrag ansehen - $ITEMNAME$ - $FIELD$",
"description": "Title for a link that opens a view for an item.",
"placeholders": {
"itemname": {
@@ -4268,7 +4268,7 @@
}
},
"autofillTitleWithField": {
- "message": "Autofill - $ITEMNAME$ - $FIELD$",
+ "message": "Auto-Ausfüllen - $ITEMNAME$ - $FIELD$",
"description": "Title for a button that autofills a login item.",
"placeholders": {
"itemname": {
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Gefährdetes Passwort ändern"
+ },
+ "introCarouselLabel": {
+ "message": "Willkommen bei Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Sicherheit, priorisiert"
+ },
+ "securityPrioritizedBody": {
+ "message": "Speichere Zugangsdaten, Karten und Identitäten in deinem sicheren Tresor. Bitwarden verwendet Zero-Knowledge und Ende-zu-Ende-Verschlüsselung, um das zu schützen, was für dich wichtig ist."
+ },
+ "quickLogin": {
+ "message": "Schnelle und einfache Anmeldung"
+ },
+ "quickLoginBody": {
+ "message": "Richte biometrisches Entsperren und Auto-Ausfüllen ein, um dich ohne einen einzigen Buchstaben einzugeben bei deinen Konten anzumelden."
+ },
+ "secureUser": {
+ "message": "Optimiere deine Zugangsdaten"
+ },
+ "secureUserBody": {
+ "message": "Verwende den Generator, um einzigartige Passwörter für alle deine Konten zu erstellen und zu speichern."
+ },
+ "secureDevices": {
+ "message": "Deine Daten, wann und wo du sie brauchst"
+ },
+ "secureDevicesBody": {
+ "message": "Speicher eine unbegrenzte Anzahl von Passwörter auf unbegrenzt vielen Geräten mit Bitwarden-Apps für Smartphones, Browser und Desktop."
}
}
diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json
index a4564b3705e..f1e6c6cb6e1 100644
--- a/apps/browser/src/_locales/el/messages.json
+++ b/apps/browser/src/_locales/el/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json
index f19382347b0..321e17eaa75 100644
--- a/apps/browser/src/_locales/en_GB/messages.json
+++ b/apps/browser/src/_locales/en_GB/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritised"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json
index eb86d90a74e..2432a120295 100644
--- a/apps/browser/src/_locales/en_IN/messages.json
+++ b/apps/browser/src/_locales/en_IN/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritised"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json
index 11d21e74d0f..55379670548 100644
--- a/apps/browser/src/_locales/es/messages.json
+++ b/apps/browser/src/_locales/es/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json
index 8576ee1db7e..9a1b4037b8f 100644
--- a/apps/browser/src/_locales/et/messages.json
+++ b/apps/browser/src/_locales/et/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json
index 542c9a0d03f..2d04203bc01 100644
--- a/apps/browser/src/_locales/eu/messages.json
+++ b/apps/browser/src/_locales/eu/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json
index 91acd3c7081..e7d5f58e49c 100644
--- a/apps/browser/src/_locales/fa/messages.json
+++ b/apps/browser/src/_locales/fa/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json
index 0875639868e..e108f2ceab6 100644
--- a/apps/browser/src/_locales/fi/messages.json
+++ b/apps/browser/src/_locales/fi/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Vaihda vaarantunut salasana"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json
index 939137c36ce..e288bd2195a 100644
--- a/apps/browser/src/_locales/fil/messages.json
+++ b/apps/browser/src/_locales/fil/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json
index 488cec4066d..254c18556f7 100644
--- a/apps/browser/src/_locales/fr/messages.json
+++ b/apps/browser/src/_locales/fr/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Changer le mot de passe à risque"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json
index 23a29400e25..efed6c4bbd4 100644
--- a/apps/browser/src/_locales/gl/messages.json
+++ b/apps/browser/src/_locales/gl/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json
index bff75506ad0..7350c865e5e 100644
--- a/apps/browser/src/_locales/he/messages.json
+++ b/apps/browser/src/_locales/he/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "שנה סיסמה בסיכון"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json
index 215ea3e9cd4..1815ab4118b 100644
--- a/apps/browser/src/_locales/hi/messages.json
+++ b/apps/browser/src/_locales/hi/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json
index 1549c5c793a..5c6cf17f02c 100644
--- a/apps/browser/src/_locales/hr/messages.json
+++ b/apps/browser/src/_locales/hr/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Promijeni rizičnu lozinku"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json
index 92745a09c3d..50f02f2bc75 100644
--- a/apps/browser/src/_locales/hu/messages.json
+++ b/apps/browser/src/_locales/hu/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Kockázatos jelszó megváltoztatása"
+ },
+ "introCarouselLabel": {
+ "message": "Üdvözlet a Bitwardenben"
+ },
+ "securityPrioritized": {
+ "message": "Biztonság, prioritásos"
+ },
+ "securityPrioritizedBody": {
+ "message": "Mentsük el a bejelentkezési adatokat, kártyákat és azonosításokat a biztonságos széfbe. A Bitwarden tudás nélküli, végpontok közötti titkosítást használ a felhasználók számára fontos dolgok védelmére."
+ },
+ "quickLogin": {
+ "message": "Gyors és könnyű bejelentkezés"
+ },
+ "quickLoginBody": {
+ "message": "Állítsuk be a biometrikus feloldást és az automatikus kitöltést, hogy egyetlen betű beírása nélkül jelentkezzünk be a fiókokba."
+ },
+ "secureUser": {
+ "message": "A bejelentkezések magasabb szintre emelése"
+ },
+ "secureUserBody": {
+ "message": "Használjuk a generátort, hogy erős, egyedi jelszavakat hozzunk létre és mentsük az összes fióknál."
+ },
+ "secureDevices": {
+ "message": "Saját adatok, mikor és hol van rá szükség"
+ },
+ "secureDevicesBody": {
+ "message": "Mentsünk el a korlátlan jelszót korlátlan számú eszközön a Bitwarden mobil, böngésző és asztali alkalmazásokkal."
}
}
diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json
index b9218126dbb..91a4447b9b2 100644
--- a/apps/browser/src/_locales/id/messages.json
+++ b/apps/browser/src/_locales/id/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Ubah kata sandi yang berrisiko"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json
index cd7daff61f5..c3ecb237732 100644
--- a/apps/browser/src/_locales/it/messages.json
+++ b/apps/browser/src/_locales/it/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Cambia parola d'accesso a rischio"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json
index c49688b9e7a..9bacc66bce3 100644
--- a/apps/browser/src/_locales/ja/messages.json
+++ b/apps/browser/src/_locales/ja/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "危険なパスワードの変更"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json
index 71032cbedf9..8ad1efeb4a2 100644
--- a/apps/browser/src/_locales/ka/messages.json
+++ b/apps/browser/src/_locales/ka/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json
index 19e11bd4579..c43d83029c9 100644
--- a/apps/browser/src/_locales/km/messages.json
+++ b/apps/browser/src/_locales/km/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json
index dc82624e0df..8415a9fbf3f 100644
--- a/apps/browser/src/_locales/kn/messages.json
+++ b/apps/browser/src/_locales/kn/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json
index 990f43d5d7d..00d3c7e31ac 100644
--- a/apps/browser/src/_locales/ko/messages.json
+++ b/apps/browser/src/_locales/ko/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json
index fb010417cee..53dc8a85743 100644
--- a/apps/browser/src/_locales/lt/messages.json
+++ b/apps/browser/src/_locales/lt/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json
index 85e08f44da5..d9438c91bcb 100644
--- a/apps/browser/src/_locales/lv/messages.json
+++ b/apps/browser/src/_locales/lv/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json
index 8a580999709..a1447331124 100644
--- a/apps/browser/src/_locales/ml/messages.json
+++ b/apps/browser/src/_locales/ml/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json
index c794ed94a86..8832e30fa0c 100644
--- a/apps/browser/src/_locales/mr/messages.json
+++ b/apps/browser/src/_locales/mr/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json
index 19e11bd4579..c43d83029c9 100644
--- a/apps/browser/src/_locales/my/messages.json
+++ b/apps/browser/src/_locales/my/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json
index 4eac03f91d6..303f9fc8487 100644
--- a/apps/browser/src/_locales/nb/messages.json
+++ b/apps/browser/src/_locales/nb/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json
index 19e11bd4579..c43d83029c9 100644
--- a/apps/browser/src/_locales/ne/messages.json
+++ b/apps/browser/src/_locales/ne/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json
index 129dd8e4b7b..2f1ecad557f 100644
--- a/apps/browser/src/_locales/nl/messages.json
+++ b/apps/browser/src/_locales/nl/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Risicovol wachtwoord wijzigen"
+ },
+ "introCarouselLabel": {
+ "message": "Welkom bij Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, geprioriteerd"
+ },
+ "securityPrioritizedBody": {
+ "message": "Logins, kaarten en identiteiten in je beveiligde kluis opslaan. Bitwarden gebruikt zero-knowledge, end-to-end versleuteling om te beschermen wat belangrijk voor jou is."
+ },
+ "quickLogin": {
+ "message": "Snel en eenvoudig inloggen"
+ },
+ "quickLoginBody": {
+ "message": "Biometrische ontgrendelen en automatisch invullen instellen zodat je kunt inloggen op je accounts zonder één letter te typen."
+ },
+ "secureUser": {
+ "message": "Versterk je logins"
+ },
+ "secureUserBody": {
+ "message": "Gebruik de generator voor het aanmaken en bewaren van sterke, unieke wachtwoorden voor al je accounts."
+ },
+ "secureDevices": {
+ "message": "Jouw gegevens, wanneer en waar je ze nodig hebt"
+ },
+ "secureDevicesBody": {
+ "message": "Onbeperkt wachtwoorden opslaan op alle apparaten met Bitwarden-apps voor mobiel, browser en desktop."
}
}
diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json
index 19e11bd4579..c43d83029c9 100644
--- a/apps/browser/src/_locales/nn/messages.json
+++ b/apps/browser/src/_locales/nn/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json
index 19e11bd4579..c43d83029c9 100644
--- a/apps/browser/src/_locales/or/messages.json
+++ b/apps/browser/src/_locales/or/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json
index 1657aea6f32..13587a74e52 100644
--- a/apps/browser/src/_locales/pl/messages.json
+++ b/apps/browser/src/_locales/pl/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Zmień hasło zagrożone"
+ },
+ "introCarouselLabel": {
+ "message": "Witaj w Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Bezpieczeństwo priorytetem"
+ },
+ "securityPrioritizedBody": {
+ "message": "Zapisz dane logowania, karty i tożsamości w bezpiecznym sejfie. Bitwarden stosuje szyfrowanie end-to-end z wiedzą zerową, aby chronić to, co jest dla Ciebie ważne."
+ },
+ "quickLogin": {
+ "message": "Szybkie i łatwe logowanie"
+ },
+ "quickLoginBody": {
+ "message": "Skonfiguruj odblokowanie i autouzupełnianie biometryczne, aby zalogować się na swoje konta bez wpisywania pojedynczej litery."
+ },
+ "secureUser": {
+ "message": "Ulepsz swoje loginy"
+ },
+ "secureUserBody": {
+ "message": "Użyj generatora do tworzenia i zapisywania silnych, unikalnych haseł dla wszystkich kont."
+ },
+ "secureDevices": {
+ "message": "Twoje dane, kiedy i gdzie potrzebujesz"
+ },
+ "secureDevicesBody": {
+ "message": "Zapisuj nieograniczoną liczbę haseł na nieograniczonej liczbie urządzeń dzięki aplikacjom Bitwarden na urządzenia mobilne, przeglądarki i komputery stacjonarne."
}
}
diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json
index 0961f0f596c..3ffceca3611 100644
--- a/apps/browser/src/_locales/pt_BR/messages.json
+++ b/apps/browser/src/_locales/pt_BR/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Alterar senhas vulneráveis"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json
index bc836cbd03e..a4e0a0de541 100644
--- a/apps/browser/src/_locales/pt_PT/messages.json
+++ b/apps/browser/src/_locales/pt_PT/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Alterar palavra-passe em risco"
+ },
+ "introCarouselLabel": {
+ "message": "Bem-vindo ao Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Segurança, com prioridade"
+ },
+ "securityPrioritizedBody": {
+ "message": "Guarde credenciais, cartões e identidades no seu cofre seguro. O Bitwarden utiliza encriptação de ponto a ponto e conhecimento zero para proteger o que é importante para si."
+ },
+ "quickLogin": {
+ "message": "Início de sessão rápido e fácil"
+ },
+ "quickLoginBody": {
+ "message": "Configure o desbloqueio biométrico e o preenchimento automático para iniciar sessão nas suas contas sem escrever uma única letra."
+ },
+ "secureUser": {
+ "message": "Eleve o nível das suas credenciais"
+ },
+ "secureUserBody": {
+ "message": "Utilize o gerador para criar e guardar palavras-passe fortes e únicas para todas as suas contas."
+ },
+ "secureDevices": {
+ "message": "Os seus dados, quando e onde precisar"
+ },
+ "secureDevicesBody": {
+ "message": "Guarde palavras-passe ilimitadas em dispositivos ilimitados com as apps Bitwarden para telemóvel, navegador e computador."
}
}
diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json
index 6d6fc724ec9..9ce305ff74c 100644
--- a/apps/browser/src/_locales/ro/messages.json
+++ b/apps/browser/src/_locales/ro/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json
index 03ce360c68f..3847f3eb4fc 100644
--- a/apps/browser/src/_locales/ru/messages.json
+++ b/apps/browser/src/_locales/ru/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Изменить пароль, подверженный риску"
+ },
+ "introCarouselLabel": {
+ "message": "Добро пожаловать в Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Безопасность, приоритет"
+ },
+ "securityPrioritizedBody": {
+ "message": "Сохраняйте логины, карты и личные данные в своем защищенном хранилище. Bitwarden использует сквозное шифрование, чтобы защитить то, что для вас важно."
+ },
+ "quickLogin": {
+ "message": "Быстрая и простая авторизация"
+ },
+ "quickLoginBody": {
+ "message": "Настройте биометрическую разблокировку и автозаполнение, чтобы входить в свои аккаунты, не набирая ни одной буквы."
+ },
+ "secureUser": {
+ "message": "Сделайте авторизацию еще проще"
+ },
+ "secureUserBody": {
+ "message": "Используйте генератор для создания и сохранения надежных, уникальных паролей для всех ваших аккаунтов."
+ },
+ "secureDevices": {
+ "message": "Ваши данные, в любое время в любом месте"
+ },
+ "secureDevicesBody": {
+ "message": "Сохраняйте неограниченное количество паролей на неограниченном количестве устройств с помощью мобильных, браузерных и десктопных приложений Bitwarden."
}
}
diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json
index d44ec8188de..db54d7f7c18 100644
--- a/apps/browser/src/_locales/si/messages.json
+++ b/apps/browser/src/_locales/si/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json
index 96b042bf7eb..555e01fec20 100644
--- a/apps/browser/src/_locales/sk/messages.json
+++ b/apps/browser/src/_locales/sk/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Zmeniť rizikové heslá"
+ },
+ "introCarouselLabel": {
+ "message": "Vitajte v Bitwardene"
+ },
+ "securityPrioritized": {
+ "message": "Bezpečnosť na prvom mieste"
+ },
+ "securityPrioritizedBody": {
+ "message": "Ukladajte prihlasovacie údaje, karty a identity do zabezpečeného trezoru. Bitwarden používa šifrovanie s nulovou znalosťou od začiatku do konca na ochranu toho, čo je pre vás dôležité."
+ },
+ "quickLogin": {
+ "message": "Rýchle a jednoduché prihlásenie"
+ },
+ "quickLoginBody": {
+ "message": "Nastavte si odomykanie biometrickými údajmi a automatické vypĺňanie na prihlasovanie do účtov bez zadávania jediného písmena."
+ },
+ "secureUser": {
+ "message": "Posuňte prihlasovanie na vyššiu úroveň"
+ },
+ "secureUserBody": {
+ "message": "Pomocou generátora vytvorte a uložte silné a jedinečné heslá pre všetky svoje účty."
+ },
+ "secureDevices": {
+ "message": "Vaše údaje, kedykoľvek a kdekoľvek ich potrebujete"
+ },
+ "secureDevicesBody": {
+ "message": "Ukladajte neobmedzený počet hesiel na neobmedzenom počte zariadení pomocou mobilných aplikácií, prehliadačov a desktopových aplikácií Bitwardenu."
}
}
diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json
index 704a9983d80..c7dc9590fe0 100644
--- a/apps/browser/src/_locales/sl/messages.json
+++ b/apps/browser/src/_locales/sl/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json
index 9bc9d122d75..2fdbad437c9 100644
--- a/apps/browser/src/_locales/sr/messages.json
+++ b/apps/browser/src/_locales/sr/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Променити ризичну лозинку"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json
index 83a6f0a49b6..15de82320b8 100644
--- a/apps/browser/src/_locales/sv/messages.json
+++ b/apps/browser/src/_locales/sv/messages.json
@@ -194,7 +194,7 @@
"description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible."
},
"autoFill": {
- "message": "Fyll i automatiskt"
+ "message": "Autofyll"
},
"autoFillLogin": {
"message": "Autofyll inloggning"
@@ -1530,10 +1530,10 @@
"description": "Represents the message for allowing the user to enable the autofill overlay"
},
"autofillSuggestionsSectionTitle": {
- "message": "Autofill suggestions"
+ "message": "Förslag för autofyll"
},
"showInlineMenuLabel": {
- "message": "Show autofill suggestions on form fields"
+ "message": "Visa förslag för autofyll i formulärfält"
},
"showInlineMenuIdentitiesLabel": {
"message": "Visa identiteter som förslag"
@@ -1566,7 +1566,7 @@
"description": "Overlay appearance select option for showing the field on click of the overlay icon"
},
"enableAutoFillOnPageLoadSectionTitle": {
- "message": "Autofill on page load"
+ "message": "Autofyll vid sidladdning"
},
"enableAutoFillOnPageLoad": {
"message": "Aktivera automatisk ifyllnad vid sidhämtning"
@@ -2428,7 +2428,7 @@
"message": "Autofill is blocked for this website."
},
"autofillBlockedNoticeGuidance": {
- "message": "Change this in settings"
+ "message": "Ändra detta i inställningar"
},
"change": {
"message": "Change"
@@ -2525,10 +2525,10 @@
"message": "Illustration of a Bitwarden’s notification prompting the user to update the login."
},
"turnOnAutofill": {
- "message": "Turn on autofill"
+ "message": "Aktivera autofyll"
},
"turnedOnAutofill": {
- "message": "Turned on autofill"
+ "message": "Aktiverade autofyll"
},
"dismiss": {
"message": "Dismiss"
@@ -3384,7 +3384,7 @@
"message": "Inställningar för automatisk ifyllnad"
},
"autofillKeyboardShortcutSectionTitle": {
- "message": "Autofill shortcut"
+ "message": "Kortkommando för autofyll"
},
"autofillKeyboardShortcutUpdateLabel": {
"message": "Ändra genväg"
@@ -3396,7 +3396,7 @@
"message": "Tangentbordsgenväg för automatisk ifyllnad"
},
"autofillLoginShortcutNotSet": {
- "message": "The autofill login shortcut is not set. Change this in the browser's settings."
+ "message": "Kortkommandot för autofyll av inloggning är inte inställt. Du kan ändra det i webbläsarens inställningar."
},
"autofillLoginShortcutText": {
"message": "The autofill login shortcut is $COMMAND$. Manage all shortcuts in the browser's settings.",
@@ -4365,7 +4365,7 @@
"message": "Objekt utan mapp"
},
"itemDetails": {
- "message": "Item details"
+ "message": "Objektdetaljer"
},
"itemName": {
"message": "Objektnamn"
@@ -4456,13 +4456,13 @@
}
},
"personalDetails": {
- "message": "Personal details"
+ "message": "Personuppgifter"
},
"identification": {
"message": "Identifikation"
},
"contactInfo": {
- "message": "Contact info"
+ "message": "Kontaktuppgifter"
},
"downloadAttachment": {
"message": "Download - $ITEMNAME$",
@@ -4478,13 +4478,13 @@
"description": "Used within the inline menu to provide an aria description when users are attempting to fill a card cipher."
},
"loginCredentials": {
- "message": "Login credentials"
+ "message": "Inloggningsuppgifter"
},
"authenticatorKey": {
"message": "Autentiseringsnyckel"
},
"autofillOptions": {
- "message": "Autofill options"
+ "message": "Alternativ för autofyll"
},
"websiteUri": {
"message": "Webbplats (URI)"
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json
index 19e11bd4579..c43d83029c9 100644
--- a/apps/browser/src/_locales/te/messages.json
+++ b/apps/browser/src/_locales/te/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json
index 6942485618d..8ac6e7197d8 100644
--- a/apps/browser/src/_locales/th/messages.json
+++ b/apps/browser/src/_locales/th/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json
index 6388b3d04a3..a19d4977ed5 100644
--- a/apps/browser/src/_locales/tr/messages.json
+++ b/apps/browser/src/_locales/tr/messages.json
@@ -881,7 +881,7 @@
"message": "Duo two-step login is required for your account. Follow the steps below to finish logging in."
},
"followTheStepsBelowToFinishLoggingIn": {
- "message": "Follow the steps below to finish logging in."
+ "message": "Girişi tamamlamak için aşağıdaki adımları izleyin."
},
"restartRegistration": {
"message": "Kaydı yeniden başlat"
@@ -1407,14 +1407,14 @@
}
},
"dontAskAgainOnThisDeviceFor30Days": {
- "message": "Don't ask again on this device for 30 days"
+ "message": "Bu cihazda 30 gün boyunca sorma"
},
"selectAnotherMethod": {
- "message": "Select another method",
+ "message": "Başka bir yöntem seç",
"description": "Select another two-step login method"
},
"useYourRecoveryCode": {
- "message": "Use your recovery code"
+ "message": "Kurtarma kodu kullan"
},
"insertU2f": {
"message": "Güvenlik anahtarınızı bilgisayarınızın USB portuna takın. Düğmesi varsa dokunun."
@@ -1426,7 +1426,7 @@
"message": "WebAutn ile doğrula"
},
"readSecurityKey": {
- "message": "Read security key"
+ "message": "Güvenlik anahtarını oku"
},
"awaitingSecurityKeyInteraction": {
"message": "Awaiting security key interaction..."
@@ -1444,7 +1444,7 @@
"message": "İki aşamalı giriş seçenekleri"
},
"selectTwoStepLoginMethod": {
- "message": "Select two-step login method"
+ "message": "İki aşamalı giriş yöntemini seçin"
},
"recoveryCodeDesc": {
"message": "İki aşamalı doğrulama sağlayıcılarınıza ulaşamıyor musunuz? Kurtarma kodunuzu kullanarak hesabınızdaki tüm iki aşamalı giriş sağlayıcılarını devre dışı bırakabilirsiniz."
@@ -2149,7 +2149,7 @@
"message": "Customize your vault experience with quick copy actions, compact mode, and more!"
},
"newCustomizationOptionsCalloutLink": {
- "message": "View all Appearance settings"
+ "message": "Görünüm ayarlarının hepsini göster"
},
"lock": {
"message": "Kilitle",
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Bitwarden’a hoş geldiniz"
+ },
+ "securityPrioritized": {
+ "message": "Önce güvenlik"
+ },
+ "securityPrioritizedBody": {
+ "message": "Hesaplarınızı, kartlarınızı ve kimliklerinizi güvenli kasanıza kaydedin. Bitwarden'ın sıfır bilgi ispatlı uçtan uca şifrelemesi sizin için önemli olan her şeyi korur."
+ },
+ "quickLogin": {
+ "message": "Hızlı ve kolay giriş"
+ },
+ "quickLoginBody": {
+ "message": "Hesaplarınıza parola yazmadan giriş yapmak için biyometrik kilit açmayı ayarlayabilirsiniz."
+ },
+ "secureUser": {
+ "message": "Hesaplarınızı güçlendirin"
+ },
+ "secureUserBody": {
+ "message": "Hesaplarınız için güçlü ve benzersiz parolalar oluşturmak amacıyla üreteci kullanabilirsiniz."
+ },
+ "secureDevices": {
+ "message": "Verilerinize her zaman, her yerden ulaşın"
+ },
+ "secureDevicesBody": {
+ "message": "Bitwarden mobil, tarayıcı ve masaüstü uygulamalarıyla istediğiniz kadar cihaza istediğiniz kadar parola kaydedebilirsiniz."
}
}
diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json
index 5e7c1bdd660..6558796bfb5 100644
--- a/apps/browser/src/_locales/uk/messages.json
+++ b/apps/browser/src/_locales/uk/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Змінити ризикований пароль"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json
index c0bded59ef9..a2b6756785c 100644
--- a/apps/browser/src/_locales/vi/messages.json
+++ b/apps/browser/src/_locales/vi/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json
index 3683e769914..adbc102c496 100644
--- a/apps/browser/src/_locales/zh_CN/messages.json
+++ b/apps/browser/src/_locales/zh_CN/messages.json
@@ -3306,13 +3306,13 @@
"message": "通知已发送到您的设备。"
},
"notificationSentDevicePart1": {
- "message": "Unlock Bitwarden on your device or on the"
+ "message": "解锁您设备上的 Bitwarden 或通过"
},
"notificationSentDeviceAnchor": {
"message": "网页 App"
},
"notificationSentDevicePart2": {
- "message": "在批准前,请确保指纹短语与下面的相匹配。"
+ "message": "批准前,请确保指纹短语与下面的相匹配。"
},
"aNotificationWasSentToYourDevice": {
"message": "通知已发送到您的设备"
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "更改有风险的密码"
+ },
+ "introCarouselLabel": {
+ "message": "欢迎使用 Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "安全优先"
+ },
+ "securityPrioritizedBody": {
+ "message": "将登录、支付卡和身份保存到您的安全密码库。Bitwarden 使用零知识、端到端的加密来保护您的重要信息。"
+ },
+ "quickLogin": {
+ "message": "快速便捷的登录"
+ },
+ "quickLoginBody": {
+ "message": "设置生物识别解锁和自动填充,无需输入任何字符即可登录您的账户。"
+ },
+ "secureUser": {
+ "message": "提升您的登录体验"
+ },
+ "secureUserBody": {
+ "message": "使用生成器创建并保存强大且唯一的密码,以保护您的所有账户。"
+ },
+ "secureDevices": {
+ "message": "随时随地在您需要时获取您的数据"
+ },
+ "secureDevicesBody": {
+ "message": "使用 Bitwarden 移动端、浏览器和桌面 App 在无限制的设备上保存无限数量的密码。"
}
}
diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json
index adefe506790..d5d55725444 100644
--- a/apps/browser/src/_locales/zh_TW/messages.json
+++ b/apps/browser/src/_locales/zh_TW/messages.json
@@ -5140,5 +5140,32 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
+ },
+ "introCarouselLabel": {
+ "message": "Welcome to Bitwarden"
+ },
+ "securityPrioritized": {
+ "message": "Security, prioritized"
+ },
+ "securityPrioritizedBody": {
+ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you."
+ },
+ "quickLogin": {
+ "message": "Quick and easy login"
+ },
+ "quickLoginBody": {
+ "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter."
+ },
+ "secureUser": {
+ "message": "Level up your logins"
+ },
+ "secureUserBody": {
+ "message": "Use the generator to create and save strong, unique passwords for all your accounts."
+ },
+ "secureDevices": {
+ "message": "Your data, when and where you need it"
+ },
+ "secureDevicesBody": {
+ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps."
}
}
diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts
index ffce7827358..663fc863f5e 100644
--- a/apps/browser/src/background/main.background.ts
+++ b/apps/browser/src/background/main.background.ts
@@ -200,6 +200,7 @@ import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
+import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import {
legacyPasswordGenerationServiceFactory,
legacyUsernameGenerationServiceFactory,
@@ -400,6 +401,7 @@ export default class MainBackground {
sdkLoadService: SdkLoadService;
cipherAuthorizationService: CipherAuthorizationService;
inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
+ taskService: TaskService;
onUpdatedRan: boolean;
onReplacedRan: boolean;
@@ -1296,6 +1298,16 @@ export default class MainBackground {
this.configService,
);
+ this.taskService = new DefaultTaskService(
+ this.stateProvider,
+ this.apiService,
+ this.organizationService,
+ this.configService,
+ this.authService,
+ this.notificationsService,
+ messageListener,
+ );
+
this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
}
@@ -1377,6 +1389,11 @@ export default class MainBackground {
await this.fullSync(false);
this.backgroundSyncService.init();
this.notificationsService.startListening();
+
+ if (await this.configService.getFeatureFlag(FeatureFlag.SecurityTasks)) {
+ this.taskService.listenForTaskNotifications();
+ }
+
resolve();
}, 500);
});
diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts
index b030ff46270..0e6922e3083 100644
--- a/apps/browser/src/platform/services/local-backed-session-storage.service.ts
+++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts
@@ -90,7 +90,6 @@ export class LocalBackedSessionStorageService
this.logService.warning(
`Possible unnecessary write to local session storage. Key: ${key}`,
);
- this.logService.warning(obj as any);
}
} catch (err) {
this.logService.warning(`Error while comparing values for key: ${key}`);
diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts
index eb5cd459111..fa4137d9849 100644
--- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts
+++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts
@@ -1,16 +1,14 @@
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { RouterModule } from "@angular/router";
-import { map, switchMap } from "rxjs";
+import { map, of, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
-import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
-// TODO: This component will need to be reworked to use the new EndUserNotificationService in PM-10609
-
@Component({
selector: "vault-at-risk-password-callout",
standalone: true,
@@ -19,15 +17,24 @@ import { I18nPipe } from "@bitwarden/ui-common";
})
export class AtRiskPasswordCalloutComponent {
private taskService = inject(TaskService);
- private activeAccount$ = inject(AccountService).activeAccount$.pipe(filterOutNullish());
+ private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId);
protected pendingTasks$ = this.activeAccount$.pipe(
- switchMap((user) =>
- this.taskService
- .pendingTasks$(user.id)
- .pipe(
- map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)),
- ),
+ switchMap((userId) =>
+ this.taskService.tasksEnabled$(userId).pipe(
+ switchMap((enabled) => {
+ if (!enabled) {
+ return of([]);
+ }
+ return this.taskService
+ .pendingTasks$(userId)
+ .pipe(
+ map((tasks) =>
+ tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential),
+ ),
+ );
+ }),
+ ),
),
);
}
diff --git a/apps/desktop/resources/entitlements.mas.plist b/apps/desktop/resources/entitlements.mas.plist
index bb06ae10431..f3bc20011ff 100644
--- a/apps/desktop/resources/entitlements.mas.plist
+++ b/apps/desktop/resources/entitlements.mas.plist
@@ -35,6 +35,7 @@
/Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/
/Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/
/Library/Application Support/Vivaldi/NativeMessagingHosts/
+ /Library/Application Support/Zen/NativeMessagingHosts/
com.apple.security.cs.allow-jit
diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json
index 0433e68d761..1aedecedc5c 100644
--- a/apps/desktop/src/locales/de/messages.json
+++ b/apps/desktop/src/locales/de/messages.json
@@ -2467,7 +2467,7 @@
}
},
"exportingIndividualVaultWithAttachmentsDescription": {
- "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included",
+ "message": "Nur die einzelnen Tresor-Einträge einschließlich Anhängen von $EMAIL$ werden exportiert. Tresor-Einträge der Organisation werden nicht berücksichtigt",
"placeholders": {
"email": {
"content": "$1",
@@ -3563,6 +3563,6 @@
"message": "Gefährdetes Passwort ändern"
},
"move": {
- "message": "Move"
+ "message": "Verschieben"
}
}
diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json
index 168618ca6f3..189f3648984 100644
--- a/apps/desktop/src/locales/el/messages.json
+++ b/apps/desktop/src/locales/el/messages.json
@@ -649,7 +649,7 @@
"message": "Σύνδεση στο Bitwarden"
},
"enterTheCodeSentToYourEmail": {
- "message": "Enter the code sent to your email"
+ "message": "Εισάγετε τον κωδικό που στάλθηκε στο email σας"
},
"enterTheCodeFromYourAuthenticatorApp": {
"message": "Enter the code from your authenticator app"
diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json
index 7a168fb4e05..cb6e63e398c 100644
--- a/apps/desktop/src/locales/zh_CN/messages.json
+++ b/apps/desktop/src/locales/zh_CN/messages.json
@@ -2788,13 +2788,13 @@
"message": "通知已发送到您的设备"
},
"notificationSentDevicePart1": {
- "message": "Unlock Bitwarden on your device or on the "
+ "message": "解锁您设备上的 Bitwarden 或通过"
},
"notificationSentDeviceAnchor": {
"message": "网页 App"
},
"notificationSentDevicePart2": {
- "message": "在批准前,请确保指纹短语与下面的相匹配。"
+ "message": "批准前,请确保指纹短语与下面的相匹配。"
},
"needAnotherOptionV1": {
"message": "需要其他选项吗?"
diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts
index 107d546811c..93525164ff5 100644
--- a/apps/desktop/src/main/native-messaging.main.ts
+++ b/apps/desktop/src/main/native-messaging.main.ts
@@ -172,7 +172,7 @@ export class NativeMessagingMain {
const p = path.join(value, "NativeMessagingHosts", "com.8bit.bitwarden.json");
let manifest: any = chromeJson;
- if (key === "Firefox") {
+ if (key === "Firefox" || key === "Zen") {
manifest = firefoxJson;
}
@@ -313,6 +313,7 @@ export class NativeMessagingMain {
"Microsoft Edge Dev": `${this.homedir()}/Library/Application\ Support/Microsoft\ Edge\ Dev/`,
"Microsoft Edge Canary": `${this.homedir()}/Library/Application\ Support/Microsoft\ Edge\ Canary/`,
Vivaldi: `${this.homedir()}/Library/Application\ Support/Vivaldi/`,
+ Zen: `${this.homedir()}/Library/Application\ Support/Zen/`,
};
/* eslint-enable no-useless-escape */
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 2af524c92b7..1f4ae9c29cf 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
- "version": "2025.3.1",
+ "version": "2025.4.0",
"scripts": {
"build:oss": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
"build:bit": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-web/webpack.config.js",
diff --git a/apps/web/src/app/admin-console/organizations/manage/organization-trust.component.html b/apps/web/src/app/admin-console/organizations/manage/organization-trust.component.html
new file mode 100644
index 00000000000..9a0310fde2a
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/manage/organization-trust.component.html
@@ -0,0 +1,23 @@
+
diff --git a/apps/web/src/app/admin-console/organizations/manage/organization-trust.component.ts b/apps/web/src/app/admin-console/organizations/manage/organization-trust.component.ts
new file mode 100644
index 00000000000..3f013c9fc74
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/manage/organization-trust.component.ts
@@ -0,0 +1,69 @@
+import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
+import { Component, OnInit, Inject } from "@angular/core";
+import { FormBuilder } from "@angular/forms";
+
+import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service";
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import { DialogService } from "@bitwarden/components";
+import { KeyService } from "@bitwarden/key-management";
+
+type OrganizationTrustDialogData = {
+ /** display name of the organization */
+ name: string;
+ /** identifies the organization */
+ orgId: string;
+ /** org public key */
+ publicKey: Uint8Array;
+};
+@Component({
+ selector: "organization-trust",
+ templateUrl: "organization-trust.component.html",
+})
+export class OrganizationTrustComponent implements OnInit {
+ loading = true;
+ fingerprint: string = "";
+ confirmForm = this.formBuilder.group({});
+
+ constructor(
+ @Inject(DIALOG_DATA) protected params: OrganizationTrustDialogData,
+ private formBuilder: FormBuilder,
+ private keyService: KeyService,
+ protected organizationManagementPreferencesService: OrganizationManagementPreferencesService,
+ private logService: LogService,
+ private dialogRef: DialogRef,
+ ) {}
+
+ async ngOnInit() {
+ try {
+ const fingerprint = await this.keyService.getFingerprint(
+ this.params.orgId,
+ this.params.publicKey,
+ );
+ if (fingerprint != null) {
+ this.fingerprint = fingerprint.join("-");
+ }
+ } catch (e) {
+ this.logService.error(e);
+ }
+ this.loading = false;
+ }
+
+ submit = async () => {
+ if (this.loading) {
+ return;
+ }
+
+ this.dialogRef.close(true);
+ };
+
+ /**
+ * Strongly typed helper to open a OrganizationTrustComponent
+ * @param dialogService Instance of the dialog service that will be used to open the dialog
+ * @param data The data to pass to the dialog
+ */
+ static open(dialogService: DialogService, data: OrganizationTrustDialogData) {
+ return dialogService.open(OrganizationTrustComponent, {
+ data,
+ });
+ }
+}
diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password-entry.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password-entry.ts
new file mode 100644
index 00000000000..64dbef574c7
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password-entry.ts
@@ -0,0 +1,11 @@
+export class OrganizationUserResetPasswordEntry {
+ orgId: string;
+ publicKey: Uint8Array;
+ orgName: string;
+
+ constructor(orgId: string, publicKey: Uint8Array, orgName: string) {
+ this.orgId = orgId;
+ this.publicKey = publicKey;
+ this.orgName = orgName;
+ }
+}
diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts
index eff417ead32..ce167950727 100644
--- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts
+++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts
@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { mock, MockProxy } from "jest-mock-extended";
-import { of } from "rxjs";
+import { BehaviorSubject, of } from "rxjs";
import {
OrganizationUserApiService,
@@ -14,6 +14,7 @@ import { OrganizationApiService } from "@bitwarden/common/admin-console/services
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -23,6 +24,9 @@ import { KdfType, KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service";
+const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
+const mockPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
+
describe("OrganizationUserResetPasswordService", () => {
let sut: OrganizationUserResetPasswordService;
@@ -51,6 +55,21 @@ describe("OrganizationUserResetPasswordService", () => {
);
});
+ beforeEach(() => {
+ organizationService.organizations$.mockReturnValue(
+ new BehaviorSubject([
+ createOrganization("1", "org1", true),
+ createOrganization("2", "org2", false),
+ ]),
+ );
+ organizationApiService.getKeys.mockResolvedValue(
+ new OrganizationKeysResponse({
+ privateKey: "privateKey",
+ publicKey: "publicKey",
+ }),
+ );
+ });
+
afterEach(() => {
jest.resetAllMocks();
});
@@ -59,55 +78,47 @@ describe("OrganizationUserResetPasswordService", () => {
expect(sut).toBeTruthy();
});
- describe("getRecoveryKey", () => {
+ describe("buildRecoveryKey", () => {
const mockOrgId = "test-org-id";
beforeEach(() => {
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "test-private-key",
- publicKey: "test-public-key",
+ publicKey: Utils.fromUtf8ToArray("test-public-key"),
}),
);
- const mockRandomBytes = new Uint8Array(64) as CsprngArray;
- const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
- keyService.getUserKey.mockResolvedValue(mockUserKey);
-
encryptService.rsaEncrypt.mockResolvedValue(
new EncString(EncryptionType.Rsa2048_OaepSha1_B64, "mockEncryptedUserKey"),
);
});
it("should return an encrypted user key", async () => {
- const encryptedString = await sut.buildRecoveryKey(mockOrgId);
+ const encryptedString = await sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys);
expect(encryptedString).toBeDefined();
});
- it("should only use the user key from memory if one is not provided", async () => {
- const mockRandomBytes = new Uint8Array(64) as CsprngArray;
- const mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
-
- await sut.buildRecoveryKey(mockOrgId, mockUserKey);
-
- expect(keyService.getUserKey).not.toHaveBeenCalled();
- });
-
it("should throw an error if the organization keys are null", async () => {
organizationApiService.getKeys.mockResolvedValue(null);
- await expect(sut.buildRecoveryKey(mockOrgId)).rejects.toThrow();
+ await expect(sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys)).rejects.toThrow();
});
it("should throw an error if the user key can't be found", async () => {
keyService.getUserKey.mockResolvedValue(null);
- await expect(sut.buildRecoveryKey(mockOrgId)).rejects.toThrow();
+ await expect(sut.buildRecoveryKey(mockOrgId, null, mockPublicKeys)).rejects.toThrow();
});
it("should rsa encrypt the user key", async () => {
- await sut.buildRecoveryKey(mockOrgId);
-
+ await sut.buildRecoveryKey(mockOrgId, mockUserKey, mockPublicKeys);
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(expect.anything(), expect.anything());
});
+
+ it("should throw an error if the public key is not trusted", async () => {
+ await expect(
+ sut.buildRecoveryKey(mockOrgId, mockUserKey, [new Uint8Array(64)]),
+ ).rejects.toThrow();
+ });
});
describe("resetMasterPassword", () => {
@@ -163,6 +174,20 @@ describe("OrganizationUserResetPasswordService", () => {
});
});
+ describe("getPublicKeys", () => {
+ it("should return public keys for organizations that have reset password enrolled", async () => {
+ const result = await sut.getPublicKeys("userId" as UserId);
+ expect(result).toHaveLength(1);
+ });
+
+ it("should result should contain the correct data for the org", async () => {
+ const result = await sut.getPublicKeys("userId" as UserId);
+ expect(result[0].orgId).toBe("1");
+ expect(result[0].orgName).toBe("org1");
+ expect(result[0].publicKey).toEqual(Utils.fromB64ToArray("publicKey"));
+ });
+ });
+
describe("getRotatedData", () => {
beforeEach(() => {
organizationService.organizations$.mockReturnValue(
@@ -171,7 +196,7 @@ describe("OrganizationUserResetPasswordService", () => {
organizationApiService.getKeys.mockResolvedValue(
new OrganizationKeysResponse({
privateKey: "test-private-key",
- publicKey: "test-public-key",
+ publicKey: Utils.fromUtf8ToArray("test-public-key"),
}),
);
encryptService.rsaEncrypt.mockResolvedValue(
@@ -182,7 +207,7 @@ describe("OrganizationUserResetPasswordService", () => {
it("should return all re-encrypted account recovery keys", async () => {
const result = await sut.getRotatedData(
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
- new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
+ mockPublicKeys,
"mockUserId" as UserId,
);
@@ -191,22 +216,18 @@ describe("OrganizationUserResetPasswordService", () => {
it("throws if the new user key is null", async () => {
await expect(
- sut.getRotatedData(
- new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
- null,
- "mockUserId" as UserId,
- ),
+ sut.getRotatedData(null, mockPublicKeys, "mockUserId" as UserId),
).rejects.toThrow("New user key is required for rotation.");
});
});
});
-function createOrganization(id: string, name: string) {
+function createOrganization(id: string, name: string, resetPasswordEnrolled = true): Organization {
const org = new Organization();
org.id = id;
org.name = name;
org.identifier = name;
org.isMember = true;
- org.resetPasswordEnrolled = true;
+ org.resetPasswordEnrolled = resetPasswordEnrolled;
return org;
}
diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts
index 8e583d3106c..4b5c03a5a5b 100644
--- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts
+++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts
@@ -21,16 +21,22 @@ import {
Argon2KdfConfig,
KdfConfig,
PBKDF2KdfConfig,
- UserKeyRotationDataProvider,
+ UserKeyRotationKeyRecoveryProvider,
KeyService,
KdfType,
} from "@bitwarden/key-management";
+import { OrganizationUserResetPasswordEntry } from "./organization-user-reset-password-entry";
+
@Injectable({
providedIn: "root",
})
export class OrganizationUserResetPasswordService
- implements UserKeyRotationDataProvider
+ implements
+ UserKeyRotationKeyRecoveryProvider<
+ OrganizationUserResetPasswordWithIdRequest,
+ OrganizationUserResetPasswordEntry
+ >
{
constructor(
private keyService: KeyService,
@@ -42,11 +48,17 @@ export class OrganizationUserResetPasswordService
) {}
/**
- * Returns the user key encrypted by the organization's public key.
- * Intended for use in enrollment
+ * Builds a recovery key for a user to recover their account.
+ *
* @param orgId desired organization
+ * @param userKey user key
+ * @param trustedPublicKeys public keys of organizations that the user trusts
*/
- async buildRecoveryKey(orgId: string, userKey?: UserKey): Promise {
+ async buildRecoveryKey(
+ orgId: string,
+ userKey: UserKey,
+ trustedPublicKeys: Uint8Array[],
+ ): Promise {
// Retrieve Public Key
const orgKeys = await this.organizationApiService.getKeys(orgId);
if (orgKeys == null) {
@@ -55,13 +67,16 @@ export class OrganizationUserResetPasswordService
const publicKey = Utils.fromB64ToArray(orgKeys.publicKey);
- // RSA Encrypt user key with organization's public key
- userKey ??= await this.keyService.getUserKey();
- if (userKey == null) {
- throw new Error("No user key found");
+ if (
+ !trustedPublicKeys.some(
+ (key) => Utils.fromBufferToHex(key) === Utils.fromBufferToHex(publicKey),
+ )
+ ) {
+ throw new Error("Untrusted public key");
}
- const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
+ // RSA Encrypt user key with organization's public key
+ const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
return encryptedKey.encryptedString;
}
@@ -138,6 +153,21 @@ export class OrganizationUserResetPasswordService
);
}
+ async getPublicKeys(userId: UserId): Promise {
+ const allOrgs = (await firstValueFrom(this.organizationService.organizations$(userId))).filter(
+ (org) => org.resetPasswordEnrolled,
+ );
+
+ const entries: OrganizationUserResetPasswordEntry[] = [];
+ for (const org of allOrgs) {
+ const publicKey = await this.organizationApiService.getKeys(org.id);
+ const encodedPublicKey = Utils.fromB64ToArray(publicKey.publicKey);
+ const entry = new OrganizationUserResetPasswordEntry(org.id, encodedPublicKey, org.name);
+ entries.push(entry);
+ }
+ return entries;
+ }
+
/**
* Returns existing account recovery keys re-encrypted with the new user key.
* @param originalUserKey the original user key
@@ -147,8 +177,8 @@ export class OrganizationUserResetPasswordService
* @returns a list of account recovery keys that have been re-encrypted with the new user key
*/
async getRotatedData(
- originalUserKey: UserKey,
newUserKey: UserKey,
+ trustedPublicKeys: Uint8Array[],
userId: UserId,
): Promise {
if (newUserKey == null) {
@@ -156,9 +186,8 @@ export class OrganizationUserResetPasswordService
}
const allOrgs = await firstValueFrom(this.organizationService.organizations$(userId));
-
if (!allOrgs) {
- return;
+ throw new Error("Could not get organizations");
}
const requests: OrganizationUserResetPasswordWithIdRequest[] = [];
@@ -169,7 +198,7 @@ export class OrganizationUserResetPasswordService
}
// Re-enroll - encrypt user key with organization public key
- const encryptedKey = await this.buildRecoveryKey(org.id, newUserKey);
+ const encryptedKey = await this.buildRecoveryKey(org.id, newUserKey, trustedPublicKeys);
// Create/Execute request
const request = new OrganizationUserResetPasswordWithIdRequest();
diff --git a/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts b/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts
index d603f154dfd..15e7af1cd2d 100644
--- a/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts
+++ b/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts
@@ -1,19 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
+import { firstValueFrom, lastValueFrom } from "rxjs";
+
import {
OrganizationUserApiService,
OrganizationUserResetPasswordEnrollmentRequest,
} from "@bitwarden/admin-console/common";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
+import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification";
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 { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
+import { KeyService } from "@bitwarden/key-management";
+import { OrganizationTrustComponent } from "../manage/organization-trust.component";
import { OrganizationUserResetPasswordService } from "../members/services/organization-user-reset-password/organization-user-reset-password.service";
interface EnrollMasterPasswordResetData {
@@ -28,12 +34,14 @@ export class EnrollMasterPasswordReset {
data: EnrollMasterPasswordResetData,
resetPasswordService: OrganizationUserResetPasswordService,
organizationUserApiService: OrganizationUserApiService,
- platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
syncService: SyncService,
logService: LogService,
userVerificationService: UserVerificationService,
toastService: ToastService,
+ keyService: KeyService,
+ accountService: AccountService,
+ organizationApiService: OrganizationApiServiceAbstraction,
) {
const result = await UserVerificationDialogComponent.open(dialogService, {
title: "enrollAccountRecovery",
@@ -44,12 +52,33 @@ export class EnrollMasterPasswordReset {
verificationType: {
type: "custom",
verificationFn: async (secret: VerificationWithSecret) => {
+ const activeUserId = (await firstValueFrom(accountService.activeAccount$)).id;
+
+ const publicKey = Utils.fromB64ToArray(
+ (await organizationApiService.getKeys(data.organization.id)).publicKey,
+ );
+
const request =
await userVerificationService.buildRequest(
secret,
);
+ const dialogRef = OrganizationTrustComponent.open(dialogService, {
+ name: data.organization.name,
+ orgId: data.organization.id,
+ publicKey,
+ });
+ const result = await lastValueFrom(dialogRef.closed);
+ if (result !== true) {
+ throw new Error("Organization not trusted, aborting user key rotation");
+ }
+
+ const trustedOrgPublicKeys = [publicKey];
+ const userKey = await firstValueFrom(keyService.userKey$(activeUserId));
+
request.resetPasswordKey = await resetPasswordService.buildRecoveryKey(
data.organization.id,
+ userKey,
+ trustedOrgPublicKeys,
);
// Process the enrollment request, which is an endpoint that is
diff --git a/apps/web/src/app/auth/emergency-access/models/emergency-access.ts b/apps/web/src/app/auth/emergency-access/models/emergency-access.ts
index 8fe53f52e57..b8ae5907bb9 100644
--- a/apps/web/src/app/auth/emergency-access/models/emergency-access.ts
+++ b/apps/web/src/app/auth/emergency-access/models/emergency-access.ts
@@ -42,3 +42,7 @@ export class ViewTypeEmergencyAccess {
keyEncrypted: string;
ciphers: CipherResponse[] = [];
}
+
+export class GranteeEmergencyAccessWithPublicKey extends GranteeEmergencyAccess {
+ publicKey: Uint8Array;
+}
diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts
index dfeb53f0c19..6ad2c4de70e 100644
--- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts
+++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts
@@ -11,6 +11,7 @@ import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.resp
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -41,6 +42,9 @@ describe("EmergencyAccessService", () => {
let emergencyAccessService: EmergencyAccessService;
let configService: ConfigService;
+ const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
+ const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")];
+
beforeAll(() => {
emergencyAccessApiService = mock();
apiService = mock();
@@ -226,10 +230,6 @@ describe("EmergencyAccessService", () => {
});
describe("getRotatedData", () => {
- const mockRandomBytes = new Uint8Array(64) as CsprngArray;
- const mockOriginalUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
- const mockNewUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
-
const allowedStatuses = [
EmergencyAccessStatusType.Confirmed,
EmergencyAccessStatusType.RecoveryInitiated,
@@ -250,7 +250,7 @@ describe("EmergencyAccessService", () => {
emergencyAccessApiService.getEmergencyAccessTrusted.mockResolvedValue(mockEmergencyAccess);
apiService.getUserPublicKey.mockResolvedValue({
userId: "mockUserId",
- publicKey: "mockPublicKey",
+ publicKey: Utils.fromUtf8ToB64("trustedPublicKey"),
} as UserKeyResponse);
encryptService.rsaEncrypt.mockImplementation((plainValue, publicKey) => {
@@ -262,17 +262,32 @@ describe("EmergencyAccessService", () => {
it("Only returns emergency accesses with allowed statuses", async () => {
const result = await emergencyAccessService.getRotatedData(
- mockOriginalUserKey,
mockNewUserKey,
+ mockTrustedPublicKeys,
"mockUserId" as UserId,
);
expect(result).toHaveLength(allowedStatuses.length);
});
+ it("Throws if emergency access public key is not trusted", async () => {
+ apiService.getUserPublicKey.mockResolvedValue({
+ userId: "mockUserId",
+ publicKey: Utils.fromUtf8ToB64("untrustedPublicKey"),
+ } as UserKeyResponse);
+
+ await expect(
+ emergencyAccessService.getRotatedData(
+ mockNewUserKey,
+ mockTrustedPublicKeys,
+ "mockUserId" as UserId,
+ ),
+ ).rejects.toThrow("Public key for user is not trusted.");
+ });
+
it("throws if new user key is null", async () => {
await expect(
- emergencyAccessService.getRotatedData(mockOriginalUserKey, null, "mockUserId" as UserId),
+ emergencyAccessService.getRotatedData(null, mockTrustedPublicKeys, "mockUserId" as UserId),
).rejects.toThrow("New user key is required for rotation.");
});
});
diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts
index 7ac2f21a223..e86e0822ef3 100644
--- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts
+++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts
@@ -22,14 +22,18 @@ import {
Argon2KdfConfig,
KdfConfig,
PBKDF2KdfConfig,
- UserKeyRotationDataProvider,
KeyService,
KdfType,
+ UserKeyRotationKeyRecoveryProvider,
} from "@bitwarden/key-management";
import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type";
import { EmergencyAccessType } from "../enums/emergency-access-type";
-import { GranteeEmergencyAccess, GrantorEmergencyAccess } from "../models/emergency-access";
+import {
+ GranteeEmergencyAccess,
+ GranteeEmergencyAccessWithPublicKey,
+ GrantorEmergencyAccess,
+} from "../models/emergency-access";
import { EmergencyAccessAcceptRequest } from "../request/emergency-access-accept.request";
import { EmergencyAccessConfirmRequest } from "../request/emergency-access-confirm.request";
import { EmergencyAccessInviteRequest } from "../request/emergency-access-invite.request";
@@ -38,12 +42,17 @@ import {
EmergencyAccessUpdateRequest,
EmergencyAccessWithIdRequest,
} from "../request/emergency-access-update.request";
+import { EmergencyAccessGranteeDetailsResponse } from "../response/emergency-access.response";
import { EmergencyAccessApiService } from "./emergency-access-api.service";
@Injectable()
export class EmergencyAccessService
- implements UserKeyRotationDataProvider
+ implements
+ UserKeyRotationKeyRecoveryProvider<
+ EmergencyAccessWithIdRequest,
+ GranteeEmergencyAccessWithPublicKey
+ >
{
constructor(
private emergencyAccessApiService: EmergencyAccessApiService,
@@ -301,30 +310,12 @@ export class EmergencyAccessService
this.emergencyAccessApiService.postEmergencyAccessPassword(id, request);
}
- /**
- * Returns existing emergency access keys re-encrypted with new user key.
- * Intended for grantor.
- * @param originalUserKey the original user key
- * @param newUserKey the new user key
- * @param userId the user id
- * @throws Error if newUserKey is nullish
- * @returns an array of re-encrypted emergency access requests or an empty array if there are no requests
- */
- async getRotatedData(
- originalUserKey: UserKey,
- newUserKey: UserKey,
- userId: UserId,
- ): Promise {
- if (newUserKey == null) {
- throw new Error("New user key is required for rotation.");
- }
-
- const requests: EmergencyAccessWithIdRequest[] = [];
+ private async getEmergencyAccessData(): Promise {
const existingEmergencyAccess =
await this.emergencyAccessApiService.getEmergencyAccessTrusted();
if (!existingEmergencyAccess || existingEmergencyAccess.data.length === 0) {
- return requests;
+ return [];
}
// Any Invited or Accepted requests won't have the key yet, so we don't need to update them
@@ -337,13 +328,73 @@ export class EmergencyAccessService
allowedStatuses.has(d.status),
);
- for (const details of filteredAccesses) {
- // Get public key of grantee
- const publicKeyResponse = await this.apiService.getUserPublicKey(details.granteeId);
- const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
+ return filteredAccesses;
+ }
+
+ async getPublicKeys(): Promise {
+ const emergencyAccessData = await this.getEmergencyAccessData();
+ const emergencyAccessDataWithPublicKeys = await Promise.all(
+ emergencyAccessData.map(async (details) => {
+ const grantee = new GranteeEmergencyAccessWithPublicKey();
+ grantee.id = details.id;
+ grantee.granteeId = details.granteeId;
+ grantee.name = details.name;
+ grantee.email = details.email;
+ grantee.type = details.type;
+ grantee.status = details.status;
+ grantee.waitTimeDays = details.waitTimeDays;
+ grantee.creationDate = details.creationDate;
+ grantee.avatarColor = details.avatarColor;
+ grantee.publicKey = Utils.fromB64ToArray(
+ (await this.apiService.getUserPublicKey(details.granteeId)).publicKey,
+ );
+ return grantee;
+ }),
+ );
+
+ return emergencyAccessDataWithPublicKeys;
+ }
+
+ /**
+ * Returns existing emergency access keys re-encrypted with new user key.
+ * Intended for grantor.
+ * @param newUserKey the new user key
+ * @param trustedPublicKeys the public keys of the emergency access grantors. These *must* be trusted somehow, and MUST NOT be passed in untrusted
+ * @param userId the user id
+ * @throws Error if newUserKey is nullish
+ * @returns an array of re-encrypted emergency access requests or an empty array if there are no requests
+ */
+ async getRotatedData(
+ newUserKey: UserKey,
+ trustedPublicKeys: Uint8Array[],
+ userId: UserId,
+ ): Promise {
+ if (newUserKey == null) {
+ throw new Error("New user key is required for rotation.");
+ }
+
+ const requests: EmergencyAccessWithIdRequest[] = [];
+
+ this.logService.info(
+ "Starting emergency access rotation, with trusted keys: ",
+ trustedPublicKeys,
+ );
+
+ const allDetails = await this.getPublicKeys();
+ for (const details of allDetails) {
+ if (
+ trustedPublicKeys.find(
+ (pk) => Utils.fromBufferToHex(pk) === Utils.fromBufferToHex(details.publicKey),
+ ) == null
+ ) {
+ this.logService.info(
+ `Public key for user ${details.granteeId} is not trusted, skipping rotation.`,
+ );
+ throw new Error("Public key for user is not trusted.");
+ }
// Encrypt new user key with public key
- const encryptedKey = await this.encryptKey(newUserKey, publicKey);
+ const encryptedKey = await this.encryptKey(newUserKey, details.publicKey);
const updateRequest = new EmergencyAccessWithIdRequest();
updateRequest.id = details.id;
diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts b/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts
index f613bdc7ddc..e7137e1d5bb 100644
--- a/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts
+++ b/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts
@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { FakeGlobalStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
import { MockProxy, mock } from "jest-mock-extended";
+import { BehaviorSubject } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -11,14 +12,19 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options";
+import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { FakeGlobalState } from "@bitwarden/common/spec/fake-state";
import { OrgKey } from "@bitwarden/common/types/key";
+import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
+import { OrganizationTrustComponent } from "../../admin-console/organizations/manage/organization-trust.component";
import { I18nService } from "../../core/i18n.service";
import {
@@ -41,6 +47,8 @@ describe("AcceptOrganizationInviteService", () => {
let i18nService: MockProxy;
let globalStateProvider: FakeGlobalStateProvider;
let globalState: FakeGlobalState;
+ let dialogService: MockProxy;
+ let accountService: MockProxy;
beforeEach(() => {
apiService = mock();
@@ -55,6 +63,8 @@ describe("AcceptOrganizationInviteService", () => {
i18nService = mock();
globalStateProvider = new FakeGlobalStateProvider();
globalState = globalStateProvider.getFake(ORGANIZATION_INVITE);
+ dialogService = mock();
+ accountService = mock();
sut = new AcceptOrganizationInviteService(
apiService,
@@ -68,6 +78,8 @@ describe("AcceptOrganizationInviteService", () => {
organizationUserApiService,
i18nService,
globalStateProvider,
+ dialogService,
+ accountService,
);
});
@@ -142,7 +154,7 @@ describe("AcceptOrganizationInviteService", () => {
expect(authService.logOut).not.toHaveBeenCalled();
});
- it("accepts the invitation request when the org has a master password policy, but the user has already passed it", async () => {
+ it("accepts the invitation request when the org has a master password policy, but the user has already passed it and autoenroll is not enabled", async () => {
const invite = createOrgInvite();
policyApiService.getPoliciesByToken.mockResolvedValue([
{
@@ -167,6 +179,53 @@ describe("AcceptOrganizationInviteService", () => {
expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
expect(authService.logOut).not.toHaveBeenCalled();
});
+
+ it("accepts the invitation request and enrolls when autoenroll is enabled", async () => {
+ const invite = createOrgInvite();
+ policyApiService.getPoliciesByToken.mockResolvedValue([
+ {
+ type: PolicyType.MasterPassword,
+ enabled: true,
+ } as Policy,
+ ]);
+ organizationApiService.getKeys.mockResolvedValue(
+ new OrganizationKeysResponse({
+ privateKey: "privateKey",
+ publicKey: "publicKey",
+ }),
+ );
+ accountService.activeAccount$ = new BehaviorSubject({ id: "activeUserId" }) as any;
+ keyService.userKey$.mockReturnValue(new BehaviorSubject({ key: "userKey" } as any));
+ encryptService.rsaEncrypt.mockResolvedValue({
+ encryptedString: "encryptedString",
+ } as EncString);
+
+ jest.mock("../../admin-console/organizations/manage/organization-trust.component");
+ OrganizationTrustComponent.open = jest.fn().mockReturnValue({
+ closed: new BehaviorSubject(true),
+ });
+
+ await globalState.update(() => invite);
+
+ policyService.getResetPasswordPolicyOptions.mockReturnValue([
+ {
+ autoEnrollEnabled: true,
+ } as ResetPasswordPolicyOptions,
+ true,
+ ]);
+
+ const result = await sut.validateAndAcceptInvite(invite);
+
+ expect(result).toBe(true);
+ expect(OrganizationTrustComponent.open).toHaveBeenCalled();
+ expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(
+ "userKey",
+ Utils.fromB64ToArray("publicKey"),
+ );
+ expect(organizationUserApiService.postOrganizationUserAccept).toHaveBeenCalled();
+ expect(organizationUserApiService.postOrganizationUserAcceptInit).not.toHaveBeenCalled();
+ expect(authService.logOut).not.toHaveBeenCalled();
+ });
});
});
diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.service.ts b/apps/web/src/app/auth/organization-invite/accept-organization.service.ts
index 22a79ef3696..837031380f3 100644
--- a/apps/web/src/app/auth/organization-invite/accept-organization.service.ts
+++ b/apps/web/src/app/auth/organization-invite/accept-organization.service.ts
@@ -15,6 +15,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -27,8 +28,11 @@ import {
ORGANIZATION_INVITE_DISK,
} from "@bitwarden/common/platform/state";
import { OrgKey } from "@bitwarden/common/types/key";
+import { DialogService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
+import { OrganizationTrustComponent } from "../../admin-console/organizations/manage/organization-trust.component";
+
import { OrganizationInvite } from "./organization-invite";
// We're storing the organization invite for 2 reasons:
@@ -63,6 +67,8 @@ export class AcceptOrganizationInviteService {
private readonly organizationUserApiService: OrganizationUserApiService,
private readonly i18nService: I18nService,
private readonly globalStateProvider: GlobalStateProvider,
+ private readonly dialogService: DialogService,
+ private readonly accountService: AccountService,
) {
this.organizationInvitationState = this.globalStateProvider.get(ORGANIZATION_INVITE);
}
@@ -183,9 +189,19 @@ export class AcceptOrganizationInviteService {
}
const publicKey = Utils.fromB64ToArray(response.publicKey);
+ const dialogRef = OrganizationTrustComponent.open(this.dialogService, {
+ name: invite.organizationName,
+ orgId: invite.organizationId,
+ publicKey,
+ });
+ const result = await firstValueFrom(dialogRef.closed);
+ if (result !== true) {
+ throw new Error("Organization not trusted, aborting user key rotation");
+ }
+ const activeUserId = (await firstValueFrom(this.accountService.activeAccount$)).id;
+ const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId));
// RSA Encrypt user's encKey.key with organization public key
- const userKey = await this.keyService.getUserKey();
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, publicKey);
// Add reset password key to accept request
diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts
index 4aa0a753f63..18ee9462f4f 100644
--- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts
+++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts
@@ -23,17 +23,47 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
-import { ToastService } from "@bitwarden/components";
-import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
+import { DialogService, ToastService } from "@bitwarden/components";
+import { KeyService, DEFAULT_KDF_CONFIG } from "@bitwarden/key-management";
+import {
+ AccountRecoveryTrustComponent,
+ EmergencyAccessTrustComponent,
+ KeyRotationTrustInfoComponent,
+} from "@bitwarden/key-management-ui";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
-import { WebauthnLoginAdminService } from "../../auth/core";
+import { WebauthnLoginAdminService } from "../../auth";
import { EmergencyAccessService } from "../../auth/emergency-access";
+import { EmergencyAccessStatusType } from "../../auth/emergency-access/enums/emergency-access-status-type";
+import { EmergencyAccessType } from "../../auth/emergency-access/enums/emergency-access-type";
import { EmergencyAccessWithIdRequest } from "../../auth/emergency-access/request/emergency-access-update.request";
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
import { UserKeyRotationService } from "./user-key-rotation.service";
+const initialPromptedOpenTrue = jest.fn();
+initialPromptedOpenTrue.mockReturnValue({ closed: new BehaviorSubject(true) });
+const initialPromptedOpenFalse = jest.fn();
+initialPromptedOpenFalse.mockReturnValue({ closed: new BehaviorSubject(false) });
+
+const emergencyAccessTrustOpenTrusted = jest.fn();
+emergencyAccessTrustOpenTrusted.mockReturnValue({
+ closed: new BehaviorSubject(true),
+});
+const emergencyAccessTrustOpenUntrusted = jest.fn();
+emergencyAccessTrustOpenUntrusted.mockReturnValue({
+ closed: new BehaviorSubject(false),
+});
+
+const accountRecoveryTrustOpenTrusted = jest.fn();
+accountRecoveryTrustOpenTrusted.mockReturnValue({
+ closed: new BehaviorSubject(true),
+});
+const accountRecoveryTrustOpenUntrusted = jest.fn();
+accountRecoveryTrustOpenUntrusted.mockReturnValue({
+ closed: new BehaviorSubject(false),
+});
+
describe("KeyRotationService", () => {
let keyRotationService: UserKeyRotationService;
@@ -52,6 +82,7 @@ describe("KeyRotationService", () => {
let mockWebauthnLoginAdminService: MockProxy;
let mockLogService: MockProxy;
let mockVaultTimeoutService: MockProxy;
+ let mockDialogService: MockProxy;
let mockToastService: MockProxy;
let mockI18nService: MockProxy;
@@ -62,6 +93,8 @@ describe("KeyRotationService", () => {
name: "mockName",
};
+ const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
+
beforeAll(() => {
mockUserVerificationService = mock();
mockApiService = mock();
@@ -69,7 +102,32 @@ describe("KeyRotationService", () => {
mockFolderService = mock();
mockSendService = mock();
mockEmergencyAccessService = mock();
+ mockEmergencyAccessService.getPublicKeys.mockResolvedValue(
+ mockTrustedPublicKeys.map((key) => {
+ return {
+ publicKey: key,
+ id: "mockId",
+ granteeId: "mockGranteeId",
+ name: "mockName",
+ email: "mockEmail",
+ type: EmergencyAccessType.Takeover,
+ status: EmergencyAccessStatusType.Accepted,
+ waitTimeDays: 5,
+ creationDate: "mockCreationDate",
+ avatarColor: "mockAvatarColor",
+ };
+ }),
+ );
mockResetPasswordService = mock();
+ mockResetPasswordService.getPublicKeys.mockResolvedValue(
+ mockTrustedPublicKeys.map((key) => {
+ return {
+ publicKey: key,
+ orgId: "mockOrgId",
+ orgName: "mockOrgName",
+ };
+ }),
+ );
mockDeviceTrustService = mock();
mockKeyService = mock();
mockEncryptService = mock();
@@ -80,6 +138,7 @@ describe("KeyRotationService", () => {
mockVaultTimeoutService = mock();
mockToastService = mock();
mockI18nService = mock();
+ mockDialogService = mock();
keyRotationService = new UserKeyRotationService(
mockUserVerificationService,
@@ -98,9 +157,14 @@ describe("KeyRotationService", () => {
mockVaultTimeoutService,
mockToastService,
mockI18nService,
+ mockDialogService,
);
});
+ beforeEach(() => {
+ jest.mock("@bitwarden/key-management-ui");
+ });
+
beforeEach(() => {
jest.clearAllMocks();
});
@@ -134,6 +198,8 @@ describe("KeyRotationService", () => {
// Mock user key
mockKeyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
+ mockKeyService.getFingerprint.mockResolvedValue(["a", "b"]);
+
// Mock private key
privateKey = new BehaviorSubject("mockPrivateKey" as any);
mockKeyService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey);
@@ -184,15 +250,21 @@ describe("KeyRotationService", () => {
expect(arg.webauthnKeys.length).toBe(2);
});
- it("rotates the user key and encrypted data", async () => {
+ it("rotates the userkey and encrypted data and changes master password", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
- "mockNewMasterPassword",
+ "newMasterPassword",
mockUser,
);
expect(mockApiService.postUserKeyUpdateV2).toHaveBeenCalled();
const arg = mockApiService.postUserKeyUpdateV2.mock.calls[0][0];
+ expect(arg.accountUnlockData.masterPasswordUnlockData.masterKeyEncryptedUserKey).toBe(
+ "mockNewUserKey",
+ );
expect(arg.oldMasterKeyAuthenticationHash).toBe("mockMasterPasswordHash");
expect(arg.accountUnlockData.masterPasswordUnlockData.email).toBe("mockEmail");
expect(arg.accountUnlockData.masterPasswordUnlockData.kdfType).toBe(
@@ -201,11 +273,52 @@ describe("KeyRotationService", () => {
expect(arg.accountUnlockData.masterPasswordUnlockData.kdfIterations).toBe(
DEFAULT_KDF_CONFIG.iterations,
);
+
expect(arg.accountKeys.accountPublicKey).toBe(Utils.fromUtf8ToB64("mockPublicKey"));
expect(arg.accountKeys.userKeyEncryptedAccountPrivateKey).toBe("mockEncryptedData");
+
expect(arg.accountData.ciphers.length).toBe(2);
expect(arg.accountData.folders.length).toBe(2);
expect(arg.accountData.sends.length).toBe(2);
+ expect(arg.accountUnlockData.emergencyAccessUnlockData.length).toBe(1);
+ expect(arg.accountUnlockData.organizationAccountRecoveryUnlockData.length).toBe(1);
+ expect(arg.accountUnlockData.passkeyUnlockData.length).toBe(2);
+ });
+
+ it("returns early when first trust warning dialog is declined", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenFalse;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
+ await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
+ "mockMasterPassword",
+ "newMasterPassword",
+ mockUser,
+ );
+ expect(mockApiService.postUserKeyUpdateV2).not.toHaveBeenCalled();
+ });
+
+ it("returns early when emergency access trust warning dialog is declined", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenUntrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
+ await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
+ "mockMasterPassword",
+ "newMasterPassword",
+ mockUser,
+ );
+ expect(mockApiService.postUserKeyUpdateV2).not.toHaveBeenCalled();
+ });
+
+ it("returns early when account recovery trust warning dialog is declined", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenUntrusted;
+ await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
+ "mockMasterPassword",
+ "newMasterPassword",
+ mockUser,
+ );
+ expect(mockApiService.postUserKeyUpdateV2).not.toHaveBeenCalled();
});
it("legacy throws if master password provided is falsey", async () => {
@@ -296,6 +409,9 @@ describe("KeyRotationService", () => {
});
it("throws if server rotation fails", async () => {
+ KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
+ EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
+ AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
mockApiService.postUserKeyUpdateV2.mockRejectedValueOnce(new Error("mockError"));
await expect(
diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts
index 9dc844c0104..7d00e970ad7 100644
--- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts
+++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts
@@ -19,8 +19,13 @@ import { UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
-import { ToastService } from "@bitwarden/components";
+import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
+import {
+ AccountRecoveryTrustComponent,
+ EmergencyAccessTrustComponent,
+ KeyRotationTrustInfoComponent,
+} from "@bitwarden/key-management-ui";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth/core";
@@ -53,6 +58,7 @@ export class UserKeyRotationService {
private vaultTimeoutService: VaultTimeoutService,
private toastService: ToastService,
private i18nService: I18nService,
+ private dialogService: DialogService,
) {}
/**
@@ -81,6 +87,20 @@ export class UserKeyRotationService {
);
}
+ const emergencyAccessGrantees = await this.emergencyAccessService.getPublicKeys();
+ const orgs = await this.resetPasswordService.getPublicKeys(user.id);
+ if (orgs.length > 0 || emergencyAccessGrantees.length > 0) {
+ const trustInfoDialog = KeyRotationTrustInfoComponent.open(this.dialogService, {
+ numberOfEmergencyAccessUsers: emergencyAccessGrantees.length,
+ orgName: orgs.length > 0 ? orgs[0].orgName : undefined,
+ });
+ const result = await firstValueFrom(trustInfoDialog.closed);
+ if (!result) {
+ this.logService.info("[Userkey rotation] Trust info dialog closed. Aborting!");
+ return;
+ }
+ }
+
const {
masterKey: oldMasterKey,
email,
@@ -156,25 +176,70 @@ export class UserKeyRotationService {
}
const accountDataRequest = new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
+ for (const details of emergencyAccessGrantees) {
+ this.logService.info("[Userkey rotation] Emergency access grantee: " + details.name);
+ this.logService.info(
+ "[Userkey rotation] Emergency access grantee fingerprint: " +
+ (await this.keyService.getFingerprint(details.granteeId, details.publicKey)).join("-"),
+ );
+
+ const dialogRef = EmergencyAccessTrustComponent.open(this.dialogService, {
+ name: details.name,
+ userId: details.granteeId,
+ publicKey: details.publicKey,
+ });
+ const result = await firstValueFrom(dialogRef.closed);
+ if (result === true) {
+ this.logService.info("[Userkey rotation] Emergency access grantee confirmed");
+ } else {
+ this.logService.info("[Userkey rotation] Emergency access grantee not confirmed");
+ return;
+ }
+ }
+ const trustedUserPublicKeys = emergencyAccessGrantees.map((d) => d.publicKey);
+
const emergencyAccessUnlockData = await this.emergencyAccessService.getRotatedData(
- originalUserKey,
newUnencryptedUserKey,
+ trustedUserPublicKeys,
user.id,
);
+
+ for (const organization of orgs) {
+ this.logService.info(
+ "[Userkey rotation] Reset password organization: " + organization.orgName,
+ );
+ this.logService.info(
+ "[Userkey rotation] Trusted organization public key: " + organization.publicKey,
+ );
+ const fingerprint = await this.keyService.getFingerprint(
+ organization.orgId,
+ organization.publicKey,
+ );
+ this.logService.info(
+ "[Userkey rotation] Trusted organization fingerprint: " + fingerprint.join("-"),
+ );
+
+ const dialogRef = AccountRecoveryTrustComponent.open(this.dialogService, {
+ name: organization.orgName,
+ orgId: organization.orgId,
+ publicKey: organization.publicKey,
+ });
+ const result = await firstValueFrom(dialogRef.closed);
+ if (result === true) {
+ this.logService.info("[Userkey rotation] Organization trusted");
+ } else {
+ this.logService.info("[Userkey rotation] Organization not trusted");
+ return;
+ }
+ }
+ const trustedOrgPublicKeys = orgs.map((d) => d.publicKey);
// Note: Reset password keys request model has user verification
// properties, but the rotation endpoint uses its own MP hash.
- const organizationAccountRecoveryUnlockData = await this.resetPasswordService.getRotatedData(
- originalUserKey,
+ const organizationAccountRecoveryUnlockData = (await this.resetPasswordService.getRotatedData(
newUnencryptedUserKey,
+ trustedOrgPublicKeys,
user.id,
- );
- if (organizationAccountRecoveryUnlockData == null) {
- this.logService.info(
- "[Userkey rotation] Organization account recovery data is null. Aborting!",
- );
- throw new Error("Organization account recovery data is null");
- }
-
+ ))!;
const passkeyUnlockData = await this.webauthnLoginAdminService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
@@ -236,6 +301,9 @@ export class UserKeyRotationService {
);
}
+ const emergencyAccessGrantees = await this.emergencyAccessService.getPublicKeys();
+ const orgs = await this.resetPasswordService.getPublicKeys(user.id);
+
// Verify master password
// UV service sets master key on success since it is stored in memory and can be lost on refresh
const verification = {
@@ -307,20 +375,22 @@ export class UserKeyRotationService {
request.sends = rotatedSends;
}
+ const trustedUserPublicKeys = emergencyAccessGrantees.map((d) => d.publicKey);
const rotatedEmergencyAccessKeys = await this.emergencyAccessService.getRotatedData(
- originalUserKey,
newUserKey,
+ trustedUserPublicKeys,
user.id,
);
if (rotatedEmergencyAccessKeys != null) {
request.emergencyAccessKeys = rotatedEmergencyAccessKeys;
}
+ const trustedOrgPublicKeys = orgs.map((d) => d.publicKey);
// Note: Reset password keys request model has user verification
// properties, but the rotation endpoint uses its own MP hash.
const rotatedResetPasswordKeys = await this.resetPasswordService.getRotatedData(
originalUserKey,
- newUserKey,
+ trustedOrgPublicKeys,
user.id,
);
if (rotatedResetPasswordKeys != null) {
diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts
index 0f1a4c5b6ef..b6b0ccb4984 100644
--- a/apps/web/src/app/shared/loose-components.module.ts
+++ b/apps/web/src/app/shared/loose-components.module.ts
@@ -9,6 +9,7 @@ import { LayoutComponent, NavigationModule } from "@bitwarden/components";
import { OrganizationLayoutComponent } from "../admin-console/organizations/layouts/organization-layout.component";
import { EventsComponent as OrgEventsComponent } from "../admin-console/organizations/manage/events.component";
+import { OrganizationTrustComponent } from "../admin-console/organizations/manage/organization-trust.component";
import { UserConfirmComponent as OrgUserConfirmComponent } from "../admin-console/organizations/manage/user-confirm.component";
import { VerifyRecoverDeleteOrgComponent } from "../admin-console/organizations/manage/verify-recover-delete-org.component";
import { AcceptFamilySponsorshipComponent } from "../admin-console/organizations/sponsorships/accept-family-sponsorship.component";
@@ -128,6 +129,7 @@ import { SharedModule } from "./shared.module";
OrgReusedPasswordsReportComponent,
OrgUnsecuredWebsitesReportComponent,
OrgUserConfirmComponent,
+ OrganizationTrustComponent,
OrgWeakPasswordsReportComponent,
PreferencesComponent,
PremiumBadgeComponent,
@@ -186,6 +188,7 @@ import { SharedModule } from "./shared.module";
OrgReusedPasswordsReportComponent,
OrgUnsecuredWebsitesReportComponent,
OrgUserConfirmComponent,
+ OrganizationTrustComponent,
OrgWeakPasswordsReportComponent,
PreferencesComponent,
PremiumBadgeComponent,
diff --git a/apps/web/src/app/tools/reports/reports-layout.component.html b/apps/web/src/app/tools/reports/reports-layout.component.html
index e011d47d7aa..283d9213cc7 100644
--- a/apps/web/src/app/tools/reports/reports-layout.component.html
+++ b/apps/web/src/app/tools/reports/reports-layout.component.html
@@ -1,7 +1,7 @@
-
-
+
+
{{ "backToReports" | i18n }}
diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts
index 4abf8a9ee9c..666424c7add 100644
--- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts
+++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts
@@ -32,6 +32,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { DialogService, ToastService } from "@bitwarden/components";
+import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "../../../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { EnrollMasterPasswordReset } from "../../../../admin-console/organizations/users/enroll-master-password-reset.component";
@@ -70,6 +71,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private configService: ConfigService,
private organizationService: OrganizationService,
+ private keyService: KeyService,
private accountService: AccountService,
private linkSsoService: LinkSsoService,
) {}
@@ -221,12 +223,14 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
{ organization: org },
this.resetPasswordService,
this.organizationUserApiService,
- this.platformUtilsService,
this.i18nService,
this.syncService,
this.logService,
this.userVerificationService,
this.toastService,
+ this.keyService,
+ this.accountService,
+ this.organizationApiService,
);
} else {
// Remove reset password
diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json
index f34f875f6b0..edf253fac8b 100644
--- a/apps/web/src/locales/de/messages.json
+++ b/apps/web/src/locales/de/messages.json
@@ -6718,7 +6718,7 @@
}
},
"exportingIndividualVaultWithAttachmentsDescription": {
- "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included",
+ "message": "Nur die einzelnen Tresor-Einträge einschließlich Anhängen von $EMAIL$ werden exportiert. Tresor-Einträge der Organisation werden nicht berücksichtigt",
"placeholders": {
"email": {
"content": "$1",
@@ -8709,7 +8709,7 @@
"message": "Das Löschen von Sammlungen auf Eigentümer und Administratoren beschränken"
},
"limitItemDeletionDescription": {
- "message": "Limit item deletion to members with the Manage collection permissions"
+ "message": "Die Löschung von Einträgen auf Mitglieder mit den Berechtigungen zur Verwaltung von Sammlungen beschränken"
},
"allowAdminAccessToAllCollectionItemsDesc": {
"message": "Besitzer und Administratoren können alle Sammlungen und Einträge verwalten"
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 5922e80cb3d..6c730338fea 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -5902,6 +5902,9 @@
"fingerprint": {
"message": "Fingerprint"
},
+ "fingerprintPhrase": {
+ "message": "Fingerprint phrase:"
+ },
"removeUsers": {
"message": "Remove users"
},
@@ -10355,6 +10358,33 @@
"organizationNameMaxLength": {
"message": "Organization name cannot exceed 50 characters."
},
+ "rotationCompletedTitle": {
+ "message": "Key rotation successful"
+ },
+ "rotationCompletedDesc": {
+ "message": "Your master password and encryption keys have been updated. Your other devices have been logged out."
+ },
+ "trustUserEmergencyAccess": {
+ "message": "Trust and confirm user"
+ },
+ "trustOrganization": {
+ "message": "Trust organization"
+ },
+ "trust": {
+ "message": "Trust"
+ },
+ "doNotTrust": {
+ "message": "Do not trust"
+ },
+ "emergencyAccessTrustWarning": {
+ "message": "For the security of your account, only confirm if you have granted emergency access to this user and their fingerprint matches what is displayed in their account"
+ },
+ "orgTrustWarning": {
+ "message": "For the security of your account, only proceed if you are a member of this organization, have account recovery enabled, and the fingerprint displayed below matches the organization's fingerprint."
+ },
+ "trustUser":{
+ "message": "Trust user"
+ },
"sshKeyWrongPassword": {
"message": "The password you entered is incorrect."
},
@@ -10524,6 +10554,30 @@
"assignedExceedsAvailable": {
"message": "Assigned seats exceed available seats."
},
+ "userkeyRotationDisclaimerEmergencyAccessText": {
+ "message": "Fingerprint phrase for $NUM_USERS$ contacts for which you have enabled emergency access.",
+ "placeholders": {
+ "num_users": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
+ "userkeyRotationDisclaimerAccountRecoveryOrgsText": {
+ "message": "Fingerprint phrase for the organization $ORG_NAME$ for which you have enabled account recovery.",
+ "placeholders": {
+ "org_name": {
+ "content": "$1",
+ "example": "My org"
+ }
+ }
+ },
+ "userkeyRotationDisclaimerDescription": {
+ "message": "Rotating your encryption keys will require you to trust keys of any organizations that can recover your account, and any contacts that you have enabled emergency access for. To continue, make sure you can verify the following:"
+ },
+ "userkeyRotationDisclaimerTitle": {
+ "message": "Untrusted encryption keys"
+ },
"changeAtRiskPassword": {
"message": "Change at-risk password"
},
diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json
index 747db745bb5..f8bb62b0b00 100644
--- a/apps/web/src/locales/zh_CN/messages.json
+++ b/apps/web/src/locales/zh_CN/messages.json
@@ -1394,7 +1394,7 @@
"message": "通知已发送到您的设备。"
},
"notificationSentDevicePart1": {
- "message": "Unlock Bitwarden on your device or on the "
+ "message": "解锁您设备上的 Bitwarden 或通过"
},
"areYouTryingToAccessYourAccount": {
"message": "您正在尝试访问您的账户吗?"
@@ -1418,7 +1418,7 @@
"message": "网页 App"
},
"notificationSentDevicePart2": {
- "message": "在批准前,请确保指纹短语与下面的相匹配。"
+ "message": "批准前,请确保指纹短语与下面的相匹配。"
},
"notificationSentDeviceComplete": {
"message": "解锁您设备上的 Bitwarden。批准前,请确保指纹短语与下面的相匹配。"
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.component.html
index 560c164415c..1c8c12a1fe1 100644
--- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.component.html
+++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.component.html
@@ -1,22 +1,22 @@
-
+
{{ "loading" | i18n }}
-
-
+
+
diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html
index f2e550cb68e..31eb54d6110 100644
--- a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html
+++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html
@@ -34,20 +34,12 @@
{{ "loading" | i18n }}
-
-
+
-
- {{ "members" | i18n }}
- {{ "groups" | i18n }}
- {{ "collections" | i18n }}
- {{ "items" | i18n }}
-
+ {{ "members" | i18n }}
+ {{ "groups" | i18n }}
+ {{ "collections" | i18n }}
+ {{ "items" | i18n }}
@@ -64,8 +56,8 @@
- {{ row.groupsCount }}
- {{ row.collectionsCount }}
- {{ row.itemsCount }}
+ {{ row.groupsCount }}
+ {{ row.collectionsCount }}
+ {{ row.itemsCount }}
diff --git a/libs/angular/src/auth/components/set-pin.component.ts b/libs/angular/src/auth/components/set-pin.component.ts
index 9f63e5e923f..d2fceb34bb9 100644
--- a/libs/angular/src/auth/components/set-pin.component.ts
+++ b/libs/angular/src/auth/components/set-pin.component.ts
@@ -16,7 +16,7 @@ export class SetPinComponent implements OnInit {
showMasterPasswordOnClientRestartOption = true;
setPinForm = this.formBuilder.group({
- pin: ["", [Validators.required]],
+ pin: ["", [Validators.required, Validators.minLength(4)]],
requireMasterPasswordOnClientRestart: true,
});
@@ -37,24 +37,26 @@ export class SetPinComponent implements OnInit {
}
submit = async () => {
- const pin = this.setPinForm.get("pin").value;
+ const pinFormControl = this.setPinForm.controls.pin;
const requireMasterPasswordOnClientRestart = this.setPinForm.get(
"requireMasterPasswordOnClientRestart",
).value;
- if (Utils.isNullOrWhitespace(pin)) {
- this.dialogRef.close(false);
+ if (Utils.isNullOrWhitespace(pinFormControl.value) || pinFormControl.invalid) {
return;
}
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const userKey = await this.keyService.getUserKey();
- const userKeyEncryptedPin = await this.pinService.createUserKeyEncryptedPin(pin, userKey);
+ const userKeyEncryptedPin = await this.pinService.createUserKeyEncryptedPin(
+ pinFormControl.value,
+ userKey,
+ );
await this.pinService.setUserKeyEncryptedPin(userKeyEncryptedPin, userId);
const pinKeyEncryptedUserKey = await this.pinService.createPinKeyEncryptedUserKey(
- pin,
+ pinFormControl.value,
userKey,
userId,
);
diff --git a/libs/angular/src/platform/abstractions/view-cache.service.ts b/libs/angular/src/platform/abstractions/view-cache.service.ts
index 0ee09afb812..a282ef67967 100644
--- a/libs/angular/src/platform/abstractions/view-cache.service.ts
+++ b/libs/angular/src/platform/abstractions/view-cache.service.ts
@@ -9,7 +9,7 @@ type Deserializer = {
* @param jsonValue The JSON object representation of your state.
* @returns The fully typed version of your state.
*/
- readonly deserializer?: (jsonValue: Jsonify) => T;
+ readonly deserializer?: (jsonValue: Jsonify) => T | null;
};
type BaseCacheOptions = {
diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts
index fc8be749c94..42fca029ec5 100644
--- a/libs/angular/src/services/jslib-services.module.ts
+++ b/libs/angular/src/services/jslib-services.module.ts
@@ -1475,7 +1475,15 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: TaskService,
useClass: DefaultTaskService,
- deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
+ deps: [
+ StateProvider,
+ ApiServiceAbstraction,
+ OrganizationServiceAbstraction,
+ ConfigService,
+ AuthServiceAbstraction,
+ NotificationsService,
+ MessageListener,
+ ],
}),
safeProvider({
provide: EndUserNotificationService,
diff --git a/libs/auth/src/angular/login-via-auth-request/auth_request_login_readme.md b/libs/auth/src/angular/login-via-auth-request/auth_request_login_readme.md
new file mode 100644
index 00000000000..240316f788c
--- /dev/null
+++ b/libs/auth/src/angular/login-via-auth-request/auth_request_login_readme.md
@@ -0,0 +1,203 @@
+# Authentication Flows Documentation
+
+## Standard Auth Request Flows
+
+### Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory
+
+1. Unauthed user clicks "Login with device"
+2. Navigates to /login-with-device which creates a StandardAuthRequest
+3. Receives approval from a device with authRequestPublicKey(masterKey)
+4. Decrypts masterKey
+5. Decrypts userKey
+6. Proceeds to vault
+
+### Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory
+
+1. Unauthed user clicks "Login with device"
+2. Navigates to /login-with-device which creates a StandardAuthRequest
+3. Receives approval from a device with authRequestPublicKey(userKey)
+4. Decrypts userKey
+5. Proceeds to vault
+
+**Note:** This flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could
+get into this flow:
+
+1. An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey
+ in memory
+2. The org admin:
+ - Changes the member decryption options from "Trusted devices" to "Master password" AND
+ - Turns off the "Require single sign-on authentication" policy
+3. On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO
+4. The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in
+ memory
+
+### Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory
+
+1. SSO TD user authenticates via SSO
+2. Navigates to /login-initiated
+3. Clicks "Approve from your other device"
+4. Navigates to /login-with-device which creates a StandardAuthRequest
+5. Receives approval from device with authRequestPublicKey(masterKey)
+6. Decrypts masterKey
+7. Decrypts userKey
+8. Establishes trust (if required)
+9. Proceeds to vault
+
+### Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory
+
+1. SSO TD user authenticates via SSO
+2. Navigates to /login-initiated
+3. Clicks "Approve from your other device"
+4. Navigates to /login-with-device which creates a StandardAuthRequest
+5. Receives approval from device with authRequestPublicKey(userKey)
+6. Decrypts userKey
+7. Establishes trust (if required)
+8. Proceeds to vault
+
+## Admin Auth Request Flow
+
+### Flow: Authed SSO TD user requests admin approval
+
+1. SSO TD user authenticates via SSO
+2. Navigates to /login-initiated
+3. Clicks "Request admin approval"
+4. Navigates to /admin-approval-requested which creates an AdminAuthRequest
+5. Receives approval from device with authRequestPublicKey(userKey)
+6. Decrypts userKey
+7. Establishes trust (if required)
+8. Proceeds to vault
+
+**Note:** TDE users are required to be enrolled in admin account recovery, which gives the admin access to the user's
+userKey. This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
+
+## Summary Table
+
+| Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory\* |
+| --------------- | ----------- | --------------------------------------------------- | ------------------------- | ------------------------------------------------- |
+| Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
+| Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
+| Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
+| Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no |
+| Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
+
+**Note:** The phrase "in memory" here is important. It is possible for a user to have a master password for their
+account, but not have a masterKey IN MEMORY for a specific device. For example, if a user registers an account with a
+master password, then joins an SSO TD org, then logs in to a device via SSO and admin auth request, they are now logged
+into that device but that device does not have masterKey IN MEMORY.
+
+## State Management
+
+### View Cache
+
+The component uses `LoginViaAuthRequestCacheService` to manage persistent state across extension close and reopen.
+This cache stores:
+
+- Auth Request ID
+- Private Key
+- Access Code
+
+The cache is used to:
+
+1. Preserve authentication state during extension close
+2. Allow resumption of pending auth requests
+3. Enable processing of approved requests after extension close and reopen.
+
+### Component State Variables
+
+Key state variables maintained during the authentication process:
+
+#### Authentication Keys
+
+```
+private authRequestKeyPair: {
+ publicKey: Uint8Array | undefined;
+ privateKey: Uint8Array | undefined;
+} | undefined
+```
+
+- Stores the RSA key pair used for secure communication
+- Generated during auth request initialization
+- Required for decrypting approved auth responses
+
+#### Access Code
+
+```
+private accessCode: string | undefined
+```
+
+- 25-character generated password
+- Used for retrieving auth responses when user is not authenticated
+- Required for standard auth flows
+
+#### Authentication Status
+
+```
+private authStatus: AuthenticationStatus | undefined
+```
+
+- Tracks whether user is authenticated via SSO
+- Determines available flows and API endpoints
+- Affects navigation paths (`/login` vs `/login-initiated`)
+
+#### Flow Control
+
+```
+protected flow = Flow.StandardAuthRequest
+```
+
+- Determines current authentication flow (Standard vs Admin)
+- Affects UI rendering and request handling
+- Set based on route and authentication state
+
+### State Flow Examples
+
+#### Standard Auth Request Cache Flow
+
+1. User initiates login with device
+2. Component generates auth request and keys
+3. Cache service stores:
+ ```
+ cacheLoginView(
+ authRequestResponse.id,
+ authRequestKeyPair.privateKey,
+ accessCode
+ )
+ ```
+4. On page refresh/revisit:
+ - Component retrieves cached view
+ - Reestablishes connection using cached credentials
+ - Continues monitoring for approval
+
+#### Admin Auth Request State Flow
+
+1. User requests admin approval
+2. Component stores admin request in `AuthRequestService`:
+ ```
+ setAdminAuthRequest(
+ new AdminAuthRequestStorable({
+ id: authRequestResponse.id,
+ privateKey: authRequestKeyPair.privateKey
+ }),
+ userId
+ )
+ ```
+3. On subsequent visits:
+ - Component checks for existing admin requests
+ - Either resumes monitoring or starts new request
+ - Clears state after successful approval
+
+### State Cleanup
+
+State cleanup occurs in several scenarios:
+
+- Component destruction (`ngOnDestroy`)
+- Successful authentication
+- Request denial or timeout
+- Manual navigation away
+
+Key cleanup actions:
+
+1. Hub connection termination
+2. Cache clearance
+3. Admin request state removal
+4. Key pair disposal
diff --git a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html
index d6b91b960b0..38dc874cd0f 100644
--- a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html
+++ b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.html
@@ -1,57 +1,65 @@
-
-
-
- {{ "notificationSentDevicePart1" | i18n }}
- {{ "notificationSentDeviceAnchor" | i18n }} . {{ "notificationSentDevicePart2" | i18n }}
-
-
- {{ "notificationSentDeviceComplete" | i18n }}
-
+
+
+
+
+
- {{ "fingerprintPhraseHeader" | i18n }}
- {{ fingerprintPhrase }}
+
+
+
+
+ {{ "notificationSentDevicePart1" | i18n }}
+ {{ "notificationSentDeviceAnchor" | i18n }} . {{ "notificationSentDevicePart2" | i18n }}
+
+
+ {{ "notificationSentDeviceComplete" | i18n }}
+
-
- {{ "resendNotification" | i18n }}
-
+ {{ "fingerprintPhraseHeader" | i18n }}
+ {{ fingerprintPhrase }}
-
-
+
+ {{ "resendNotification" | i18n }}
+
-
- {{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}
+
+
-
{{ "fingerprintPhraseHeader" | i18n }}
-
{{ fingerprintPhrase }}
+
+ {{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}
-
-
-
+ {{ "fingerprintPhraseHeader" | i18n }}
+ {{ fingerprintPhrase }}
+
+
+
+
+
diff --git a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts
index ea753a3f0c5..8e79bd54f76 100644
--- a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts
+++ b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts
@@ -62,12 +62,13 @@ const matchOptions: IsActiveMatchOptions = {
providers: [{ provide: LoginViaAuthRequestCacheService }],
})
export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
- private authRequest: AuthRequest | undefined = undefined;
private authRequestKeyPair:
| { publicKey: Uint8Array | undefined; privateKey: Uint8Array | undefined }
| undefined = undefined;
+ private accessCode: string | undefined = undefined;
private authStatus: AuthenticationStatus | undefined = undefined;
private showResendNotificationTimeoutSeconds = 12;
+ protected loading = true;
protected backToRoute = "/login";
protected clientType: ClientType;
@@ -110,13 +111,14 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
this.authRequestService.authRequestPushNotification$
.pipe(takeUntilDestroyed())
.subscribe((requestId) => {
- this.verifyAndHandleApprovedAuthReq(requestId).catch((e: Error) => {
+ this.loading = true;
+ this.handleExistingAuthRequestLogin(requestId).catch((e: Error) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: e.message,
});
-
+ this.loading = false;
this.logService.error("Failed to use approved auth request: " + e.message);
});
});
@@ -149,24 +151,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
} else {
await this.initStandardAuthRequestFlow();
}
+ this.loading = false;
}
private async initAdminAuthRequestFlow(): Promise {
this.flow = Flow.AdminAuthRequest;
- // Get email from state for admin auth requests because it is available and also
- // prevents it from being lost on refresh as the loginEmailService email does not persist.
- this.email = await firstValueFrom(
- this.accountService.activeAccount$.pipe(map((a) => a?.email)),
- );
-
- if (!this.email) {
- await this.handleMissingEmail();
- return;
- }
-
- // We only allow a single admin approval request to be active at a time
- // so we must check state to see if we have an existing one or not
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (!userId) {
this.logService.error(
@@ -175,12 +165,13 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
- const existingAdminAuthRequest = await this.authRequestService.getAdminAuthRequest(userId);
+ // [Admin Request Flow State Management] Check cached auth request
+ const existingAdminAuthRequest = await this.reloadCachedAdminAuthRequest(userId);
if (existingAdminAuthRequest) {
- await this.handleExistingAdminAuthRequest(existingAdminAuthRequest, userId);
+ await this.handleExistingAdminAuthRequestLogin(existingAdminAuthRequest, userId);
} else {
- await this.startAdminAuthRequestLogin();
+ await this.handleNewAdminAuthRequestLogin();
}
}
@@ -194,7 +185,24 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
- await this.startStandardAuthRequestLogin();
+ // [Standard Flow State Management] Check cached auth request
+ const cachedAuthRequest: LoginViaAuthRequestView | null =
+ this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
+
+ if (cachedAuthRequest) {
+ this.logService.info("Found cached auth request.");
+ if (!cachedAuthRequest.id) {
+ this.logService.error(
+ "No id on the cached auth request when in the standard auth request flow.",
+ );
+ return;
+ }
+
+ await this.reloadCachedStandardAuthRequest(cachedAuthRequest);
+ await this.handleExistingAuthRequestLogin(cachedAuthRequest.id);
+ } else {
+ await this.handleNewStandardAuthRequestLogin();
+ }
}
private async handleMissingEmail(): Promise {
@@ -212,11 +220,17 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
this.loginViaAuthRequestCacheService.clearCacheLoginView();
}
- private async startAdminAuthRequestLogin(): Promise {
+ private async handleNewAdminAuthRequestLogin(): Promise {
try {
- await this.buildAuthRequest(AuthRequestType.AdminApproval);
+ if (!this.email) {
+ this.logService.error("No email when starting admin auth request login.");
+ return;
+ }
- if (!this.authRequest) {
+ // At this point we know there is no
+ const authRequest = await this.buildAuthRequest(this.email, AuthRequestType.AdminApproval);
+
+ if (!authRequest) {
this.logService.error("Auth request failed to build.");
return;
}
@@ -226,9 +240,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
- const authRequestResponse = await this.authRequestApiService.postAdminAuthRequest(
- this.authRequest as AuthRequest,
- );
+ const authRequestResponse =
+ await this.authRequestApiService.postAdminAuthRequest(authRequest);
+
const adminAuthReqStorable = new AdminAuthRequestStorable({
id: authRequestResponse.id,
privateKey: this.authRequestKeyPair.privateKey,
@@ -253,104 +267,154 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}
}
- protected async startStandardAuthRequestLogin(
- clearCachedRequest: boolean = false,
+ /**
+ * We only allow a single admin approval request to be active at a time
+ * so we can check to see if it's stored in state with the state service
+ * provider.
+ * @param userId
+ * @protected
+ */
+ protected async reloadCachedAdminAuthRequest(
+ userId: UserId,
+ ): Promise {
+ // Get email from state for admin auth requests because it is available and also
+ // prevents it from being lost on refresh as the loginEmailService email does not persist.
+ this.email = await firstValueFrom(
+ this.accountService.activeAccount$.pipe(map((a) => a?.email)),
+ );
+
+ if (!this.email) {
+ await this.handleMissingEmail();
+ return null;
+ }
+
+ return await this.authRequestService.getAdminAuthRequest(userId);
+ }
+
+ /**
+ * Restores a cached authentication request into the component's state.
+ *
+ * This function checks for the presence of a cached authentication request and,
+ * if available, updates the component's state with the necessary details to
+ * continue processing the request. It ensures that the user's email and the
+ * private key from the cached request are available.
+ *
+ * The private key is converted from Base64 to an ArrayBuffer, and a fingerprint
+ * phrase is derived to verify the request's integrity. The function then sets
+ * the authentication request key pair in the component's state, preparing it
+ * to handle any responses or approvals.
+ *
+ * @param cachedAuthRequest The request to load into the component state
+ * @returns Promise to await for completion
+ */
+ protected async reloadCachedStandardAuthRequest(
+ cachedAuthRequest: LoginViaAuthRequestView,
): Promise {
+ if (cachedAuthRequest) {
+ if (!this.email) {
+ this.logService.error(
+ "Email not defined when trying to reload cached standard auth request.",
+ );
+ return;
+ }
+
+ if (!cachedAuthRequest.privateKey) {
+ this.logService.error(
+ "No private key on the cached auth request when trying to reload cached standard auth request.",
+ );
+ return;
+ }
+
+ if (!cachedAuthRequest.accessCode) {
+ this.logService.error(
+ "No access code on the cached auth request when trying to reload cached standard auth request.",
+ );
+ return;
+ }
+
+ const privateKey = Utils.fromB64ToArray(cachedAuthRequest.privateKey);
+
+ // Re-derive the user's fingerprint phrase
+ // It is important to not use the server's public key here as it could have been compromised via MITM
+ const derivedPublicKeyArrayBuffer =
+ await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
+
+ this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
+ this.email,
+ derivedPublicKeyArrayBuffer,
+ );
+
+ // We don't need the public key for handling the authentication request because
+ // the handleExistingAuthRequestLogin function will receive the public key back
+ // from the looked up auth request, and all we need is to make sure that
+ // we can use the cached private key that is associated with it.
+ this.authRequestKeyPair = {
+ privateKey: privateKey,
+ publicKey: undefined,
+ };
+
+ this.accessCode = cachedAuthRequest.accessCode;
+ }
+ }
+
+ protected async handleNewStandardAuthRequestLogin(): Promise {
this.showResendNotification = false;
- if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
- // Used for manually refreshing the auth request when clicking the resend auth request
- // on the ui.
- if (clearCachedRequest) {
- this.loginViaAuthRequestCacheService.clearCacheLoginView();
+ try {
+ if (!this.email) {
+ this.logService.error("Email not defined when starting standard auth request login.");
+ return;
}
- try {
- const loginAuthRequestView: LoginViaAuthRequestView | null =
- this.loginViaAuthRequestCacheService.getCachedLoginViaAuthRequestView();
+ const authRequest = await this.buildAuthRequest(
+ this.email,
+ AuthRequestType.AuthenticateAndUnlock,
+ );
- if (!loginAuthRequestView) {
- await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
-
- // I tried several ways to get the IDE/linter to play nice with checking for null values
- // in less code / more efficiently, but it struggles to identify code paths that
- // are more complicated than this.
- if (!this.authRequest) {
- this.logService.error("AuthRequest failed to initialize from buildAuthRequest.");
- return;
- }
-
- if (!this.fingerprintPhrase) {
- this.logService.error("FingerprintPhrase failed to initialize from buildAuthRequest.");
- return;
- }
-
- if (!this.authRequestKeyPair) {
- this.logService.error("KeyPair failed to initialize from buildAuthRequest.");
- return;
- }
-
- const authRequestResponse: AuthRequestResponse =
- await this.authRequestApiService.postAuthRequest(this.authRequest);
-
- this.loginViaAuthRequestCacheService.cacheLoginView(
- this.authRequest,
- authRequestResponse,
- this.fingerprintPhrase,
- this.authRequestKeyPair,
- );
-
- if (authRequestResponse.id) {
- await this.anonymousHubService.createHubConnection(authRequestResponse.id);
- }
- } else {
- // Grab the cached information and store it back in component state.
- // We don't need the public key for handling the authentication request because
- // the verifyAndHandleApprovedAuthReq function will receive the public key back
- // from the looked up auth request and all we need is to make sure that
- // we can use the cached private key that is associated with it.
- this.authRequest = loginAuthRequestView.authRequest;
- this.fingerprintPhrase = loginAuthRequestView.fingerprintPhrase;
- this.authRequestKeyPair = {
- privateKey: loginAuthRequestView.privateKey
- ? Utils.fromB64ToArray(loginAuthRequestView.privateKey)
- : undefined,
- publicKey: undefined,
- };
-
- if (!loginAuthRequestView.authRequestResponse) {
- this.logService.error("No cached auth request response.");
- return;
- }
-
- if (loginAuthRequestView.authRequestResponse.id) {
- await this.anonymousHubService.createHubConnection(
- loginAuthRequestView.authRequestResponse.id,
- );
- }
- }
- } catch (e) {
- this.logService.error(e);
+ // I tried several ways to get the IDE/linter to play nice with checking for null values
+ // in less code / more efficiently, but it struggles to identify code paths that
+ // are more complicated than this.
+ if (!authRequest) {
+ this.logService.error("AuthRequest failed to initialize from buildAuthRequest.");
+ return;
}
- } else {
- try {
- await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
- if (!this.authRequest) {
- this.logService.error("No auth request found.");
+ if (!this.fingerprintPhrase) {
+ this.logService.error("FingerprintPhrase failed to initialize from buildAuthRequest.");
+ return;
+ }
+
+ if (!this.authRequestKeyPair) {
+ this.logService.error("KeyPair failed to initialize from buildAuthRequest.");
+ return;
+ }
+
+ const authRequestResponse: AuthRequestResponse =
+ await this.authRequestApiService.postAuthRequest(authRequest);
+
+ if (await this.configService.getFeatureFlag(FeatureFlag.PM9112_DeviceApprovalPersistence)) {
+ if (!this.authRequestKeyPair.privateKey) {
+ this.logService.error("No private key when trying to cache the login view.");
return;
}
- const authRequestResponse = await this.authRequestApiService.postAuthRequest(
- this.authRequest,
- );
-
- if (authRequestResponse.id) {
- await this.anonymousHubService.createHubConnection(authRequestResponse.id);
+ if (!this.accessCode) {
+ this.logService.error("No access code when trying to cache the login view.");
+ return;
}
- } catch (e) {
- this.logService.error(e);
+
+ this.loginViaAuthRequestCacheService.cacheLoginView(
+ authRequestResponse.id,
+ this.authRequestKeyPair.privateKey,
+ this.accessCode,
+ );
}
+
+ if (authRequestResponse.id) {
+ await this.anonymousHubService.createHubConnection(authRequestResponse.id);
+ }
+ } catch (e) {
+ this.logService.error(e);
}
setTimeout(() => {
@@ -358,7 +422,10 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}, this.showResendNotificationTimeoutSeconds * 1000);
}
- private async buildAuthRequest(authRequestType: AuthRequestType): Promise {
+ private async buildAuthRequest(
+ email: string,
+ authRequestType: AuthRequestType,
+ ): Promise {
const authRequestKeyPairArray = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
this.authRequestKeyPair = {
@@ -369,36 +436,27 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
const deviceIdentifier = await this.appIdService.getAppId();
if (!this.authRequestKeyPair.publicKey) {
- this.logService.error("AuthRequest public key not set to value in building auth request.");
- return;
+ const errorMessage = "No public key when building an auth request.";
+ this.logService.error(errorMessage);
+ throw new Error(errorMessage);
}
- const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
- const accessCode = await this.passwordGenerationService.generatePassword({
+ this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
+ email,
+ this.authRequestKeyPair.publicKey,
+ );
+
+ this.accessCode = await this.passwordGenerationService.generatePassword({
type: "password",
length: 25,
});
- if (!this.email) {
- this.logService.error("Email not defined when building auth request.");
- return;
- }
+ const b64PublicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
- this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
- this.email,
- this.authRequestKeyPair.publicKey,
- );
-
- this.authRequest = new AuthRequest(
- this.email,
- deviceIdentifier,
- publicKey,
- authRequestType,
- accessCode,
- );
+ return new AuthRequest(email, deviceIdentifier, b64PublicKey, authRequestType, this.accessCode);
}
- private async handleExistingAdminAuthRequest(
+ private async handleExistingAdminAuthRequestLogin(
adminAuthRequestStorable: AdminAuthRequestStorable,
userId: UserId,
): Promise {
@@ -414,7 +472,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
- return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
+ return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
}
this.logService.error(error);
return;
@@ -422,28 +480,12 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
// Request doesn't exist anymore
if (!adminAuthRequestResponse) {
- return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
+ return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
}
- // Re-derive the user's fingerprint phrase
- // It is important to not use the server's public key here as it could have been compromised via MITM
- const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
- adminAuthRequestStorable.privateKey,
- );
-
- if (!this.email) {
- this.logService.error("Email not defined when handling an existing an admin auth request.");
- return;
- }
-
- this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
- this.email,
- derivedPublicKeyArrayBuffer,
- );
-
// Request denied
if (adminAuthRequestResponse.isAnswered && !adminAuthRequestResponse.requestApproved) {
- return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
+ return await this.clearExistingAdminAuthRequestAndStartNewRequest(userId);
}
// Request approved
@@ -455,6 +497,22 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
}
+ if (!this.email) {
+ this.logService.error("Email not defined when handling an existing an admin auth request.");
+ return;
+ }
+
+ // Re-derive the user's fingerprint phrase
+ // It is important to not use the server's public key here as it could have been compromised via MITM
+ const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
+ adminAuthRequestStorable.privateKey,
+ );
+
+ this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
+ this.email,
+ derivedPublicKeyArrayBuffer,
+ );
+
// Request still pending response from admin set keypair and create hub connection
// so that any approvals will be received via push notification
this.authRequestKeyPair = {
@@ -464,117 +522,99 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
await this.anonymousHubService.createHubConnection(adminAuthRequestStorable.id);
}
- private async verifyAndHandleApprovedAuthReq(requestId: string): Promise {
- /**
- * ***********************************
- * Standard Auth Request Flows
- * ***********************************
- *
- * Flow 1: Unauthed user requests approval from device; Approving device has a masterKey in memory.
- *
- * Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
- * > receives approval from a device with authRequestPublicKey(masterKey) > decrypts masterKey > decrypts userKey > proceed to vault
- *
- * Flow 2: Unauthed user requests approval from device; Approving device does NOT have a masterKey in memory.
- *
- * Unauthed user clicks "Login with device" > navigates to /login-with-device which creates a StandardAuthRequest
- * > receives approval from a device with authRequestPublicKey(userKey) > decrypts userKey > proceeds to vault
- *
- * Note: this flow is an uncommon scenario and relates to TDE off-boarding. The following describes how a user could get into this flow:
- * 1) An SSO TD user logs into a device via an Admin auth request approval, therefore this device does NOT have a masterKey in memory.
- * 2) The org admin...
- * (2a) Changes the member decryption options from "Trusted devices" to "Master password" AND
- * (2b) Turns off the "Require single sign-on authentication" policy
- * 3) On another device, the user clicks "Login with device", which they can do because the org no longer requires SSO.
- * 4) The user approves from the device they had previously logged into with SSO TD, which does NOT have a masterKey in memory (see step 1 above).
- *
- * Flow 3: Authed SSO TD user requests approval from device; Approving device has a masterKey in memory.
- *
- * SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
- * > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(masterKey)
- * > decrypts masterKey > decrypts userKey > establishes trust (if required) > proceeds to vault
- *
- * Flow 4: Authed SSO TD user requests approval from device; Approving device does NOT have a masterKey in memory.
- *
- * SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Approve from your other device"
- * > navigates to /login-with-device which creates a StandardAuthRequest > receives approval from device with authRequestPublicKey(userKey)
- * > decrypts userKey > establishes trust (if required) > proceeds to vault
- *
- * ***********************************
- * Admin Auth Request Flow
- * ***********************************
- *
- * Flow: Authed SSO TD user requests admin approval.
- *
- * SSO TD user authenticates via SSO > navigates to /login-initiated > clicks "Request admin approval"
- * > navigates to /admin-approval-requested which creates an AdminAuthRequest > receives approval from device with authRequestPublicKey(userKey)
- * > decrypts userKey > establishes trust (if required) > proceeds to vault
- *
- * Note: TDE users are required to be enrolled in admin password reset, which gives the admin access to the user's userKey.
- * This is how admins are able to send over the authRequestPublicKey(userKey) to the user to allow them to unlock.
- *
- *
- * Summary Table
- * |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
- * | Flow | Auth Status | Clicks Button [active route] | Navigates to | Approving device has masterKey in memory (see note 1) |
- * |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
- * | Standard Flow 1 | unauthed | "Login with device" [/login] | /login-with-device | yes |
- * | Standard Flow 2 | unauthed | "Login with device" [/login] | /login-with-device | no |
- * | Standard Flow 3 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | yes |
- * | Standard Flow 4 | authed | "Approve from your other device" [/login-initiated] | /login-with-device | no |
- * | Admin Flow | authed | "Request admin approval" [/login-initiated] | /admin-approval-requested | NA - admin requests always send encrypted userKey |
- * |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
- * * Note 1: The phrase "in memory" here is important. It is possible for a user to have a master password for their account, but not have a masterKey IN MEMORY for
- * a specific device. For example, if a user registers an account with a master password, then joins an SSO TD org, then logs in to a device via SSO and
- * admin auth request, they are now logged into that device but that device does not have masterKey IN MEMORY.
- */
-
+ /**
+ * This is used for trying to get the auth request back out of state.
+ * @param requestId
+ * @private
+ */
+ private async retrieveAuthRequest(requestId: string): Promise {
+ let authRequestResponse: AuthRequestResponse | undefined = undefined;
try {
+ // There are two cases here, the first being
const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
+ // Get the response based on whether we've authenticated or not. We need to call a different API method
+ // based on whether we have a token or need to use the accessCode.
if (userHasAuthenticatedViaSSO) {
- // Get the auth request from the server
- // User is authenticated, therefore the endpoint does not require an access code.
- const authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId);
-
- if (authRequestResponse.requestApproved) {
- // Handles Standard Flows 3-4 and Admin Flow
- await this.handleAuthenticatedFlows(authRequestResponse);
- }
+ authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId);
} else {
- if (!this.authRequest) {
- this.logService.error("No auth request defined when handling approved auth request.");
- return;
+ if (!this.accessCode) {
+ const errorMessage = "No access code available when handling approved auth request.";
+ this.logService.error(errorMessage);
+ throw new Error(errorMessage);
}
-
- // Get the auth request from the server
- // User is unauthenticated, therefore the endpoint requires an access code for user verification.
- const authRequestResponse = await this.authRequestApiService.getAuthResponse(
+ authRequestResponse = await this.authRequestApiService.getAuthResponse(
requestId,
- this.authRequest.accessCode,
+ this.accessCode,
);
+ }
+ } catch (error) {
+ // If the request no longer exists, we treat it as if it's been answered (and denied).
+ if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
+ authRequestResponse = undefined;
+ } else {
+ this.logService.error(error);
+ }
+ }
- if (authRequestResponse.requestApproved) {
- // Handles Standard Flows 1-2
- await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
+ if (authRequestResponse === undefined) {
+ throw new Error("Auth request response not generated");
+ }
+
+ return authRequestResponse;
+ }
+
+ /**
+ * Determines if the Auth Request has been approved, deleted or denied, and handles
+ * the response accordingly.
+ * @param requestId The ID of the Auth Request to process
+ * @returns A boolean indicating whether the Auth Request was successfully processed
+ */
+ private async handleExistingAuthRequestLogin(requestId: string): Promise {
+ this.showResendNotification = false;
+
+ try {
+ const authRequestResponse = await this.retrieveAuthRequest(requestId);
+
+ // Request doesn't exist anymore, so we'll clear the cache and start a new request.
+ if (!authRequestResponse) {
+ return await this.clearExistingStandardAuthRequestAndStartNewRequest();
+ }
+
+ // Request denied, so we'll clear the cache and start a new request.
+ if (authRequestResponse.isAnswered && !authRequestResponse.requestApproved) {
+ return await this.clearExistingStandardAuthRequestAndStartNewRequest();
+ }
+
+ // Request approved, so we'll log the user in.
+ if (authRequestResponse.requestApproved) {
+ const userHasAuthenticatedViaSSO = this.authStatus === AuthenticationStatus.Locked;
+ if (userHasAuthenticatedViaSSO) {
+ // [Standard Flow 3-4] Handle authenticated SSO TD user flows
+ return await this.handleAuthenticatedFlows(authRequestResponse);
+ } else {
+ // [Standard Flow 1-2] Handle unauthenticated user flows
+ return await this.handleUnauthenticatedFlows(authRequestResponse, requestId);
}
}
+
+ // At this point, we know that the request is still pending, so we'll start a hub connection to listen for a response.
+ await this.anonymousHubService.createHubConnection(requestId);
} catch (error) {
if (error instanceof ErrorResponse) {
await this.router.navigate([this.backToRoute]);
this.validationService.showError(error);
- return;
}
-
this.logService.error(error);
- } finally {
- // Manually clean out the cache to make sure sensitive
- // data does not persist longer than it needs to.
- this.loginViaAuthRequestCacheService.clearCacheLoginView();
}
+
+ setTimeout(() => {
+ this.showResendNotification = true;
+ }, this.showResendNotificationTimeoutSeconds * 1000);
}
private async handleAuthenticatedFlows(authRequestResponse: AuthRequestResponse) {
+ // [Standard Flow 3-4] Handle authenticated SSO TD user flows
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (!userId) {
this.logService.error(
@@ -599,6 +639,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
authRequestResponse: AuthRequestResponse,
requestId: string,
) {
+ // [Standard Flow 1-2] Handle unauthenticated user flows
const authRequestLoginCredentials = await this.buildAuthRequestLoginCredentials(
requestId,
authRequestResponse,
@@ -609,6 +650,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
+ // Clear the cached auth request from state since we're using it to log in.
+ this.loginViaAuthRequestCacheService.clearCacheLoginView();
+
// Note: keys are set by AuthRequestLoginStrategy success handling
const authResult = await this.loginStrategyService.logIn(authRequestLoginCredentials);
@@ -621,21 +665,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
userId: UserId,
): Promise {
/**
- * See verifyAndHandleApprovedAuthReq() for flow details.
- *
+ * [Flow Type Detection]
* We determine the type of `key` based on the presence or absence of `masterPasswordHash`:
- * - If `masterPasswordHash` has a value, we receive the `key` as an authRequestPublicKey(masterKey) [plus we have authRequestPublicKey(masterPasswordHash)]
- * - If `masterPasswordHash` does not have a value, we receive the `key` as an authRequestPublicKey(userKey)
+ * - If `masterPasswordHash` exists: Standard Flow 1 or 3 (device has masterKey)
+ * - If no `masterPasswordHash`: Standard Flow 2, 4, or Admin Flow (device sends userKey)
*/
if (authRequestResponse.masterPasswordHash) {
- // ...in Standard Auth Request Flow 3
+ // [Standard Flow 1 or 3] Device has masterKey
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
authRequestResponse,
privateKey,
userId,
);
} else {
- // ...in Standard Auth Request Flow 4 or Admin Auth Request Flow
+ // [Standard Flow 2, 4, or Admin Flow] Device sends userKey
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
authRequestResponse,
privateKey,
@@ -643,15 +686,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
}
+ // [Admin Flow Cleanup] Clear one-time use admin auth request
// clear the admin auth request from state so it cannot be used again (it's a one time use)
// TODO: this should eventually be enforced via deleting this on the server once it is used
await this.authRequestService.clearAdminAuthRequest(userId);
+ // [Standard Flow Cleanup] Clear the cached auth request from state
+ this.loginViaAuthRequestCacheService.clearCacheLoginView();
+
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("loginApproved"),
});
+ // [Device Trust] Establish trust if required
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
@@ -686,9 +734,9 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return;
}
- if (!this.authRequest) {
+ if (!this.accessCode) {
this.logService.error(
- "AuthRequest not defined when building auth request login credentials.",
+ "Access code not defined when building auth request login credentials.",
);
return;
}
@@ -711,7 +759,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
return new AuthRequestLoginCredentials(
this.email,
- this.authRequest.accessCode,
+ this.accessCode,
requestId,
null, // no userKey
masterKey,
@@ -725,7 +773,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
);
return new AuthRequestLoginCredentials(
this.email,
- this.authRequest.accessCode,
+ this.accessCode,
requestId,
userKey,
null, // no masterKey
@@ -734,12 +782,20 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
}
}
- private async handleExistingAdminAuthReqDeletedOrDenied(userId: UserId) {
+ private async clearExistingAdminAuthRequestAndStartNewRequest(userId: UserId) {
// clear the admin auth request from state
await this.authRequestService.clearAdminAuthRequest(userId);
// start new auth request
- await this.startAdminAuthRequestLogin();
+ await this.handleNewAdminAuthRequestLogin();
+ }
+
+ private async clearExistingStandardAuthRequestAndStartNewRequest(): Promise {
+ // clear the auth request from state
+ this.loginViaAuthRequestCacheService.clearCacheLoginView();
+
+ // start new auth request
+ await this.handleNewStandardAuthRequestLogin();
}
private async handlePostLoginNavigation(loginResponse: AuthResult) {
diff --git a/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.spec.ts b/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.spec.ts
index 82ac0f1006d..89b78500f1f 100644
--- a/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.spec.ts
+++ b/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.spec.ts
@@ -2,11 +2,9 @@ import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
-import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
-import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
-import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { Utils } from "@bitwarden/common/platform/misc/utils";
import { LoginViaAuthRequestCacheService } from "./default-login-via-auth-request-cache.service";
@@ -39,12 +37,12 @@ describe("LoginViaAuthRequestCache", () => {
});
it("`getCachedLoginViaAuthRequestView` returns the cached data", async () => {
- cacheSignal.set({ ...buildAuthenticMockAuthView() });
+ cacheSignal.set({ ...buildMockState() });
service = testBed.inject(LoginViaAuthRequestCacheService);
await service.init();
expect(service.getCachedLoginViaAuthRequestView()).toEqual({
- ...buildAuthenticMockAuthView(),
+ ...buildMockState(),
});
});
@@ -54,20 +52,19 @@ describe("LoginViaAuthRequestCache", () => {
const parameters = buildAuthenticMockAuthView();
- service.cacheLoginView(
- parameters.authRequest,
- parameters.authRequestResponse,
- parameters.fingerprintPhrase,
- { publicKey: new Uint8Array(), privateKey: new Uint8Array() },
- );
+ service.cacheLoginView(parameters.id, parameters.privateKey, parameters.accessCode);
- expect(cacheSignal.set).toHaveBeenCalledWith(parameters);
+ expect(cacheSignal.set).toHaveBeenCalledWith({
+ id: parameters.id,
+ privateKey: Utils.fromBufferToB64(parameters.privateKey),
+ accessCode: parameters.accessCode,
+ });
});
});
describe("feature disabled", () => {
beforeEach(async () => {
- cacheSignal.set({ ...buildAuthenticMockAuthView() } as LoginViaAuthRequestView);
+ cacheSignal.set({ ...buildMockState() } as LoginViaAuthRequestView);
getFeatureFlag.mockResolvedValue(false);
cacheSetMock.mockClear();
@@ -82,12 +79,7 @@ describe("LoginViaAuthRequestCache", () => {
it("does not update the signal value", () => {
const params = buildAuthenticMockAuthView();
- service.cacheLoginView(
- params.authRequest,
- params.authRequestResponse,
- params.fingerprintPhrase,
- { publicKey: new Uint8Array(), privateKey: new Uint8Array() },
- );
+ service.cacheLoginView(params.id, params.privateKey, params.accessCode);
expect(cacheSignal.set).not.toHaveBeenCalled();
});
@@ -95,17 +87,17 @@ describe("LoginViaAuthRequestCache", () => {
const buildAuthenticMockAuthView = () => {
return {
- fingerprintPhrase: "",
- privateKey: "",
- publicKey: "",
- authRequest: new AuthRequest(
- "test@gmail.com",
- "deviceIdentifier",
- "publicKey",
- AuthRequestType.Unlock,
- "accessCode",
- ),
- authRequestResponse: new AuthRequestResponse({}),
+ id: "testId",
+ privateKey: new Uint8Array(),
+ accessCode: "testAccessCode",
+ };
+ };
+
+ const buildMockState = () => {
+ return {
+ id: "testId",
+ privateKey: Utils.fromBufferToB64(new Uint8Array()),
+ accessCode: "testAccessCode",
};
};
});
diff --git a/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.ts b/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.ts
index 30ba8879546..493fea5c14b 100644
--- a/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.ts
+++ b/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.ts
@@ -1,8 +1,6 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
-import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
-import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { LoginViaAuthRequestView } from "@bitwarden/common/auth/models/view/login-via-auth-request.view";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -45,12 +43,7 @@ export class LoginViaAuthRequestCacheService {
/**
* Update the cache with the new LoginView.
*/
- cacheLoginView(
- authRequest: AuthRequest,
- authRequestResponse: AuthRequestResponse,
- fingerprintPhrase: string,
- keys: { privateKey: Uint8Array | undefined; publicKey: Uint8Array | undefined },
- ): void {
+ cacheLoginView(id: string, privateKey: Uint8Array, accessCode: string): void {
if (!this.featureEnabled) {
return;
}
@@ -59,11 +52,9 @@ export class LoginViaAuthRequestCacheService {
// data can be properly formed when json-ified. If not done, they are not stored properly and
// will not be parsable by the cryptography library after coming out of storage.
this.defaultLoginViaAuthRequestCache.set({
- authRequest,
- authRequestResponse,
- fingerprintPhrase,
- privateKey: keys.privateKey ? Utils.fromBufferToB64(keys.privateKey.buffer) : undefined,
- publicKey: keys.publicKey ? Utils.fromBufferToB64(keys.publicKey.buffer) : undefined,
+ id: id,
+ privateKey: Utils.fromBufferToB64(privateKey.buffer),
+ accessCode: accessCode,
} as LoginViaAuthRequestView);
}
diff --git a/libs/common/src/auth/models/view/login-via-auth-request.view.ts b/libs/common/src/auth/models/view/login-via-auth-request.view.ts
index 0691b8efd86..c2113415cfd 100644
--- a/libs/common/src/auth/models/view/login-via-auth-request.view.ts
+++ b/libs/common/src/auth/models/view/login-via-auth-request.view.ts
@@ -1,17 +1,16 @@
import { Jsonify } from "type-fest";
-import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
-import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { View } from "@bitwarden/common/models/view/view";
export class LoginViaAuthRequestView implements View {
- authRequest: AuthRequest | undefined = undefined;
- authRequestResponse: AuthRequestResponse | undefined = undefined;
- fingerprintPhrase: string | undefined = undefined;
+ id: string | undefined = undefined;
+ accessCode: string | undefined = undefined;
privateKey: string | undefined = undefined;
- publicKey: string | undefined = undefined;
- static fromJSON(obj: Partial>): LoginViaAuthRequestView {
+ static fromJSON(obj: Partial>): LoginViaAuthRequestView | null {
+ if (obj == null) {
+ return null;
+ }
return Object.assign(new LoginViaAuthRequestView(), obj);
}
}
diff --git a/libs/common/src/billing/services/account/billing-account-profile-state.service.ts b/libs/common/src/billing/services/account/billing-account-profile-state.service.ts
index 155ce1493b4..efb22086075 100644
--- a/libs/common/src/billing/services/account/billing-account-profile-state.service.ts
+++ b/libs/common/src/billing/services/account/billing-account-profile-state.service.ts
@@ -68,15 +68,18 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP
this.hasPremiumFromAnyOrganization$(userId),
]).pipe(
concatMap(async ([hasPremiumPersonally, hasPremiumFromOrg]) => {
- const isCloud = !this.platformUtilsService.isSelfHost();
-
- let billing = null;
- if (isCloud) {
- billing = await this.apiService.getUserBillingHistory();
+ if (hasPremiumPersonally === true || !hasPremiumFromOrg === true) {
+ return true;
}
- const cloudAndBillingHistory = isCloud && !billing?.hasNoHistory;
- return hasPremiumPersonally || !hasPremiumFromOrg || cloudAndBillingHistory;
+ const isCloud = !this.platformUtilsService.isSelfHost();
+
+ if (isCloud) {
+ const billing = await this.apiService.getUserBillingHistory();
+ return !billing?.hasNoHistory;
+ }
+
+ return false;
}),
);
}
diff --git a/libs/common/src/enums/notification-type.enum.ts b/libs/common/src/enums/notification-type.enum.ts
index c366af1eb61..0e4d0bfee3d 100644
--- a/libs/common/src/enums/notification-type.enum.ts
+++ b/libs/common/src/enums/notification-type.enum.ts
@@ -26,4 +26,6 @@ export enum NotificationType {
SyncOrganizationCollectionSettingChanged = 19,
Notification = 20,
NotificationStatus = 21,
+
+ PendingSecurityTasks = 22,
}
diff --git a/libs/common/src/platform/sync/core-sync.service.ts b/libs/common/src/platform/sync/core-sync.service.ts
index 92a10baf6d2..1865ffb852f 100644
--- a/libs/common/src/platform/sync/core-sync.service.ts
+++ b/libs/common/src/platform/sync/core-sync.service.ts
@@ -105,14 +105,14 @@ export abstract class CoreSyncService implements SyncService {
if (remoteFolder != null) {
await this.folderService.upsert(new FolderData(remoteFolder), userId);
this.messageSender.send("syncedUpsertedFolder", { folderId: notification.id });
- return this.syncCompleted(true);
+ return this.syncCompleted(true, userId);
}
}
} catch (e) {
this.logService.error(e);
}
}
- return this.syncCompleted(false);
+ return this.syncCompleted(false, userId);
}
async syncDeleteFolder(notification: SyncFolderNotification, userId: UserId): Promise {
@@ -123,10 +123,10 @@ export abstract class CoreSyncService implements SyncService {
if (authStatus >= AuthenticationStatus.Locked) {
await this.folderService.delete(notification.id, userId);
this.messageSender.send("syncedDeletedFolder", { folderId: notification.id });
- this.syncCompleted(true);
+ this.syncCompleted(true, userId);
return true;
}
- return this.syncCompleted(false);
+ return this.syncCompleted(false, userId);
}
async syncUpsertCipher(
@@ -183,18 +183,18 @@ export abstract class CoreSyncService implements SyncService {
if (remoteCipher != null) {
await this.cipherService.upsert(new CipherData(remoteCipher));
this.messageSender.send("syncedUpsertedCipher", { cipherId: notification.id });
- return this.syncCompleted(true);
+ return this.syncCompleted(true, userId);
}
}
} catch (e) {
if (e != null && e.statusCode === 404 && isEdit) {
await this.cipherService.delete(notification.id, userId);
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
- return this.syncCompleted(true);
+ return this.syncCompleted(true, userId);
}
}
}
- return this.syncCompleted(false);
+ return this.syncCompleted(false, userId);
}
async syncDeleteCipher(notification: SyncCipherNotification, userId: UserId): Promise {
@@ -204,9 +204,9 @@ export abstract class CoreSyncService implements SyncService {
if (authStatus >= AuthenticationStatus.Locked) {
await this.cipherService.delete(notification.id, userId);
this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id });
- return this.syncCompleted(true);
+ return this.syncCompleted(true, userId);
}
- return this.syncCompleted(false);
+ return this.syncCompleted(false, userId);
}
async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise {
@@ -234,14 +234,15 @@ export abstract class CoreSyncService implements SyncService {
if (remoteSend != null) {
await this.sendService.upsert(new SendData(remoteSend));
this.messageSender.send("syncedUpsertedSend", { sendId: notification.id });
- return this.syncCompleted(true);
+ return this.syncCompleted(true, activeUserId);
}
}
} catch (e) {
this.logService.error(e);
}
}
- return this.syncCompleted(false);
+ // TODO: Update syncCompleted userId when send service allows modification of non-active users
+ return this.syncCompleted(false, undefined);
}
async syncDeleteSend(notification: SyncSendNotification): Promise {
@@ -249,10 +250,11 @@ export abstract class CoreSyncService implements SyncService {
if (await this.stateService.getIsAuthenticated()) {
await this.sendService.delete(notification.id);
this.messageSender.send("syncedDeletedSend", { sendId: notification.id });
- this.syncCompleted(true);
+ // TODO: Update syncCompleted userId when send service allows modification of non-active users
+ this.syncCompleted(true, undefined);
return true;
}
- return this.syncCompleted(false);
+ return this.syncCompleted(false, undefined);
}
// Helpers
@@ -262,9 +264,9 @@ export abstract class CoreSyncService implements SyncService {
this.messageSender.send("syncStarted");
}
- protected syncCompleted(successfully: boolean): boolean {
+ protected syncCompleted(successfully: boolean, userId: UserId | undefined): boolean {
this.syncInProgress = false;
- this.messageSender.send("syncCompleted", { successfully: successfully });
+ this.messageSender.send("syncCompleted", { successfully: successfully, userId });
return successfully;
}
}
diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts
index 30a59e9c165..a6b1b974645 100644
--- a/libs/common/src/platform/sync/default-sync.service.ts
+++ b/libs/common/src/platform/sync/default-sync.service.ts
@@ -3,9 +3,9 @@
import { firstValueFrom, map } from "rxjs";
import {
- CollectionService,
CollectionData,
CollectionDetailsResponse,
+ CollectionService,
} from "@bitwarden/admin-console/common";
import { KeyService } from "@bitwarden/key-management";
@@ -107,7 +107,7 @@ export class DefaultSyncService extends CoreSyncService {
this.syncStarted();
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
if (authStatus === AuthenticationStatus.LoggedOut) {
- return this.syncCompleted(false);
+ return this.syncCompleted(false, userId);
}
const now = new Date();
@@ -116,14 +116,14 @@ export class DefaultSyncService extends CoreSyncService {
needsSync = await this.needsSyncing(forceSync);
} catch (e) {
if (allowThrowOnError) {
- this.syncCompleted(false);
+ this.syncCompleted(false, userId);
throw e;
}
}
if (!needsSync) {
await this.setLastSync(now, userId);
- return this.syncCompleted(false);
+ return this.syncCompleted(false, userId);
}
try {
@@ -139,13 +139,13 @@ export class DefaultSyncService extends CoreSyncService {
await this.syncPolicies(response.policies, response.profile.id);
await this.setLastSync(now, userId);
- return this.syncCompleted(true);
+ return this.syncCompleted(true, userId);
} catch (e) {
if (allowThrowOnError) {
- this.syncCompleted(false);
+ this.syncCompleted(false, userId);
throw e;
} else {
- return this.syncCompleted(false);
+ return this.syncCompleted(false, userId);
}
}
}
diff --git a/libs/common/src/vault/tasks/abstractions/task.service.ts b/libs/common/src/vault/tasks/abstractions/task.service.ts
index 4a0c086330e..79cefff0b71 100644
--- a/libs/common/src/vault/tasks/abstractions/task.service.ts
+++ b/libs/common/src/vault/tasks/abstractions/task.service.ts
@@ -1,4 +1,4 @@
-import { Observable } from "rxjs";
+import { Observable, Subscription } from "rxjs";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
@@ -43,4 +43,9 @@ export abstract class TaskService {
* @param userId - The user who is completing the task.
*/
abstract markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise;
+
+ /**
+ * Creates a subscription for pending security task notifications or completed syncs for unlocked users.
+ */
+ abstract listenForTaskNotifications(): Subscription;
}
diff --git a/libs/common/src/vault/tasks/services/default-task.service.spec.ts b/libs/common/src/vault/tasks/services/default-task.service.spec.ts
index c38fa0e9e72..4d468d09766 100644
--- a/libs/common/src/vault/tasks/services/default-task.service.spec.ts
+++ b/libs/common/src/vault/tasks/services/default-task.service.spec.ts
@@ -1,9 +1,15 @@
-import { BehaviorSubject, firstValueFrom } from "rxjs";
+import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
+import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
+import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
+import { NotificationType } from "@bitwarden/common/enums";
+import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { Message, MessageListener } from "@bitwarden/common/platform/messaging";
+import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
@@ -16,10 +22,13 @@ import { DefaultTaskService } from "./default-task.service";
describe("Default task service", () => {
let fakeStateProvider: FakeStateProvider;
+ const userId = "user-id" as UserId;
const mockApiSend = jest.fn();
const mockGetAllOrgs$ = jest.fn();
const mockGetFeatureFlag$ = jest.fn();
-
+ const mockAuthStatuses$ = new BehaviorSubject>({});
+ const mockNotifications$ = new Subject();
+ const mockMessages$ = new Subject>>();
let service: DefaultTaskService;
beforeEach(async () => {
@@ -27,12 +36,15 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockClear();
mockGetFeatureFlag$.mockClear();
- fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
+ fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
service = new DefaultTaskService(
fakeStateProvider,
{ send: mockApiSend } as unknown as ApiService,
{ organizations$: mockGetAllOrgs$ } as unknown as OrganizationService,
{ getFeatureFlag$: mockGetFeatureFlag$ } as unknown as ConfigService,
+ { authStatuses$: mockAuthStatuses$.asObservable() } as unknown as AuthService,
+ { notifications$: mockNotifications$.asObservable() } as unknown as NotificationsService,
+ { allMessages$: mockMessages$.asObservable() } as unknown as MessageListener,
);
});
@@ -257,4 +269,235 @@ describe("Default task service", () => {
]);
});
});
+
+ describe("listenForTaskNotifications()", () => {
+ it("should not subscribe to notifications when there are no unlocked users", () => {
+ mockAuthStatuses$.next({
+ [userId]: AuthenticationStatus.Locked,
+ });
+
+ service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
+ const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn());
+ const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn());
+
+ const subscription = service.listenForTaskNotifications();
+
+ expect(notificationHelper$).not.toHaveBeenCalled();
+ expect(syncCompletedHelper$).not.toHaveBeenCalled();
+ subscription.unsubscribe();
+ });
+
+ it("should not subscribe to notifications when no users have tasks enabled", () => {
+ mockAuthStatuses$.next({
+ [userId]: AuthenticationStatus.Unlocked,
+ });
+
+ service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(false));
+ const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn());
+ const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn());
+
+ const subscription = service.listenForTaskNotifications();
+
+ expect(notificationHelper$).not.toHaveBeenCalled();
+ expect(syncCompletedHelper$).not.toHaveBeenCalled();
+ subscription.unsubscribe();
+ });
+
+ it("should subscribe to notifications when there are unlocked users with tasks enabled", () => {
+ mockAuthStatuses$.next({
+ [userId]: AuthenticationStatus.Unlocked,
+ });
+ service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
+
+ const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn());
+ const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn());
+
+ const subscription = service.listenForTaskNotifications();
+
+ expect(notificationHelper$).toHaveBeenCalled();
+ expect(syncCompletedHelper$).toHaveBeenCalled();
+ subscription.unsubscribe();
+ });
+
+ describe("notification handling", () => {
+ it("should refresh tasks when a notification is received for an allowed user", async () => {
+ mockAuthStatuses$.next({
+ [userId]: AuthenticationStatus.Unlocked,
+ });
+ service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
+
+ const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn(
+ () => new Subject(),
+ ));
+ const refreshTasks = jest.spyOn(service, "refreshTasks");
+
+ const subscription = service.listenForTaskNotifications();
+
+ const notification = {
+ type: NotificationType.PendingSecurityTasks,
+ } as NotificationResponse;
+ mockNotifications$.next([notification, userId]);
+
+ await new Promise(process.nextTick);
+
+ expect(syncCompletedHelper$).toHaveBeenCalled();
+ expect(refreshTasks).toHaveBeenCalledWith(userId);
+ subscription.unsubscribe();
+ });
+
+ it("should ignore notifications for other users", async () => {
+ mockAuthStatuses$.next({
+ [userId]: AuthenticationStatus.Unlocked,
+ });
+ service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
+
+ const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn(
+ () => new Subject(),
+ ));
+ const refreshTasks = jest.spyOn(service, "refreshTasks");
+
+ const subscription = service.listenForTaskNotifications();
+
+ const notification = {
+ type: NotificationType.PendingSecurityTasks,
+ } as NotificationResponse;
+ mockNotifications$.next([notification, "other-user-id" as UserId]);
+
+ await new Promise(process.nextTick);
+
+ expect(syncCompletedHelper$).toHaveBeenCalled();
+ expect(refreshTasks).not.toHaveBeenCalledWith(userId);
+ subscription.unsubscribe();
+ });
+
+ it("should ignore other notifications types", async () => {
+ mockAuthStatuses$.next({
+ [userId]: AuthenticationStatus.Unlocked,
+ });
+ service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
+
+ const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn(
+ () => new Subject(),
+ ));
+ const refreshTasks = jest.spyOn(service, "refreshTasks");
+
+ const subscription = service.listenForTaskNotifications();
+
+ const notification = {
+ type: NotificationType.SyncSettings,
+ } as NotificationResponse;
+ mockNotifications$.next([notification, userId]);
+
+ await new Promise(process.nextTick);
+
+ expect(syncCompletedHelper$).toHaveBeenCalled();
+ expect(refreshTasks).not.toHaveBeenCalledWith(userId);
+ subscription.unsubscribe();
+ });
+ });
+
+ describe("sync completed handling", () => {
+ it("should refresh tasks when a sync completed message is received for an allowed user", async () => {
+ mockAuthStatuses$.next({
+ [userId]: AuthenticationStatus.Unlocked,
+ });
+ service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
+
+ const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
+ () => new Subject(),
+ ));
+ const refreshTasks = jest.spyOn(service, "refreshTasks");
+
+ const subscription = service.listenForTaskNotifications();
+
+ mockMessages$.next({
+ command: "syncCompleted",
+ userId,
+ successfully: true,
+ });
+
+ await new Promise(process.nextTick);
+
+ expect(notificationHelper$).toHaveBeenCalled();
+ expect(refreshTasks).toHaveBeenCalledWith(userId);
+ subscription.unsubscribe();
+ });
+
+ it("should ignore non syncCompleted messages", async () => {
+ mockAuthStatuses$.next({
+ [userId]: AuthenticationStatus.Unlocked,
+ });
+ service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
+
+ const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
+ () => new Subject(),
+ ));
+ const refreshTasks = jest.spyOn(service, "refreshTasks");
+
+ const subscription = service.listenForTaskNotifications();
+
+ mockMessages$.next({
+ command: "other-command",
+ });
+
+ await new Promise(process.nextTick);
+
+ expect(notificationHelper$).toHaveBeenCalled();
+ expect(refreshTasks).not.toHaveBeenCalledWith(userId);
+ subscription.unsubscribe();
+ });
+
+ it("should ignore failed sync messages", async () => {
+ mockAuthStatuses$.next({
+ [userId]: AuthenticationStatus.Unlocked,
+ });
+ service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
+
+ const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
+ () => new Subject(),
+ ));
+ const refreshTasks = jest.spyOn(service, "refreshTasks");
+
+ const subscription = service.listenForTaskNotifications();
+
+ mockMessages$.next({
+ command: "syncCompleted",
+ userId,
+ successfully: false,
+ });
+
+ await new Promise(process.nextTick);
+
+ expect(notificationHelper$).toHaveBeenCalled();
+ expect(refreshTasks).not.toHaveBeenCalledWith(userId);
+ subscription.unsubscribe();
+ });
+
+ it("should ignore sync messages for other users", async () => {
+ mockAuthStatuses$.next({
+ [userId]: AuthenticationStatus.Unlocked,
+ });
+ service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true));
+
+ const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn(
+ () => new Subject(),
+ ));
+ const refreshTasks = jest.spyOn(service, "refreshTasks");
+
+ const subscription = service.listenForTaskNotifications();
+
+ mockMessages$.next({
+ command: "syncCompleted",
+ userId: "other-user-id" as UserId,
+ successfully: true,
+ });
+
+ await new Promise(process.nextTick);
+
+ expect(notificationHelper$).toHaveBeenCalled();
+ expect(refreshTasks).not.toHaveBeenCalledWith(userId);
+ subscription.unsubscribe();
+ });
+ });
+ });
});
diff --git a/libs/common/src/vault/tasks/services/default-task.service.ts b/libs/common/src/vault/tasks/services/default-task.service.ts
index ff370229663..016eed2e7d6 100644
--- a/libs/common/src/vault/tasks/services/default-task.service.ts
+++ b/libs/common/src/vault/tasks/services/default-task.service.ts
@@ -1,10 +1,15 @@
-import { combineLatest, map, switchMap } from "rxjs";
+import { combineLatest, filter, map, merge, Observable, of, Subscription, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
+import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
+import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
+import { NotificationType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
+import { MessageListener } from "@bitwarden/common/platform/messaging";
+import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
@@ -14,12 +19,21 @@ import { SecurityTaskStatus } from "../enums";
import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models";
import { SECURITY_TASKS } from "../state/security-task.state";
+const getUnlockedUserIds = map, UserId[]>((authStatuses) =>
+ Object.entries(authStatuses ?? {})
+ .filter(([, status]) => status >= AuthenticationStatus.Unlocked)
+ .map(([userId]) => userId as UserId),
+);
+
export class DefaultTaskService implements TaskService {
constructor(
private stateProvider: StateProvider,
private apiService: ApiService,
private organizationService: OrganizationService,
private configService: ConfigService,
+ private authService: AuthService,
+ private notificationService: NotificationsService,
+ private messageListener: MessageListener,
) {}
tasksEnabled$ = perUserCache$((userId) => {
@@ -36,6 +50,7 @@ export class DefaultTaskService implements TaskService {
switchMap(async (tasks) => {
if (tasks == null) {
await this.fetchTasksFromApi(userId);
+ return null;
}
return tasks;
}),
@@ -97,4 +112,66 @@ export class DefaultTaskService implements TaskService {
): Promise {
return this.taskState(userId).update(() => tasks);
}
+
+ /**
+ * Helper observable that filters the list of unlocked user IDs to only those with tasks enabled.
+ * @private
+ */
+ private getOnlyTaskEnabledUsers = switchMap>((unlockedUserIds) => {
+ if (unlockedUserIds.length === 0) {
+ return of([]);
+ }
+
+ return combineLatest(
+ unlockedUserIds.map((userId) =>
+ this.tasksEnabled$(userId).pipe(map((enabled) => (enabled ? userId : null))),
+ ),
+ ).pipe(map((userIds) => userIds.filter((userId) => userId !== null) as UserId[]));
+ });
+
+ /**
+ * Helper observable that emits whenever a security task notification is received for a user in the provided list.
+ * @private
+ */
+ private securityTaskNotifications$(filterByUserIds: UserId[]) {
+ return this.notificationService.notifications$.pipe(
+ filter(
+ ([notification, userId]) =>
+ notification.type === NotificationType.PendingSecurityTasks &&
+ filterByUserIds.includes(userId),
+ ),
+ map(([, userId]) => userId),
+ );
+ }
+
+ /**
+ * Helper observable that emits whenever a sync is completed for a user in the provided list.
+ */
+ private syncCompletedMessage$(filterByUserIds: UserId[]) {
+ return this.messageListener.allMessages$.pipe(
+ filter((msg) => msg.command === "syncCompleted" && !!msg.successfully && !!msg.userId),
+ map((msg) => msg.userId as UserId),
+ filter((userId) => filterByUserIds.includes(userId)),
+ );
+ }
+
+ /**
+ * Creates a subscription for pending security task notifications or completed syncs for unlocked users.
+ */
+ listenForTaskNotifications(): Subscription {
+ return this.authService.authStatuses$
+ .pipe(
+ getUnlockedUserIds,
+ this.getOnlyTaskEnabledUsers,
+ filter((allowedUserIds) => allowedUserIds.length > 0),
+ switchMap((allowedUserIds) =>
+ merge(
+ this.securityTaskNotifications$(allowedUserIds),
+ this.syncCompletedMessage$(allowedUserIds),
+ ),
+ ),
+ switchMap((userId) => this.refreshTasks(userId)),
+ )
+ .subscribe();
+ }
}
diff --git a/libs/components/src/badge/badge.component.ts b/libs/components/src/badge/badge.component.ts
index 0f8a64ee998..893257ff225 100644
--- a/libs/components/src/badge/badge.component.ts
+++ b/libs/components/src/badge/badge.component.ts
@@ -5,7 +5,14 @@ import { Component, ElementRef, HostBinding, Input } from "@angular/core";
import { FocusableElement } from "../shared/focusable-element";
-export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
+export type BadgeVariant =
+ | "primary"
+ | "secondary"
+ | "success"
+ | "danger"
+ | "warning"
+ | "info"
+ | "notification";
const styles: Record = {
primary: ["tw-bg-primary-100", "tw-border-primary-700", "!tw-text-primary-700"],
@@ -14,6 +21,11 @@ const styles: Record = {
danger: ["tw-bg-danger-100", "tw-border-danger-700", "!tw-text-danger-700"],
warning: ["tw-bg-warning-100", "tw-border-warning-700", "!tw-text-warning-700"],
info: ["tw-bg-info-100", "tw-border-info-700", "!tw-text-info-700"],
+ notification: [
+ "tw-bg-notification-100",
+ "tw-border-notification-600",
+ "!tw-text-notification-600",
+ ],
};
const hoverStyles: Record = {
@@ -27,6 +39,11 @@ const hoverStyles: Record = {
danger: ["hover:tw-bg-danger-600", "hover:tw-border-danger-600", "hover:!tw-text-contrast"],
warning: ["hover:tw-bg-warning-600", "hover:tw-border-warning-600", "hover:!tw-text-black"],
info: ["hover:tw-bg-info-600", "hover:tw-border-info-600", "hover:!tw-text-black"],
+ notification: [
+ "hover:tw-bg-notification-600",
+ "hover:tw-border-notification-600",
+ "hover:!tw-text-contrast",
+ ],
};
@Component({
diff --git a/libs/components/src/badge/badge.stories.ts b/libs/components/src/badge/badge.stories.ts
index bff9eec6163..6473ba8c867 100644
--- a/libs/components/src/badge/badge.stories.ts
+++ b/libs/components/src/badge/badge.stories.ts
@@ -36,6 +36,7 @@ export const Variants: Story = {
Danger
Warning
Info
+ Notification
Hover
Primary
@@ -44,6 +45,7 @@ export const Variants: Story = {
Danger
Warning
Info
+ Notification
Focus Visible
Primary
@@ -52,6 +54,7 @@ export const Variants: Story = {
Danger
Warning
Info
+ Notification
Disabled
Primary
@@ -60,6 +63,7 @@ export const Variants: Story = {
Danger
Warning
Info
+ Notification
`,
}),
};
@@ -112,6 +116,13 @@ export const Info: Story = {
},
};
+export const Notification: Story = {
+ ...Primary,
+ args: {
+ variant: "notification",
+ },
+};
+
export const Truncated: Story = {
...Primary,
args: {
diff --git a/libs/importer/src/components/import.component.html b/libs/importer/src/components/import.component.html
index 072271f8205..23a9afa1111 100644
--- a/libs/importer/src/components/import.component.html
+++ b/libs/importer/src/components/import.component.html
@@ -165,7 +165,12 @@
-->
The process is exactly the same as importing from Google Chrome.
diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts
index bd3202067ca..a8c4b4e0a8a 100644
--- a/libs/importer/src/models/import-options.ts
+++ b/libs/importer/src/models/import-options.ts
@@ -46,6 +46,7 @@ export const regularImportOptions = [
{ id: "ascendocsv", name: "Ascendo DataVault (csv)" },
{ id: "meldiumcsv", name: "Meldium (csv)" },
{ id: "passkeepcsv", name: "PassKeep (csv)" },
+ { id: "edgecsv", name: "Edge (csv)" },
{ id: "operacsv", name: "Opera (csv)" },
{ id: "vivaldicsv", name: "Vivaldi (csv)" },
{ id: "gnomejson", name: "GNOME Passwords and Keys/Seahorse (json)" },
diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts
index cc9cdc39320..bd18e78d542 100644
--- a/libs/importer/src/services/import.service.ts
+++ b/libs/importer/src/services/import.service.ts
@@ -239,6 +239,7 @@ export class ImportService implements ImportServiceAbstraction {
return new PadlockCsvImporter();
case "keepass2xml":
return new KeePass2XmlImporter();
+ case "edgecsv":
case "chromecsv":
case "operacsv":
case "vivaldicsv":
diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts
index 1eb9b88b072..2f98538caad 100644
--- a/libs/key-management-ui/src/index.ts
+++ b/libs/key-management-ui/src/index.ts
@@ -4,3 +4,6 @@
export { LockComponent } from "./lock/components/lock.component";
export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service";
+export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component";
+export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component";
+export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";
diff --git a/libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.html b/libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.html
new file mode 100644
index 00000000000..f9aee3c1470
--- /dev/null
+++ b/libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.html
@@ -0,0 +1,26 @@
+
+
+ {{ "userkeyRotationDisclaimerTitle" | i18n }}
+
+
+ {{ "userkeyRotationDisclaimerDescription" | i18n }}
+
+
+ {{ "userkeyRotationDisclaimerAccountRecoveryOrgsText" | i18n: params.orgName }}
+
+ 0">
+ {{
+ "userkeyRotationDisclaimerEmergencyAccessText" | i18n: params.numberOfEmergencyAccessUsers
+ }}
+
+
+
+
+
+ {{ "continue" | i18n }}
+
+
+ {{ "close" | i18n }}
+
+
+
diff --git a/libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.ts b/libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.ts
new file mode 100644
index 00000000000..51e6058ae5b
--- /dev/null
+++ b/libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.ts
@@ -0,0 +1,58 @@
+import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
+import { CommonModule } from "@angular/common";
+import { Component, Inject } from "@angular/core";
+import { FormsModule, ReactiveFormsModule } from "@angular/forms";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import {
+ AsyncActionsModule,
+ ButtonModule,
+ DialogModule,
+ DialogService,
+} from "@bitwarden/components";
+
+type KeyRotationTrustDialogData = {
+ orgName?: string;
+ numberOfEmergencyAccessUsers: number;
+};
+
+@Component({
+ selector: "key-rotation-trust-info",
+ templateUrl: "key-rotation-trust-info.component.html",
+ standalone: true,
+ imports: [
+ CommonModule,
+ JslibModule,
+ DialogModule,
+ ButtonModule,
+ ReactiveFormsModule,
+ AsyncActionsModule,
+ FormsModule,
+ ],
+})
+export class KeyRotationTrustInfoComponent {
+ constructor(
+ @Inject(DIALOG_DATA) protected params: KeyRotationTrustDialogData,
+ private logService: LogService,
+ private dialogRef: DialogRef,
+ ) {}
+
+ async submit() {
+ try {
+ this.dialogRef.close(true);
+ } catch (e) {
+ this.logService.error(e);
+ }
+ }
+ /**
+ * Strongly typed helper to open a KeyRotationTrustComponent
+ * @param dialogService Instance of the dialog service that will be used to open the dialog
+ * @param data The data to pass to the dialog
+ */
+ static open(dialogService: DialogService, data: KeyRotationTrustDialogData) {
+ return dialogService.open(KeyRotationTrustInfoComponent, {
+ data,
+ });
+ }
+}
diff --git a/libs/key-management-ui/src/trust/account-recovery-trust.component.html b/libs/key-management-ui/src/trust/account-recovery-trust.component.html
new file mode 100644
index 00000000000..5829558dd69
--- /dev/null
+++ b/libs/key-management-ui/src/trust/account-recovery-trust.component.html
@@ -0,0 +1,21 @@
+
+
+ {{ "orgTrustWarning" | i18n }}
+
+ {{ "fingerprintPhrase" | i18n }} {{ fingerprint }}
+
+
+
+
+ {{ "trust" | i18n }}
+
+
+ {{ "doNotTrust" | i18n }}
+
+
+
diff --git a/libs/key-management-ui/src/trust/account-recovery-trust.component.ts b/libs/key-management-ui/src/trust/account-recovery-trust.component.ts
new file mode 100644
index 00000000000..e1ec390a460
--- /dev/null
+++ b/libs/key-management-ui/src/trust/account-recovery-trust.component.ts
@@ -0,0 +1,94 @@
+import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
+import { CommonModule } from "@angular/common";
+import { Component, OnInit, Inject } from "@angular/core";
+import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import {
+ AsyncActionsModule,
+ ButtonModule,
+ CalloutModule,
+ DialogModule,
+ DialogService,
+ FormFieldModule,
+ LinkModule,
+ TypographyModule,
+} from "@bitwarden/components";
+import { KeyService } from "@bitwarden/key-management";
+
+type AccountRecoveryTrustDialogData = {
+ /** display name of the user */
+ name: string;
+ /** org id */
+ orgId: string;
+ /** org public key */
+ publicKey: Uint8Array;
+};
+@Component({
+ selector: "account-recovery-trust",
+ templateUrl: "account-recovery-trust.component.html",
+ standalone: true,
+ imports: [
+ CommonModule,
+ JslibModule,
+ DialogModule,
+ ButtonModule,
+ LinkModule,
+ TypographyModule,
+ ReactiveFormsModule,
+ FormFieldModule,
+ AsyncActionsModule,
+ FormsModule,
+ CalloutModule,
+ ],
+})
+export class AccountRecoveryTrustComponent implements OnInit {
+ loading = true;
+ fingerprint: string = "";
+ confirmForm = this.formBuilder.group({});
+
+ constructor(
+ @Inject(DIALOG_DATA) protected params: AccountRecoveryTrustDialogData,
+ private formBuilder: FormBuilder,
+ private keyService: KeyService,
+ private logService: LogService,
+ private dialogRef: DialogRef,
+ ) {}
+
+ async ngOnInit() {
+ try {
+ const fingerprint = await this.keyService.getFingerprint(
+ this.params.orgId,
+ this.params.publicKey,
+ );
+ if (fingerprint != null) {
+ this.fingerprint = fingerprint.join("-");
+ }
+ } catch (e) {
+ this.logService.error(e);
+ }
+ this.loading = false;
+ }
+
+ async submit() {
+ if (this.loading) {
+ return;
+ }
+
+ this.dialogRef.close(true);
+ }
+ /**
+ * Strongly typed helper to open a AccountRecoveryTrustComponent
+ * @param dialogService Instance of the dialog service that will be used to open the dialog
+ * @param data The data to pass to the dialog
+ */
+ static open(dialogService: DialogService, data: AccountRecoveryTrustDialogData) {
+ return dialogService.open(
+ AccountRecoveryTrustComponent,
+ {
+ data,
+ },
+ );
+ }
+}
diff --git a/libs/key-management-ui/src/trust/emergency-access-trust.component.html b/libs/key-management-ui/src/trust/emergency-access-trust.component.html
new file mode 100644
index 00000000000..13f1ce4cc8e
--- /dev/null
+++ b/libs/key-management-ui/src/trust/emergency-access-trust.component.html
@@ -0,0 +1,32 @@
+
+
+ {{ "emergencyAccessTrustWarning" | i18n }}
+
+ {{ "fingerprintEnsureIntegrityVerify" | i18n }}
+
+ {{ "learnMore" | i18n }}
+
+
+ {{ fingerprint }}
+
+
+
+
+ {{ "trust" | i18n }}
+
+
+ {{ "doNotTrust" | i18n }}
+
+
+
diff --git a/libs/key-management-ui/src/trust/emergency-access-trust.component.ts b/libs/key-management-ui/src/trust/emergency-access-trust.component.ts
new file mode 100644
index 00000000000..29ee64798e9
--- /dev/null
+++ b/libs/key-management-ui/src/trust/emergency-access-trust.component.ts
@@ -0,0 +1,94 @@
+import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
+import { CommonModule } from "@angular/common";
+import { Component, OnInit, Inject } from "@angular/core";
+import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
+import {
+ AsyncActionsModule,
+ ButtonModule,
+ CalloutModule,
+ DialogModule,
+ DialogService,
+ FormFieldModule,
+ LinkModule,
+ TypographyModule,
+} from "@bitwarden/components";
+import { KeyService } from "@bitwarden/key-management";
+
+type EmergencyAccessTrustDialogData = {
+ /** display name of the user */
+ name: string;
+ /** userid of the user */
+ userId: string;
+ /** user public key */
+ publicKey: Uint8Array;
+};
+@Component({
+ selector: "emergency-access-trust",
+ templateUrl: "emergency-access-trust.component.html",
+ standalone: true,
+ imports: [
+ CommonModule,
+ JslibModule,
+ DialogModule,
+ ButtonModule,
+ LinkModule,
+ TypographyModule,
+ ReactiveFormsModule,
+ FormFieldModule,
+ AsyncActionsModule,
+ FormsModule,
+ CalloutModule,
+ ],
+})
+export class EmergencyAccessTrustComponent implements OnInit {
+ loading = true;
+ fingerprint: string = "";
+ confirmForm = this.formBuilder.group({});
+
+ constructor(
+ @Inject(DIALOG_DATA) protected params: EmergencyAccessTrustDialogData,
+ private formBuilder: FormBuilder,
+ private keyService: KeyService,
+ private logService: LogService,
+ private dialogRef: DialogRef,
+ ) {}
+
+ async ngOnInit() {
+ try {
+ const fingerprint = await this.keyService.getFingerprint(
+ this.params.userId,
+ this.params.publicKey,
+ );
+ if (fingerprint != null) {
+ this.fingerprint = fingerprint.join("-");
+ }
+ } catch (e) {
+ this.logService.error(e);
+ }
+ this.loading = false;
+ }
+
+ async submit() {
+ if (this.loading) {
+ return;
+ }
+
+ this.dialogRef.close(true);
+ }
+ /**
+ * Strongly typed helper to open a EmergencyAccessTrustComponent
+ * @param dialogService Instance of the dialog service that will be used to open the dialog
+ * @param data The data to pass to the dialog
+ */
+ static open(dialogService: DialogService, data: EmergencyAccessTrustDialogData) {
+ return dialogService.open(
+ EmergencyAccessTrustComponent,
+ {
+ data,
+ },
+ );
+ }
+}
diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts
index 4b257fbbebd..d67fec4c98e 100644
--- a/libs/key-management/src/abstractions/key.service.ts
+++ b/libs/key-management/src/abstractions/key.service.ts
@@ -337,6 +337,17 @@ export abstract class KeyService {
userId: UserId,
): Observable<{ privateKey: UserPrivateKey; publicKey: UserPublicKey } | null>;
+ /**
+ * Gets an observable stream of the given users decrypted private key and public key, guaranteed to be consistent.
+ * Will emit null if the user doesn't have a userkey to decrypt the encrypted private key, or null if the user doesn't have a private key
+ * at all.
+ *
+ * @param userId The user id of the user to get the data for.
+ */
+ abstract userEncryptionKeyPair$(
+ userId: UserId,
+ ): Observable<{ privateKey: UserPrivateKey; publicKey: UserPublicKey } | null>;
+
/**
* Generates a fingerprint phrase for the user based on their public key
*
diff --git a/libs/key-management/src/abstractions/user-key-rotation-key-recovery-provider.abstraction.ts b/libs/key-management/src/abstractions/user-key-rotation-key-recovery-provider.abstraction.ts
new file mode 100644
index 00000000000..12a621e4cac
--- /dev/null
+++ b/libs/key-management/src/abstractions/user-key-rotation-key-recovery-provider.abstraction.ts
@@ -0,0 +1,31 @@
+import { UserId } from "@bitwarden/common/types/guid";
+import { UserKey } from "@bitwarden/common/types/key";
+
+/**
+ * Constructs key rotation requests for key recovery encryption of the userkey.
+ * @typeparam TRequest A request model that contains the newly encrypted userkey must have an id property
+ */
+export interface UserKeyRotationKeyRecoveryProvider<
+ TRequest extends { id: string } | { organizationId: string },
+ TPublicKeyData,
+> {
+ /**
+ * Get the public keys for this recovery method from the server.
+ * WARNING these are NOT trusted, and need to either be manually trusted by the user, or compared against
+ * a signed trust database for the user. THE SERVER CAN SPOOF THESE.
+ */
+ getPublicKeys(userId: UserId): Promise;
+
+ /**
+ * Provides re-encrypted data for the user key rotation process
+ * @param newUserKey The new user key
+ * @param trustedPublicKeys The public keys that the user trusted
+ * @param userId The owner of the data, useful for fetching data
+ * @returns A list of data that has been re-encrypted with the new user key
+ */
+ getRotatedData(
+ newUserKey: UserKey,
+ trustedPublicKeys: Uint8Array[],
+ userId: UserId,
+ ): Promise;
+}
diff --git a/libs/key-management/src/index.ts b/libs/key-management/src/index.ts
index f3efd75b098..d21b79540e0 100644
--- a/libs/key-management/src/index.ts
+++ b/libs/key-management/src/index.ts
@@ -10,6 +10,7 @@ export * from "./biometrics/biometric.state";
export { CipherDecryptionKeys, KeyService } from "./abstractions/key.service";
export { DefaultKeyService } from "./key.service";
export { UserKeyRotationDataProvider } from "./abstractions/user-key-rotation-data-provider.abstraction";
+export { UserKeyRotationKeyRecoveryProvider } from "./abstractions/user-key-rotation-key-recovery-provider.abstraction";
export {
PBKDF2KdfConfig,
Argon2KdfConfig,
diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html
index c441c921c39..99f1b1e47f5 100644
--- a/libs/vault/src/cipher-view/cipher-view.component.html
+++ b/libs/vault/src/cipher-view/cipher-view.component.html
@@ -10,7 +10,7 @@
[title]="''"
>
-
+
{{ "changeAtRiskPassword" | i18n }}
diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html
index e44f1ebb3d8..2c1acf96a14 100644
--- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html
+++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html
@@ -90,7 +90,7 @@
>
-
+
{{ "changeAtRiskPassword" | i18n }}
diff --git a/package-lock.json b/package-lock.json
index d3bb0b9eb0c..7d5d56b46bd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -244,7 +244,7 @@
},
"apps/web": {
"name": "@bitwarden/web-vault",
- "version": "2025.3.1"
+ "version": "2025.4.0"
},
"libs/admin-console": {
"name": "@bitwarden/admin-console",