diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 203c7ae7607..2665f345568 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,7 +8,7 @@ apps/desktop/desktop_native @bitwarden/team-platform-dev apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-dev apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-dev -## No ownership fo Cargo.lock and Cargo.toml to allow dependency updates +## No ownership for Cargo.lock and Cargo.toml to allow dependency updates apps/desktop/desktop_native/Cargo.lock apps/desktop/desktop_native/Cargo.toml @@ -96,6 +96,12 @@ libs/logging @bitwarden/team-platform-dev libs/storage-test-utils @bitwarden/team-platform-dev libs/messaging @bitwarden/team-platform-dev libs/messaging-internal @bitwarden/team-platform-dev +libs/serialization @bitwarden/team-platform-dev +libs/guid @bitwarden/team-platform-dev +libs/client-type @bitwarden/team-platform-dev +libs/core-test-utils @bitwarden/team-platform-dev +libs/state @bitwarden/team-platform-dev +libs/state-test-utils @bitwarden/team-platform-dev # Web utils used across app and connectors apps/web/src/utils/ @bitwarden/team-platform-dev # Web core and shared files diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index bd7d70e8543..be140b9a20e 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -269,19 +269,19 @@ jobs: # Declare variable as indexed array declare -a FILES - # Search for source files that are greater than 4M + # Search for source files that are greater than 5M TARGET_DIR='./browser-source/apps/browser' while IFS=' ' read -r RESULT; do FILES+=("$RESULT") - done < <(find $TARGET_DIR -size +4M) + done < <(find $TARGET_DIR -size +5M) # Validate results and provide messaging if [[ ${#FILES[@]} -ne 0 ]]; then - echo "File(s) exceeds size limit: 4MB" + echo "File(s) exceeds size limit: 5MB" for FILE in ${FILES[@]}; do echo "- $(du --si $FILE)" done - echo "ERROR Firefox rejects extension uploads that contain files larger than 4MB" + echo "ERROR Firefox rejects extension uploads that contain files larger than 5MB" # Invoke failure exit 1 fi diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index b4163d161cf..f00ae07fba3 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -280,7 +280,7 @@ jobs: IMAGE_NAME: ${{ steps.image-name.outputs.name }} run: | mkdir build - docker run --rm --volume $(pwd)/build:/temp --entrypoint bash \ + docker run --rm --volume $(pwd)/build:/temp --entrypoint sh \ $IMAGE_NAME -c "cp -r ./ /temp" zip -r web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip build diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index e21f7ae1e79..d3788dc77b9 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -69,7 +69,6 @@ jobs: azure_login_client_key_name: ${{ steps.config.outputs.azure_login_client_key_name }} azure_login_subscription_id_key_name: ${{ steps.config.outputs.azure_login_subscription_id_key_name }} retrieve_secrets_keyvault: ${{ steps.config.outputs.retrieve_secrets_keyvault }} - sync_utility: ${{ steps.config.outputs.sync_utility }} sync_delete_destination_files: ${{ steps.config.outputs.sync_delete_destination_files }} slack_channel_name: ${{ steps.config.outputs.slack_channel_name }} steps: @@ -127,8 +126,6 @@ jobs: echo "slack_channel_name=alerts-deploy-dev" >> $GITHUB_OUTPUT ;; esac - # Set the sync utility to use for deployment to the environment (az-sync or azcopy) - echo "sync_utility=azcopy" >> $GITHUB_OUTPUT - name: Environment Protection env: @@ -337,32 +334,6 @@ jobs: description: 'Deployment from branch/tag: ${{ inputs.branch-or-tag }}' ref: ${{ needs.artifact-check.outputs.artifact_build_commit }} - - name: Login to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets[needs.setup.outputs.azure_login_subscription_id_key_name] }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets[needs.setup.outputs.azure_login_client_key_name] }} - - - name: Retrieve Storage Account connection string for az sync - if: ${{ needs.setup.outputs.sync_utility == 'az-sync' }} - id: retrieve-secrets-az-sync - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: ${{ needs.setup.outputs.retrieve_secrets_keyvault }} - secrets: "sa-bitwarden-web-vault-dev-key-temp" - - - name: Retrieve Storage Account name and SPN credentials for azcopy - if: ${{ needs.setup.outputs.sync_utility == 'azcopy' }} - id: retrieve-secrets-azcopy - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: ${{ needs.setup.outputs.retrieve_secrets_keyvault }} - secrets: "sa-bitwarden-web-vault-name,sp-bitwarden-web-vault-password,sp-bitwarden-web-vault-appid,sp-bitwarden-web-vault-tenant" - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - name: 'Download latest cloud asset using GitHub Run ID: ${{ inputs.build-web-run-id }}' if: ${{ inputs.build-web-run-id }} uses: bitwarden/gh-actions/download-artifacts@main @@ -389,28 +360,32 @@ jobs: working-directory: apps/web run: unzip ${{ env._ENVIRONMENT_ARTIFACT }} - - name: Sync to Azure Storage Account using az storage blob sync - if: ${{ needs.setup.outputs.sync_utility == 'az-sync' }} - working-directory: apps/web - run: | - az storage blob sync \ - --source "./build" \ - --container '$web' \ - --connection-string "${{ steps.retrieve-secrets-az-sync.outputs.sa-bitwarden-web-vault-dev-key-temp }}" \ - --delete-destination=${{ inputs.force-delete-destination }} + - name: Login to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets[needs.setup.outputs.azure_login_subscription_id_key_name] }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets[needs.setup.outputs.azure_login_client_key_name] }} + + - name: Retrieve Storage Account name + id: retrieve-secrets-azcopy + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: ${{ needs.setup.outputs.retrieve_secrets_keyvault }} + secrets: "sa-bitwarden-web-vault-name" - name: Sync to Azure Storage Account using azcopy - if: ${{ needs.setup.outputs.sync_utility == 'azcopy' }} working-directory: apps/web env: - AZCOPY_AUTO_LOGIN_TYPE: SPN - AZCOPY_SPA_APPLICATION_ID: ${{ steps.retrieve-secrets-azcopy.outputs.sp-bitwarden-web-vault-appid }} - AZCOPY_SPA_CLIENT_SECRET: ${{ steps.retrieve-secrets-azcopy.outputs.sp-bitwarden-web-vault-password }} - AZCOPY_TENANT_ID: ${{ steps.retrieve-secrets-azcopy.outputs.sp-bitwarden-web-vault-tenant }} + AZCOPY_AUTO_LOGIN_TYPE: AZCLI + AZCOPY_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} run: | azcopy sync ./build 'https://${{ steps.retrieve-secrets-azcopy.outputs.sa-bitwarden-web-vault-name }}.blob.core.windows.net/$web/' \ --delete-destination=${{ inputs.force-delete-destination }} --compare-hash="MD5" + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Debug sync logs if: ${{ inputs.debug }} run: cat /home/runner/.azcopy/*.log diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index c96dae51c0e..57774fa9991 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -50,4 +50,7 @@ jobs: permissions: contents: read pull-requests: write - id-token: write \ No newline at end of file + id-token: write + with: + sonar-test-inclusions: "**/*.spec.ts" + sonar-exclusions: "**/*.spec.ts" diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index d7aef05ab92..65ee9cab458 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "البحث في الخزانة" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "تعديل" }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 5e7bf056980..16b74ffe175 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Seyfdə axtar" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Düzəliş et" }, diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index a49899eaee0..d7a1db3adc8 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Пошук у сховішчы" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Рэдагаваць" }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 672e029a662..79e13cdb677 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Търсене в трезора" }, + "resetSearch": { + "message": "Нулиране на търсенето" + }, "edit": { "message": "Редактиране" }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 4e30612b9a6..a3c029fb963 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "ভল্ট খুঁজুন" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "সম্পাদনা" }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index be64d0bade5..8a94ba3e9e9 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Search vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index f6c40da1096..42fb9c24003 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Cerca en la caixa forta" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edita" }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 4e0096a1520..3f8dd2e2b48 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Vyhledat v trezoru" }, + "resetSearch": { + "message": "Resetovat hledání" + }, "edit": { "message": "Upravit" }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 1235b49dd2c..307373da9aa 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Chwilio'r gell" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Golygu" }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index bc34810f97f..4b6da81a994 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Søg i boks" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Redigér" }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 2e3d9369c41..9ef82a6d5ae 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Tresor durchsuchen" }, + "resetSearch": { + "message": "Suche zurücksetzen" + }, "edit": { "message": "Bearbeiten" }, diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 014d17b74c8..fa4f3ac0f3c 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Αναζήτηση στο vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Επεξεργασία" }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index a17a48e95b8..a70fbd85123 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Search vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 9f383c2f0e3..39de06249fc 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Search vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 35a28528f49..3b681054abc 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Buscar en caja fuerte" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Editar" }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index daadcbf00e9..8b61aa70a60 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Otsi hoidlast" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Muuda" }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index e5f836fcaae..73bd992dacb 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Bilatu kutxa gotorrean" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Editatu" }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index e551e96f74a..18afcb775f9 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "جستجوی گاوصندوق" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "ویرایش" }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 22f2046bae3..894b50b5273 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Etsi holvista" }, + "resetSearch": { + "message": "Nollaa haku" + }, "edit": { "message": "Muokkaa" }, @@ -887,7 +890,7 @@ "message": "Viimeistele kirjautuminen seuraamalla seuraavia vaiheita." }, "followTheStepsBelowToFinishLoggingInWithSecurityKey": { - "message": "Follow the steps below to finish logging in with your security key." + "message": "Seuraa alla olevia ohjeita, jotta pääset kirjautumaan suojausavaimellasi." }, "restartRegistration": { "message": "Aloita rekisteröityminen alusta" @@ -1063,7 +1066,7 @@ "message": "Tallenna" }, "notificationViewAria": { - "message": "View $ITEMNAME$, opens in new window", + "message": "Näytä $ITEMNAME$. Avautuu uudessa ikkunassa", "placeholders": { "itemName": { "content": "$1" @@ -1093,15 +1096,15 @@ } }, "notificationLoginSaveConfirmation": { - "message": "saved to Bitwarden.", + "message": "tallennettu Bitwardeniin.", "description": "Shown to user after item is saved." }, "notificationLoginUpdatedConfirmation": { - "message": "updated in Bitwarden.", + "message": "päivitetty Bitwardeniin.", "description": "Shown to user after item is updated." }, "selectItemAriaLabel": { - "message": "Select $ITEMTYPE$, $ITEMNAME$", + "message": "Valitse $ITEMTYPE$, $ITEMNAME$", "description": "Used by screen readers. $1 is the item type (like vault or folder), $2 is the selected item name.", "placeholders": { "itemType": { @@ -1121,7 +1124,7 @@ "description": "Button text for updating an existing login entry." }, "unlockToSave": { - "message": "Unlock to save this login", + "message": "Avaa tallentaaksesi tämä kirjautumistieto", "description": "User prompt to take action in order to save the login they just entered." }, "saveLogin": { @@ -1174,10 +1177,10 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + "message": "Kun olet vaihtanut salasanaasi, sinun täytyy kirjautua sisään uudella salasanalla. Aktiiviset istunnot muilla laitteilla kirjataan ulos tunnin kuluessa." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Vaihda pääsalasanasi, jotta voit jatkaa tilin palautusta." }, "enableChangedPasswordNotification": { "message": "Kysy päivitetäänkö kirjautumistieto" @@ -1372,7 +1375,7 @@ "message": "Ominaisuus ei ole käytettävissä" }, "legacyEncryptionUnsupported": { - "message": "Legacy encryption is no longer supported. Please contact support to recover your account." + "message": "Vanhaa salausta ei enää tueta. Ota yhteyttä tukeen palauttaaksesi tilisi." }, "premiumMembership": { "message": "Premium-jäsenyys" @@ -1606,10 +1609,10 @@ "message": "Automaattitäytön ehdotukset" }, "autofillSpotlightTitle": { - "message": "Easily find autofill suggestions" + "message": "Löydä helposti automaattisen täytön ehdotukset" }, "autofillSpotlightDesc": { - "message": "Turn off your browser's autofill settings, so they don't conflict with Bitwarden." + "message": "Poista käytöstä selaimesi oletuksena asetetut automaattisen täytön asetukset, joten ne eivät aiheuta ongelmia Bitwardenin kanssa." }, "turnOffBrowserAutofill": { "message": "Poista automaattitäyttö käytöstä selaimessa $BROWSER$", @@ -1830,7 +1833,7 @@ "message": "Turvakoodi (CVC/CVV)" }, "cardNumber": { - "message": "card number" + "message": "kortin numero" }, "ex": { "message": "esim." @@ -1932,7 +1935,7 @@ "message": "SSH-avain" }, "typeNote": { - "message": "Note" + "message": "Muistiinpano" }, "newItemHeader": { "message": "Uusi $TYPE$", @@ -2166,7 +2169,7 @@ "message": "Aseta PIN-koodi Bitwardenin avaukselle. PIN-asetukset tyhjentyvät, jos kirjaudut laajennuksesta kokonaan ulos." }, "setPinCode": { - "message": "You can use this PIN to unlock Bitwarden. Your PIN will be reset if you ever fully log out of the application." + "message": "Voit käyttää PIN-koodia avataksesi Bitwardenin. PIN-koodisi nollataan, mikäli kirjaudut täysin ulos sovelluksesta." }, "pinRequired": { "message": "PIN-koodi vaaditaan." @@ -2497,10 +2500,10 @@ "message": "Organisaatiokäytäntö estää kohteiden tuonnin yksityiseen holviisi." }, "restrictCardTypeImport": { - "message": "Cannot import card item types" + "message": "Ei voitu tuoda kortteja" }, "restrictCardTypeImportDesc": { - "message": "A policy set by 1 or more organizations prevents you from importing cards to your vaults." + "message": "Käytäntö, jonka on asettanut 1 tai useampi organisaatiosi estää sinua tuomasta korttitietoja holviisi." }, "domainsTitle": { "message": "Verkkotunnukset", @@ -2547,7 +2550,7 @@ } }, "atRiskPassword": { - "message": "At-risk password" + "message": "Riskialttiit salasanat" }, "atRiskPasswords": { "message": "Vaarantuneet salasanat" @@ -2584,7 +2587,7 @@ } }, "atRiskChangePrompt": { - "message": "Your password for this site is at-risk. $ORGANIZATION$ has requested that you change it.", + "message": "Salasanasi tälle sivustolle ei ole turvallinen. $ORGANIZATION$ on ilmoittanut, että se tulisi vaihtaa.", "placeholders": { "organization": { "content": "$1", @@ -2594,7 +2597,7 @@ "description": "Notification body when a login triggers an at-risk password change request and the change password domain is known." }, "atRiskNavigatePrompt": { - "message": "$ORGANIZATION$ wants you to change this password because it is at-risk. Navigate to your account settings to change the password.", + "message": "$ORGANIZATION$ ovat pyytäneet, että vaihdat tämän salasnaan, sillä se ei ole turvallinen. Mene tilin asetuksiin ja vaihda salasana.", "placeholders": { "organization": { "content": "$1", @@ -2723,7 +2726,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "maxAccessCountReached": { - "message": "Max access count reached", + "message": "Käyttökertojen enimmäismäärä on saavutettu", "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "hideTextByDefault": { @@ -2929,7 +2932,7 @@ "message": "Sinun on vahvistettava sähköpostiosoitteesi käyttääksesi ominaisuutta. Voit vahvistaa osoitteesi verkkoholvissa." }, "masterPasswordSuccessfullySet": { - "message": "Master password successfully set" + "message": "Pääsalasana asetettu" }, "updatedMasterPassword": { "message": "Pääsalasanasi on vaihdettu" @@ -3070,13 +3073,13 @@ "message": "Yksilöllistä tunnistetta ei löytynyt." }, "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." + "message": "Pääsalasanaa ei enää tarvita tämän organisaation jäsenille. Ole hyvä ja vahvista alla oleva verkkotunnus organisaation ylläpitäjän kanssa." }, "organizationName": { "message": "Organisaation nimi" }, "keyConnectorDomain": { - "message": "Key Connector domain" + "message": "Key Connector URL" }, "leaveOrganization": { "message": "Poistu organisaatiosta" @@ -3464,7 +3467,7 @@ "message": "Pyyntö lähetetty" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Kirjautuminen hyväksytty tunnuksella $EMAIL$ laitteella $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3480,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Kirjautumispyyntö on estetty toiselta laitteelta. Jos se olit sinä, yritä kirjautua uudelleen samalla laitteella." }, "device": { - "message": "Device" + "message": "Laite" }, "loginStatus": { - "message": "Login status" + "message": "Kirjautumisen tila" }, "masterPasswordChanged": { "message": "Pääsalasana tallennettiin" @@ -3582,53 +3585,53 @@ "message": "Muista tämä laite tehdäksesi tulevista kirjautumisista saumattomia" }, "manageDevices": { - "message": "Manage devices" + "message": "Hallinnoi laitteita" }, "currentSession": { - "message": "Current session" + "message": "Nykyinen istunto" }, "mobile": { - "message": "Mobile", + "message": "Mobiili", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Laajennus", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Työpöytä", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Verkkoholvi" }, "webApp": { - "message": "Web app" + "message": "Verkkosovellus" }, "cli": { - "message": "CLI" + "message": "Komentorivi" }, "sdk": { "message": "SDK", "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Pyyntö odottaa" }, "firstLogin": { - "message": "First login" + "message": "Ensimmäinen kirjautuminen" }, "trusted": { - "message": "Trusted" + "message": "Luotettu" }, "needsApproval": { - "message": "Needs approval" + "message": "Vaatii hyväksynnän" }, "devices": { - "message": "Devices" + "message": "Laitteet" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Kirjautumisyritys sähköpostilla $EMAIL$ ", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3640,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Hyväksy pääsy" }, "denyAccess": { - "message": "Deny access" + "message": "Estä pääsy" }, "time": { - "message": "Time" + "message": "Aika" }, "deviceType": { - "message": "Device Type" + "message": "Laitteen tyyppi" }, "loginRequest": { - "message": "Login request" + "message": "Kirjautumispyyntö" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Tämä pyyntö ei ole enää voimassa." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Yritätkö kirjautua tilillesi?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Kirjautuminen vahvistettu tunnuksella $EMAIL$ laitteella $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3674,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Estit toisen laitteen lähettämän kirjautumispyynnön. Jos kuitenkin tunnistit kirjautumisyrityksen, suorita kirjautuminen uudelleen." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Kirjautumispyyntö on jo vanhentunut." }, "justNow": { - "message": "Just now" + "message": "juuri nyt" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "pyydetty $MINUTES$ minuuttia sitten", "placeholders": { "minutes": { "content": "$1", @@ -3710,10 +3713,10 @@ "message": "Pyydä hyväksyntää ylläpidolta" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "Kirjautuminen epäonnistui" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "Sinun on kirjauduttava luotettuun laitteeseen tai pyydettävä järjestelmänvalvojaasi antamaan sinulle salasana." }, "ssoIdentifierRequired": { "message": "Organisaation kertakirjautumistunniste tarvitaan." @@ -3789,23 +3792,23 @@ "message": "Organisaatio ei ole luotettu" }, "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" + "message": "Tilisi turvallisuuden varmistamiseksi, vahvista vain, jos olet antanut hätäpääsyn tälle käyttäjälle ja hänen sormenjälkensä vastaa sitä, mitä hänen tilillään näkyy" }, "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." + "message": "Tilisi turvallisuuden takaamiseksi jatka vain, jos olet tämän organisaation jäsen, tilin palautus on käytössä ja alla näkyvä sormenjälki vastaa organisaatiosi sormenjälkeä." }, "orgTrustWarning1": { - "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." + "message": "Tällä organisaatiolla on yrityskäytäntö, joka tulee ilmi, kun yrität palauttaa tiliäsi. Ilmoittautuminen sallii organisaation ylläpitäjien vaihtaa salasanasi. Jatka vain, jos tunnistat tämän organisaation ja alla näkyvän sormenjäljen, joka vastaa organisaation sormenjälkeä." }, "trustUser": { "message": "Luota käyttäjään" }, "sendsTitleNoItems": { - "message": "Send sensitive information safely", + "message": "Lähetä arkaluonteisia tietoja turvallisesti", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { - "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", + "message": "Jaa tiedostoja ja dataa turvallisesti kenen tahansa kanssa millä tahansa alustalla. Tiedot pysyvät päästä päähän salattuina.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "inputRequired": { @@ -4395,23 +4398,23 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "message": "URI:n Säännöllinen lauseke -asetus on se tapa, jolla Bitwarden tekee automaattisen täytön.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { - "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", + "message": "Säänöllinen lauseke -asetus on kehittynyt asetus, joka lisää kirjautumistietoja kaappausriskiä.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", + "message": "Alkaa sanoilla -asetus on kehittynyt asetus, joka lisää kirjautumistietoja kaappausriskiä.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "Lisätietoa vastaavuustunnistuksesta", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Lisäasetukset", "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { @@ -4598,7 +4601,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopioi $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -4754,16 +4757,16 @@ "message": "Hanki mobiilisovellus" }, "getTheMobileAppDesc": { - "message": "Access your passwords on the go with the Bitwarden mobile app." + "message": "Pääse käsiksi salasanoihisi, jos et pääse tietokoneen ääreen Bitwarden-mobiilisovelluksella." }, "getTheDesktopApp": { "message": "Hanki työpöytäsovellus" }, "getTheDesktopAppDesc": { - "message": "Access your vault without a browser, then set up unlock with biometrics to expedite unlocking in both the desktop app and browser extension." + "message": "Käytä holviasi ilman selainta ja aseta sitten lukitus biometriikan avulla nopeuttaaksesi lukituksen avaamista sekä työpöytäsovelluksessa että selaimessa." }, "downloadFromBitwardenNow": { - "message": "Download from bitwarden.com now" + "message": "Lataa bitwarden.comista nyt" }, "getItOnGooglePlay": { "message": "Hanki se Google Playstä" @@ -5233,16 +5236,16 @@ "message": "Biometrinen avaus ei ole tällä hetkellä käytettävissä tuntemattomasta syystä." }, "unlockVault": { - "message": "Unlock your vault in seconds" + "message": "Avaa holvisi lukitus sekunneissa" }, "unlockVaultDesc": { - "message": "You can customize your unlock and timeout settings to more quickly access your vault." + "message": "Voit muokata avaus- ja aikakatkaisuasetuksiasi päästäksesi holvisi nopeammin käsiksi." }, "unlockPinSet": { - "message": "Unlock PIN set" + "message": "PIN asetettu" }, "unlockWithBiometricSet": { - "message": "Unlock with biometrics set" + "message": "Biometrinen kirjautuminen otettu käyttöön" }, "authenticating": { "message": "Todennetaan" @@ -5464,7 +5467,7 @@ "message": "Holvin asetukset" }, "emptyVaultDescription": { - "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." + "message": "Holvi suojaa muutakin kuin salasanojasi. Säilytä kirjautumsitietojen lisäksi kortteja, muistiinpanoja ja henkilötietoja turvallisesti." }, "introCarouselLabel": { "message": "Tervetuloa Bitwardeniin" @@ -5500,7 +5503,7 @@ "message": "Tuo olemassa olevat salasanat" }, "emptyVaultNudgeBody": { - "message": "Use the importer to quickly transfer logins to Bitwarden without manually adding them." + "message": "Käytä tuojaa siirtääksesi kirjautumisia nopeasti Bitwardeniin lisäämättä niitä manuaalisesti." }, "emptyVaultNudgeButton": { "message": "Tuo nyt" @@ -5509,19 +5512,19 @@ "message": "Tervetuloa holviisi!" }, "hasItemsVaultNudgeBodyOne": { - "message": "Autofill items for the current page" + "message": "Täytä nykyisen sivun kohteet automaattisesti" }, "hasItemsVaultNudgeBodyTwo": { - "message": "Favorite items for easy access" + "message": "Suosikkikohteita helppoon käyttöön" }, "hasItemsVaultNudgeBodyThree": { - "message": "Search your vault for something else" + "message": "Etsi holvistasi jotain muuta" }, "newLoginNudgeTitle": { "message": "Säästä aikaa automaattitäytöllä" }, "newLoginNudgeBodyOne": { - "message": "Include a", + "message": "Sisällytä a", "description": "This is in multiple parts to allow for bold text in the middle of the sentence.", "example": "Include a Website so this login appears as an autofill suggestion." }, @@ -5531,63 +5534,63 @@ "example": "Include a Website so this login appears as an autofill suggestion." }, "newLoginNudgeBodyTwo": { - "message": "so this login appears as an autofill suggestion.", + "message": ", joten tämä kirjautuminen näkyy automaattisen täytön ehdotuksena.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence.", "example": "Include a Website so this login appears as an autofill suggestion." }, "newCardNudgeTitle": { - "message": "Seamless online checkout" + "message": "Saumaton verkkomaksaminen" }, "newCardNudgeBody": { - "message": "With cards, easily autofill payment forms securely and accurately." + "message": "Korteilla voit helposti täyttää automaattisesti maksulomakkeet turvallisesti ja oikein." }, "newIdentityNudgeTitle": { - "message": "Simplify creating accounts" + "message": "Yksinkertaista tilien luomista" }, "newIdentityNudgeBody": { - "message": "With identities, quickly autofill long registration or contact forms." + "message": "Identiteettien avulla täytä pitkät rekisteröinti- tai yhteydenottolomakkeet nopeasti automaattisesti." }, "newNoteNudgeTitle": { - "message": "Keep your sensitive data safe" + "message": "Pidä arkaluonteiset tietosi turvassa" }, "newNoteNudgeBody": { - "message": "With notes, securely store sensitive data like banking or insurance details." + "message": "Muistiinpanojen avulla tallennetaan turvallisesti arkaluonteiset tiedot, kuten pankkitiedot." }, "newSshNudgeTitle": { - "message": "Developer-friendly SSH access" + "message": "Kehittäjäystävällinen SSH-käyttö" }, "newSshNudgeBodyOne": { - "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication.", + "message": "Säilytä avaimet ja yhdistä SSH-agenttiin, niin saat nopean ja salatun todennuksen.", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "newSshNudgeBodyTwo": { - "message": "Learn more about SSH agent", + "message": "Lue lisää SSH-agentista", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "generatorNudgeTitle": { - "message": "Quickly create passwords" + "message": "Luo salasanat nopeasti" }, "generatorNudgeBodyOne": { - "message": "Easily create strong and unique passwords by clicking on", + "message": "Luo helposti vahvoja ja uniikkeja salasanoja klikkaamalla", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyTwo": { - "message": "to help you keep your logins secure.", + "message": "auttaakssi sinua pitämään kirjautumisesi turvassa.", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyAria": { - "message": "Easily create strong and unique passwords by clicking on the Generate password button to help you keep your logins secure.", + "message": "Luo helposti vahvoja ja uniikkeja salasanoja klikkaamalla Luo salasana -painiketta. Sen avuilla voit pitää kirjautumisesi turvallisina.", "description": "Aria label for the body content of the generator nudge" }, "noPermissionsViewPage": { - "message": "You do not have permissions to view this page. Try logging in with a different account." + "message": "Sinulla ei ole oikeuksia tähän sivuun. Yritä kirjautua sisään toisella tilillä." }, "wasmNotSupported": { - "message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.", + "message": "WebAssembly ei ole tuettu selaimessasi tai se ei ole käytössä. WebAssembly vaaditaan, jotta voi käyttää Bitwardenia.", "description": "'WebAssembly' is a technical term and should not be translated." } } diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 88610d6874c..dfc8d65cfd9 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Hanapin ang vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "I-edit" }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 680a19f33cc..c35f4ca4bb9 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Rechercher dans le coffre" }, + "resetSearch": { + "message": "Réinitialiser la recherche" + }, "edit": { "message": "Modifier" }, @@ -1063,7 +1066,7 @@ "message": "Enregistrer" }, "notificationViewAria": { - "message": "View $ITEMNAME$, opens in new window", + "message": "Afficher $ITEMNAME$, s'ouvre dans une nouvelle fenêtre", "placeholders": { "itemName": { "content": "$1" @@ -1076,14 +1079,14 @@ "description": "Aria label for the new item button in notification bar confirmation message when error is prompted" }, "notificationEditTooltip": { - "message": "Edit before saving", + "message": "Modifier avant d'enregistrer", "description": "Tooltip and Aria label for edit button on cipher item" }, "newNotification": { "message": "Nouvelle notification" }, "labelWithNotification": { - "message": "$LABEL$: New notification", + "message": "$LABEL$: Nouvelle notification", "description": "Label for the notification with a new login suggestion.", "placeholders": { "label": { @@ -1101,7 +1104,7 @@ "description": "Shown to user after item is updated." }, "selectItemAriaLabel": { - "message": "Select $ITEMTYPE$, $ITEMNAME$", + "message": "Sélectionner $ITEMTYPE$, $ITEMNAME$", "description": "Used by screen readers. $1 is the item type (like vault or folder), $2 is the selected item name.", "placeholders": { "itemType": { @@ -1141,7 +1144,7 @@ "description": "Message displayed when login details are successfully updated." }, "loginUpdateTaskSuccess": { - "message": "Great job! You took the steps to make you and $ORGANIZATION$ more secure.", + "message": "Bon travail ! Vous avez pris les mesures pour rendre vous et $ORGANIZATION$ plus sécurisés.", "placeholders": { "organization": { "content": "$1" @@ -1150,7 +1153,7 @@ "description": "Shown to user after login is updated." }, "loginUpdateTaskSuccessAdditional": { - "message": "Thank you for making $ORGANIZATION$ more secure. You have $TASK_COUNT$ more passwords to update.", + "message": "Merci d'avoir rendu $ORGANIZATION$ plus sécurisé. Il vous reste $TASK_COUNT$ mots de passe à mettre à jour.", "placeholders": { "organization": { "content": "$1" @@ -1174,10 +1177,10 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "After changing your password, you will need to log in with your new password. Active sessions on other devices will be logged out within one hour." + "message": "Après avoir changé votre mot de passe, vous devrez vous connecter avec votre nouveau mot de passe. Les sessions actives sur d'autres appareils seront déconnectées dans l'heure qui suit." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Change your master password to complete account recovery." + "message": "Changez votre mot de passe principal pour finaliser la récupération de compte." }, "enableChangedPasswordNotification": { "message": "Demander de mettre à jour un identifiant existant" @@ -1372,7 +1375,7 @@ "message": "Fonctionnalité indisponible" }, "legacyEncryptionUnsupported": { - "message": "Legacy encryption is no longer supported. Please contact support to recover your account." + "message": "Le chiffrement hérité n'est plus pris en charge. Veuillez contacter le support pour récupérer votre compte." }, "premiumMembership": { "message": "Adhésion Premium" @@ -1606,10 +1609,10 @@ "message": "Suggestions de saisie automatique" }, "autofillSpotlightTitle": { - "message": "Easily find autofill suggestions" + "message": "Trouver facilement des suggestions de remplissage automatique" }, "autofillSpotlightDesc": { - "message": "Turn off your browser's autofill settings, so they don't conflict with Bitwarden." + "message": "Désactivez les paramètres de remplissage automatique de votre navigateur pour qu'ils n'entrent pas en conflit avec Bitwarden." }, "turnOffBrowserAutofill": { "message": "Désactiver le remplissage automatique de $BROWSER$", @@ -1830,7 +1833,7 @@ "message": "Code de sécurité" }, "cardNumber": { - "message": "card number" + "message": "numéro de carte" }, "ex": { "message": "ex." @@ -2166,7 +2169,7 @@ "message": "Définissez votre code PIN pour déverrouiller Bitwarden. Les paramètres relatifs à votre code PIN seront réinitialisés si vous vous déconnectez complètement de l'application." }, "setPinCode": { - "message": "You can use this PIN to unlock Bitwarden. Your PIN will be reset if you ever fully log out of the application." + "message": "Vous pouvez utiliser ce code PIN pour déverrouiller Bitwarden. Votre code PIN sera réinitialisé si vous vous déconnectez complètement de l'application." }, "pinRequired": { "message": "Le code PIN est requis." @@ -2217,7 +2220,7 @@ "message": "Utiliser ce mot de passe" }, "useThisPassphrase": { - "message": "Use this passphrase" + "message": "Utilisez cette phrase secrète" }, "useThisUsername": { "message": "Utiliser ce nom d'utilisateur" @@ -2497,10 +2500,10 @@ "message": "Une politique d'organisation a bloqué l'import d'éléments dans votre coffre personel." }, "restrictCardTypeImport": { - "message": "Cannot import card item types" + "message": "Impossible d'importer des types d'éléments de carte" }, "restrictCardTypeImportDesc": { - "message": "A policy set by 1 or more organizations prevents you from importing cards to your vaults." + "message": "Une politique définie par 1 ou plusieurs organisations vous empêche d'importer des cartes dans vos coffres." }, "domainsTitle": { "message": "Domaines", @@ -2584,7 +2587,7 @@ } }, "atRiskChangePrompt": { - "message": "Your password for this site is at-risk. $ORGANIZATION$ has requested that you change it.", + "message": "Votre mot de passe pour ce site est à risque. $ORGANIZATION$ vous a demandé de le modifier.", "placeholders": { "organization": { "content": "$1", @@ -2594,7 +2597,7 @@ "description": "Notification body when a login triggers an at-risk password change request and the change password domain is known." }, "atRiskNavigatePrompt": { - "message": "$ORGANIZATION$ wants you to change this password because it is at-risk. Navigate to your account settings to change the password.", + "message": "$ORGANIZATION$ souhaite que vous changiez ce mot de passe car il est à risque. Accédez aux paramètres de votre compte pour modifier le mot de passe.", "placeholders": { "organization": { "content": "$1", @@ -2632,14 +2635,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 d'une liste de connexions à risque." }, "generatePasswordSlideDesc": { "message": "Générez rapidement un mot de passe fort et unique grâce au menu de saisie automatique de Bitwarden sur le site à risque.", "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 du menu de remplissage automatique de Bitwarden affichant un mot de passe généré." }, "updateInBitwarden": { "message": "Mettre à jour dans Bitwarden" @@ -2649,7 +2652,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 d'une notification de Bitwarden invitant l'utilisateur à mettre à jour la connexion." }, "turnOnAutofill": { "message": "Activer la saisie automatique" @@ -2723,7 +2726,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "maxAccessCountReached": { - "message": "Max access count reached", + "message": "Nombre maximal d'accès atteint", "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "hideTextByDefault": { @@ -2929,7 +2932,7 @@ "message": "Vous devez vérifier votre courriel pour utiliser cette fonctionnalité. Vous pouvez vérifier votre courriel dans le coffre web." }, "masterPasswordSuccessfullySet": { - "message": "Master password successfully set" + "message": "Mot de passe principal défini avec succès" }, "updatedMasterPassword": { "message": "Mot de passe principal mis à jour" @@ -3070,13 +3073,13 @@ "message": "Aucun identifiant unique trouvé." }, "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." + "message": "Un mot de passe principal n’est plus requis pour les membres de l’organisation suivante. Veuillez confirmer le domaine ci-dessous auprès de l'administrateur de votre organisation." }, "organizationName": { "message": "Nom de l'organisation" }, "keyConnectorDomain": { - "message": "Key Connector domain" + "message": "Domaine du connecteur clé" }, "leaveOrganization": { "message": "Quitter l'organisation" @@ -3112,7 +3115,7 @@ } }, "exportingIndividualVaultWithAttachmentsDescription": { - "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "message": "Seuls les éléments individuels du coffre-fort, y compris les pièces jointes associées à $EMAIL$, seront exportés. Les éléments du coffre-fort de l'organisation ne seront pas inclus", "placeholders": { "email": { "content": "$1", @@ -3464,7 +3467,7 @@ "message": "Demande envoyée" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Demande de connexion approuvée pour $EMAIL$ sur $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,16 +3480,16 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Vous avez refusé une tentative de connexion depuis un autre appareil. Si c'était vous, essayez de vous reconnecter avec l'appareil." }, "device": { - "message": "Device" + "message": "Appareil" }, "loginStatus": { - "message": "Login status" + "message": "Statut de connexion" }, "masterPasswordChanged": { - "message": "Master password saved" + "message": "Mot de passe principal enregistré" }, "exposedMasterPassword": { "message": "Mot de passe principal exposé" @@ -3582,10 +3585,10 @@ "message": "Mémorisez cet appareil pour faciliter les futures connexions" }, "manageDevices": { - "message": "Manage devices" + "message": "Gérer les appareils" }, "currentSession": { - "message": "Current session" + "message": "Session en cours" }, "mobile": { "message": "Mobile", @@ -3596,14 +3599,14 @@ "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Bureau", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Coffre-fort Web" }, "webApp": { - "message": "Web app" + "message": "Application web" }, "cli": { "message": "CLI" @@ -3613,22 +3616,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Demande en attente" }, "firstLogin": { - "message": "First login" + "message": "Première connexion" }, "trusted": { - "message": "Trusted" + "message": "Approuvé" }, "needsApproval": { - "message": "Needs approval" + "message": "Requiert une approbation" }, "devices": { - "message": "Devices" + "message": "Appareils" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Tentative d'accès par $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3640,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Confirmer l'accès" }, "denyAccess": { - "message": "Deny access" + "message": "Refuser l'accès" }, "time": { - "message": "Time" + "message": "Temps" }, "deviceType": { - "message": "Device Type" + "message": "Type d'appareil" }, "loginRequest": { - "message": "Login request" + "message": "Demande de connexion" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Cette demande n'est plus valide." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Essayez-vous d'accéder à votre compte ?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Connexion confirmée pour $EMAIL$ sur $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3674,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Vous avez refusé une tentative de connexion depuis un autre appareil. Si c'était vraiment vous, essayez de vous reconnecter avec l'appareil." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "La demande de connexion a déjà expiré." }, "justNow": { - "message": "Just now" + "message": "À l’instant" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Demandé $MINUTES$ il y a quelques minutes", "placeholders": { "minutes": { "content": "$1", @@ -3710,10 +3713,10 @@ "message": "Demander l'approbation de l'administrateur" }, "unableToCompleteLogin": { - "message": "Unable to complete login" + "message": "Impossible de terminer la connexion" }, "loginOnTrustedDeviceOrAskAdminToAssignPassword": { - "message": "You need to log in on a trusted device or ask your administrator to assign you a password." + "message": "Vous devez vous connecter sur un appareil de confiance ou demander à votre administrateur de vous attribuer un mot de passe." }, "ssoIdentifierRequired": { "message": "Identifiant SSO de l'organisation requis." @@ -3786,26 +3789,26 @@ "message": "Ne pas faire confiance" }, "organizationNotTrusted": { - "message": "Organization is not trusted" + "message": "L'organisation n'est pas fiable" }, "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" + "message": "Pour la sécurité de votre compte, ne confirmez que si vous avez accordé un accès d'urgence à cet utilisateur et que son empreinte digitale correspond à ce qui est affiché dans son compte" }, "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." + "message": "Pour la sécurité de votre compte, ne procédez que si vous êtes membre de cette organisation, que la récupération de compte est activée et que l'empreinte digitale affichée ci-dessous correspond à l'empreinte digitale de l'organisation." }, "orgTrustWarning1": { - "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." + "message": "Cette organisation dispose d’une politique d’entreprise qui vous inscrira au recouvrement de compte. L'inscription permettra aux administrateurs de l'organisation de modifier votre mot de passe. Ne continuez que si vous reconnaissez cette organisation et que la phrase d'empreinte digitale affichée ci-dessous correspond à l'empreinte digitale de l'organisation." }, "trustUser": { "message": "Faire confiance à l'utilisateur" }, "sendsTitleNoItems": { - "message": "Send sensitive information safely", + "message": "Envoyez des informations sensibles en toute sécurité", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { - "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", + "message": "Partagez des fichiers et des données en toute sécurité avec n'importe qui, sur n'importe quelle plateforme. Vos informations resteront cryptées de bout en bout tout en limitant l'exposition.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "inputRequired": { @@ -4395,19 +4398,19 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "message": "La détection de correspondance d'URI est la manière dont Bitwarden identifie les suggestions de remplissage automatique.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { - "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Expression régulière\" est une option avancée présentant un risque accru d’exposition des informations d’identification.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", + "message": "\"Commence par\" est une option avancée présentant un risque accru d’exposition des informations d’identification.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "En savoir plus sur la détection des correspondances", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { @@ -4560,7 +4563,7 @@ } }, "viewItemTitleWithField": { - "message": "View item - $ITEMNAME$ - $FIELD$", + "message": "Afficher l'élément - $ITEMNAME$ - $FIELD$", "description": "Title for a link that opens a view for an item.", "placeholders": { "itemname": { @@ -4584,7 +4587,7 @@ } }, "autofillTitleWithField": { - "message": "Autofill - $ITEMNAME$ - $FIELD$", + "message": "Remplissage automatique - $ITEMNAME$ - $FIELD$", "description": "Title for a button that autofills a login item.", "placeholders": { "itemname": { @@ -4598,7 +4601,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Copiez $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -4754,19 +4757,19 @@ "message": "Télécharger l'application mobile" }, "getTheMobileAppDesc": { - "message": "Access your passwords on the go with the Bitwarden mobile app." + "message": "Accédez à vos mots de passe en déplacement avec l'application mobile Bitwarden." }, "getTheDesktopApp": { "message": "Télécharger l'application de bureau" }, "getTheDesktopAppDesc": { - "message": "Access your vault without a browser, then set up unlock with biometrics to expedite unlocking in both the desktop app and browser extension." + "message": "Accédez à votre coffre-fort sans navigateur, puis configurez le déverrouillage avec la biométrie pour accélérer le déverrouillage à la fois dans l'application de bureau et dans l'extension du navigateur." }, "downloadFromBitwardenNow": { - "message": "Download from bitwarden.com now" + "message": "Téléchargez maintenant depuis bitwarden.com" }, "getItOnGooglePlay": { - "message": "Get it on Google Play" + "message": "Obtenez-le sur Google Play" }, "downloadOnTheAppStore": { "message": "Télécharger depuis l’App Store" @@ -5236,13 +5239,13 @@ "message": "Déverouillez votre coffre en quelques secondes" }, "unlockVaultDesc": { - "message": "You can customize your unlock and timeout settings to more quickly access your vault." + "message": "Vous pouvez personnaliser vos paramètres de déverrouillage et de délai d'attente pour accéder plus rapidement à votre coffre-fort." }, "unlockPinSet": { - "message": "Unlock PIN set" + "message": "Déverrouiller l'ensemble de codes PIN" }, "unlockWithBiometricSet": { - "message": "Unlock with biometrics set" + "message": "Déverrouiller avec l'ensemble biométrique" }, "authenticating": { "message": "Authentification" @@ -5256,7 +5259,7 @@ "description": "Notification message for when a password has been regenerated" }, "saveToBitwarden": { - "message": "Save to Bitwarden", + "message": "Enregistrer dans Bitwarden", "description": "Confirmation message for saving a login to Bitwarden" }, "spaceCharacterDescriptor": { @@ -5464,7 +5467,7 @@ "message": "Options du coffre" }, "emptyVaultDescription": { - "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." + "message": "Le coffre-fort protège bien plus que vos mots de passe. Stockez ici en toute sécurité des identifiants, des cartes d'identité, des cartes et des notes sécurisés." }, "introCarouselLabel": { "message": "Bienvenue sur Bitwarden" @@ -5473,34 +5476,34 @@ "message": "Priorité à la sécurité" }, "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." + "message": "Enregistrez les identifiants, les cartes et les identités dans votre coffre-fort sécurisé. Bitwarden utilise un cryptage de bout en bout à connaissance nulle pour protéger ce qui est important pour vous." }, "quickLogin": { "message": "Connexion rapide et facile" }, "quickLoginBody": { - "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter." + "message": "Configurez le déverrouillage biométrique et le remplissage automatique pour vous connecter à vos comptes sans taper une seule lettre." }, "secureUser": { - "message": "Level up your logins" + "message": "Améliorez vos connexions" }, "secureUserBody": { - "message": "Use the generator to create and save strong, unique passwords for all your accounts." + "message": "Utilisez le générateur pour créer et enregistrer des mots de passe forts et uniques pour tous vos comptes." }, "secureDevices": { - "message": "Your data, when and where you need it" + "message": "Vos données, quand et où vous en avez besoin" }, "secureDevicesBody": { - "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps." + "message": "Enregistrez des mots de passe illimités sur un nombre illimité d'appareils avec les applications mobiles, de navigateur et de bureau Bitwarden." }, "nudgeBadgeAria": { "message": "1 notification" }, "emptyVaultNudgeTitle": { - "message": "Import existing passwords" + "message": "Importer des mots de passe existants" }, "emptyVaultNudgeBody": { - "message": "Use the importer to quickly transfer logins to Bitwarden without manually adding them." + "message": "Utilisez l'importateur pour transférer rapidement les connexions vers Bitwarden sans les ajouter manuellement." }, "emptyVaultNudgeButton": { "message": "Importer maintenant" @@ -5509,19 +5512,19 @@ "message": "Bienvenue dans votre coffre !" }, "hasItemsVaultNudgeBodyOne": { - "message": "Autofill items for the current page" + "message": "Remplissage automatique des éléments de la page actuelle" }, "hasItemsVaultNudgeBodyTwo": { - "message": "Favorite items for easy access" + "message": "Articles préférés pour un accès facile" }, "hasItemsVaultNudgeBodyThree": { - "message": "Search your vault for something else" + "message": "Recherchez autre chose dans votre coffre-fort" }, "newLoginNudgeTitle": { "message": "Gagnez du temps avec le remplissage automatique" }, "newLoginNudgeBodyOne": { - "message": "Include a", + "message": "Inclure un", "description": "This is in multiple parts to allow for bold text in the middle of the sentence.", "example": "Include a Website so this login appears as an autofill suggestion." }, @@ -5531,33 +5534,33 @@ "example": "Include a Website so this login appears as an autofill suggestion." }, "newLoginNudgeBodyTwo": { - "message": "so this login appears as an autofill suggestion.", + "message": "cette connexion apparaît donc comme une suggestion de remplissage automatique.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence.", "example": "Include a Website so this login appears as an autofill suggestion." }, "newCardNudgeTitle": { - "message": "Seamless online checkout" + "message": "Paiement en ligne transparent" }, "newCardNudgeBody": { - "message": "With cards, easily autofill payment forms securely and accurately." + "message": "Avec les cartes, remplissez facilement et automatiquement les formulaires de paiement de manière sécurisée et précise." }, "newIdentityNudgeTitle": { - "message": "Simplify creating accounts" + "message": "Simplifiez la création de comptes" }, "newIdentityNudgeBody": { - "message": "With identities, quickly autofill long registration or contact forms." + "message": "Avec les identités, remplissez rapidement automatiquement les longs formulaires d'inscription ou de contact." }, "newNoteNudgeTitle": { - "message": "Keep your sensitive data safe" + "message": "Gardez vos données sensibles en sécurité" }, "newNoteNudgeBody": { - "message": "With notes, securely store sensitive data like banking or insurance details." + "message": "Avec des notes, stockez en toute sécurité des données sensibles telles que des informations bancaires ou d’assurance." }, "newSshNudgeTitle": { - "message": "Developer-friendly SSH access" + "message": "Accès SSH convivial pour les développeurs" }, "newSshNudgeBodyOne": { - "message": "Store your keys and connect with the SSH agent for fast, encrypted authentication.", + "message": "Stockez vos clés et connectez-vous à l'agent SSH pour une authentification rapide et cryptée.", "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, @@ -5567,27 +5570,27 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "generatorNudgeTitle": { - "message": "Quickly create passwords" + "message": "Créez rapidement des mots de passe" }, "generatorNudgeBodyOne": { - "message": "Easily create strong and unique passwords by clicking on", + "message": "Créez facilement des mots de passe forts et uniques en cliquant sur", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyTwo": { - "message": "to help you keep your logins secure.", + "message": "pour vous aider à garder vos connexions sécurisées.", "description": "Two part message", "example": "Easily create strong and unique passwords by clicking on {icon} to help you keep your logins secure." }, "generatorNudgeBodyAria": { - "message": "Easily create strong and unique passwords by clicking on the Generate password button to help you keep your logins secure.", + "message": "Créez facilement des mots de passe forts et uniques en cliquant sur le bouton Générer un mot de passe pour vous aider à sécuriser vos connexions.", "description": "Aria label for the body content of the generator nudge" }, "noPermissionsViewPage": { - "message": "You do not have permissions to view this page. Try logging in with a different account." + "message": "Vous n'avez pas les autorisations pour consulter cette page. Essayez de vous connecter avec un autre compte." }, "wasmNotSupported": { - "message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.", + "message": "WebAssembly n'est pas pris en charge sur votre navigateur ou n'est pas activé. WebAssembly est requis pour utiliser l'application Bitwarden.", "description": "'WebAssembly' is a technical term and should not be translated." } } diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 559d0ca82b3..c7d47b61209 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Buscar na caixa forte" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Editar" }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index e4d959785bf..c13bcad10c6 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "חיפוש בכספת" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "ערוך" }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 4fd2652d786..cc89f9cc2dd 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "वॉल्ट खोजे" }, + "resetSearch": { + "message": "खोज रीसेट करें" + }, "edit": { "message": "संपादन करें" }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 07c3e20cd18..97bd79f2950 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Pretraži trezor" }, + "resetSearch": { + "message": "Ponovno postavljanje pretraživanja" + }, "edit": { "message": "Uredi" }, @@ -1830,7 +1833,7 @@ "message": "Sigurnosni kôd" }, "cardNumber": { - "message": "card number" + "message": "broj kartice" }, "ex": { "message": "npr." @@ -3464,7 +3467,7 @@ "message": "Zahtjev poslan" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Prijava za $EMAIL$ potvrđena na uređaju $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3480,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Odbijena je prijava na drugom uređaju. Ako si ovo stvarno ti, pokušaj se ponovno prijaviti uređajem." }, "device": { - "message": "Device" + "message": "Uređaj" }, "loginStatus": { - "message": "Login status" + "message": "Status prijave" }, "masterPasswordChanged": { "message": "Glavna lozinka promijenjena" @@ -3582,17 +3585,17 @@ "message": "Zapamti ovaj uređaj kako bi buduće prijave bile brže" }, "manageDevices": { - "message": "Manage devices" + "message": "Upravljaj uređajima" }, "currentSession": { - "message": "Current session" + "message": "Trenutna sesija" }, "mobile": { - "message": "Mobile", + "message": "Mobitel", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Proširenje", "description": "Browser extension/addon" }, "desktop": { @@ -3600,10 +3603,10 @@ "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Web trezor" }, "webApp": { - "message": "Web app" + "message": "Web aplikacija" }, "cli": { "message": "CLI" @@ -3613,22 +3616,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Zahtjev u tijeku" }, "firstLogin": { - "message": "First login" + "message": "Prva prijava" }, "trusted": { - "message": "Trusted" + "message": "Pouzdan" }, "needsApproval": { - "message": "Needs approval" + "message": "Zahtijeva odobrenje" }, "devices": { - "message": "Devices" + "message": "Uređaji" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "$EMAIL$ pokušava pristupiti", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3640,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Potvrdi pristup" }, "denyAccess": { - "message": "Deny access" + "message": "Odbij pristup" }, "time": { - "message": "Time" + "message": "Vrijeme" }, "deviceType": { - "message": "Device Type" + "message": "Vrsta uređaja" }, "loginRequest": { - "message": "Login request" + "message": "Zahtjev za prijavu" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Ovaj zahtjev više nije valjan." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Pokušavaš li pristupiti svom računu?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Prijava za $EMAIL$ potvrđena na uređaju $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3674,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Odbijena je prijava na drugom uređaju. Ako si ovo stvarno ti, pokušaj se ponovno prijaviti uređajem." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Zahtjev za prijavu je već istekao." }, "justNow": { - "message": "Just now" + "message": "Upravo" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Zatraženo prije $MINUTES$ minute/a", "placeholders": { "minutes": { "content": "$1", @@ -4598,7 +4601,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Kopiraj $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index b77d613da51..cfe79ea4b20 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Keresés a széfben" }, + "resetSearch": { + "message": "Keresés visszaállítása" + }, "edit": { "message": "Szerkesztés" }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 07e094e10bd..4ce58ee7618 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Cari brankas" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 167cc51c0b1..164d46bcec5 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Cerca nella cassaforte" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Modifica" }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 49d22bd065f..977d04ca28a 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "保管庫を検索" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "編集" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 1e75805638c..da70c255803 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Search vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "ჩასწორება" }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 9a6d9a4d316..f4c58318bc1 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Search vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index c7a821de19b..31b285f6780 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "ವಾಲ್ಟ್ ಹುಡುಕಿ" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "ಎಡಿಟ್" }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 730d3eeda61..89d59488aaf 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -3,7 +3,7 @@ "message": "Bitwarden" }, "appLogoLabel": { - "message": "Bitwarden logo" + "message": "Bitwarden 로고" }, "extName": { "message": "Bitwarden 비밀번호 관리자", @@ -465,10 +465,10 @@ "message": "암호 생성" }, "passwordGenerated": { - "message": "Password generated" + "message": "비밀번호 생성됨" }, "passphraseGenerated": { - "message": "Passphrase generated" + "message": "패스프레이즈 생성됨" }, "usernameGenerated": { "message": "Username generated" @@ -547,6 +547,9 @@ "searchVault": { "message": "보관함 검색" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "편집" }, @@ -1483,17 +1486,17 @@ "message": "Don't ask again on this device for 30 days" }, "selectAnotherMethod": { - "message": "Select another method", + "message": "다른 방법 시도", "description": "Select another two-step login method" }, "useYourRecoveryCode": { - "message": "Use your recovery code" + "message": "복구 코드 사용" }, "insertU2f": { "message": "보안 키를 컴퓨터의 USB 포트에 삽입하고 버튼이 있는 경우 누르세요." }, "openInNewTab": { - "message": "Open in new tab" + "message": "새 탭에서 열기" }, "webAuthnAuthenticate": { "message": "WebAuthn 인증" diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 29815c9de82..5a4f0d8c419 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Ieškoti saugykloje" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Keisti" }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index ba43b1e5f44..81447182f26 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Meklēt glabātavā" }, + "resetSearch": { + "message": "Atiestatīt meklēšanu" + }, "edit": { "message": "Labot" }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index a39fe07b2c6..11709a4611b 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "വാൾട് തിരയുക" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "തിരുത്തുക" }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index c79b8b322a7..e7d06e4d5f9 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "तिजोरीत शोधा" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 9a6d9a4d316..f4c58318bc1 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Search vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 01353cac5e0..7cbe8747d3b 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Søk i hvelvet" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Rediger" }, @@ -759,7 +762,7 @@ "message": "Hovedpassord" }, "masterPassImportant": { - "message": "Your master password cannot be recovered if you forget it!" + "message": "Hovedpassordet kan ikke gjenopprettes hvis du glemmer det!" }, "masterPassHintLabel": { "message": "Få et hint om hovedpassordet" @@ -814,7 +817,7 @@ "message": "En verifiseringskode er påkrevd." }, "webauthnCancelOrTimeout": { - "message": "The authentication was cancelled or took too long. Please try again." + "message": "Autentiseringen ble avbrutt eller tok for lang tid. Prøv igjen." }, "invalidVerificationCode": { "message": "Ugyldig bekreftelseskode" @@ -1097,7 +1100,7 @@ "description": "Shown to user after item is saved." }, "notificationLoginUpdatedConfirmation": { - "message": "updated in Bitwarden.", + "message": "oppdatert i Bitwarden.", "description": "Shown to user after item is updated." }, "selectItemAriaLabel": { @@ -1252,25 +1255,25 @@ "message": "Filformat" }, "fileEncryptedExportWarningDesc": { - "message": "This file export will be password protected and require the file password to decrypt." + "message": "Denne fileksporten vil bli passordbeskyttet og vil kreve filpassordet ved dekryptering." }, "filePassword": { "message": "Filpassord" }, "exportPasswordDescription": { - "message": "This password will be used to export and import this file" + "message": "Dette passordet brukes for eksport og import av denne filen" }, "accountRestrictedOptionDescription": { - "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + "message": "Bruk kontokrypteringsnøkkelen, avledet fra ditt kontobrukernavn og hovedpassord, for å kryptere eksporten og hindre import til andre kontoer enn den aktuelle Bitwarden-kontoen." }, "passwordProtectedOptionDescription": { - "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + "message": "Sett et filpassord for kryptering av eksporten. Bruk passordet for å dekryptere og importere til en hvilken som helst Bitwarden-konto." }, "exportTypeHeading": { "message": "Eksporttype" }, "accountRestricted": { - "message": "Account restricted" + "message": "Kontoen er begrenset" }, "filePasswordAndConfirmFilePasswordDoNotMatch": { "message": "«Filpassord» og «Bekreft filpassord» stemmer ikke overens." @@ -1621,7 +1624,7 @@ } }, "turnOffAutofill": { - "message": "Turn off autofill" + "message": "Skru av autoutfylling" }, "showInlineMenuLabel": { "message": "Vis autoutfyll-forslag i tekstbokser" @@ -1830,7 +1833,7 @@ "message": "Sikkerhetskode" }, "cardNumber": { - "message": "card number" + "message": "kortnummer" }, "ex": { "message": "f.eks." @@ -2534,7 +2537,7 @@ "message": "Endre" }, "changePassword": { - "message": "Change password", + "message": "Endre passord", "description": "Change password button for browser at risk notification on login." }, "changeButtonTitle": { @@ -2723,7 +2726,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "maxAccessCountReached": { - "message": "Max access count reached", + "message": "Maks antall tilganger er nådd", "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "hideTextByDefault": { @@ -3073,7 +3076,7 @@ "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." }, "organizationName": { - "message": "Organization name" + "message": "Organisasjonens navn" }, "keyConnectorDomain": { "message": "Key Connector domain" @@ -3103,7 +3106,7 @@ "message": "Eksporterer personlig hvelv" }, "exportingIndividualVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", + "message": "Kun de individuelle hvelvgjenstandene som er assosiert med $EMAIL$ vil bli eksportert. Organisasjonshvelv-gjenstander vil ikke bli inkludert. Kun hvelvgjenstandsinfo vil bli eksportert og vil ikke inkludere assosierte vedlegg.", "placeholders": { "email": { "content": "$1", @@ -3121,7 +3124,7 @@ } }, "exportingOrganizationVaultTitle": { - "message": "Exporting organization vault" + "message": "Eksporterer organisasjonshvelv" }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", @@ -3480,13 +3483,13 @@ "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." }, "device": { - "message": "Device" + "message": "Enhet" }, "loginStatus": { - "message": "Login status" + "message": "Innloggingsstatus" }, "masterPasswordChanged": { - "message": "Master password saved" + "message": "Hovedpassordet er endret" }, "exposedMasterPassword": { "message": "Eksponert hovedpassord" @@ -3585,28 +3588,28 @@ "message": "Manage devices" }, "currentSession": { - "message": "Current session" + "message": "Gjeldende økt" }, "mobile": { - "message": "Mobile", + "message": "Mobil", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Utvidelse", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Skrivebord", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Netthvelv" }, "webApp": { - "message": "Web app" + "message": "Nettapp" }, "cli": { - "message": "CLI" + "message": "Ledetekst" }, "sdk": { "message": "SDK", @@ -3619,13 +3622,13 @@ "message": "First login" }, "trusted": { - "message": "Trusted" + "message": "Betrodd" }, "needsApproval": { - "message": "Needs approval" + "message": "Trenger godkjenning" }, "devices": { - "message": "Devices" + "message": "Enheter" }, "accessAttemptBy": { "message": "Access attempt by $EMAIL$", @@ -3637,22 +3640,22 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Bekreft tilgang" }, "denyAccess": { - "message": "Deny access" + "message": "Nekt tilgang" }, "time": { - "message": "Time" + "message": "Tid" }, "deviceType": { - "message": "Device Type" + "message": "Enhetstype" }, "loginRequest": { - "message": "Login request" + "message": "Innloggingsforespørsel" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Denne forespørselen er ikke lenger gyldig." }, "areYouTryingToAccessYourAccount": { "message": "Are you trying to access your account?" @@ -3674,13 +3677,13 @@ "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Innloggingsforespørselen har allerede utløpt." }, "justNow": { - "message": "Just now" + "message": "Akkurat nå" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Forespurt for $MINUTES$ minutter siden", "placeholders": { "minutes": { "content": "$1", @@ -3716,7 +3719,7 @@ "message": "You need to log in on a trusted device or ask your administrator to assign you a password." }, "ssoIdentifierRequired": { - "message": "Organization SSO identifier is required." + "message": "Organisasjonsidentifikator er påkrevd." }, "creatingAccountOn": { "message": "Oppretter en konto på" @@ -3744,7 +3747,7 @@ "description": "European Union" }, "accessDenied": { - "message": "Access denied. You do not have permission to view this page." + "message": "Ingen tilgang. Du har ikke tillatelse til å se denne siden." }, "general": { "message": "Generelt" @@ -3753,10 +3756,10 @@ "message": "Vis" }, "accountSuccessfullyCreated": { - "message": "Account successfully created!" + "message": "Kontoen ble vellykket opprettet!" }, "adminApprovalRequested": { - "message": "Admin approval requested" + "message": "Admin-godkjenning forespurt" }, "adminApprovalRequestSentToAdmins": { "message": "Forespørselen din har blitt sendt til administratoren din." @@ -3836,7 +3839,7 @@ } }, "inputForbiddenCharacters": { - "message": "The following characters are not allowed: $CHARACTERS$", + "message": "Følgende tegn er ikke tillatt: $CHARACTERS$", "placeholders": { "characters": { "content": "$1", @@ -4043,7 +4046,7 @@ "message": "Importeringsfeil" }, "importErrorDesc": { - "message": "There was a problem with the data you tried to import. Please resolve the errors listed below in your source file and try again." + "message": "Det oppstod et problem med dataene du prøvde å importere. Vennligst løs feilene listet nedenfor i kildefilen og prøv på nytt." }, "resolveTheErrorsBelowAndTryAgain": { "message": "Fiks feilene nedenfor og prøv igjen." @@ -4103,7 +4106,7 @@ "message": "Total" }, "importWarning": { - "message": "You are importing data to $ORGANIZATION$. Your data may be shared with members of this organization. Do you want to proceed?", + "message": "Du importerer data til $ORGANIZATION$. Dataene kan deles med medlemmer av denne organisasjonen. Vil du fortsette?", "placeholders": { "organization": { "content": "$1", @@ -4130,7 +4133,7 @@ "message": "Ingenting ble importert." }, "importEncKeyError": { - "message": "Error decrypting the exported file. Your encryption key does not match the encryption key used export the data." + "message": "Feil under dekryptering av den eksporterte filen. Krypteringsnøkkelen samsvarte ikke med krypteringsnøkkelen som ble brukt eksport av data." }, "invalidFilePassword": { "message": "Ugyldig filpassord, vennligst bruk passordet du skrev inn da du opprettet eksportfilen." @@ -4148,7 +4151,7 @@ "message": "Velg en samling" }, "importTargetHint": { - "message": "Select this option if you want the imported file contents moved to a $DESTINATION$", + "message": "Velg dette alternativet hvis du vil flytte den importerte filens innhold til en $DESTINATION$", "description": "Located as a hint under the import target. Will be appended by either folder or collection, depending if the user is importing into an individual or an organizational vault.", "placeholders": { "destination": { @@ -4210,7 +4213,7 @@ "message": "Passkoden vil ikke bli kopiert" }, "passkeyNotCopiedAlert": { - "message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?" + "message": "Passnøkkelen kopieres ikke til det klonede elementet. Vil du fortsette kloningen av denne gjenstanden?" }, "passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword": { "message": "Verification required by the initiating site. This feature is not yet implemented for accounts without master password." @@ -4411,7 +4414,7 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Avanserte alternativer", "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { @@ -4745,31 +4748,31 @@ } }, "downloadBitwarden": { - "message": "Download Bitwarden" + "message": "Last ned Bitwarden" }, "downloadBitwardenOnAllDevices": { "message": "Download Bitwarden on all devices" }, "getTheMobileApp": { - "message": "Get the mobile app" + "message": "Skaff deg mobilappen" }, "getTheMobileAppDesc": { "message": "Access your passwords on the go with the Bitwarden mobile app." }, "getTheDesktopApp": { - "message": "Get the desktop app" + "message": "Skaff deg skrivebordsappen" }, "getTheDesktopAppDesc": { "message": "Access your vault without a browser, then set up unlock with biometrics to expedite unlocking in both the desktop app and browser extension." }, "downloadFromBitwardenNow": { - "message": "Download from bitwarden.com now" + "message": "Last ned fra bitwarden.com nå" }, "getItOnGooglePlay": { - "message": "Get it on Google Play" + "message": "Skaff den på Google Play" }, "downloadOnTheAppStore": { - "message": "Download on the App Store" + "message": "Last ned fra App Store" }, "permanentlyDeleteAttachmentConfirmation": { "message": "Are you sure you want to permanently delete this attachment?" @@ -5494,7 +5497,7 @@ "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps." }, "nudgeBadgeAria": { - "message": "1 notification" + "message": "1 varsel" }, "emptyVaultNudgeTitle": { "message": "Importer eksisterende passord" @@ -5521,12 +5524,12 @@ "message": "Spar tid med auto-utfylling" }, "newLoginNudgeBodyOne": { - "message": "Include a", + "message": "Inkluder en", "description": "This is in multiple parts to allow for bold text in the middle of the sentence.", "example": "Include a Website so this login appears as an autofill suggestion." }, "newLoginNudgeBodyBold": { - "message": "Website", + "message": "Nettsted", "description": "This is in multiple parts to allow for bold text in the middle of the sentence.", "example": "Include a Website so this login appears as an autofill suggestion." }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 9a6d9a4d316..f4c58318bc1 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Search vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index b5220861652..0c2bd31935f 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Kluis doorzoeken" }, + "resetSearch": { + "message": "Zoekopdracht resetten" + }, "edit": { "message": "Bewerken" }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 9a6d9a4d316..f4c58318bc1 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Search vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 9a6d9a4d316..f4c58318bc1 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Search vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index aa8ab76d543..04112b08219 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -26,7 +26,7 @@ "message": "Nowy w Bitwarden?" }, "logInWithPasskey": { - "message": "Logowaniem kluczem dostępu" + "message": "Logowanie kluczem dostępu" }, "useSingleSignOn": { "message": "Użyj logowania jednokrotnego" @@ -96,7 +96,7 @@ "message": "Dołącz do organizacji" }, "joinOrganizationName": { - "message": "Dołącz do $ORGANIZATIONNAME$", + "message": "Dołącz do organizacji $ORGANIZATIONNAME$", "placeholders": { "organizationName": { "content": "$1", @@ -338,7 +338,7 @@ "message": "Przejść do bitwarden.com?" }, "bitwardenForBusiness": { - "message": "Bitwarden dla biznesu" + "message": "Bitwarden dla firm" }, "bitwardenAuthenticator": { "message": "Bitwarden Authenticator" @@ -347,16 +347,16 @@ "message": "Bitwarden Authenticator umożliwia przechowywanie kluczy uwierzytelniających i generowanie kodów TOTP dla weryfikacji dwustopniowej. Dowiedz się wiecej na bitwarden.com" }, "bitwardenSecretsManager": { - "message": "Menedżer sekretów Bitwarden" + "message": "Bitwarden Secrets Manager" }, "continueToSecretsManagerPageDesc": { - "message": "Bezpiecznie przechowuj, zarządzaj i udostępniaj sekrety programistów z Menedżerem sekretów Bitwarden. Dowiedz się więcej na stronie bitwarden.com." + "message": "Bezpiecznie przechowuj, zarządzaj i udostępniaj sekrety deweloperów za pomocą usługi Bitwarden Secrets Manager. Dowiedz się więcej na stronie bitwarden.com." }, "passwordlessDotDev": { "message": "Passwordless.dev" }, "continueToPasswordlessDotDevPageDesc": { - "message": "Twórz przyjemne i bezpieczne doświadczenia z logowaniem wolne od tradycyjnych haseł za pomocą Passwordless.dev. Dowiedz się więcej na stronie bitwarden.com." + "message": "Loguj się szybko i bezpiecznie bez tradycyjnych haseł za pomocą usługi Passwordless.dev. Dowiedz się więcej na stronie bitwarden.com." }, "freeBitwardenFamilies": { "message": "Darmowy plan rodzinny" @@ -541,12 +541,15 @@ "description": "Label for the avoid ambiguous characters checkbox." }, "generatorPolicyInEffect": { - "message": "Wymagania polityki przedsiębiorstwa zostały użyte do ustawienia opcji generatora.", + "message": "Zasady organizacji zostały zastosowane do opcji generatora.", "description": "Indicates that a policy limits the credential generator screen." }, "searchVault": { "message": "Szukaj w sejfie" }, + "resetSearch": { + "message": "Zresetuj wyszukiwanie" + }, "edit": { "message": "Edytuj" }, @@ -647,7 +650,7 @@ "message": "Blokowanie sejfu" }, "otherOptions": { - "message": "Pozostałe opcje" + "message": "Inne opcje" }, "rateExtension": { "message": "Oceń rozszerzenie" @@ -741,10 +744,10 @@ "message": "4 godziny" }, "onLocked": { - "message": "Po zablokowaniu komputera" + "message": "Po zablokowaniu urządzenia" }, "onRestart": { - "message": "Po restarcie przeglądarki" + "message": "Po uruchomieniu przeglądarki" }, "never": { "message": "Nigdy" @@ -802,7 +805,7 @@ "message": "Zalogowano!" }, "youSuccessfullyLoggedIn": { - "message": "Zalogowałeś się pomyślnie" + "message": "Zalogowano" }, "youMayCloseThisWindow": { "message": "Możesz zamknąć to okno" @@ -830,7 +833,7 @@ } }, "autofillError": { - "message": "Nie można zastosować autouzupełnienia na tej stronie. Skopiuj i wklej informacje ręcznie." + "message": "Nie można uzupełnić elementu na tej stronie internetowej. Skopiuj i wklej informacje ręcznie." }, "totpCaptureError": { "message": "Nie można zeskanować kodu QR z obecnej strony" @@ -860,7 +863,7 @@ "message": "Wylogowano" }, "loggedOutDesc": { - "message": "Zostałeś wylogowany z konta." + "message": "Wylogowano z konta." }, "loginExpired": { "message": "Twoja sesja wygasła." @@ -890,13 +893,13 @@ "message": "Wykonaj poniższe kroki, aby zakończyć logowanie za pomocą klucza bezpieczeństwa." }, "restartRegistration": { - "message": "Zacznij rejestrację od początku" + "message": "Rozpocznij rejestrację od początku" }, "expiredLink": { "message": "Link wygasł" }, "pleaseRestartRegistrationOrTryLoggingIn": { - "message": "Zrestartuj rejestrację lub spróbuj się zalogować." + "message": "Rozpocznij rejestrację od początku lub spróbuj się zalogować." }, "youMayAlreadyHaveAnAccount": { "message": "Możesz mieć już konto" @@ -926,7 +929,7 @@ "message": "Logowanie dwustopniowe zwiększa bezpieczeństwo konta, wymagając weryfikacji logowania za pomocą innego urządzenia, takiego jak klucz bezpieczeństwa, aplikacja uwierzytelniająca, wiadomość SMS, połączenie telefoniczne lub wiadomość e-mail. Logowanie dwustopniowe możesz skonfigurować w sejfie internetowym bitwarden.com. Czy chcesz przejść do strony?" }, "twoStepLoginConfirmationContent": { - "message": "Spraw, aby Twoje konto było bezpieczniejsze poprzez skonfigurowanie dwustopniowego logowania w aplikacji internetowej Bitwarden." + "message": "Zwiększ bezpieczeństwo konta, konfigurując logowanie dwustopniowe w aplikacji internetowej Bitwarden." }, "twoStepLoginConfirmationTitle": { "message": "Przejść do aplikacji internetowej?" @@ -1025,22 +1028,22 @@ "message": "Proponuj dodanie elementu, jeśli nie ma go w sejfie. Dotyczy wszystkich zalogowanych kont." }, "showCardsInVaultViewV2": { - "message": "Pokazuj zawsze karty w sugestiach autouzupełniania" + "message": "Zawsze pokazuj karty w sugestiach autouzupełniania" }, "showCardsCurrentTab": { "message": "Pokaż karty na stronie głównej" }, "showCardsCurrentTabDesc": { - "message": "Pokaż elementy karty na stronie głównej, aby ułatwić autouzupełnianie." + "message": "Wyświetla karty na głównej karcie sejfu." }, "showIdentitiesInVaultViewV2": { - "message": "Pokazuj zawsze tożsamości w sugestiach autouzupełniania" + "message": "Zawsze pokazuj tożsamości w sugestiach autouzupełniania" }, "showIdentitiesCurrentTab": { "message": "Pokaż tożsamości na stronie głównej" }, "showIdentitiesCurrentTabDesc": { - "message": "Pokaż elementy tożsamości na stronie głównej, aby ułatwić autouzupełnianie." + "message": "Wyświetla tożsamości na głównej karcie sejfu." }, "clickToAutofillOnVault": { "message": "Kliknij na elementy, aby je uzupełnić" @@ -1063,7 +1066,7 @@ "message": "Zapisz" }, "notificationViewAria": { - "message": "Wyświetl $ITEMNAME$, otworzy się w nowym oknie", + "message": "Pokaż $ITEMNAME$, otwiera się w nowym oknie", "placeholders": { "itemName": { "content": "$1" @@ -1129,7 +1132,7 @@ "description": "Prompt asking the user if they want to save their login details." }, "updateLogin": { - "message": "Zaktualizuj obecne dane logowania", + "message": "Zaktualizuj dane logowania", "description": "Prompt asking the user if they want to update an existing login entry." }, "loginSaveSuccess": { @@ -1174,7 +1177,7 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "Po zmianie hasła musisz się zalogować przy użyciu nowego hasła. Aktywne sesje na innych urządzeniach zostaną wylogowane w ciągu jednej godziny." + "message": "Po zmianie hasła zaloguj się za pomocą nowego hasła. Aktywne sesje na innych urządzeniach zostaną wylogowane w ciągu jednej godziny." }, "accountRecoveryUpdateMasterPasswordSubtitle": { "message": "Zmień hasło główne, aby zakończyć odzyskiwanie konta." @@ -1201,7 +1204,7 @@ "message": "Zaktualizuj" }, "notificationUnlockDesc": { - "message": "Odblokuj swój sejf Bitwarden, aby ukończyć żądanie autouzupełniania." + "message": "Odblokuj sejf Bitwarden, aby uzupełnić dane." }, "notificationUnlock": { "message": "Odblokuj" @@ -1270,7 +1273,7 @@ "message": "Rodzaj eksportu" }, "accountRestricted": { - "message": "Konto ograniczone" + "message": "Konto zostało ograniczone" }, "filePasswordAndConfirmFilePasswordDoNotMatch": { "message": "Hasła pliku nie pasują do siebie." @@ -1287,7 +1290,7 @@ "message": "Potwierdź eksportowanie sejfu" }, "exportWarningDesc": { - "message": "Plik zawiera dane sejfu w niezaszyfrowanym formacie. Nie powinieneś go przechowywać, ani przesyłać poprzez niezabezpieczone kanały (takie jak poczta e-mail). Skasuj go natychmiast po użyciu." + "message": "Plik zawiera dane sejfu w niezaszyfrowanym formacie. Nie należy go przechowywać ani przesyłać poprzez niezabezpieczone kanały (takie jak poczta e-mail). Usuń go natychmiast po użyciu." }, "encExportKeyWarningDesc": { "message": "Dane eksportu zostaną zaszyfrowane za pomocą klucza szyfrowania konta. Jeśli kiedykolwiek zmienisz ten klucz, wyeksportuj dane ponownie, ponieważ nie będziesz w stanie odszyfrować tego pliku." @@ -1302,7 +1305,7 @@ "message": "Udostępnione" }, "bitwardenForBusinessPageDesc": { - "message": "Bitwarden dla biznesu pozwala na udostępnianie zawartości sejfu innym użytkownikom za pośrednictwem organizacji. Dowiedz się więcej na stronie bitwarden.com." + "message": "Bitwarden dla firm pozwala na udostępnianie zawartości sejfu innym użytkownikom za pośrednictwem organizacji. Dowiedz się więcej na stronie bitwarden.com." }, "moveToOrganization": { "message": "Przenieś do organizacji" @@ -1456,7 +1459,7 @@ "message": "Automatycznie kopiuje kod TOTP do schowka podczas autouzupełniania." }, "enableAutoBiometricsPrompt": { - "message": "Poproś o dane biometryczne przy uruchomieniu" + "message": "Wymagaj odblokowania biometrią po uruchomieniu przeglądarki" }, "premiumRequired": { "message": "Konto premium jest wymagane" @@ -1468,7 +1471,7 @@ "message": "Limit czasu uwierzytelniania" }, "authenticationSessionTimedOut": { - "message": "Upłynął limit czasu uwierzytelniania. Uruchom ponownie proces logowania." + "message": "Upłynął limit czasu uwierzytelniania. Zaloguj się ponownie." }, "verificationCodeEmailSent": { "message": "Wiadomość weryfikacyjna została wysłana na adres $EMAIL$.", @@ -1508,10 +1511,10 @@ "message": "Logowanie jest niedostępne" }, "noTwoStepProviders": { - "message": "Konto posiada włączoną opcję logowania dwustopniowego, jednak ta przeglądarka nie wspiera żadnego ze skonfigurowanych mechanizmów autoryzacji dwustopniowej." + "message": "Konto jest zabezpieczone logowaniem dwustopniowym, ale żadna ze skonfigurowanych metod nie jest obsługiwana w tej przeglądarce." }, "noTwoStepProviders2": { - "message": "Proszę użyć obsługiwanej przeglądarki (takiej jak Chrome) i/lub dodać dodatkowych dostawców, którzy są lepiej wspierani przez przeglądarki internetowe (np. aplikacja uwierzytelniająca)." + "message": "Użyj obsługiwanej przeglądarki (np. Chrome) lub dodaj dodatkowe opcje logowania dwustopniowego, które są obsługiwane na różnych przeglądarkach (takie jak aplikacja uwierzytelniająca)." }, "twoStepOptions": { "message": "Opcje logowania dwustopniowego" @@ -1609,7 +1612,7 @@ "message": "Łatwe wyszukiwanie sugestii autouzupełniania" }, "autofillSpotlightDesc": { - "message": "Wyłącz ustawienia autouzupełniania swojej przeglądarki, aby nie kolidowały z Bitwarden." + "message": "Wyłącz autouzupełnianie przeglądarki, aby uniknąć konfliktów z Bitwarden." }, "turnOffBrowserAutofill": { "message": "Wyłącz autouzupełnianie $BROWSER$", @@ -1645,15 +1648,15 @@ "message": "Edytuj ustawienia przeglądarki." }, "autofillOverlayVisibilityOff": { - "message": "Wył.", + "message": "Wyłączone", "description": "Overlay setting select option for disabling autofill overlay" }, "autofillOverlayVisibilityOnFieldFocus": { - "message": "Gdy pole jest zaznaczone", + "message": "Po kliknięciu pola", "description": "Overlay appearance select option for showing the field on focus of the input element" }, "autofillOverlayVisibilityOnButtonClick": { - "message": "Gdy wybrano ikonę autouzupełniania", + "message": "Po kliknięciu ikony autouzupełniania", "description": "Overlay appearance select option for showing the field on click of the overlay icon" }, "enableAutoFillOnPageLoadSectionTitle": { @@ -1971,7 +1974,7 @@ "message": "Wyczyść historię generatora" }, "cleargGeneratorHistoryDescription": { - "message": "Jeśli zatwierdzisz, wszystkie wygenerowane hasła zostaną usunięte z historii generatora. Czy chcesz kontynuować mimo to?" + "message": "Wszystkie wpisy zostaną trwale usunięte z historii generatora. Czy na pewno chcesz kontynuować?" }, "back": { "message": "Wstecz" @@ -1980,7 +1983,7 @@ "message": "Kolekcje" }, "nCollections": { - "message": "Kolekcje ($COUNT$)", + "message": "W $COUNT$ kolekcjach", "placeholders": { "count": { "content": "$1", @@ -2066,7 +2069,7 @@ "description": "Default URI match detection for autofill." }, "toggleOptions": { - "message": "Zmień opcje" + "message": "Przełącz opcje" }, "toggleCurrentUris": { "message": "Przełącz obecny URI", @@ -2096,13 +2099,13 @@ "message": "Brak zawartości do pokazania" }, "nothingGeneratedRecently": { - "message": "Nic nie zostało wygenerowane przez ciebie w ostatnim czasie" + "message": "Nic nie zostało wygenerowane w ostatnim czasie" }, "remove": { "message": "Usuń" }, "default": { - "message": "Domyślne" + "message": "Domyślna" }, "dateUpdated": { "message": "Zaktualizowano", @@ -2147,7 +2150,7 @@ "message": "Hasło główne jest słabe" }, "weakMasterPasswordDesc": { - "message": "Wybrane przez Ciebie hasło główne jest słabe. Powinieneś użyć silniejszego hasła (lub frazy), aby właściwie chronić swoje konto Bitwarden. Czy na pewno chcesz użyć tego hasła głównego?" + "message": "Użyj silniejszego hasła, aby odpowiednio chronić konto Bitwarden. Czy na pewno chcesz użyć tego hasła głównego?" }, "pin": { "message": "Kod PIN", @@ -2163,10 +2166,10 @@ "message": "Ustaw kod PIN" }, "setYourPinCode": { - "message": "Ustaw kod PIN do odblokowywania aplikacji Bitwarden. Ustawienia odblokowywania kodem PIN zostaną zresetowane po wylogowaniu." + "message": "Ustaw kod PIN do odblokowania aplikacji Bitwarden. Ustawienia kodu PIN zostaną zresetowane po wylogowaniu." }, "setPinCode": { - "message": "Możesz użyć tego kodu PIN, aby odblokować Bitwarden. Twój kod PIN zostanie zresetowany, jeśli kiedykolwiek wylogujesz się z aplikacji." + "message": "Możesz użyć tego kodu PIN do odblokowania aplikacji Bitwarden. Kod PIN zostanie zresetowany po wylogowaniu." }, "pinRequired": { "message": "Kod PIN jest wymagany." @@ -2187,13 +2190,13 @@ "message": "Oczekiwanie na potwierdzenie z aplikacji desktopowej" }, "awaitDesktopDesc": { - "message": "Włącz dane biometryczne w aplikacji desktopowej Bitwarden, aby włączyć tę samą funkcję w przeglądarce." + "message": "Włącz najpierw biometrię w aplikacji desktopowej Bitwarden, aby skonfigurować dane biometryczne w przeglądarce." }, "lockWithMasterPassOnRestart": { "message": "Zablokuj hasłem głównym po uruchomieniu przeglądarki" }, "lockWithMasterPassOnRestart1": { - "message": "Wymagaj hasła głównego przy ponownym uruchomieniu przeglądarki" + "message": "Wymagaj hasła głównego po uruchomieniu przeglądarki" }, "selectOneCollection": { "message": "Musisz wybrać co najmniej jedną kolekcję." @@ -2223,14 +2226,14 @@ "message": "Użyj tej nazwy użytkownika" }, "securePasswordGenerated": { - "message": "Wygenerowane bezpieczne hasło! Nie zapomnij również zaktualizować hasła na stronie." + "message": "Bezpieczne hasło zostało wygenerowane! Nie zapomnij zaktualizować hasła na stronie internetowej." }, "useGeneratorHelpTextPartOne": { "message": "Użyj generatora", "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" }, "useGeneratorHelpTextPartTwo": { - "message": ", aby utworzyć mocne unikalne hasło", + "message": ", aby utworzyć silne i unikalne hasło", "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" }, "vaultCustomization": { @@ -2296,10 +2299,10 @@ "message": "Czy chcesz uzupełnić dane logowania?" }, "autofillIframeWarning": { - "message": "Formularz jest hostowany przez inną domenę niż zapisany adres URI dla tego loginu. Wybierz OK, aby i tak automatycznie wypełnić lub anuluj, aby zatrzymać." + "message": "Formularz jest hostowany przez inną domenę niż URI zapisanych danych logowania. Kliknij OK, aby uzupełnić dane logowania lub Anuluj, aby zatrzymać." }, "autofillIframeWarningTip": { - "message": "Aby zapobiec temu ostrzeżeniu w przyszłości, zapisz ten URI, $HOSTNAME$, dla tej witryny.", + "message": "Aby uniknąć ostrzeżenia w przyszłości, zapisz URI $HOSTNAME$ w danych logowania.", "placeholders": { "hostname": { "content": "$1", @@ -2368,7 +2371,7 @@ "message": "Anuluj subskrypcję" }, "atAnyTime": { - "message": "w każdej chwili." + "message": "w dowolnym momencie." }, "byContinuingYouAgreeToThe": { "message": "Kontynuując, akceptujesz" @@ -2407,7 +2410,7 @@ "message": "Weryfikacja synchronizacji z aplikacją desktopową" }, "desktopIntegrationVerificationText": { - "message": "Zweryfikuj aplikację desktopową z odciskiem klucza: " + "message": "Zweryfikuj aplikację desktopową z identyfikatorem: " }, "desktopIntegrationDisabledTitle": { "message": "Połączenie z przeglądarką jest wyłączone" @@ -2419,7 +2422,7 @@ "message": "Uruchom aplikację desktopową Bitwarden" }, "startDesktopDesc": { - "message": "Aplikacja desktopowa Bitwarden, przed odblokowaniem danymi biometrycznymi, musi zostać ponownie uruchomiona." + "message": "Aplikacja desktopowa Bitwarden musi zostać uruchomiona przed odblokowaniem za pomocą biometrii." }, "errorEnableBiometricTitle": { "message": "Nie można włączyć biometrii" @@ -2434,7 +2437,7 @@ "message": "Komunikacja z aplikacją desktopową została przerwana" }, "nativeMessagingWrongUserDesc": { - "message": "W aplikacji desktopowej jesteś zalogowany na inne konto. Upewnij się, że w obu aplikacjach jesteś zalogowany na to same konto." + "message": "Aplikacja desktopowa jest zalogowana na inne konto. Upewnij się, że obie aplikacje są zalogowane na to samo konto." }, "nativeMessagingWrongUserTitle": { "message": "Konto jest niezgodne" @@ -2443,13 +2446,13 @@ "message": "Klucz biometrii jest nieprawidłowy" }, "nativeMessagingWrongUserKeyDesc": { - "message": "Odblokowanie biometryczne się nie powiodło. Sekretny klucz biometryczny nie odblokował sejfu. Spróbuj skonfigurować biometrię ponownie." + "message": "Odblokowanie biometrią nie powiodło się. Klucz biometrii nie odblokował sejfu. Spróbuj ponownie skonfigurować biometrię." }, "biometricsNotEnabledTitle": { "message": "Biometria jest wyłączona" }, "biometricsNotEnabledDesc": { - "message": "Aby włączyć dane biometryczne w przeglądarce, musisz włączyć tę samą funkcję w ustawianiach aplikacji desktopowej." + "message": "Aby skonfigurować dane biometryczne w przeglądarce, włącz najpierw biometrię w aplikacji desktopowej." }, "biometricsNotSupportedTitle": { "message": "Biometria nie jest obsługiwana" @@ -2485,7 +2488,7 @@ "message": "Wystąpił błąd żądania uprawnienia" }, "nativeMessaginPermissionSidebarDesc": { - "message": "Ta operacja nie może zostać wykonana na pasku bocznym. Spróbuj ponownie w nowym oknie." + "message": "Akcji nie można wykonać na pasku bocznym. Otwórz rozszerzenie w oknie." }, "personalOwnershipSubmitError": { "message": "Ze względu na zasadę organizacji, nie możesz zapisywać elementów w osobistym sejfie. Zmień właściciela elementu na organizację i wybierz jedną z dostępnych kolekcji." @@ -2494,7 +2497,7 @@ "message": "Zasada organizacji ma wpływ na opcję własności elementów." }, "personalOwnershipPolicyInEffectImports": { - "message": "Polityka organizacji zablokowała importowanie elementów do Twojego sejfu." + "message": "Zasada organizacji zablokowała importowanie elementów do osobistego sejfu." }, "restrictCardTypeImport": { "message": "Nie można zaimportować karty" @@ -2619,7 +2622,7 @@ "message": "Zmień zagrożone hasła szybciej" }, "changeAtRiskPasswordsFasterDesc": { - "message": "Zaktualizuj swoje ustawienia, aby szybko autouzupełniać hasła i generować nowe" + "message": "Zaktualizuj ustawienia, aby szybko uzupełniać hasła i generować nowe." }, "reviewAtRiskLogins": { "message": "Sprawdź zagrożone dane logowania" @@ -2645,7 +2648,7 @@ "message": "Zaktualizuj w Bitwarden" }, "updateInBitwardenSlideDesc": { - "message": "Bitwarden poprosi Cię o aktualizację hasła w menedżerze haseł.", + "message": "Bitwarden zaproponuje aktualizację hasła w menedżerze haseł.", "description": "Description of the update in Bitwarden slide on the at-risk password page carousel" }, "updateInBitwardenSlideImgAltPeriod": { @@ -2887,7 +2890,7 @@ "message": "Aby wybrać plik, otwórz rozszerzenie w oknie." }, "popOut": { - "message": "Odepnij" + "message": "Otwórz w nowym oknie" }, "sendFileCalloutHeader": { "message": "Zanim zaczniesz" @@ -2960,7 +2963,7 @@ "description": "Used as a message within the notification bar when no folders are found" }, "orgPermissionsUpdatedMustSetPassword": { - "message": "Uprawnienia w Twojej organizacji zostały zaktualizowane, musisz teraz ustawić hasło główne.", + "message": "Uprawnienia organizacji zostały zaktualizowane. Ustaw hasło główne.", "description": "Used as a card title description on the set password page to explain why the user is there" }, "orgRequiresYouToSetPassword": { @@ -3146,7 +3149,7 @@ "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "aby uniknąć dalszej utraty danych.", + "message": "aby uniknąć dodatkowej utraty danych.", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "generateUsername": { @@ -3243,7 +3246,7 @@ } }, "forwarderGeneratedBy": { - "message": "Wygenerowane przez Bitwarden.", + "message": "Wygenerowano przez Bitwarden.", "description": "Displayed with the address on the forwarding service's configuration screen." }, "forwarderGeneratedByWithWebsite": { @@ -3365,7 +3368,7 @@ "message": "Klucz API" }, "ssoKeyConnectorError": { - "message": "Błąd serwera Key Connector: upewnij się, że serwer Key Connector jest dostępny i działa poprawnie." + "message": "Wystąpił błąd serwera Key Connector. Upewnij się, że serwer jest dostępny i działa poprawnie." }, "premiumSubcriptionRequired": { "message": "Wymagana jest subskrypcja premium" @@ -3395,7 +3398,7 @@ "message": "Inny dostawca" }, "thirdPartyServerMessage": { - "message": "Połączono z implementacją serwera innego dostawcy, $SERVERNAME$. Zweryfikuj błędy za pomocą oficjalnego serwera lub zgłoś je serwerowi innego dostawcy.", + "message": "Połączono z implementacją serwera innego dostawcy $SERVERNAME$. Zweryfikuj błędy za pomocą oficjalnego serwera lub zgłoś je serwerowi.", "placeholders": { "servername": { "content": "$1", @@ -3428,7 +3431,7 @@ "message": "Unikalny identyfikator konta" }, "fingerprintMatchInfo": { - "message": "Upewnij się, że sejf jest odblokowany, a unikalny identyfikator konta pasuje do drugiego urządzenia." + "message": "Upewnij się, że sejf jest odblokowany, a identyfikator pasuje do drugiego urządzenia." }, "resendNotification": { "message": "Wyślij ponownie powiadomienie" @@ -3446,7 +3449,7 @@ "message": "aplikacji internetowej" }, "notificationSentDevicePart2": { - "message": "Upewnij się, że fraza odcisku palca zgadza się z tą poniżej, zanim zatwierdzisz." + "message": "Upewnij się, że identyfikator jest zgodny." }, "aNotificationWasSentToYourDevice": { "message": "Powiadomienie zostało wysłane na urządzenie" @@ -3483,7 +3486,7 @@ "message": "Urządzenie" }, "loginStatus": { - "message": "Status zalogowania" + "message": "Status logowania" }, "masterPasswordChanged": { "message": "Hasło główne zostało zapisane" @@ -3492,7 +3495,7 @@ "message": "Hasło główne zostało ujawnione" }, "exposedMasterPasswordDesc": { - "message": "Hasło ujawnione w wyniku naruszenia ochrony danych. Użyj unikalnego hasła, aby chronić swoje konto. Czy na pewno chcesz użyć ujawnionego hasła?" + "message": "Hasło zostało ujawnione w wycieku danych. Użyj unikalnego hasła, aby chronić konto. Czy na pewno chcesz użyć ujawnionego hasła?" }, "weakAndExposedMasterPassword": { "message": "Hasło główne jest słabe i ujawnione" @@ -3501,7 +3504,7 @@ "message": "Hasło jest słabe i zostało ujawnione w wycieku danych. Użyj mocnego i unikalnego hasła, aby chronić konto. Czy na pewno chcesz użyć tego hasła?" }, "checkForBreaches": { - "message": "Sprawdź znane naruszenia ochrony danych tego hasła" + "message": "Sprawdź hasło w znanych wyciekach danych" }, "important": { "message": "Ważne:" @@ -3534,7 +3537,7 @@ } }, "autofillSelectInfoWithoutCommand": { - "message": "Wybierz element z tego ekranu lub zobacz inne opcje w ustawieniach." + "message": "Wybierz element lub zobacz inne opcje w ustawieniach." }, "gotIt": { "message": "Ok" @@ -3579,7 +3582,7 @@ "message": "Otwiera w nowym oknie" }, "rememberThisDeviceToMakeFutureLoginsSeamless": { - "message": "Zapamiętaj to urządzenie, aby przyszłe logowania były bezproblemowe" + "message": "Zapamiętaj urządzenie, aby przyszłe logowania były bezproblemowe" }, "manageDevices": { "message": "Zarządzaj urządzeniami" @@ -3622,7 +3625,7 @@ "message": "Zaufane" }, "needsApproval": { - "message": "Wymagane potwierdzenie" + "message": "Potwierdzenie jest wymagane" }, "devices": { "message": "Urządzenia" @@ -3680,7 +3683,7 @@ "message": "Teraz" }, "requestedXMinutesAgo": { - "message": "Poproszono $MINUTES$ min temu", + "message": "$MINUTES$ min temu", "placeholders": { "minutes": { "content": "$1", @@ -3689,10 +3692,10 @@ } }, "deviceApprovalRequired": { - "message": "Wymagane zatwierdzenie urządzenia. Wybierz opcję zatwierdzenia poniżej:" + "message": "Potwierdzenie urządzenia jest wymagane. Wybierz opcję:" }, "deviceApprovalRequiredV2": { - "message": "Wymagane zatwierdzenie urządzenia" + "message": "Potwierdzenie urządzenia jest wymagane" }, "selectAnApprovalOptionBelow": { "message": "Wybierz opcję potwierdzenia" @@ -3725,7 +3728,7 @@ "message": "Sprawdź swoją pocztę e-mail" }, "followTheLinkInTheEmailSentTo": { - "message": "Kliknij łącze w wiadomości e-mail wysłanej do" + "message": "Kliknij link w wiadomości wysłanej na adres" }, "andContinueCreatingYourAccount": { "message": "i kontynuuj tworzenie konta." @@ -3792,7 +3795,7 @@ "message": "Dla bezpieczeństwa Twojego konta potwierdź tylko, jeśli przyznano temu użytkownikowi dostęp awaryjny i jego odcisk palca pasuje do tego, co widnieje na jego koncie" }, "orgTrustWarning": { - "message": "Dla zapewnienia bezpieczeństwa konta kontynuuj tylko wtedy, gdy jesteś członkiem tej organizacji, włączono odzyskiwanie konta, a odcisk palca wyświetlany poniżej pasuje do odcisku palca organizacji." + "message": "Kontynuuj tylko wtedy, gdy jesteś członkiem organizacji, masz włączone odzyskiwanie konta, a unikalny identyfikator pasuje do organizacji." }, "orgTrustWarning1": { "message": "Zasada organizacji pozwala administratorom organizacji na zmianę Twojego hasła. Kontynuuj tylko wtedy, gdy rozpoznajesz organizację, a unikalny identyfikator pasuje do organizacji." @@ -3805,7 +3808,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoItems": { - "message": "Udostępniaj pliki i teksty każdemu, na dowolnej platformie. Informacje będę szyfrowane end-to-end, zapewniając poufność.", + "message": "Udostępniaj pliki i teksty każdemu na dowolnej platformie. Informacje będę szyfrowane end-to-end, zapewniając poufność.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "inputRequired": { @@ -3943,7 +3946,7 @@ "message": "Przełącz nawigację boczną" }, "skipToContent": { - "message": "Przejdź do treści" + "message": "Przejdź do zawartości" }, "bitwardenOverlayButton": { "message": "Przycisk menu autouzupełniania Bitwarden", @@ -3958,7 +3961,7 @@ "description": "Page title in overlay" }, "unlockYourAccountToViewMatchingLogins": { - "message": "Odblokuj swoje konto, aby wyświetlić pasujące elementy", + "message": "Odblokuj konto, aby zobaczy pasujące dane logowania", "description": "Text to display in overlay when the account is locked." }, "unlockYourAccountToViewAutofillSuggestions": { @@ -3970,7 +3973,7 @@ "description": "Button text to display in overlay when the account is locked." }, "unlockAccountAria": { - "message": "Odblokuj swoje konto, otwiera się w nowym oknie", + "message": "Odblokuj konto, otwiera się w nowym oknie", "description": "Screen reader text (aria-label) for unlock account button in overlay" }, "totpCodeAria": { @@ -3978,7 +3981,7 @@ "description": "Aria label for the totp code displayed in the inline menu for autofill" }, "totpSecondsSpanAria": { - "message": "Pozostały czas do wygaśnięcia bieżącego TOTP", + "message": "Pozostały czas do wygaśnięcia kodu TOTP", "description": "Aria label for the totp seconds displayed in the inline menu for autofill" }, "fillCredentialsFor": { @@ -4006,7 +4009,7 @@ "description": "Button text to display within inline menu when there are no matching items on a login field" }, "addNewLoginItemAria": { - "message": "Dodaj nowe dane logowania do sejfu, otwiera się w nowym oknie", + "message": "Dodaj nowe dane logowania, otwiera się w nowym oknie", "description": "Screen reader text (aria-label) for new login button within inline menu" }, "newCard": { @@ -4014,7 +4017,7 @@ "description": "Button text to display within inline menu when there are no matching items on a credit card field" }, "addNewCardItemAria": { - "message": "Dodaj nową kartę do sejfu, otwiera się w nowym oknie", + "message": "Dodaj nową kartę, otwiera się w nowym oknie", "description": "Screen reader text (aria-label) for new card button within inline menu" }, "newIdentity": { @@ -4022,7 +4025,7 @@ "description": "Button text to display within inline menu when there are no matching items on an identity field" }, "addNewIdentityItemAria": { - "message": "Dodaj nową tożsamość do sejfu, otwiera się w nowym oknie", + "message": "Dodaj nową tożsamość, otwiera się w nowym oknie", "description": "Screen reader text (aria-label) for new identity button within inline menu" }, "bitwardenOverlayMenuAvailable": { @@ -4118,13 +4121,13 @@ "message": "Konto wymaga logowania dwustopniowego Duo." }, "popoutExtension": { - "message": "Otwórz rozszerzenie w nowym oknie" + "message": "Otwórz rozszerzenie w oknie" }, "launchDuo": { "message": "Uruchom Duo" }, "importFormatError": { - "message": "Dane nie są poprawnie sformatowane. Sprawdź importowany plik i spróbuj ponownie." + "message": "Dane nie są prawidłowo sformatowane. Sprawdź plik i spróbuj ponownie." }, "importNothingError": { "message": "Nic nie zostało zaimportowane." @@ -4133,7 +4136,7 @@ "message": "Wystąpił błąd podczas odszyfrowywania pliku. Klucz szyfrowania nie pasuje do klucza użytego podczas eksportowania danych." }, "invalidFilePassword": { - "message": "Hasło do pliku jest nieprawidłowe. Użyj hasła które podano przy tworzeniu pliku eksportu." + "message": "Hasło pliku jest nieprawidłowe. Użyj prawidłowego hasła." }, "destination": { "message": "Miejsce docelowe" @@ -4148,7 +4151,7 @@ "message": "Wybierz kolekcję" }, "importTargetHint": { - "message": "Wybierz tę opcję, jeśli chcesz, aby zawartość zaimportowanego pliku została przeniesiona do $DESTINATION$", + "message": "Wybierz tę opcję, jeśli chcesz przenieść dane do $DESTINATION$", "description": "Located as a hint under the import target. Will be appended by either folder or collection, depending if the user is importing into an individual or an organizational vault.", "placeholders": { "destination": { @@ -4189,7 +4192,7 @@ "message": "Potwierdź importowanie sejfu" }, "confirmVaultImportDesc": { - "message": "Plik jest chroniony hasłem. Wprowadź hasło pliku, aby zaimportować dane." + "message": "Plik jest chroniony hasłem. Wpisz hasło pliku, aby zaimportować dane." }, "confirmFilePassword": { "message": "Potwierdź hasło pliku" @@ -4246,7 +4249,7 @@ "message": "Wybierz dane logowania, do których przypisać klucz dostępu" }, "chooseCipherForPasskeyAuth": { - "message": "Wybierz klucz dostępu, żeby się zalogować" + "message": "Wybierz klucz dostępu" }, "passkeyItem": { "message": "Element klucza dostępu" @@ -4261,16 +4264,16 @@ "message": "Funkcja nie jest jeszcze obsługiwana" }, "yourPasskeyIsLocked": { - "message": "Wymagane uwierzytelnienie, aby używać klucza dostępu. Sprawdź swoją tożsamość, aby kontynuować." + "message": "Aby użyć klucza dostępu, wymagane jest uwierzytelnienie. Zweryfikuj swoją tożsamość." }, "multifactorAuthenticationCancelled": { - "message": "Uwierzytelnianie wieloskładnikowe zostało anulowane" + "message": "Logowanie dwustopniowe zostało anulowane" }, "noLastPassDataFound": { "message": "Nie znaleziono danych LastPass" }, "incorrectUsernameOrPassword": { - "message": "Nieprawidłowa nazwa użytkownika lub hasło" + "message": "Nazwa użytkownika lub hasło są nieprawidłowe" }, "incorrectPassword": { "message": "Hasło jest nieprawidłowe" @@ -4282,7 +4285,7 @@ "message": "Kod PIN jest nieprawidłowy" }, "multifactorAuthenticationFailed": { - "message": "Uwierzytelnianie wieloskładnikowe nie powiodło się" + "message": "Logowanie dwustopniowe nie powiodło się" }, "includeSharedFolders": { "message": "Dołącz udostępnione foldery" @@ -4294,10 +4297,10 @@ "message": "Importowanie konta..." }, "lastPassMFARequired": { - "message": "Wymagane jest uwierzytelnianie wieloskładnikowe LastPass" + "message": "Logowanie dwustopniowe LastPass jest wymagane" }, "lastPassMFADesc": { - "message": "Wprowadź jednorazowy kod z aplikacji uwierzytelniającej" + "message": "Wpisz jednorazowy kod z aplikacji uwierzytelniającej" }, "lastPassOOBDesc": { "message": "Zatwierdź żądanie logowania w aplikacji uwierzytelniającej lub wprowadź jednorazowe hasło." @@ -4309,7 +4312,7 @@ "message": "Hasło główne LastPass" }, "lastPassAuthRequired": { - "message": "Wymagane uwierzytelnianie LastPass" + "message": "Uwierzytelnianie LastPass jest wymagane" }, "awaitingSSO": { "message": "Oczekiwanie na logowanie jednokrotne" @@ -4328,7 +4331,7 @@ "message": "Importuj z CSV" }, "lastPassTryAgainCheckEmail": { - "message": "Spróbuj ponownie lub poszukaj wiadomości e-mail od LastPass, aby zweryfikować, że to Ty." + "message": "Spróbuj ponownie lub poszukaj wiadomości od LastPass, aby zweryfikować logowanie." }, "collection": { "message": "Kolekcja" @@ -4373,13 +4376,13 @@ "message": "hostowany w" }, "useDeviceOrHardwareKey": { - "message": "Użyj swojego urządzenia lub klucza sprzętowego" + "message": "Użyj urządzenia lub klucza sprzętowego" }, "justOnce": { "message": "Tylko raz" }, "alwaysForThisSite": { - "message": "Zawsze dla tej witryny" + "message": "Zawsze dla tej strony" }, "domainAddedToExcludedDomains": { "message": "Domena $DOMAIN$ została dodana do wykluczonych domen.", @@ -4411,7 +4414,7 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Ustawienia zaawansowane", + "message": "Opcje zaawansowane", "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { @@ -4443,7 +4446,7 @@ "description": "Dialog title facilitating the ability to override a chrome browser's default autofill behavior" }, "overrideDefaultBrowserAutofillDescription": { - "message": "Ignorowanie tej opcji może spowodować konflikty pomiędzy menu autouzupełniania Bitwarden a przeglądarką.", + "message": "Zignorowanie tej opcji może spowodować konflikty pomiędzy autouzupełnianiem Bitwarden a przeglądarką.", "description": "Dialog message facilitating the ability to override a chrome browser's default autofill behavior" }, "overrideDefaultBrowserAutoFillSettings": { @@ -4748,19 +4751,19 @@ "message": "Pobierz Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Pobierz Bitwarden na wszystkich urządzeniach" + "message": "Bitwarden na inne urządzenia" }, "getTheMobileApp": { "message": "Pobierz aplikację mobilną" }, "getTheMobileAppDesc": { - "message": "Uzyskaj dostęp do haseł przy pomocy aplikacji mobilnej Bitwarden." + "message": "Uzyskaj dostęp do haseł za pomocą aplikacji mobilnej Bitwarden." }, "getTheDesktopApp": { "message": "Pobierz aplikację desktopową" }, "getTheDesktopAppDesc": { - "message": "Uzyskaj dostęp do sejfu bez przeglądarki, a następnie ustaw odblokowanie biometryczne, aby przyspieszyć odblokowanie zarówno w aplikacji desktopowej, jak i w rozszerzeniu przeglądarki." + "message": "Uzyskaj dostęp do sejfu bez przeglądarki. Skonfiguruj biometrię, aby przyśpieszyć odblokowywanie aplikacji." }, "downloadFromBitwardenNow": { "message": "Pobierz z bitwarden.com" @@ -4787,7 +4790,7 @@ "message": "Filtruj sejf" }, "filterApplied": { - "message": "Zastosowano jeden filtr" + "message": "Zastosowano 1 filtr" }, "filterAppliedPlural": { "message": "$COUNT$ filtrów zastosowanych", @@ -4817,7 +4820,7 @@ } }, "cardNumberEndsWith": { - "message": "numer karty kończy się", + "message": "numer karty kończący się", "description": "Used within the inline menu to provide an aria description when users are attempting to fill a card cipher." }, "loginCredentials": { @@ -4886,7 +4889,7 @@ "message": "Karta wygasła" }, "cardExpiredMessage": { - "message": "Jeśli ją wznowiłeś, zaktualizuj informacje o karcie" + "message": "Jeśli karta została odnowiona, zaktualizuj informacje o niej" }, "cardDetails": { "message": "Szczegóły karty" @@ -4924,7 +4927,7 @@ "description": "A section header for a list of passwords." }, "logInWithPasskeyAriaLabel": { - "message": "Logowaniem kluczem dostępu", + "message": "Logowanie kluczem dostępu", "description": "ARIA label for the inline menu button that logs in with a passkey." }, "assign": { @@ -4964,16 +4967,16 @@ "message": "Użyj pól tekstowych dla danych takich jak pytania bezpieczeństwa" }, "hiddenHelpText": { - "message": "Użyj ukrytych pól dla danych poufnych, takich jak hasło" + "message": "Użyj ukrytych pól dla danych poufnych takich jak hasło" }, "checkBoxHelpText": { - "message": "Użyj pól wyboru, jeśli chcesz automatycznie wypełnić pole wyboru formularza, np. zapamiętaj e-mail" + "message": "Użyj pól wyboru, gdy chcesz uzupełnić pole wyboru formularza, np. zapamiętaj adres e-mail" }, "linkedHelpText": { "message": "Użyj powiązanego pola, gdy masz problemy z autouzupełnianiem na konkretnej stronie internetowej." }, "linkedLabelHelpText": { - "message": "Wprowadź atrybut z HTML'a: id, name, aria-label lub placeholder." + "message": "Wpisz identyfikator, nazwę, etykietę lub tekst zastępczy pola HTML." }, "editField": { "message": "Edytuj pole" @@ -5006,7 +5009,7 @@ } }, "reorderToggleButton": { - "message": "Zmień kolejność $LABEL$. Użyj klawiszy ze strzałkami, aby przenieść element w górę lub w dół.", + "message": "Zmień kolejność pola $LABEL$. Użyj klawiszy ze strzałkami, aby przenieść element w górę lub w dół.", "placeholders": { "label": { "content": "$1", @@ -5015,10 +5018,10 @@ } }, "reorderWebsiteUriButton": { - "message": "Zmień kolejność URI strony. Użyj klawiszy ze strzałkami, aby przenieść element w górę lub w dół." + "message": "Zmień kolejność URI stron internetowych. Użyj klawiszy ze strzałkami, aby przenieść element w górę lub w dół." }, "reorderFieldUp": { - "message": "$LABEL$ przeniósł się w górę, pozycja $INDEX$ z $LENGTH$", + "message": "Pole $LABEL$ zostało przeniesione w górę. Pozycja $INDEX$ z $LENGTH$", "placeholders": { "label": { "content": "$1", @@ -5075,7 +5078,7 @@ "message": "Przypisano kolekcje" }, "nothingSelected": { - "message": "Nie zaznaczyłeś żadnych elementów." + "message": "Nie zaznaczono żadnych elementów." }, "itemsMovedToOrg": { "message": "Elementy zostały przeniesione do organizacji $ORGNAME$", @@ -5096,7 +5099,7 @@ } }, "reorderFieldDown": { - "message": "$LABEL$ przeniósł się w dół, pozycja $INDEX$ z $LENGTH$", + "message": "Pole $LABEL$ zostało przeniesione w dół. Pozycja $INDEX$ z $LENGTH$", "placeholders": { "label": { "content": "$1", @@ -5140,7 +5143,7 @@ "message": "Domyślny systemu" }, "enterprisePolicyRequirementsApplied": { - "message": "Zastosowano wymagania zasady organizacji" + "message": "Wymagania zasady organizacji zostały zastosowane" }, "sshPrivateKey": { "message": "Klucz prywatny" @@ -5167,7 +5170,7 @@ "message": "RSA 4096-bit" }, "retry": { - "message": "Powtórz" + "message": "Spróbuj ponownie" }, "vaultCustomTimeoutMinimum": { "message": "Minimalny niestandardowy czas to 1 minuta." @@ -5176,7 +5179,7 @@ "message": "Dostępna jest dodatkowa zawartość" }, "fileSavedToDevice": { - "message": "Plik zapisany na urządzeniu. Zarządzaj plikiem na swoim urządzeniu." + "message": "Plik został zapisany na urządzeniu." }, "showCharacterCount": { "message": "Pokaż liczbę znaków" @@ -5200,10 +5203,10 @@ "message": "Przywróć" }, "deleteForever": { - "message": "Usuń na zawsze" + "message": "Usuń trwale" }, "noEditPermissions": { - "message": "Nie masz uprawnień do edycji tego elementu" + "message": "Nie masz uprawnień do edycji elementu" }, "biometricsStatusHelptextUnlockNeeded": { "message": "Odblokowanie biometrią jest niedostępne, ponieważ najpierw wymagane jest odblokowanie kodem PIN lub hasłem." @@ -5410,10 +5413,10 @@ "message": "Szerokość rozszerzenia" }, "wide": { - "message": "Szerokie" + "message": "Szeroka" }, "extraWide": { - "message": "Bardzo szerokie" + "message": "Bardzo szeroka" }, "sshKeyWrongPassword": { "message": "Hasło jest nieprawidłowe." @@ -5455,16 +5458,16 @@ "message": "Zaktualizuj aplikację desktopową" }, "updateDesktopAppOrDisableFingerprintDialogMessage": { - "message": "Aby używać odblokowywania biometrycznego, zaktualizuj aplikację na komputerze lub wyłącz odblokowywanie odciskiem palca w ustawieniach aplikacji na komputerze." + "message": "Aby używać biometrii, zaktualizuj aplikację desktopową." }, "changeAtRiskPassword": { "message": "Zmień zagrożone hasło" }, "settingsVaultOptions": { - "message": "Ustawienia Sejfu" + "message": "Opcje sejfu" }, "emptyVaultDescription": { - "message": "Sejf chroni nie tylko Twoje hasła. Przechowuj tutaj bezpiecznie loginy, identyfikatory, karty i notatki." + "message": "Sejf chroni nie tylko hasła. Przechowuj bezpiecznie dane logowania, identyfikatory, karty i notatki." }, "introCarouselLabel": { "message": "Witaj w Bitwarden" @@ -5509,13 +5512,13 @@ "message": "Witaj w sejfie!" }, "hasItemsVaultNudgeBodyOne": { - "message": "Autouzupełnianie elementów dla bieżącej strony" + "message": "Uzupełniaj elementy na stronie internetowej" }, "hasItemsVaultNudgeBodyTwo": { - "message": "Ulubione elementy dla szybkiego dostępu" + "message": "Dodawaj do ulubionych wybrane elementy" }, "hasItemsVaultNudgeBodyThree": { - "message": "Przeszukaj sejf w poszukiwaniu czegoś innego" + "message": "Przeszukuj sejf w poszukiwaniu czegoś innego" }, "newLoginNudgeTitle": { "message": "Oszczędzaj czas dzięki autouzupełnianiu" @@ -5584,7 +5587,7 @@ "description": "Aria label for the body content of the generator nudge" }, "noPermissionsViewPage": { - "message": "Nie masz uprawnień do przeglądania tej strony. Spróbuj zalogować się na inne konto." + "message": "Nie masz uprawnień do przeglądania tej strony. Zaloguj się na inne konto." }, "wasmNotSupported": { "message": "WebAssembly nie jest obsługiwany w przeglądarce lub jest wyłączony. WebAssembly jest wymagany do korzystania z aplikacji Bitwarden.", diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 7d5509a628b..46fbb9beca3 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Pesquisar no Cofre" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Editar" }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 1e77e1c3035..3cd813847d1 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Procurar no cofre" }, + "resetSearch": { + "message": "Repor pesquisa" + }, "edit": { "message": "Editar" }, diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 7f54640af25..13fe8aa9482 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -398,7 +398,7 @@ "message": "Numele folderului" }, "folderHintText": { - "message": "Nest a folder by adding the parent folder's name followed by a “/”. Example: Social/Forums" + "message": "Grupează un folder prin adăugarea numelui folderului părinte urmat de \"/\" Exemplu: Social/Forums" }, "noFoldersAdded": { "message": "No folders added" @@ -547,6 +547,9 @@ "searchVault": { "message": "Căutare în seif" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Editare" }, @@ -887,7 +890,7 @@ "message": "Follow the steps below to finish logging in." }, "followTheStepsBelowToFinishLoggingInWithSecurityKey": { - "message": "Follow the steps below to finish logging in with your security key." + "message": "Urmează pașii de mai jos pentru a finaliza autentificarea cu cheia de securitate." }, "restartRegistration": { "message": "Reporniți înregistrarea" @@ -929,7 +932,7 @@ "message": "Make your account more secure by setting up two-step login in the Bitwarden web app." }, "twoStepLoginConfirmationTitle": { - "message": "Continue to web app?" + "message": "Continuați în aplicația web?" }, "editedFolder": { "message": "Dosar salvat" @@ -1046,7 +1049,7 @@ "message": "Click items to autofill on Vault view" }, "clickToAutofill": { - "message": "Click items in autofill suggestion to fill" + "message": "Faceți clic pe elementele din sugestia de completare automată pentru a completa" }, "clearClipboard": { "message": "Golire clipboard", diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index db236cbfcc8..a5a6809a059 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Поиск в хранилище" }, + "resetSearch": { + "message": "Сбросить поиск" + }, "edit": { "message": "Изменить" }, @@ -3795,7 +3798,7 @@ "message": "В целях обеспечения безопасности вашего аккаунта продолжайте только в том случае, если вы являетесь членом этой организации, у вас включено восстановление аккаунта, а отображаемый ниже отпечаток совпадает с отпечатком организации." }, "orgTrustWarning1": { - "message": "В этой организации действует политика, которая позволит вам участвовать в восстановлении аккаунта. Регистрация позволит администраторам организации изменить ваш пароль. Продолжайте, только если вы знаете эту организацию и фраза отпечатков, показанная ниже, совпадает с отпечатками организации." + "message": "Эта организация имеет корпоративную политику, которая зарегистрирует вас в системе восстановления аккаунта. Регистрация позволит администраторам организации изменить ваш пароль. Продолжайте только в том случае, если вы узнаете эту организацию и фраза отпечатка, отображаемая ниже, соответствует отпечатку организации." }, "trustUser": { "message": "Доверенный пользователь" @@ -5242,7 +5245,7 @@ "message": "Установить PIN--код разблокировки" }, "unlockWithBiometricSet": { - "message": "Разблокировать с помощью биометрии" + "message": "Разблокировка с помощью биометрии настроена" }, "authenticating": { "message": "Аутентификация" diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 13e6c2522bf..7bc0ba2694a 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "සුරක්ෂිතාගාරය සොයන්න" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "සංස්කරණය" }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index e98c643edb9..28a687be339 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Prehľadávať trezor" }, + "resetSearch": { + "message": "Resetovať vyhľadávanie" + }, "edit": { "message": "Upraviť" }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 397b7be54e8..2db40266cd7 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Išči v trezorju" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Uredi" }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index f68d0b97447..3ee18612553 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Претражи сеф" }, + "resetSearch": { + "message": "Ресетовати претрагу" + }, "edit": { "message": "Уреди" }, @@ -1830,7 +1833,7 @@ "message": "Сигурносни код" }, "cardNumber": { - "message": "card number" + "message": "број картице" }, "ex": { "message": "нпр." @@ -3464,7 +3467,7 @@ "message": "Захтев је послат" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Захтев за пријаву одобрен за $EMAIL$ на $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3480,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Одбили сте покушај пријаве са другог уређаја. Ако сте то били ви, покушајте поново да се пријавите помоћу уређаја." }, "device": { - "message": "Device" + "message": "Уређај" }, "loginStatus": { - "message": "Login status" + "message": "Статус пријаве" }, "masterPasswordChanged": { "message": "Главна лозинка сачувана" @@ -3582,28 +3585,28 @@ "message": "Запамтити овај уређај да би будуће пријаве биле беспрекорне" }, "manageDevices": { - "message": "Manage devices" + "message": "Управљање уређајима" }, "currentSession": { - "message": "Current session" + "message": "Тренутна сесија" }, "mobile": { - "message": "Mobile", + "message": "Мобилни", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Додатак", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Десктоп", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Интернет Сеф" }, "webApp": { - "message": "Web app" + "message": "Веб апликација" }, "cli": { "message": "CLI" @@ -3613,22 +3616,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Захтев је на чекању" }, "firstLogin": { - "message": "First login" + "message": "Прва пријава" }, "trusted": { - "message": "Trusted" + "message": "Поуздан" }, "needsApproval": { - "message": "Needs approval" + "message": "Потребно је одобрење" }, "devices": { - "message": "Devices" + "message": "Уређаји" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Покушај приступа са $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3640,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Потврди приступ" }, "denyAccess": { - "message": "Deny access" + "message": "Одбиј приступ" }, "time": { - "message": "Time" + "message": "Време" }, "deviceType": { - "message": "Device Type" + "message": "Тип уређаја" }, "loginRequest": { - "message": "Login request" + "message": "Захтев за пријаву" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Овај захтев више није важећи." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Да ли покушавате да приступите вашем налогу?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Пријава потврђена за $EMAIL$ на $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3674,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Одбили сте покушај пријаве са другог уређаја. Ако сте то заиста били ви, покушајте поново да се пријавите помоћу уређаја." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Захтев за пријаву је већ истекао." }, "justNow": { - "message": "Just now" + "message": "Управо сада" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Затражено пре $MINUTES$ минута", "placeholders": { "minutes": { "content": "$1", @@ -4598,7 +4601,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Копирај $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index cbfc3e478f5..49d2765bf6e 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Sök i valvet" }, + "resetSearch": { + "message": "Nollställ sökning" + }, "edit": { "message": "Redigera" }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 9a6d9a4d316..f4c58318bc1 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Search vault" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 49515eb1c64..fd92c71c200 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "ค้นหาในตู้นิรภัย" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "แก้ไข" }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index cd7c8d3f0b6..83265497ddf 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -462,13 +462,13 @@ "message": "Parola oluştur" }, "generatePassphrase": { - "message": "Parola üret" + "message": "Parola cümlesi üret" }, "passwordGenerated": { "message": "Parola üretildi" }, "passphraseGenerated": { - "message": "Parola ifadesi oluşturuldu" + "message": "Parola cümlesi üretildi" }, "usernameGenerated": { "message": "Kullanıcı adı üretildi" @@ -547,6 +547,9 @@ "searchVault": { "message": "Kasada ara" }, + "resetSearch": { + "message": "Aramayı sıfırla" + }, "edit": { "message": "Düzenle" }, @@ -569,7 +572,7 @@ "message": "Kimlik doğrulama sırrı" }, "passphrase": { - "message": "Uzun söz" + "message": "Parola cümlesi" }, "favorite": { "message": "Favori" @@ -2217,7 +2220,7 @@ "message": "Bu parolayı kullan" }, "useThisPassphrase": { - "message": "Bu parola ifadesini kullanın" + "message": "Bu parola cümlesini kullan" }, "useThisUsername": { "message": "Bu kullanıcı adını kullan" @@ -3180,7 +3183,7 @@ } }, "passphraseNumWordsRecommendationHint": { - "message": "Güçlü bir parola ifadesi oluşturmak için $RECOMMENDED$ veya daha fazla kelime kullanın.", + "message": " Güçlü bir parola cümlesi üretmek için en az $RECOMMENDED$ kelime kullanın.", "description": "Appended to `spinboxBoundariesHint` to recommend a number of words to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 083d89fbd12..a03440efe02 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "Пошук" }, + "resetSearch": { + "message": "Скинути пошук" + }, "edit": { "message": "Змінити" }, @@ -1830,7 +1833,7 @@ "message": "Код безпеки" }, "cardNumber": { - "message": "card number" + "message": "номер картки" }, "ex": { "message": "зразок" @@ -3464,7 +3467,7 @@ "message": "Запит надіслано" }, "loginRequestApprovedForEmailOnDevice": { - "message": "Login request approved for $EMAIL$ on $DEVICE$", + "message": "Запит входу підтверджено для $EMAIL$ на $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3477,13 +3480,13 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "Ви відхилили спробу входу з іншого пристрою. Якщо це були ви, спробуйте ввійти з пристроєм знову." }, "device": { - "message": "Device" + "message": "Пристрій" }, "loginStatus": { - "message": "Login status" + "message": "Стан входу в систему" }, "masterPasswordChanged": { "message": "Головний пароль збережено" @@ -3582,28 +3585,28 @@ "message": "Запам'ятайте цей пристрій, щоб спростити майбутні входи в систему" }, "manageDevices": { - "message": "Manage devices" + "message": "Керувати пристроями" }, "currentSession": { - "message": "Current session" + "message": "Поточний сеанс" }, "mobile": { - "message": "Mobile", + "message": "Мобільний", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Розширення", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "Комп'ютер", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "Вебсховище" }, "webApp": { - "message": "Web app" + "message": "Вебпрограма" }, "cli": { "message": "CLI" @@ -3613,22 +3616,22 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "Запит в очікуванні" }, "firstLogin": { - "message": "First login" + "message": "Перший вхід" }, "trusted": { - "message": "Trusted" + "message": "Надійний" }, "needsApproval": { - "message": "Needs approval" + "message": "Потребує підтвердження" }, "devices": { - "message": "Devices" + "message": "Пристрої" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Спроба доступу з $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3637,28 +3640,28 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Підтвердити доступ" }, "denyAccess": { - "message": "Deny access" + "message": "Заборонити доступ" }, "time": { - "message": "Time" + "message": "Час" }, "deviceType": { - "message": "Device Type" + "message": "Тип пристрою" }, "loginRequest": { - "message": "Login request" + "message": "Запит входу" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Цей запит більше недійсний." }, "areYouTryingToAccessYourAccount": { - "message": "Are you trying to access your account?" + "message": "Ви намагаєтесь отримати доступ до свого облікового запису?" }, "logInConfirmedForEmailOnDevice": { - "message": "Login confirmed for $EMAIL$ on $DEVICE$", + "message": "Підтверджено вхід для $EMAIL$ на $DEVICE$", "placeholders": { "email": { "content": "$1", @@ -3671,16 +3674,16 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again." + "message": "Ви відхилили спробу входу з іншого пристрою. Якщо це були дійсно ви, спробуйте ввійти з пристроєм знову." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Термін дії запиту на вхід завершився." }, "justNow": { - "message": "Just now" + "message": "Щойно" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Запитано $MINUTES$ хвилин тому", "placeholders": { "minutes": { "content": "$1", @@ -4598,7 +4601,7 @@ } }, "copyFieldCipherName": { - "message": "Copy $FIELD$, $CIPHERNAME$", + "message": "Копіювати $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index b5de1c2981c..f25cc9c51dc 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -453,7 +453,7 @@ "message": "Ứng dụng web Bitwarden" }, "importItems": { - "message": "Nhập mục" + "message": "Nhập vào kho" }, "select": { "message": "Chọn" @@ -547,6 +547,9 @@ "searchVault": { "message": "Tìm kiếm trong kho lưu trữ" }, + "resetSearch": { + "message": "Đặt lại tìm kiếm" + }, "edit": { "message": "Sửa" }, @@ -1013,16 +1016,16 @@ "description": "This is the folder for uncategorized items" }, "enableAddLoginNotification": { - "message": "Hỏi để thêm đăng nhập" + "message": "Gợi ý thêm mục đăng nhập" }, "vaultSaveOptionsTitle": { "message": "Tùy chọn lưu vào kho" }, "addLoginNotificationDesc": { - "message": "Nếu không tìm thấy mục nào trong kho của bạn, hãy yêu cầu thêm mục đó." + "message": "Gợi ý thêm mục nếu không tìm thấy mục nào trong kho trùng khớp." }, "addLoginNotificationDescAlt": { - "message": "Nếu không tìm thấy mục nào trong kho của bạn, hãy yêu cầu thêm mục đó. Áp dụng cho tất cả tài khoản đã đăng nhập." + "message": "Gợi ý thêm mục nếu không tìm thấy mục nào trong kho trùng khớp. Áp dụng cho tất cả tài khoản đã đăng nhập." }, "showCardsInVaultViewV2": { "message": "Luôn hiển thị thẻ như đề xuất tự động điền trên giao diện kho" @@ -1223,7 +1226,7 @@ "description": "Default URI match detection for autofill." }, "defaultUriMatchDetectionDesc": { - "message": "Chọn cách thức mặc định hệ thống so khớp đường dẫn (URI) để xử lý đăng nhập khi thực hiện các thao tác như tự động điền." + "message": "Chọn cách hệ thống so khớp đường dẫn để xử lý đăng nhập khi thực hiện các thao tác như tự động điền." }, "theme": { "message": "Chủ đề" @@ -1735,7 +1738,7 @@ "message": "Văn bản" }, "cfTypeHidden": { - "message": "Đã ẩn đi" + "message": "Bí ẩn" }, "cfTypeBoolean": { "message": "Đúng/Sai" @@ -1845,10 +1848,10 @@ "message": "Bà" }, "ms": { - "message": "Chị" + "message": "Cô" }, "dr": { - "message": "Bác sĩ" + "message": "Tiến sĩ/Bác sĩ" }, "mx": { "message": "Mx" @@ -1902,7 +1905,7 @@ "message": "Xã / Phường" }, "stateProvince": { - "message": "Tỉnh/Thành Phố" + "message": "Tỉnh / Thành Phố" }, "zipPostalCode": { "message": "Mã bưu chính" @@ -2109,7 +2112,7 @@ "description": "ex. Date this item was updated" }, "dateCreated": { - "message": "Ngày tạo", + "message": "Tạo lúc", "description": "ex. Date this item was created" }, "datePasswordUpdated": { @@ -2688,7 +2691,7 @@ "message": "Giới hạn số lượt xem" }, "limitSendViewsHint": { - "message": "Không ai có thể xem mục Gửi này sau khi đạt đến giới hạn.", + "message": "Không ai có thể xem Send này sau khi đạt đến giới hạn.", "description": "Displayed under the limit views field on Send" }, "limitSendViewsCount": { @@ -2727,7 +2730,7 @@ "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "hideTextByDefault": { - "message": "Mặc định ẩn văn bản" + "message": "Ẩn văn bản theo mặc định" }, "expired": { "message": "Đã hết hạn" @@ -2782,7 +2785,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deletionDate": { - "message": "Ngày xóa" + "message": "Xóa sau" }, "deletionDateDescV2": { "message": "Send sẽ được xóa vĩnh viễn vào ngày này.", @@ -4312,7 +4315,7 @@ "message": "Yêu cầu xác thực LastPass" }, "awaitingSSO": { - "message": "Đang chờ xác thực Đăng nhập một lần" + "message": "Đang chờ xác thực SSO" }, "awaitingSSODesc": { "message": "Vui lòng tiếp tục đăng nhập bằng thông tin đăng nhập của công ty bạn." @@ -4757,7 +4760,7 @@ "message": "Truy cập mật khẩu của bạn mọi lúc mọi nơi với ứng dụng di động Bitwarden." }, "getTheDesktopApp": { - "message": "Tải ứng dụng cho máy tính" + "message": "Tải ứng dụng máy tính" }, "getTheDesktopAppDesc": { "message": "Truy cập kho lưu trữ của bạn mà không cần trình duyệt, sau đó thiết lập mở khóa bằng sinh trắc học để mở khóa dễ dàng trong cả ứng dụng trên máy tính và tiện ích mở rộng trình duyệt." @@ -4892,7 +4895,7 @@ "message": "Thông tin thẻ" }, "cardBrandDetails": { - "message": "Chi tiết $BRAND$", + "message": "Chi tiết thẻ $BRAND$", "placeholders": { "brand": { "content": "$1", diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index f44265425e9..2382e6fc971 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "搜索密码库" }, + "resetSearch": { + "message": "重置搜索" + }, "edit": { "message": "编辑" }, @@ -3477,7 +3480,7 @@ } }, "youDeniedLoginAttemptFromAnotherDevice": { - "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." + "message": "您拒绝了一个来自其他设备的登录尝试。若确实是您本人,请尝试再次发起设备登录。" }, "device": { "message": "设备" @@ -3585,25 +3588,25 @@ "message": "Manage devices" }, "currentSession": { - "message": "Current session" + "message": "当前会话" }, "mobile": { - "message": "Mobile", + "message": "移动端", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "扩展", "description": "Browser extension/addon" }, "desktop": { - "message": "Desktop", + "message": "桌面端", "description": "Desktop app" }, "webVault": { - "message": "Web vault" + "message": "网页密码库" }, "webApp": { - "message": "Web app" + "message": "网页 App" }, "cli": { "message": "CLI" @@ -3613,7 +3616,7 @@ "description": "Software Development Kit" }, "requestPending": { - "message": "Request pending" + "message": "请求待处理" }, "firstLogin": { "message": "First login" @@ -3671,7 +3674,7 @@ } }, "youDeniedALogInAttemptFromAnotherDevice": { - "message": "您拒绝了另一台设备的登录尝试。如果真的是您,请尝试再次使用该设备登录。" + "message": "您拒绝了一个来自其他设备的登录尝试。若确实是您本人,请尝试再次发起设备登录。" }, "loginRequestHasAlreadyExpired": { "message": "登录请求已过期。" @@ -3680,7 +3683,7 @@ "message": "Just now" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "请求于 $MINUTES$ 分钟前", "placeholders": { "minutes": { "content": "$1", @@ -4407,7 +4410,7 @@ "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "More about match detection", + "message": "更多关于匹配检测", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index b41b9271c75..d2776cb227d 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -547,6 +547,9 @@ "searchVault": { "message": "搜尋密碼庫" }, + "resetSearch": { + "message": "Reset search" + }, "edit": { "message": "編輯" }, diff --git a/apps/browser/src/auth/popup/set-password.component.html b/apps/browser/src/auth/popup/set-password.component.html deleted file mode 100644 index 71a2e3ac588..00000000000 --- a/apps/browser/src/auth/popup/set-password.component.html +++ /dev/null @@ -1,160 +0,0 @@ -
-
-
- -
-

- {{ "setMasterPassword" | i18n }} -

-
- -
-
-
-
- -
-
-
-

- {{ "orgPermissionsUpdatedMustSetPassword" | i18n }} -

- - -

{{ "orgRequiresYouToSetPassword" | i18n }}

-
- - - {{ "resetPasswordAutoEnrollInviteWarning" | i18n }} - - - -
-
-
-
-
-
- - -
-
- -
-
- - - -
-
- -
-
-
-
-
-
- - -
-
- -
-
-
-
-
-
-
-
- - -
-
- -
-
-
-
diff --git a/apps/browser/src/auth/popup/set-password.component.ts b/apps/browser/src/auth/popup/set-password.component.ts deleted file mode 100644 index 2a796854531..00000000000 --- a/apps/browser/src/auth/popup/set-password.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from "@angular/core"; - -import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component"; - -@Component({ - selector: "app-set-password", - templateUrl: "set-password.component.html", - standalone: false, -}) -export class SetPasswordComponent extends BaseSetPasswordComponent {} diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts index b50e1f55032..014f2a7638b 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts @@ -5,7 +5,6 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -13,6 +12,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutSettingsService, VaultTimeoutService, diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 6c072532a5d..b41cfe14c4f 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -25,7 +25,6 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; import { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; @@ -33,6 +32,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeout, VaultTimeoutAction, diff --git a/apps/browser/src/auth/popup/settings/vault-timeout-input.component.html b/apps/browser/src/auth/popup/settings/vault-timeout-input.component.html deleted file mode 100644 index c62f29130bf..00000000000 --- a/apps/browser/src/auth/popup/settings/vault-timeout-input.component.html +++ /dev/null @@ -1,44 +0,0 @@ -
-
- - -
-
-
-
-
- - -
-
-
-
- - -
-
-
-
-
diff --git a/apps/browser/src/auth/popup/settings/vault-timeout-input.component.ts b/apps/browser/src/auth/popup/settings/vault-timeout-input.component.ts deleted file mode 100644 index 25a4d01333d..00000000000 --- a/apps/browser/src/auth/popup/settings/vault-timeout-input.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component } from "@angular/core"; -import { NG_VALIDATORS, NG_VALUE_ACCESSOR } from "@angular/forms"; - -import { VaultTimeoutInputComponent as VaultTimeoutInputComponentBase } from "@bitwarden/auth/angular"; - -@Component({ - selector: "app-vault-timeout-input", - templateUrl: "vault-timeout-input.component.html", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - multi: true, - useExisting: VaultTimeoutInputComponent, - }, - { - provide: NG_VALIDATORS, - multi: true, - useExisting: VaultTimeoutInputComponent, - }, - ], - standalone: false, -}) -export class VaultTimeoutInputComponent extends VaultTimeoutInputComponentBase {} diff --git a/apps/browser/src/auth/popup/update-temp-password.component.html b/apps/browser/src/auth/popup/update-temp-password.component.html deleted file mode 100644 index 0ce82aa20cf..00000000000 --- a/apps/browser/src/auth/popup/update-temp-password.component.html +++ /dev/null @@ -1,142 +0,0 @@ -
-
-
- -
-

- {{ "updateMasterPassword" | i18n }} -

-
- -
-
-
- - {{ masterPasswordWarningText }} - - - -
-
-
-
-
- - -
-
-
-
-
-
-
-
-
-
- - -
-
- -
-
- - -
-
-
-
-
-
-
- - -
-
- -
-
-
-
-
-
-
- - -
-
- -
-
-
diff --git a/apps/browser/src/auth/popup/update-temp-password.component.ts b/apps/browser/src/auth/popup/update-temp-password.component.ts deleted file mode 100644 index e8cf64b7548..00000000000 --- a/apps/browser/src/auth/popup/update-temp-password.component.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Component } from "@angular/core"; -import { firstValueFrom } from "rxjs"; - -import { UpdateTempPasswordComponent as BaseUpdateTempPasswordComponent } from "@bitwarden/angular/auth/components/update-temp-password.component"; - -import { postLogoutMessageListener$ } from "./utils/post-logout-message-listener"; - -@Component({ - selector: "app-update-temp-password", - templateUrl: "update-temp-password.component.html", - standalone: false, -}) -export class UpdateTempPasswordComponent extends BaseUpdateTempPasswordComponent { - onSuccessfulChangePassword: () => Promise = this.doOnSuccessfulChangePassword.bind(this); - - private async doOnSuccessfulChangePassword() { - // start listening for "switchAccountFinish" or "doneLoggingOut" - const messagePromise = firstValueFrom(postLogoutMessageListener$); - this.messagingService.send("logout"); - // wait for messages - const command = await messagePromise; - - // doneLoggingOut already has a message handler that will navigate us - if (command === "switchAccountFinish") { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/"]); - } - } -} diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index 9c9c5c0e243..f2152b44862 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -1,9 +1,6 @@ import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; -import { UserId } from "@bitwarden/common/types/guid"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; -import { SecurityTask } from "@bitwarden/common/vault/tasks"; import { CollectionView } from "../../content/components/common-types"; import { NotificationQueueMessageTypes } from "../../enums/notification-queue-message-type.enum"; @@ -60,23 +57,10 @@ type LockedVaultPendingNotificationsData = { target: string; }; -type AtRiskPasswordNotificationsData = { - activeUserId: UserId; - cipher: CipherView; - securityTask: SecurityTask; - uri: string; -}; - type AdjustNotificationBarMessageData = { height: number; }; -type ChangePasswordMessageData = { - currentPassword: string; - newPassword: string; - url: string; -}; - type AddLoginMessageData = { username: string; password: string; @@ -92,10 +76,7 @@ type NotificationBackgroundExtensionMessage = { command: string; data?: Partial & Partial & - Partial & - Partial & - Partial; - login?: AddLoginMessageData; + Partial; folder?: string; edit?: boolean; details?: AutofillPageDetails; @@ -121,18 +102,6 @@ type NotificationBackgroundExtensionMessageHandlers = { bgCloseNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgOpenAtRiskPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgAdjustNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; - bgTriggerAddLoginNotification: ({ - message, - sender, - }: BackgroundOnMessageHandlerParams) => Promise; - bgTriggerChangedPasswordNotification: ({ - message, - sender, - }: BackgroundOnMessageHandlerParams) => Promise; - bgTriggerAtRiskPasswordNotification: ({ - message, - sender, - }: BackgroundOnMessageHandlerParams) => Promise; bgRemoveTabFromNotificationQueue: ({ sender }: BackgroundSenderParam) => void; bgSaveCipher: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; bgOpenAddEditVaultItemPopout: ({ @@ -162,7 +131,6 @@ export { NotificationQueueMessageItem, LockedVaultPendingNotificationsData, AdjustNotificationBarMessageData, - ChangePasswordMessageData, UnlockVaultMessageData, AddLoginMessageData, NotificationBackgroundExtensionMessage, diff --git a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts index 0ec6a9ae04a..d446e18b480 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts @@ -1,3 +1,6 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { SecurityTask } from "@bitwarden/common/vault/tasks"; + import AutofillPageDetails from "../../models/autofill-page-details"; export type NotificationTypeData = { @@ -8,6 +11,12 @@ export type NotificationTypeData = { launchTimestamp?: number; }; +export type LoginSecurityTaskInfo = { + securityTask: SecurityTask; + cipher: CipherView; + uri: ModifyLoginCipherFormData["uri"]; +}; + export type WebsiteOriginsWithFields = Map>; export type ActiveFormSubmissionRequests = Set; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 5e7e3ed30f5..7302ae7d705 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -3,17 +3,18 @@ import { BehaviorSubject, firstValueFrom, of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { ExtensionCommand } from "@bitwarden/common/autofill/constants"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; -import { UserNotificationSettingsService } from "@bitwarden/common/autofill/services/user-notification-settings.service"; +import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { ThemeTypes } from "@bitwarden/common/platform/enums"; import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -38,6 +39,7 @@ import { LockedVaultPendingNotificationsData, NotificationBackgroundExtensionMessage, } from "./abstractions/notification.background"; +import { ModifyLoginCipherFormData } from "./abstractions/overlay-notifications.background"; import NotificationBackground from "./notification.background"; jest.mock("rxjs", () => { @@ -58,13 +60,21 @@ describe("NotificationBackground", () => { const collectionService = mock(); let activeAccountStatusMock$: BehaviorSubject; let authService: MockProxy; - const policyService = mock(); + const policyAppliesToUser$ = new BehaviorSubject(true); + const policyService = mock({ + policyAppliesToUser$: jest.fn().mockReturnValue(policyAppliesToUser$), + }); const folderService = mock(); - const userNotificationSettingsService = mock(); + const enableChangedPasswordPromptMock$ = new BehaviorSubject(true); + const userNotificationSettingsService = mock(); + userNotificationSettingsService.enableChangedPasswordPrompt$ = enableChangedPasswordPromptMock$; + const domainSettingsService = mock(); const environmentService = mock(); const logService = mock(); + const selectedThemeMock$ = new BehaviorSubject(ThemeTypes.Light); const themeStateService = mock(); + themeStateService.selectedTheme$ = selectedThemeMock$; const configService = mock(); const accountService = mock(); const organizationService = mock(); @@ -164,7 +174,7 @@ describe("NotificationBackground", () => { }); }); - describe("notification bar extension message handlers", () => { + describe("notification bar extension message handlers and triggers", () => { beforeEach(() => { notificationBackground.init(); }); @@ -283,7 +293,12 @@ describe("NotificationBackground", () => { let pushAddLoginToQueueSpy: jest.SpyInstance; let pushChangePasswordToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; - + const mockModifyLoginCipherFormData: ModifyLoginCipherFormData = { + username: "test", + password: "password", + uri: "https://example.com", + newPassword: null, + }; beforeEach(() => { tab = createChromeTabMock(); sender = mock({ tab }); @@ -304,43 +319,34 @@ describe("NotificationBackground", () => { }); it("skips attempting to add the login if the user is logged out", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerAddLoginNotification", - login: { username: "test", password: "password", url: "https://example.com" }, - }; + const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerAddLoginNotification(data, tab); expect(getEnableAddedLoginPromptSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); }); it("skips attempting to add the login if the login data does not contain a valid url", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerAddLoginNotification", - login: { username: "test", password: "password", url: "" }, + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Locked); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerAddLoginNotification(data, tab); expect(getEnableAddedLoginPromptSpy).not.toHaveBeenCalled(); expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); }); it("skips attempting to add the login if the user with a locked vault has disabled the login notification", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerAddLoginNotification", - login: { username: "test", password: "password", url: "https://example.com" }, - }; + const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; activeAccountStatusMock$.next(AuthenticationStatus.Locked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerAddLoginNotification(data, tab); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); @@ -349,16 +355,12 @@ describe("NotificationBackground", () => { }); it("skips attempting to add the login if the user with an unlocked vault has disabled the login notification", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerAddLoginNotification", - login: { username: "test", password: "password", url: "https://example.com" }, - }; + const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); getAllDecryptedForUrlSpy.mockResolvedValueOnce([]); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerAddLoginNotification(data, tab); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); @@ -367,10 +369,7 @@ describe("NotificationBackground", () => { }); it("skips attempting to change the password for an existing login if the user has disabled changing the password notification", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerAddLoginNotification", - login: { username: "test", password: "password", url: "https://example.com" }, - }; + const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); @@ -378,8 +377,7 @@ describe("NotificationBackground", () => { mock({ login: { username: "test", password: "oldPassword" } }), ]); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerAddLoginNotification(data, tab); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); @@ -389,18 +387,14 @@ describe("NotificationBackground", () => { }); it("skips attempting to change the password for an existing login if the password has not changed", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerAddLoginNotification", - login: { username: "test", password: "password", url: "https://example.com" }, - }; + const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ mock({ login: { username: "test", password: "password" } }), ]); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerAddLoginNotification(data, tab); expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled(); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); @@ -409,48 +403,55 @@ describe("NotificationBackground", () => { }); it("adds the login to the queue if the user has a locked account", async () => { - const login = { username: "test", password: "password", url: "https://example.com" }; - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerAddLoginNotification", - login, - }; + const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; activeAccountStatusMock$.next(AuthenticationStatus.Locked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerAddLoginNotification(data, tab); - expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith("example.com", login, sender.tab, true); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + "example.com", + { + url: data.uri, + username: data.username, + password: data.password, + }, + sender.tab, + true, + ); }); it("adds the login to the queue if the user has an unlocked account and the login is new", async () => { - const login = { - username: undefined, - password: "password", - url: "https://example.com", - } as any; - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerAddLoginNotification", - login, + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + username: null, }; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ mock({ login: { username: "anotherTestUsername", password: "password" } }), ]); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerAddLoginNotification(data, tab); - expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith("example.com", login, sender.tab); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + "example.com", + { + url: data.uri, + username: data.username, + password: data.password, + }, + sender.tab, + ); }); it("adds a change password message to the queue if the user has changed an existing cipher's password", async () => { - const login = { username: "tEsT", password: "password", url: "https://example.com" }; - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerAddLoginNotification", - login, + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + username: "tEsT", }; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getEnableAddedLoginPromptSpy.mockResolvedValueOnce(true); getEnableChangedPasswordPromptSpy.mockResolvedValueOnce(true); @@ -461,13 +462,12 @@ describe("NotificationBackground", () => { }), ]); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerAddLoginNotification(data, tab); expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( "cipher-id", "example.com", - login.password, + data.password, sender.tab, ); }); @@ -478,6 +478,12 @@ describe("NotificationBackground", () => { let sender: chrome.runtime.MessageSender; let pushChangePasswordToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; + const mockModifyLoginCipherFormData: ModifyLoginCipherFormData = { + username: null, + uri: null, + password: "currentPassword", + newPassword: "newPassword", + }; beforeEach(() => { tab = createChromeTabMock(); @@ -490,69 +496,51 @@ describe("NotificationBackground", () => { }); it("skips attempting to add the change password message to the queue if the passed url is not valid", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerChangedPasswordNotification", - data: { newPassword: "newPassword", currentPassword: "currentPassword", url: "" }, - }; + const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; - sendMockExtensionMessage(message); - await flushPromises(); + await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); it("adds a change password message to the queue if the user does not have an unlocked account", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerChangedPasswordNotification", - data: { - newPassword: "newPassword", - currentPassword: "currentPassword", - url: "https://example.com", - }, + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", }; + activeAccountStatusMock$.next(AuthenticationStatus.Locked); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( null, "example.com", - message.data?.newPassword, + data?.newPassword, sender.tab, true, ); }); it("skips adding a change password message to the queue if the multiple ciphers exist for the passed URL and the current password is not found within the list of ciphers", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerChangedPasswordNotification", - data: { - newPassword: "newPassword", - currentPassword: "currentPassword", - url: "https://example.com", - }, + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ mock({ login: { username: "test", password: "password" } }), ]); - - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); it("skips adding a change password message if more than one existing cipher is found with a matching password ", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerChangedPasswordNotification", - data: { - newPassword: "newPassword", - currentPassword: "currentPassword", - url: "https://example.com", - }, + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -560,21 +548,16 @@ describe("NotificationBackground", () => { mock({ login: { username: "test2", password: "password" } }), ]); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); it("adds a change password message to the queue if a single cipher matches the passed current password", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerChangedPasswordNotification", - data: { - newPassword: "newPassword", - currentPassword: "currentPassword", - url: "https://example.com", - }, + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -584,24 +567,20 @@ describe("NotificationBackground", () => { }), ]); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( "cipher-id", "example.com", - message.data?.newPassword, + data?.newPassword, sender.tab, ); }); it("skips adding a change password message if no current password is passed in the message and more than one cipher is found for a url", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerChangedPasswordNotification", - data: { - newPassword: "newPassword", - url: "https://example.com", - }, + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -609,20 +588,17 @@ describe("NotificationBackground", () => { mock({ login: { username: "test2", password: "password" } }), ]); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); it("adds a change password message to the queue if no current password is passed with the message, but a single cipher is matched for the uri", async () => { - const message: NotificationBackgroundExtensionMessage = { - command: "bgTriggerChangedPasswordNotification", - data: { - newPassword: "newPassword", - url: "https://example.com", - }, + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", + password: null, }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -632,13 +608,12 @@ describe("NotificationBackground", () => { }), ]); - sendMockExtensionMessage(message, sender); - await flushPromises(); + await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( "cipher-id", "example.com", - message.data?.newPassword, + data?.newPassword, sender.tab, ); }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 65c1ca0277f..3f6e93d8454 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -41,7 +41,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { TaskService } from "@bitwarden/common/vault/tasks"; -import { SecurityTaskType } from "@bitwarden/common/vault/tasks/enums"; +import { SecurityTaskStatus, SecurityTaskType } from "@bitwarden/common/vault/tasks/enums"; import { SecurityTask } from "@bitwarden/common/vault/tasks/models/security-task"; // FIXME (PM-22628): Popup imports are forbidden in background @@ -68,14 +68,17 @@ import { AddChangePasswordQueueMessage, AddLoginQueueMessage, AddUnlockVaultQueueMessage, - ChangePasswordMessageData, AddLoginMessageData, NotificationQueueMessageItem, LockedVaultPendingNotificationsData, NotificationBackgroundExtensionMessage, NotificationBackgroundExtensionMessageHandlers, } from "./abstractions/notification.background"; -import { NotificationTypeData } from "./abstractions/overlay-notifications.background"; +import { + LoginSecurityTaskInfo, + ModifyLoginCipherFormData, + NotificationTypeData, +} from "./abstractions/overlay-notifications.background"; import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.background"; export default class NotificationBackground { @@ -91,12 +94,6 @@ export default class NotificationBackground { private readonly extensionMessageHandlers: NotificationBackgroundExtensionMessageHandlers = { bgAdjustNotificationBar: ({ message, sender }) => this.handleAdjustNotificationBarMessage(message, sender), - bgTriggerAddLoginNotification: ({ message, sender }) => - this.triggerAddLoginNotification(message, sender), - bgTriggerChangedPasswordNotification: ({ message, sender }) => - this.triggerChangedPasswordNotification(message, sender), - bgTriggerAtRiskPasswordNotification: ({ message, sender }) => - this.triggerAtRiskPasswordNotification(message, sender), bgCloseNotificationBar: ({ message, sender }) => this.handleCloseNotificationBarMessage(message, sender), bgOpenAtRiskPasswords: ({ message, sender }) => @@ -286,6 +283,62 @@ export default class NotificationBackground { }; } + /** + * If there is a security task for this cipher at login, return the task, cipher view, and uri. + * + * @param modifyLoginData - The modified login form data + * @param activeUserId - The currently logged in user ID + */ + private async getSecurityTaskAndCipherForLoginData( + modifyLoginData: ModifyLoginCipherFormData, + activeUserId: UserId, + ): Promise { + const tasks: SecurityTask[] = await this.getSecurityTasks(activeUserId); + if (!tasks?.length) { + return null; + } + + const urlCiphers: CipherView[] = await this.cipherService.getAllDecryptedForUrl( + modifyLoginData.uri, + activeUserId, + ); + if (!urlCiphers?.length) { + return null; + } + + const securityTaskForLogin = urlCiphers.reduce( + (taskInfo: LoginSecurityTaskInfo | null, cipher: CipherView) => { + if ( + // exit early if info was found already + taskInfo || + // exit early if the cipher was deleted + cipher.deletedDate || + // exit early if the entered login info doesn't match an existing cipher + modifyLoginData.username !== cipher.login.username || + modifyLoginData.password !== cipher.login.password + ) { + return taskInfo; + } + + // Find the first security task for the cipherId belonging to the entered login + const cipherSecurityTask = tasks.find( + ({ cipherId, status }) => + cipher.id === cipherId && // match security task cipher id to url cipher id + status === SecurityTaskStatus.Pending, // security task has not been completed + ); + + if (cipherSecurityTask) { + return { securityTask: cipherSecurityTask, cipher, uri: modifyLoginData.uri }; + } + + return taskInfo; + }, + null, + ); + + return securityTaskForLogin; + } + /** * Gets the active user server config from the config service. */ @@ -302,6 +355,10 @@ export default class NotificationBackground { return flagValue; } + /** + * Gets the current authentication status of the user. + * @returns Promise - The current authentication status of the user. + */ private async getAuthStatus() { return await firstValueFrom(this.authService.activeAccountStatus$); } @@ -400,11 +457,32 @@ export default class NotificationBackground { * @param sender - The contextual sender of the message */ async triggerAtRiskPasswordNotification( - message: NotificationBackgroundExtensionMessage, - sender: chrome.runtime.MessageSender, + data: ModifyLoginCipherFormData, + tab: chrome.tabs.Tab, ): Promise { - const { activeUserId, securityTask, cipher } = message.data; - const domain = Utils.getDomain(sender.tab.url); + const flag = await this.getNotificationFlag(); + if (!flag) { + return false; + } + + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getOptionalUserId), + ); + + if (!activeUserId) { + return false; + } + const loginSecurityTaskInfo = await this.getSecurityTaskAndCipherForLoginData( + data, + activeUserId, + ); + + if (!loginSecurityTaskInfo) { + return false; + } + + const { securityTask, cipher } = loginSecurityTaskInfo; + const domain = Utils.getDomain(tab.url); const passwordChangeUri = await new TemporaryNotificationChangeLoginService().getChangePasswordUrl(cipher); @@ -418,7 +496,7 @@ export default class NotificationBackground { .pipe(getOrganizationById(securityTask.organizationId)), ); - this.removeTabFromNotificationQueue(sender.tab); + this.removeTabFromNotificationQueue(tab); const launchTimestamp = new Date().getTime(); const queueMessage: NotificationQueueMessageItem = { domain, @@ -426,12 +504,12 @@ export default class NotificationBackground { type: NotificationQueueMessageType.AtRiskPassword, passwordChangeUri, organizationName: organization.name, - tab: sender.tab, + tab: tab, launchTimestamp, expires: new Date(launchTimestamp + NOTIFICATION_BAR_LIFESPAN_MS), }; this.notificationQueue.push(queueMessage); - await this.checkNotificationQueue(sender.tab); + await this.checkNotificationQueue(tab); return true; } @@ -444,17 +522,22 @@ export default class NotificationBackground { * @param sender - The contextual sender of the message */ async triggerAddLoginNotification( - message: NotificationBackgroundExtensionMessage, - sender: chrome.runtime.MessageSender, + data: ModifyLoginCipherFormData, + tab: chrome.tabs.Tab, ): Promise { + const login = { + url: data.uri, + username: data.username, + password: data.password || data.newPassword, + }; + const authStatus = await this.getAuthStatus(); if (authStatus === AuthenticationStatus.LoggedOut) { return false; } - const loginInfo = message.login; - const normalizedUsername = loginInfo.username ? loginInfo.username.toLowerCase() : ""; - const loginDomain = Utils.getDomain(loginInfo.url); + const normalizedUsername = login.username ? login.username.toLowerCase() : ""; + const loginDomain = Utils.getDomain(login.url); if (loginDomain == null) { return false; } @@ -463,7 +546,7 @@ export default class NotificationBackground { if (authStatus === AuthenticationStatus.Locked) { if (addLoginIsEnabled) { - await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab, true); + await this.pushAddLoginToQueue(loginDomain, login, tab, true); } return false; @@ -476,12 +559,12 @@ export default class NotificationBackground { return false; } - const ciphers = await this.cipherService.getAllDecryptedForUrl(loginInfo.url, activeUserId); + const ciphers = await this.cipherService.getAllDecryptedForUrl(login.url, activeUserId); const usernameMatches = ciphers.filter( (c) => c.login.username != null && c.login.username.toLowerCase() === normalizedUsername, ); if (addLoginIsEnabled && usernameMatches.length === 0) { - await this.pushAddLoginToQueue(loginDomain, loginInfo, sender.tab); + await this.pushAddLoginToQueue(loginDomain, login, tab); return true; } @@ -490,14 +573,9 @@ export default class NotificationBackground { if ( changePasswordIsEnabled && usernameMatches.length === 1 && - usernameMatches[0].login.password !== loginInfo.password + usernameMatches[0].login.password !== login.password ) { - await this.pushChangePasswordToQueue( - usernameMatches[0].id, - loginDomain, - loginInfo.password, - sender.tab, - ); + await this.pushChangePasswordToQueue(usernameMatches[0].id, loginDomain, login.password, tab); return true; } return false; @@ -535,23 +613,22 @@ export default class NotificationBackground { * @param sender - The contextual sender of the message */ async triggerChangedPasswordNotification( - message: NotificationBackgroundExtensionMessage, - sender: chrome.runtime.MessageSender, - ) { - const changeData = message.data as ChangePasswordMessageData; + data: ModifyLoginCipherFormData, + tab: chrome.tabs.Tab, + ): Promise { + const changeData = { + url: data.uri, + currentPassword: data.password, + newPassword: data.newPassword, + }; + const loginDomain = Utils.getDomain(changeData.url); if (loginDomain == null) { return false; } if ((await this.getAuthStatus()) < AuthenticationStatus.Unlocked) { - await this.pushChangePasswordToQueue( - null, - loginDomain, - changeData.newPassword, - sender.tab, - true, - ); + await this.pushChangePasswordToQueue(null, loginDomain, changeData.newPassword, tab, true); return true; } @@ -575,7 +652,7 @@ export default class NotificationBackground { id = ciphers[0].id; } if (id != null) { - await this.pushChangePasswordToQueue(id, loginDomain, changeData.newPassword, sender.tab); + await this.pushChangePasswordToQueue(id, loginDomain, changeData.newPassword, tab); return true; } return false; @@ -1030,18 +1107,23 @@ export default class NotificationBackground { private async getCollectionData( message: NotificationBackgroundExtensionMessage, ): Promise { - const collections = (await this.collectionService.getAllDecrypted()).reduce( - (acc, collection) => { - if (collection.organizationId === message?.orgId) { - acc.push({ - id: collection.id, - name: collection.name, - organizationId: collection.organizationId, - }); - } - return acc; - }, - [], + const collections = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.collectionService.decryptedCollections$(userId)), + map((collections) => + collections.reduce((acc, collection) => { + if (collection.organizationId === message?.orgId) { + acc.push({ + id: collection.id, + name: collection.name, + organizationId: collection.organizationId, + }); + } + return acc; + }, []), + ), + ), ); return collections; } diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 93357113fc4..1d7f2b1f9d8 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -1,17 +1,15 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, Subject, switchMap, timer } from "rxjs"; +import { Subject, switchMap, timer } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/common/vault/tasks"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { BrowserApi } from "../../platform/browser/browser-api"; +import { NotificationType, NotificationTypes } from "../notification/abstractions/notification-bar"; import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils"; import { @@ -25,12 +23,6 @@ import { } from "./abstractions/overlay-notifications.background"; import NotificationBackground from "./notification.background"; -type LoginSecurityTaskInfo = { - securityTask: SecurityTask; - cipher: CipherView; - uri: ModifyLoginCipherFormData["uri"]; -}; - export class OverlayNotificationsBackground implements OverlayNotificationsBackgroundInterface { private websiteOriginsWithFields: WebsiteOriginsWithFields = new Map(); private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set(); @@ -274,8 +266,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const modifyLoginData = this.modifyLoginCipherFormData.get(tabId); return ( !modifyLoginData || - !this.shouldAttemptAddLoginNotification(modifyLoginData) || - !this.shouldAttemptChangedPasswordNotification(modifyLoginData) + !this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Add) || + !this.shouldAttemptNotification(modifyLoginData, NotificationTypes.Change) ); }; @@ -381,7 +373,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg return; } - await this.triggerNotificationInit(requestId, modifyLoginData, tab); + await this.processNotifications(requestId, modifyLoginData, tab); }; /** @@ -401,171 +393,86 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const handleWebNavigationOnCompleted = async () => { chrome.webNavigation.onCompleted.removeListener(handleWebNavigationOnCompleted); const tab = await BrowserApi.getTab(tabId); - await this.triggerNotificationInit(requestId, modifyLoginData, tab); + await this.processNotifications(requestId, modifyLoginData, tab); }; chrome.webNavigation.onCompleted.addListener(handleWebNavigationOnCompleted); }; /** - * Initializes the add login or change password notification based on the modified login form data - * and the tab details. This will trigger the notification to be displayed to the user. + * This method attempts to trigger the add login, change password, or at-risk password notifications + * based on the modified login data and the tab details. * * @param requestId - The details of the web response * @param modifyLoginData - The modified login form data * @param tab - The tab details */ - private triggerNotificationInit = async ( + private processNotifications = async ( requestId: chrome.webRequest.ResourceRequest["requestId"], modifyLoginData: ModifyLoginCipherFormData, tab: chrome.tabs.Tab, + config: { skippable: NotificationType[] } = { skippable: [] }, ) => { - let result: string; - if (this.shouldAttemptChangedPasswordNotification(modifyLoginData)) { - // These notifications are temporarily setup as "messages" to the notification background. - // This will be structured differently in a future refactor. - const success = await this.notificationBackground.triggerChangedPasswordNotification( - { - command: "bgChangedPassword", - data: { - url: modifyLoginData.uri, - currentPassword: modifyLoginData.password, - newPassword: modifyLoginData.newPassword, - }, - }, - { tab }, - ); - if (!success) { - result = "Unqualified changedPassword notification attempt."; - } - } - - if (this.shouldAttemptAddLoginNotification(modifyLoginData)) { - const success = await this.notificationBackground.triggerAddLoginNotification( - { - command: "bgTriggerAddLoginNotification", - login: { - url: modifyLoginData.uri, - username: modifyLoginData.username, - password: modifyLoginData.password || modifyLoginData.newPassword, - }, - }, - { tab }, - ); - if (!success) { - result = "Unqualified addLogin notification attempt."; - } - } - - const shouldGetTasks = - (await this.notificationBackground.getNotificationFlag()) && !modifyLoginData.newPassword; - - if (shouldGetTasks) { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(getOptionalUserId), - ); - - if (activeUserId) { - const loginSecurityTaskInfo = await this.getSecurityTaskAndCipherForLoginData( - modifyLoginData, - activeUserId, - ); - - if (loginSecurityTaskInfo) { - await this.notificationBackground.triggerAtRiskPasswordNotification( - { - command: "bgTriggerAtRiskPasswordNotification", - data: { - activeUserId, - cipher: loginSecurityTaskInfo.cipher, - securityTask: loginSecurityTaskInfo.securityTask, - }, - }, - { tab }, - ); - } else { - result = "Unqualified atRiskPassword notification attempt."; - } - } - } - this.clearCompletedWebRequest(requestId, tab); - return result; - }; - - /** - * Determines if the change password notification should be triggered. - * - * @param modifyLoginData - The modified login form data - */ - private shouldAttemptChangedPasswordNotification = ( - modifyLoginData: ModifyLoginCipherFormData, - ) => { - return modifyLoginData?.newPassword && !modifyLoginData.username; - }; - - /** - * Determines if the add login notification should be triggered. - * - * @param modifyLoginData - The modified login form data - */ - private shouldAttemptAddLoginNotification = (modifyLoginData: ModifyLoginCipherFormData) => { - return modifyLoginData?.username && (modifyLoginData.password || modifyLoginData.newPassword); - }; - - /** - * If there is a security task for this cipher at login, return the task, cipher view, and uri. - * - * @param modifyLoginData - The modified login form data - * @param activeUserId - The currently logged in user ID - */ - private async getSecurityTaskAndCipherForLoginData( - modifyLoginData: ModifyLoginCipherFormData, - activeUserId: UserId, - ): Promise { - const tasks: SecurityTask[] = await this.notificationBackground.getSecurityTasks(activeUserId); - if (!tasks?.length) { - return null; - } - - const urlCiphers: CipherView[] = await this.cipherService.getAllDecryptedForUrl( - modifyLoginData.uri, - activeUserId, - ); - if (!urlCiphers?.length) { - return null; - } - - const securityTaskForLogin = urlCiphers.reduce( - (taskInfo: LoginSecurityTaskInfo | null, cipher: CipherView) => { - if ( - // exit early if info was found already - taskInfo || - // exit early if the cipher was deleted - cipher.deletedDate || - // exit early if the entered login info doesn't match an existing cipher - modifyLoginData.username !== cipher.login.username || - modifyLoginData.password !== cipher.login.password - ) { - return taskInfo; - } - - // Find the first security task for the cipherId belonging to the entered login - const cipherSecurityTask = tasks.find( - ({ cipherId, status }) => - cipher.id === cipherId && // match security task cipher id to url cipher id - status === SecurityTaskStatus.Pending, // security task has not been completed - ); - - if (cipherSecurityTask) { - return { securityTask: cipherSecurityTask, cipher, uri: modifyLoginData.uri }; - } - - return taskInfo; + const notificationCandidates = [ + { + type: NotificationTypes.Change, + trigger: this.notificationBackground.triggerChangedPasswordNotification, }, - null, + { + type: NotificationTypes.Add, + trigger: this.notificationBackground.triggerAddLoginNotification, + }, + { + type: NotificationTypes.AtRiskPassword, + trigger: this.notificationBackground.triggerAtRiskPasswordNotification, + }, + ].filter( + (candidate) => + this.shouldAttemptNotification(modifyLoginData, candidate.type) || + config.skippable.includes(candidate.type), ); - return securityTaskForLogin; - } + const results: string[] = []; + for (const { trigger, type } of notificationCandidates) { + const success = await trigger.bind(this.notificationBackground)(modifyLoginData, tab); + if (success) { + results.push(`Success: ${type}`); + break; + } else { + results.push(`Unqualified ${type} notification attempt.`); + } + } + + this.clearCompletedWebRequest(requestId, tab); + return results.join(" "); + }; + + /** + * Determines if the add login notification should be attempted based on the modified login form data. + * @param modifyLoginData modified login form data + * @param notificationType The type of notification to be triggered + * @returns true if the notification should be attempted, false otherwise + */ + private shouldAttemptNotification = ( + modifyLoginData: ModifyLoginCipherFormData, + notificationType: NotificationType, + ): boolean => { + switch (notificationType) { + case NotificationTypes.Change: + return modifyLoginData?.newPassword && !modifyLoginData.username; + case NotificationTypes.Add: + return ( + modifyLoginData?.username && !!(modifyLoginData.password || modifyLoginData.newPassword) + ); + case NotificationTypes.AtRiskPassword: + return !modifyLoginData.newPassword; + case NotificationTypes.Unlock: + // Unlock notifications are handled separately and do not require form data + return false; + default: + this.logService.error(`Unknown notification type: ${notificationType}`); + return false; + } + }; /** * Clears the completed web request and removes the modified login form data for the tab. diff --git a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/action-button.mdx b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/action-button.mdx index 73cd6fb93a9..fcec5bb7a82 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/action-button.mdx +++ b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/action-button.mdx @@ -25,7 +25,6 @@ It is designed with accessibility and responsive design in mind. ## Installation and Setup 1. Ensure you have the necessary dependencies installed: - - `lit`: Used to render the component. - `@emotion/css`: Used for styling the component. diff --git a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/badge-button.mdx b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/badge-button.mdx index 47d82ad68da..b5ea41b283c 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/badge-button.mdx +++ b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/badge-button.mdx @@ -25,7 +25,6 @@ handling, and a disabled state. The component is optimized for accessibility and ## Installation and Setup 1. Ensure you have the necessary dependencies installed: - - `lit`: Used to render the component. - `@emotion/css`: Used for styling the component. diff --git a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/close-button.mdx b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/close-button.mdx index da9c15246fd..03a7b72001a 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/close-button.mdx +++ b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/close-button.mdx @@ -22,7 +22,6 @@ a close icon for visual clarity. The component is designed to be intuitive and a ## Installation and Setup 1. Ensure you have the necessary dependencies installed: - - `lit`: Used to render the component. - `@emotion/css`: Used for styling the component. diff --git a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/edit-button.mdx b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/edit-button.mdx index c6c4262806b..a5a791ffbe1 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/edit-button.mdx +++ b/apps/browser/src/autofill/content/components/lit-stories/.lit-docs/edit-button.mdx @@ -25,7 +25,6 @@ or settings where inline editing is required. ## Installation and Setup 1. Ensure you have the necessary dependencies installed: - - `lit`: Used to render the component. - `@emotion/css`: Used for styling the component. diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 08435fecc77..f88852069ea 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -14,8 +14,6 @@ import { InternalUserDecryptionOptionsServiceAbstraction, LoginEmailServiceAbstraction, LogoutReason, - PinService, - PinServiceAbstraction, UserDecryptionOptionsService, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; @@ -43,6 +41,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; +import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/default-active-user.accessor"; import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service"; @@ -73,10 +72,7 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { BulkEncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/bulk-encrypt.service.implementation"; import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; -import { FallbackBulkEncryptService } from "@bitwarden/common/key-management/crypto/services/fallback-bulk-encrypt.service"; -import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/multithread-encrypt.service.implementation"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; @@ -84,6 +80,8 @@ import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarde import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; import { DefaultVaultTimeoutSettingsService, @@ -370,7 +368,6 @@ export default class MainBackground { vaultFilterService: VaultFilterService; usernameGenerationService: UsernameGenerationServiceAbstraction; encryptService: EncryptService; - bulkEncryptService: FallbackBulkEncryptService; folderApiService: FolderApiServiceAbstraction; policyApiService: PolicyApiServiceAbstraction; sendApiService: SendApiServiceAbstraction; @@ -583,13 +580,11 @@ export default class MainBackground { storageServiceProvider, ); - this.encryptService = BrowserApi.isManifestVersion(2) - ? new MultithreadEncryptServiceImplementation( - this.cryptoFunctionService, - this.logService, - true, - ) - : new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, true); + this.encryptService = new EncryptServiceImplementation( + this.cryptoFunctionService, + this.logService, + true, + ); this.singleUserStateProvider = new DefaultSingleUserStateProvider( storageServiceProvider, @@ -603,7 +598,7 @@ export default class MainBackground { this.singleUserStateProvider, ); this.activeUserStateProvider = new DefaultActiveUserStateProvider( - this.accountService, + new DefaultActiveUserAccessor(this.accountService), this.singleUserStateProvider, ); this.derivedStateProvider = new InlineDerivedStateProvider(); @@ -674,6 +669,8 @@ export default class MainBackground { this.keyGenerationService, this.encryptService, this.logService, + this.cryptoFunctionService, + this.accountService, ); this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider); @@ -887,8 +884,6 @@ export default class MainBackground { this.themeStateService = new DefaultThemeStateService(this.globalStateProvider); - this.bulkEncryptService = new FallbackBulkEncryptService(this.encryptService); - this.cipherEncryptionService = new DefaultCipherEncryptionService( this.sdkService, this.logService, @@ -903,7 +898,6 @@ export default class MainBackground { this.stateService, this.autofillSettingsService, this.encryptService, - this.bulkEncryptService, this.cipherFileUploadService, this.configService, this.stateProvider, @@ -1411,13 +1405,6 @@ export default class MainBackground { // Only the "true" background should run migrations await this.stateService.init({ runMigrations: true }); - this.configService.serverConfig$.subscribe((newConfig) => { - if (newConfig != null) { - this.encryptService.onServerConfigChange(newConfig); - this.bulkEncryptService.onServerConfigChange(newConfig); - } - }); - // This is here instead of in in the InitService b/c we don't plan for // side effects to run in the Browser InitService. const accounts = await firstValueFrom(this.accountService.accounts$); @@ -1450,15 +1437,6 @@ export default class MainBackground { this.syncServiceListener?.listener$().subscribe(); await this.autoSubmitLoginBackground.init(); - if ( - BrowserApi.isManifestVersion(2) && - (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) - ) { - await this.bulkEncryptService.setFeatureFlagEncryptService( - new BulkEncryptServiceImplementation(this.cryptoFunctionService, this.logService), - ); - } - // If the user is logged out, switch to the next account const active = await firstValueFrom(this.accountService.activeAccount$); if (active != null) { @@ -1633,7 +1611,6 @@ export default class MainBackground { this.keyService.clearKeys(userBeingLoggedOut), this.cipherService.clear(userBeingLoggedOut), this.folderService.clear(userBeingLoggedOut), - this.collectionService.clear(userBeingLoggedOut), this.vaultTimeoutSettingsService.clear(userBeingLoggedOut), this.vaultFilterService.clear(), this.biometricStateService.logout(userBeingLoggedOut), diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts index 0ae0997fe4b..7678b65d29e 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts @@ -4,10 +4,8 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; -import { - PinServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { UserId } from "@bitwarden/common/types/guid"; diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts index 52ad5a56c89..9f137d694a9 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts @@ -3,10 +3,8 @@ import { inject } from "@angular/core"; import { combineLatest, defer, firstValueFrom, map, Observable } from "rxjs"; -import { - PinServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; import { BiometricsService, diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index 894ab13dd19..c4f90db2f42 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -557,7 +557,10 @@ export const CenteredContent: Story = { -
+
+

Page with no content

Before centering a div One must first center oneself diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts index 1b4665b3222..947fecb5aac 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts @@ -50,8 +50,8 @@ describe("LocalBackedSessionStorage", () => { const result = await sut.get("test"); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-unused-expressions - expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey), - expect(result).toEqual("decrypted"); + (expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey), + expect(result).toEqual("decrypted")); }); it("caches the decrypted value when one is stored in local storage", async () => { @@ -69,8 +69,8 @@ describe("LocalBackedSessionStorage", () => { const result = await sut.get("test"); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-unused-expressions - expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey), - expect(result).toEqual("decrypted"); + (expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey), + expect(result).toEqual("decrypted")); }); it("caches the decrypted value when one is stored in local storage", async () => { diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 52a60d9c23d..f01809433e3 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -32,7 +32,6 @@ import { RegistrationStartSecondaryComponent, RegistrationStartSecondaryComponentData, RegistrationUserAddIcon, - SetPasswordJitComponent, SsoComponent, TwoFactorTimeoutIcon, TwoFactorAuthComponent, @@ -43,15 +42,13 @@ import { VaultIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, Icons } from "@bitwarden/components"; +import { AnonLayoutWrapperData, Icons } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; -import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component"; -import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-domains.component"; @@ -180,11 +177,6 @@ const routes: Routes = [ elevation: 1, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, }, - { - path: "set-password", - component: SetPasswordComponent, - data: { elevation: 1 } satisfies RouteDataProperties, - }, { path: "remove-password", component: RemovePasswordComponent, @@ -337,20 +329,6 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, - { - path: "update-temp-password", - component: UpdateTempPasswordComponent, - canActivate: [ - canAccessFeature( - FeatureFlag.PM16117_ChangeExistingPasswordRefactor, - false, - `/change-password`, - false, - ), - authGuard, - ], - data: { elevation: 1 } satisfies RouteDataProperties, - }, { path: "", component: ExtensionAnonLayoutWrapperComponent, @@ -398,7 +376,7 @@ const routes: Routes = [ }, { path: "set-initial-password", - canActivate: [canAccessFeature(FeatureFlag.PM16117_SetInitialPasswordRefactor), authGuard], + canActivate: [authGuard], component: SetInitialPasswordComponent, data: { elevation: 1, @@ -586,29 +564,7 @@ const routes: Routes = [ component: ChangePasswordComponent, }, ], - canActivate: [ - canAccessFeature(FeatureFlag.PM16117_ChangeExistingPasswordRefactor), - authGuard, - ], - }, - ], - }, - { - path: "", - component: AnonLayoutWrapperComponent, - children: [ - { - path: "set-password-jit", - component: SetPasswordJitComponent, - data: { - pageTitle: { - key: "joinOrganization", - }, - pageSubtitle: { - key: "finishJoiningThisOrganizationBySettingAMasterPassword", - }, - elevation: 1, - } satisfies RouteDataProperties & AnonLayoutWrapperData, + canActivate: [authGuard], }, ], }, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 77c87838ff7..5150c51d765 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -26,10 +26,7 @@ import { import { AccountComponent } from "../auth/popup/account-switching/account.component"; import { CurrentAccountComponent } from "../auth/popup/account-switching/current-account.component"; -import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; -import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component"; -import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component"; import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; @@ -96,11 +93,8 @@ import "../platform/popup/locales"; AppComponent, ColorPasswordPipe, ColorPasswordCountPipe, - SetPasswordComponent, TabsV2Component, - UpdateTempPasswordComponent, UserVerificationComponent, - VaultTimeoutInputComponent, RemovePasswordComponent, EnvironmentSelectorComponent, ], diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index f29745e6f59..9e750ae7341 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -3,9 +3,6 @@ import { inject, Inject, Injectable } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -30,9 +27,6 @@ export class InitService { private themingService: AbstractThemingService, private sdkLoadService: SdkLoadService, private viewCacheService: PopupViewCacheService, - private configService: ConfigService, - private encryptService: EncryptService, - private bulkEncryptService: BulkEncryptService, @Inject(DOCUMENT) private document: Document, ) {} @@ -40,12 +34,6 @@ export class InitService { return async () => { await this.sdkLoadService.loadAndInit(); await this.stateService.init({ runMigrations: false }); // Browser background is responsible for migrations - this.configService.serverConfig$.subscribe((newConfig) => { - if (newConfig != null) { - this.encryptService.onServerConfigChange(newConfig); - this.bulkEncryptService.onServerConfigChange(newConfig); - } - }); await this.i18nService.init(); this.twoFactorService.init(); await this.viewCacheService.init(); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index ac4a3de1afe..7e9017e930a 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -33,7 +33,6 @@ import { import { LockService, LoginEmailService, - PinServiceAbstraction, SsoUrlService, LogoutService, } from "@bitwarden/auth/common"; @@ -67,6 +66,7 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutService, VaultTimeoutStringType, diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts index 216ec1c3f1b..1bffcd9ad51 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts @@ -26,6 +26,7 @@ import { } from "@bitwarden/vault"; import { BrowserFido2UserInterfaceSession } from "../../../../../autofill/fido2/services/browser-fido2-user-interface.service"; +import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { PopupCloseWarningService } from "../../../../../popup/services/popup-close-warning.service"; @@ -309,6 +310,19 @@ describe("AddEditV2Component", () => { expect(navigate).not.toHaveBeenCalled(); expect(back).toHaveBeenCalled(); }); + + it.each(["add", "edit", "partial-edit"])( + "sends the addEditCipherSubmitted message when a cipher is edited, added or partially edited", + async (mode) => { + const sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage"); + component.config.mode = mode; + + await component.onCipherSaved({ id: "123-456-789" } as CipherView); + + expect(sendMessageSpy).toHaveBeenCalled(); + expect(sendMessageSpy).toHaveBeenCalledWith("addEditCipherSubmitted"); + }, + ); }); describe("handleBackButton", () => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index f019636e690..3985fc85a54 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -268,6 +268,7 @@ export class AddEditV2Component implements OnInit { // Clear popup history so after closing/reopening, Back won’t return to the add-edit form await this.popupRouterCacheService.setHistory([]); } + await BrowserApi.sendMessage("addEditCipherSubmitted"); } subscribeToParams(): void { diff --git a/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts b/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts index 8374cc254a9..0b7346c8613 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/assign-collections/assign-collections.component.ts @@ -10,6 +10,7 @@ import { Observable, combineLatest, filter, first, map, switchMap } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -70,7 +71,12 @@ export class AssignCollections { ), ); - combineLatest([cipher$, this.collectionService.decryptedCollections$]) + const decryptedCollection$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.collectionService.decryptedCollections$(userId)), + ); + + combineLatest([cipher$, decryptedCollection$]) .pipe(takeUntilDestroyed(), first()) .subscribe(([cipherView, collections]) => { let availableCollections = collections; diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index ce16ec2f3e0..9ee3c3a6e41 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -78,7 +78,7 @@ export class ItemMoreOptionsComponent { switchMap(([c, restrictedTypes]) => { // This will check for restrictions from org policies before allowing cloning. const isItemRestricted = restrictedTypes.some( - (restrictType) => restrictType.cipherType === c.type, + (restrictType) => restrictType.cipherType === CipherViewLikeUtils.getType(c), ); if (!isItemRestricted) { return this.cipherAuthorizationService.canCloneCipher$(c); @@ -93,7 +93,7 @@ export class ItemMoreOptionsComponent { switchMap((userId) => { return combineLatest([ this.organizationService.hasOrganizations(userId), - this.collectionService.decryptedCollections$, + this.collectionService.decryptedCollections$(userId), ]).pipe( map(([hasOrgs, collections]) => { const canEditCollections = collections.some((c) => !c.readOnly); diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 48788ea5ae9..32974da162d 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -139,7 +139,7 @@ describe("VaultPopupItemsService", () => { ]; organizationServiceMock.organizations$.mockReturnValue(new BehaviorSubject([mockOrg])); - collectionService.decryptedCollections$ = new BehaviorSubject(mockCollections); + collectionService.decryptedCollections$.mockReturnValue(new BehaviorSubject(mockCollections)); activeUserLastSync$ = new BehaviorSubject(new Date()); syncServiceMock.activeUserLastSync$.mockReturnValue(activeUserLastSync$); diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index b2d4fd1b262..9d44eef2e47 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -72,6 +72,11 @@ export class VaultPopupItemsService { private organizations$ = this.activeUserId$.pipe( switchMap((userId) => this.organizationService.organizations$(userId)), ); + + private decryptedCollections$ = this.activeUserId$.pipe( + switchMap((userId) => this.collectionService.decryptedCollections$(userId)), + ); + /** * Observable that contains the list of other cipher types that should be shown * in the autofill section of the Vault tab. Depends on vault settings. @@ -130,7 +135,7 @@ export class VaultPopupItemsService { private _activeCipherList$: Observable = this._allDecryptedCiphers$.pipe( switchMap((ciphers) => - combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe( + combineLatest([this.organizations$, this.decryptedCollections$]).pipe( map(([organizations, collections]) => { const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); @@ -291,7 +296,7 @@ export class VaultPopupItemsService { */ deletedCiphers$: Observable = this._allDecryptedCiphers$.pipe( switchMap((ciphers) => - combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe( + combineLatest([this.organizations$, this.decryptedCollections$]).pipe( map(([organizations, collections]) => { const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 9f1bd6e6e55..ebaeaeb6076 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -20,6 +20,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { @@ -58,7 +59,7 @@ describe("VaultPopupListFiltersService", () => { }; const collectionService = { - decryptedCollections$, + decryptedCollections$: () => decryptedCollections$, getAllNested: () => Promise.resolve([]), } as unknown as CollectionService; @@ -106,7 +107,7 @@ describe("VaultPopupListFiltersService", () => { signal: jest.fn(() => mockCachedSignal), }; - collectionService.getAllNested = () => Promise.resolve([]); + collectionService.getAllNested = () => []; TestBed.configureTestingModule({ providers: [ { @@ -382,14 +383,7 @@ describe("VaultPopupListFiltersService", () => { beforeEach(() => { decryptedCollections$.next(testCollections); - collectionService.getAllNested = () => - Promise.resolve( - testCollections.map((c) => ({ - children: [], - node: c, - parent: null, - })), - ); + collectionService.getAllNested = () => testCollections.map((c) => new TreeNode(c, null)); }); it("returns all collections", (done) => { @@ -755,15 +749,13 @@ function createSeededVaultPopupListFiltersService( } as any; const collectionServiceMock = { - decryptedCollections$: seededCollections$, + decryptedCollections$: () => seededCollections$, getAllNested: () => - Promise.resolve( - seededCollections$.value.map((c) => ({ - children: [], - node: c, - parent: null, - })), - ), + seededCollections$.value.map((c) => ({ + children: [], + node: c, + parent: null, + })), } as any; const folderServiceMock = { diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 7af6fb5f212..adc0589e7e8 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -6,7 +6,6 @@ import { debounceTime, distinctUntilChanged, filter, - from, map, Observable, shareReplay, @@ -446,7 +445,7 @@ export class VaultPopupListFiltersService { this.filters$.pipe( distinctUntilChanged((prev, curr) => prev.organization?.id === curr.organization?.id), ), - this.collectionService.decryptedCollections$, + this.collectionService.decryptedCollections$(userId), this.organizationService.memberOrganizations$(userId), this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation), ]), @@ -463,16 +462,11 @@ export class VaultPopupListFiltersService { } return sortDefaultCollections(filtered, orgs, this.i18nService.collator); }), - switchMap((collections) => { - return from(this.collectionService.getAllNested(collections)).pipe( - map( - (nested) => - new DynamicTreeNode({ - fullList: collections, - nestedList: nested, - }), - ), - ); + map((fullList) => { + return new DynamicTreeNode({ + fullList, + nestedList: this.collectionService.getAllNested(fullList), + }); }), map((tree) => tree.nestedList.map((c) => this.convertToChipSelectOption(c, "bwi-collection-shared")), diff --git a/apps/browser/store/locales/sv/copy.resx b/apps/browser/store/locales/sv/copy.resx index c37095ec167..8f3564f30c3 100644 --- a/apps/browser/store/locales/sv/copy.resx +++ b/apps/browser/store/locales/sv/copy.resx @@ -121,7 +121,7 @@ Bitwarden Lösenordshanterare - Hemma, på jobbet eller på resande fot säkrar Bitwarden enkelt alla dina lösenord, passkeys, och känslig information. + Hemma, på jobbet eller på resande fot säkrar Bitwarden enkelt alla dina lösenord, inloggningsnycklar och känslig information. Erkänd som den bästa lösenordshanteraren av PCMag, WIRED, The Verge, CNET, G2 och många fler! @@ -169,7 +169,7 @@ End-to-end krypterade lösningar för hantering av referenser från Bitwarden g - Hemma, på jobbet eller på resande fot säkrar Bitwarden enkelt alla dina lösenord, passkeys, och känslig information. + Hemma, på jobbet eller på resande fot säkrar Bitwarden enkelt alla dina lösenord, inloggningsnycklar och känslig information. Synkronisera och kom åt ditt valv från flera enheter diff --git a/apps/browser/tsconfig.json b/apps/browser/tsconfig.json index a554120bd1e..0fd6cac4230 100644 --- a/apps/browser/tsconfig.json +++ b/apps/browser/tsconfig.json @@ -3,7 +3,6 @@ "include": [ "src", "../../libs/common/src/autofill/constants", - "../../libs/common/custom-matchers.d.ts", - "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts" + "../../libs/common/custom-matchers.d.ts" ] } diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 90638f4e334..551225231f7 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -199,7 +199,6 @@ const mainConfig = { "./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", "overlay/menu": "./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", - "encrypt-worker": "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts", "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", "content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts", }, diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 3ceac859c43..d25e9a70d88 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -428,7 +428,8 @@ export class LoginCommand { ); const request = new PasswordRequest(); - request.masterPasswordHash = await this.keyService.hashMasterKey(currentPassword, null); + const masterKey = await this.keyService.getOrDeriveMasterKey(currentPassword, userId); + request.masterPasswordHash = await this.keyService.hashMasterKey(currentPassword, masterKey); request.masterPasswordHint = hint; request.newMasterPasswordHash = newPasswordHash; request.key = newUserKey[1].encryptedString; diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index ebf877011b7..c2881568656 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -4,6 +4,8 @@ import { firstValueFrom } from "rxjs"; import { CollectionRequest } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -36,6 +38,7 @@ export class EditCommand { private folderApiService: FolderApiServiceAbstraction, private accountService: AccountService, private cliRestrictedItemTypesService: CliRestrictedItemTypesService, + private policyService: PolicyService, ) {} async run( @@ -104,6 +107,18 @@ export class EditCommand { return Response.error("Editing this item type is restricted by organizational policy."); } + const isPersonalVaultItem = cipherView.organizationId == null; + + const organizationOwnershipPolicyApplies = await firstValueFrom( + this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, activeUserId), + ); + + if (isPersonalVaultItem && organizationOwnershipPolicyApplies) { + return Response.error( + "An organization policy restricts editing this cipher. Please use the share command first before modifying it.", + ); + } + const encCipher = await this.cipherService.encrypt(cipherView, activeUserId); try { const updatedCipher = await this.cipherService.updateWithServer(encCipher); @@ -155,7 +170,7 @@ export class EditCommand { let folderView = await folder.decrypt(); folderView = FolderExport.toView(req, folderView); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId); + const userKey = await this.keyService.getUserKey(activeUserId); const encFolder = await this.folderService.encrypt(folderView, userKey); try { const folder = await this.folderApiService.save(encFolder, activeUserId); diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index b20052fbb53..756316cba43 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -24,6 +24,7 @@ import { LoginUriExport } from "@bitwarden/common/models/export/login-uri.export import { LoginExport } from "@bitwarden/common/models/export/login.export"; import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { getById } from "@bitwarden/common/platform/misc"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -442,8 +443,11 @@ export class GetCommand extends DownloadCommand { private async getCollection(id: string) { let decCollection: CollectionView = null; + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); if (Utils.isGuid(id)) { - const collection = await this.collectionService.get(id); + const collection = await firstValueFrom( + this.collectionService.encryptedCollections$(activeUserId).pipe(getById(id)), + ); if (collection != null) { const orgKeys = await firstValueFrom(this.keyService.activeUserOrgKeys$); decCollection = await collection.decrypt( @@ -451,7 +455,9 @@ export class GetCommand extends DownloadCommand { ); } } else if (id.trim() !== "") { - let collections = await this.collectionService.getAllDecrypted(); + let collections = await firstValueFrom( + this.collectionService.decryptedCollections$(activeUserId), + ); collections = CliUtils.searchCollections(collections, id); if (collections.length > 1) { return Response.multipleResults(collections.map((c) => c.id)); diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index 517050728c0..94abd97d6eb 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -20,6 +20,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { KeyService } from "@bitwarden/key-management"; import { CollectionResponse } from "../admin-console/models/response/collection.response"; import { OrganizationUserResponse } from "../admin-console/models/response/organization-user.response"; @@ -42,6 +43,7 @@ export class ListCommand { private apiService: ApiService, private eventCollectionService: EventCollectionService, private accountService: AccountService, + private keyService: KeyService, private cliRestrictedItemTypesService: CliRestrictedItemTypesService, ) {} @@ -158,7 +160,10 @@ export class ListCommand { } private async listCollections(options: Options) { - let collections = await this.collectionService.getAllDecrypted(); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + let collections = await firstValueFrom( + this.collectionService.decryptedCollections$(activeUserId), + ); if (options.organizationId != null) { collections = collections.filter((c) => { @@ -178,13 +183,13 @@ export class ListCommand { } private async listOrganizationCollections(options: Options) { + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); if (options.organizationId == null || options.organizationId === "") { return Response.badRequest("`organizationid` option is required."); } if (!Utils.isGuid(options.organizationId)) { return Response.badRequest("`" + options.organizationId + "` is not a GUID."); } - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); if (!userId) { return Response.badRequest("No user found."); } @@ -207,7 +212,13 @@ export class ListCommand { const collections = response.data .filter((c) => c.organizationId === options.organizationId) .map((r) => new Collection(new CollectionData(r as ApiCollectionDetailsResponse))); - let decCollections = await this.collectionService.decryptMany(collections); + const orgKeys = await firstValueFrom(this.keyService.orgKeys$(userId)); + if (orgKeys == null) { + throw new Error("Organization keys not found."); + } + let decCollections = await firstValueFrom( + this.collectionService.decryptMany$(collections, orgKeys), + ); if (options.search != null && options.search.trim() !== "") { decCollections = CliUtils.searchCollections(decCollections, options.search); } diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index 14e6ace3b34..a460fa270a8 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -79,6 +79,7 @@ export class OssServeConfigurator { this.serviceContainer.apiService, this.serviceContainer.eventCollectionService, this.serviceContainer.accountService, + this.serviceContainer.keyService, this.serviceContainer.cliRestrictedItemTypesService, ); this.createCommand = new CreateCommand( @@ -102,6 +103,7 @@ export class OssServeConfigurator { this.serviceContainer.folderApiService, this.serviceContainer.accountService, this.serviceContainer.cliRestrictedItemTypesService, + this.serviceContainer.policyService, ); this.generateCommand = new GenerateCommand( this.serviceContainer.passwordGenerationService, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 78f961973d9..d4dfada05bf 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -16,8 +16,6 @@ import { AuthRequestService, LoginStrategyService, LoginStrategyServiceAbstraction, - PinService, - PinServiceAbstraction, UserDecryptionOptionsService, SsoUrlService, AuthRequestApiServiceAbstraction, @@ -44,6 +42,7 @@ import { } from "@bitwarden/common/auth/services/account.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; +import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/default-active-user.accessor"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; @@ -62,12 +61,13 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; -import { FallbackBulkEncryptService } from "@bitwarden/common/key-management/crypto/services/fallback-bulk-encrypt.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; import { DefaultVaultTimeoutService, DefaultVaultTimeoutSettingsService, @@ -290,7 +290,6 @@ export class ServiceContainer { cipherAuthorizationService: CipherAuthorizationService; ssoUrlService: SsoUrlService; masterPasswordApiService: MasterPasswordApiServiceAbstraction; - bulkEncryptService: FallbackBulkEncryptService; cipherEncryptionService: CipherEncryptionService; restrictedItemTypesService: RestrictedItemTypesService; cliRestrictedItemTypesService: CliRestrictedItemTypesService; @@ -325,7 +324,6 @@ export class ServiceContainer { this.logService, true, ); - this.bulkEncryptService = new FallbackBulkEncryptService(this.encryptService); this.storageService = new LowdbStorageService(this.logService, null, p, false, true); this.secureStorageService = new NodeEnvSecureStorageService( this.storageService, @@ -380,7 +378,7 @@ export class ServiceContainer { ); this.activeUserStateProvider = new DefaultActiveUserStateProvider( - this.accountService, + new DefaultActiveUserAccessor(this.accountService), this.singleUserStateProvider, ); @@ -431,16 +429,17 @@ export class ServiceContainer { migrationRunner, ); + this.kdfConfigService = new DefaultKdfConfigService(this.stateProvider); this.masterPasswordService = new MasterPasswordService( this.stateProvider, this.stateService, this.keyGenerationService, this.encryptService, this.logService, + this.cryptoFunctionService, + this.accountService, ); - this.kdfConfigService = new DefaultKdfConfigService(this.stateProvider); - this.pinService = new PinService( this.accountService, this.cryptoFunctionService, @@ -718,7 +717,6 @@ export class ServiceContainer { this.stateService, this.autofillSettingsService, this.encryptService, - this.bulkEncryptService, this.cipherFileUploadService, this.configService, this.stateProvider, @@ -901,7 +899,6 @@ export class ServiceContainer { this.keyService.clearKeys(userId), this.cipherService.clear(userId), this.folderService.clear(userId), - this.collectionService.clear(userId), ]); await this.stateEventRunnerService.handleEvent("logout", userId as UserId); @@ -921,12 +918,6 @@ export class ServiceContainer { await this.sdkLoadService.loadAndInit(); await this.storageService.init(); await this.stateService.init(); - this.configService.serverConfig$.subscribe((newConfig) => { - if (newConfig != null) { - this.encryptService.onServerConfigChange(newConfig); - this.bulkEncryptService.onServerConfigChange(newConfig); - } - }); this.containerService.attachToGlobal(global); await this.i18nService.init(); this.twoFactorService.init(); diff --git a/apps/cli/src/vault.program.ts b/apps/cli/src/vault.program.ts index d5615d0bb1c..bdcc52393ca 100644 --- a/apps/cli/src/vault.program.ts +++ b/apps/cli/src/vault.program.ts @@ -114,6 +114,7 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.apiService, this.serviceContainer.eventCollectionService, this.serviceContainer.accountService, + this.serviceContainer.keyService, this.serviceContainer.cliRestrictedItemTypesService, ); const response = await command.run(object, cmd); @@ -284,6 +285,7 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.folderApiService, this.serviceContainer.accountService, this.serviceContainer.cliRestrictedItemTypesService, + this.serviceContainer.policyService, ); const response = await command.run(object, id, encodedJson, cmd); this.processResponse(response); diff --git a/apps/cli/src/vault/create.command.ts b/apps/cli/src/vault/create.command.ts index 39a0b8d464d..33ec52eeca8 100644 --- a/apps/cli/src/vault/create.command.ts +++ b/apps/cli/src/vault/create.command.ts @@ -180,7 +180,7 @@ export class CreateCommand { private async createFolder(req: FolderExport) { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId); + const userKey = await this.keyService.getUserKey(activeUserId); const folder = await this.folderService.encrypt(FolderExport.toView(req), userKey); try { await this.folderApiService.save(folder, activeUserId); diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs index e3083422eb2..6d7b9f9db85 100644 --- a/apps/desktop/desktop_native/autotype/src/lib.rs +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -10,3 +10,13 @@ mod windowing; pub fn get_foreground_window_title() -> std::result::Result { windowing::get_foreground_window_title() } + +/// Attempts to type the input text wherever the user's cursor is. +/// +/// `input` must be an array of utf-16 encoded characters to insert. +/// +/// TODO: The error handling will be improved in a future PR: PM-23615 +#[allow(clippy::result_unit_err)] +pub fn type_input(input: Vec) -> std::result::Result<(), ()> { + windowing::type_input(input) +} diff --git a/apps/desktop/desktop_native/autotype/src/linux.rs b/apps/desktop/desktop_native/autotype/src/linux.rs index aa06da21a49..d53d7af0bd9 100644 --- a/apps/desktop/desktop_native/autotype/src/linux.rs +++ b/apps/desktop/desktop_native/autotype/src/linux.rs @@ -1,3 +1,7 @@ pub fn get_foreground_window_title() -> std::result::Result { todo!("Bitwarden does not yet support Linux autotype"); } + +pub fn type_input(_input: Vec) -> std::result::Result<(), ()> { + todo!("Bitwarden does not yet support Linux autotype"); +} diff --git a/apps/desktop/desktop_native/autotype/src/macos.rs b/apps/desktop/desktop_native/autotype/src/macos.rs index 12a4ca08d3e..7ab9f5441b7 100644 --- a/apps/desktop/desktop_native/autotype/src/macos.rs +++ b/apps/desktop/desktop_native/autotype/src/macos.rs @@ -1,3 +1,7 @@ pub fn get_foreground_window_title() -> std::result::Result { todo!("Bitwarden does not yet support Mac OS autotype"); } + +pub fn type_input(_input: Vec) -> std::result::Result<(), ()> { + todo!("Bitwarden does not yet support Mac OS autotype"); +} diff --git a/apps/desktop/desktop_native/autotype/src/windows.rs b/apps/desktop/desktop_native/autotype/src/windows.rs index d86d5dd35ae..931c111e2f5 100644 --- a/apps/desktop/desktop_native/autotype/src/windows.rs +++ b/apps/desktop/desktop_native/autotype/src/windows.rs @@ -1,7 +1,11 @@ use std::ffi::OsString; use std::os::windows::ffi::OsStringExt; -use windows::Win32::Foundation::HWND; +use windows::Win32::Foundation::{GetLastError, HWND}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + BlockInput, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, + KEYEVENTF_UNICODE, +}; use windows::Win32::UI::WindowsAndMessaging::{ GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, }; @@ -18,6 +22,29 @@ pub fn get_foreground_window_title() -> std::result::Result { Ok(window_title) } +/// Attempts to type the input text wherever the user's cursor is. +/// +/// `input` must be an array of utf-16 encoded characters to insert. +/// +/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput +pub fn type_input(input: Vec) -> Result<(), ()> { + let mut keyboard_inputs: Vec = Vec::new(); + + for i in input { + let next_down_input = build_input(InputKeyPress::Down, i); + let next_up_input = build_input(InputKeyPress::Up, i); + + keyboard_inputs.push(next_down_input); + keyboard_inputs.push(next_up_input); + } + + let _ = block_input(true); + let result = send_input(keyboard_inputs); + let _ = block_input(false); + + result +} + /// Gets the foreground window handle. /// /// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow @@ -33,8 +60,6 @@ fn get_foreground_window() -> Result { /// Gets the length of the window title bar text. /// -/// TODO: Future improvement is to use GetLastError for better error handling -/// /// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw fn get_window_title_length(window_handle: HWND) -> Result { if window_handle.is_invalid() { @@ -49,8 +74,6 @@ fn get_window_title_length(window_handle: HWND) -> Result { /// Gets the window title bar title. /// -/// TODO: Future improvement is to use GetLastError for better error handling -/// /// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw fn get_window_title(window_handle: HWND) -> Result, ()> { if window_handle.is_invalid() { @@ -73,3 +96,71 @@ fn get_window_title(window_handle: HWND) -> Result, ()> { Ok(Some(window_title.to_string_lossy().into_owned())) } + +/// Used in build_input() to specify if an input key is being pressed (down) or released (up). +enum InputKeyPress { + Down, + Up, +} + +/// A function for easily building keyboard INPUT structs used in SendInput(). +/// +/// Before modifying this function, make sure you read the SendInput() documentation: +/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput +fn build_input(key_press: InputKeyPress, character: u16) -> INPUT { + match key_press { + InputKeyPress::Down => INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: Default::default(), + wScan: character, + dwFlags: KEYEVENTF_UNICODE, + time: 0, + dwExtraInfo: 0, + }, + }, + }, + InputKeyPress::Up => INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: Default::default(), + wScan: character, + dwFlags: KEYEVENTF_KEYUP | KEYEVENTF_UNICODE, + time: 0, + dwExtraInfo: 0, + }, + }, + }, + } +} + +/// Block keyboard and mouse input events. This prevents the hotkey +/// key presses from interfering with the input sent via SendInput(). +/// +/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-blockinput +fn block_input(block: bool) -> Result<(), ()> { + match unsafe { BlockInput(block) } { + Ok(()) => Ok(()), + Err(_) => Err(()), + } +} + +/// Attempts to type the provided input wherever the user's cursor is. +/// +/// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput +fn send_input(inputs: Vec) -> Result<(), ()> { + let insert_count = unsafe { SendInput(&inputs, std::mem::size_of::() as i32) }; + + let e = unsafe { GetLastError().to_hresult().message() }; + println!("type_input() called, GetLastError() is: {:?}", e); + + if insert_count == 0 { + return Err(()); // input was blocked by another thread + } else if insert_count != inputs.len() as u32 { + return Err(()); // input insertion not completed + } + + Ok(()) +} diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index f554fdb12e8..5ea75bd6120 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -210,4 +210,5 @@ export declare namespace logging { } export declare namespace autotype { export function getForegroundWindowTitle(): string + export function typeInput(input: Array): void } diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index aa271b335ad..d0a57b5632a 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -872,8 +872,15 @@ pub mod autotype { pub fn get_foreground_window_title() -> napi::Result { autotype::get_foreground_window_title().map_err(|_| { napi::Error::from_reason( - "Autotype Error: faild to get foreground window title".to_string(), + "Autotype Error: failed to get foreground window title".to_string(), ) }) } + + #[napi] + pub fn type_input(input: Vec) -> napi::Result<(), napi::Status> { + autotype::type_input(input).map_err(|_| { + napi::Error::from_reason("Autotype Error: failed to type input".to_string()) + }) + } } diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 043393df58b..718bf7efb39 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -16,7 +16,7 @@ "module-alias": "2.2.3", "ts-node": "10.9.2", "uuid": "11.1.0", - "yargs": "17.7.2" + "yargs": "18.0.0" }, "devDependencies": { "@types/node": "22.15.3", @@ -150,24 +150,24 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -180,37 +180,19 @@ "license": "MIT" }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -227,9 +209,9 @@ } }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, "node_modules/escalade": { @@ -250,13 +232,16 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/make-error": { @@ -271,39 +256,36 @@ "integrity": "sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==", "license": "MIT" }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/ts-node": { @@ -388,17 +370,17 @@ "license": "MIT" }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -414,30 +396,29 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yn": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 56e3e4edcf8..35a110c3958 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -21,7 +21,7 @@ "module-alias": "2.2.3", "ts-node": "10.9.2", "uuid": "11.1.0", - "yargs": "17.7.2" + "yargs": "18.0.0" }, "devDependencies": { "@types/node": "22.15.3", diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 473cfa73f1d..e3ebfb467cf 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -30,88 +30,39 @@ - - - {{ - "vaultTimeoutPolicyWithActionInEffect" - | i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n) - }} - - - {{ - "vaultTimeoutPolicyInEffect" - | i18n: policy.timeout.hours : policy.timeout.minutes - }} - - - {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} - - - - -
- -
- -
- {{ - "vaultTimeoutActionLockDesc" | i18n - }} -
- -
- {{ - "vaultTimeoutActionLogOutDesc" | i18n - }} -
-
+ +

{{ "vaultTimeoutHeader" | i18n }}

+
+ + - {{ - "unlockMethodNeededToChangeTimeoutActionDesc" | i18n - }} -
-
-
+ + + + {{ "vaultTimeoutAction1" | i18n }} + + + + + + + {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+
+
+ + + {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} + + +
-

{{ name }}

- - - - {{ "new" | i18n }} - +

+ {{ name }} + @if (showConnectedBadge()) { + + @if (isConnected) { + {{ "on" | i18n }} + } + @if (!isConnected) { + {{ "off" | i18n }} + } + + } +

+

{{ description }}

+ + @if (canSetupConnection) { + + } + + @if (linkURL) { + + + } + @if (showNewBadge()) { + + {{ "new" | i18n }} + + }
diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.spec.ts index ec057f25176..382d245b235 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.spec.ts @@ -1,12 +1,15 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute } from "@angular/router"; import { mock } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; +// eslint-disable-next-line no-restricted-imports +import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; -// FIXME: remove `src` and fix import +import { ToastService } from "@bitwarden/components"; // eslint-disable-next-line no-restricted-imports import { SharedModule } from "@bitwarden/components/src/shared"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -16,6 +19,9 @@ import { IntegrationCardComponent } from "./integration-card.component"; describe("IntegrationCardComponent", () => { let component: IntegrationCardComponent; let fixture: ComponentFixture; + const mockI18nService = mock(); + const activatedRoute = mock(); + const mockOrgIntegrationApiService = mock(); const systemTheme$ = new BehaviorSubject(ThemeType.Light); const usersPreferenceTheme$ = new BehaviorSubject(ThemeType.Light); @@ -23,26 +29,22 @@ describe("IntegrationCardComponent", () => { beforeEach(async () => { // reset system theme systemTheme$.next(ThemeType.Light); + activatedRoute.snapshot = { + paramMap: { + get: jest.fn().mockReturnValue("test-organization-id"), + }, + } as any; await TestBed.configureTestingModule({ imports: [IntegrationCardComponent, SharedModule], providers: [ - { - provide: ThemeStateService, - useValue: { selectedTheme$: usersPreferenceTheme$ }, - }, - { - provide: SYSTEM_THEME_OBSERVABLE, - useValue: systemTheme$, - }, - { - provide: I18nPipe, - useValue: mock(), - }, - { - provide: I18nService, - useValue: mock(), - }, + { provide: ThemeStateService, useValue: { selectedTheme$: usersPreferenceTheme$ } }, + { provide: SYSTEM_THEME_OBSERVABLE, useValue: systemTheme$ }, + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService }, + { provide: ToastService, useValue: mock() }, ], }).compileComponents(); }); @@ -55,6 +57,7 @@ describe("IntegrationCardComponent", () => { component.image = "test-image.png"; component.linkURL = "https://example.com/"; + mockI18nService.t.mockImplementation((key) => key); fixture.detectChanges(); }); @@ -67,7 +70,7 @@ describe("IntegrationCardComponent", () => { it("renders card body", () => { const name = fixture.nativeElement.querySelector("h3"); - expect(name.textContent).toBe("Integration Name"); + expect(name.textContent).toContain("Integration Name"); }); it("assigns external rel attribute", () => { @@ -182,4 +185,28 @@ describe("IntegrationCardComponent", () => { }); }); }); + + describe("connected badge", () => { + it("shows connected badge when isConnected is true", () => { + component.isConnected = true; + + expect(component.showConnectedBadge()).toBe(true); + }); + + it("does not show connected badge when isConnected is false", () => { + component.isConnected = false; + fixture.detectChanges(); + const name = fixture.nativeElement.querySelector("h3 > span > span > span"); + + expect(name.textContent).toContain("off"); + // when isConnected is true/false, the badge should be shown as on/off + // when isConnected is undefined, the badge should not be shown + expect(component.showConnectedBadge()).toBe(true); + }); + + it("does not show connected badge when isConnected is undefined", () => { + component.isConnected = undefined; + expect(component.showConnectedBadge()).toBe(false); + }); + }); }); diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts index 20e4028e9df..1d95d3182b2 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component.ts @@ -9,13 +9,26 @@ import { OnDestroy, ViewChild, } from "@angular/core"; -import { Observable, Subject, combineLatest, takeUntil } from "rxjs"; +import { ActivatedRoute } from "@angular/router"; +import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; +// eslint-disable-next-line no-restricted-imports +import { + OrganizationIntegrationType, + OrganizationIntegrationRequest, + OrganizationIntegrationResponse, + OrganizationIntegrationApiService, +} from "@bitwarden/bit-common/dirt/integrations/index"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../../../../shared/shared.module"; +import { openHecConnectDialog } from "../integration-dialog/index"; +import { Integration } from "../models"; @Component({ selector: "app-integration-card", @@ -30,6 +43,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { @Input() image: string; @Input() imageDarkMode?: string; @Input() linkURL: string; + @Input() integrationSettings: Integration; /** Adds relevant `rel` attribute to external links */ @Input() externalURL?: boolean; @@ -41,11 +55,19 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { * @example "2024-12-31" */ @Input() newBadgeExpiration?: string; + @Input() description?: string; + @Input() isConnected?: boolean; + @Input() canSetupConnection?: boolean; constructor( private themeStateService: ThemeStateService, @Inject(SYSTEM_THEME_OBSERVABLE) private systemTheme$: Observable, + private dialogService: DialogService, + private activatedRoute: ActivatedRoute, + private apiService: OrganizationIntegrationApiService, + private toastService: ToastService, + private i18nService: I18nService, ) {} ngAfterViewInit() { @@ -93,4 +115,63 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { return expirationDate > new Date(); } + + showConnectedBadge(): boolean { + return this.isConnected !== undefined; + } + + async setupConnection() { + // invoke the dialog to connect the integration + const dialog = openHecConnectDialog(this.dialogService, { + data: { + settings: this.integrationSettings, + }, + }); + + const result = await lastValueFrom(dialog.closed); + + // the dialog was cancelled + if (!result || !result.success) { + return; + } + + // save the integration + try { + const dbResponse = await this.saveHecIntegration(result.configuration); + this.isConnected = !!dbResponse.id; + } catch { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("failedToSaveIntegration"), + }); + return; + } + } + + async saveHecIntegration(configuration: string): Promise { + const organizationId = this.activatedRoute.snapshot.paramMap.get( + "organizationId", + ) as OrganizationId; + + const request = new OrganizationIntegrationRequest( + OrganizationIntegrationType.Hec, + configuration, + ); + + const integrations = await this.apiService.getOrganizationIntegrations(organizationId); + const existingIntegration = integrations.find( + (i) => i.type === OrganizationIntegrationType.Hec, + ); + + if (existingIntegration) { + return await this.apiService.updateOrganizationIntegration( + organizationId, + existingIntegration.id, + request, + ); + } else { + return await this.apiService.createOrganizationIntegration(organizationId, request); + } + } } diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.stories.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.stories.ts deleted file mode 100644 index 256bfd3d827..00000000000 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.stories.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { importProvidersFrom } from "@angular/core"; -import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; -import { of } from "rxjs"; - -import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; -import { ThemeTypes } from "@bitwarden/common/platform/enums"; -import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; - -import { PreloadedEnglishI18nModule } from "../../../../../../core/tests"; - -import { IntegrationCardComponent } from "./integration-card.component"; - -class MockThemeService implements Partial {} - -export default { - title: "Web/Integration Layout/Integration Card", - component: IntegrationCardComponent, - decorators: [ - applicationConfig({ - providers: [importProvidersFrom(PreloadedEnglishI18nModule)], - }), - moduleMetadata({ - providers: [ - { - provide: ThemeStateService, - useClass: MockThemeService, - }, - { - provide: SYSTEM_THEME_OBSERVABLE, - useValue: of(ThemeTypes.Light), - }, - ], - }), - ], - args: { - integrations: [], - }, -} as Meta; - -type Story = StoryObj; - -export const Default: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - - `, - }), - args: { - name: "Bitwarden", - image: "/integrations/bitwarden-vertical-blue.svg", - linkURL: "https://bitwarden.com", - }, -}; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html new file mode 100644 index 00000000000..7f28317dd67 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html @@ -0,0 +1,38 @@ +
+ + + {{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }} + +
+ @if (loading) { + + + + } + @if (!loading) { + + + {{ "url" | i18n }} + + + + {{ "bearerToken" | i18n }} + + + + {{ "index" | i18n }} + + + + } +
+ + + + +
+
diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts new file mode 100644 index 00000000000..9be854545aa --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts @@ -0,0 +1,176 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { IntegrationType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { Integration } from "../../models"; + +import { + ConnectHecDialogComponent, + HecConnectDialogParams, + HecConnectDialogResult, + openHecConnectDialog, +} from "./connect-dialog-hec.component"; + +beforeAll(() => { + // Mock element.animate for jsdom + // the animate function is not available in jsdom, so we provide a mock implementation + // This is necessary for tests that rely on animations + // This mock does not perform any actual animations, it just provides a structure that allows tests + // to run without throwing errors related to missing animate function + if (!HTMLElement.prototype.animate) { + HTMLElement.prototype.animate = function () { + return { + play: () => {}, + pause: () => {}, + finish: () => {}, + cancel: () => {}, + reverse: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + onfinish: null, + oncancel: null, + startTime: 0, + currentTime: 0, + playbackRate: 1, + playState: "idle", + replaceState: "active", + effect: null, + finished: Promise.resolve(), + id: "", + remove: () => {}, + timeline: null, + ready: Promise.resolve(), + } as unknown as Animation; + }; + } +}); + +describe("ConnectDialogHecComponent", () => { + let component: ConnectHecDialogComponent; + let fixture: ComponentFixture; + let dialogRefMock = mock>(); + const mockI18nService = mock(); + + const integrationMock: Integration = { + name: "Test Integration", + image: "test-image.png", + linkURL: "https://example.com", + imageDarkMode: "test-image-dark.png", + newBadgeExpiration: "2024-12-31", + description: "Test Description", + isConnected: false, + canSetupConnection: true, + type: IntegrationType.EVENT, + } as Integration; + const connectInfo: HecConnectDialogParams = { settings: integrationMock }; + + beforeEach(async () => { + dialogRefMock = mock>(); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: connectInfo }, + { provide: DialogRef, useValue: dialogRefMock }, + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConnectHecDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + mockI18nService.t.mockImplementation((key) => key); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize form with empty values", () => { + expect(component.formGroup.value).toEqual({ + url: "", + bearerToken: "", + index: "", + service: "Test Integration", + }); + }); + + it("should have required validators for all fields", () => { + component.formGroup.setValue({ url: "", bearerToken: "", index: "", service: "" }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://test.com", + bearerToken: "token", + index: "1", + service: "Test Service", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should invalidate url if not matching pattern", () => { + component.formGroup.setValue({ + url: "ftp://test.com", + bearerToken: "token", + index: "1", + service: "Test Service", + }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://test.com", + bearerToken: "token", + index: "1", + service: "Test Service", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should call dialogRef.close with correct result on submit", async () => { + component.formGroup.setValue({ + url: "https://test.com", + bearerToken: "token", + index: "1", + service: "Test Service", + }); + + await component.submit(); + + expect(dialogRefMock.close).toHaveBeenCalledWith({ + integrationSettings: integrationMock, + configuration: JSON.stringify({ + url: "https://test.com", + bearerToken: "token", + index: "1", + service: "Test Service", + }), + success: true, + error: null, + }); + }); +}); + +describe("openCrowdstrikeConnectDialog", () => { + it("should call dialogService.open with correct params", () => { + const dialogServiceMock = mock(); + const config: DialogConfig> = { + data: { settings: { name: "Test" } as Integration }, + } as any; + + openHecConnectDialog(dialogServiceMock, config); + + expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectHecDialogComponent, config); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts new file mode 100644 index 00000000000..c0af17db8d7 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts @@ -0,0 +1,81 @@ +import { Component, Inject, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; + +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { Integration } from "../../models"; + +export type HecConnectDialogParams = { + settings: Integration; +}; + +export interface HecConnectDialogResult { + integrationSettings: Integration; + configuration: string; + success: boolean; + error: string | null; +} + +@Component({ + templateUrl: "./connect-dialog-hec.component.html", + imports: [SharedModule], +}) +export class ConnectHecDialogComponent implements OnInit { + loading = false; + formGroup = this.formBuilder.group({ + url: ["", [Validators.required, Validators.pattern("https?://.+")]], + bearerToken: ["", Validators.required], + index: ["", Validators.required], + service: ["", Validators.required], + }); + + constructor( + @Inject(DIALOG_DATA) protected connectInfo: HecConnectDialogParams, + protected formBuilder: FormBuilder, + private dialogRef: DialogRef, + ) {} + + ngOnInit(): void { + const settings = this.getSettingsAsJson(this.connectInfo.settings.configuration ?? ""); + + if (settings) { + this.formGroup.patchValue({ + url: settings?.url || "", + bearerToken: settings?.bearerToken || "", + index: settings?.index || "", + service: this.connectInfo.settings.name, + }); + } + } + + getSettingsAsJson(configuration: string) { + try { + return JSON.parse(configuration); + } catch { + return {}; + } + } + + submit = async (): Promise => { + const formJson = this.formGroup.getRawValue(); + + const result: HecConnectDialogResult = { + integrationSettings: this.connectInfo.settings, + configuration: JSON.stringify(formJson), + success: true, + error: null, + }; + + this.dialogRef.close(result); + + return; + }; +} + +export function openHecConnectDialog( + dialogService: DialogService, + config: DialogConfig>, +) { + return dialogService.open(ConnectHecDialogComponent, config); +} diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/index.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/index.ts new file mode 100644 index 00000000000..8c4891b9aa8 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-dialog/index.ts @@ -0,0 +1 @@ +export * from "./connect-dialog/connect-dialog-hec.component"; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.html b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.html index 4b4b3ac972b..661c57b47fc 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.html @@ -13,6 +13,10 @@ [imageDarkMode]="integration.imageDarkMode" [externalURL]="integration.type === IntegrationType.SDK" [newBadgeExpiration]="integration.newBadgeExpiration" + [description]="integration.description | i18n" + [isConnected]="integration.isConnected" + [canSetupConnection]="integration.canSetupConnection" + [integrationSettings]="integration" > diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.spec.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.spec.ts index 04866f4627b..01a512ac38c 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component.spec.ts @@ -1,14 +1,20 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { ActivatedRoute } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; +// eslint-disable-next-line no-restricted-imports +import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations/services"; import { IntegrationType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeTypes } from "@bitwarden/common/platform/enums"; +// eslint-disable-next-line import/order import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; // FIXME: remove `src` and fix import + +import { ToastService } from "@bitwarden/components"; // eslint-disable-next-line no-restricted-imports import { SharedModule } from "@bitwarden/components/src/shared"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -21,6 +27,8 @@ import { IntegrationGridComponent } from "./integration-grid.component"; describe("IntegrationGridComponent", () => { let component: IntegrationGridComponent; let fixture: ComponentFixture; + const mockActivatedRoute = mock(); + const mockOrgIntegrationApiService = mock(); const integrations: Integration[] = [ { name: "Integration 1", @@ -37,6 +45,12 @@ describe("IntegrationGridComponent", () => { ]; beforeEach(() => { + mockActivatedRoute.snapshot = { + paramMap: { + get: jest.fn().mockReturnValue("test-organization-id"), + }, + } as any; + TestBed.configureTestingModule({ imports: [IntegrationGridComponent, IntegrationCardComponent, SharedModule], providers: [ @@ -56,6 +70,18 @@ describe("IntegrationGridComponent", () => { provide: I18nService, useValue: mock({ t: (key, p1) => key + " " + p1 }), }, + { + provide: ActivatedRoute, + useValue: mockActivatedRoute, + }, + { + provide: OrganizationIntegrationApiService, + useValue: mockOrgIntegrationApiService, + }, + { + provide: ToastService, + useValue: mock(), + }, ], }); diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.stories.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.stories.ts deleted file mode 100644 index b6580af2881..00000000000 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.stories.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { importProvidersFrom } from "@angular/core"; -import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; -import { of } from "rxjs"; - -import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; -import { IntegrationType } from "@bitwarden/common/enums"; -import { ThemeTypes } from "@bitwarden/common/platform/enums"; -import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; - -import { PreloadedEnglishI18nModule } from "../../../../../../core/tests"; -import { IntegrationCardComponent } from "../integration-card/integration-card.component"; -import { IntegrationGridComponent } from "../integration-grid/integration-grid.component"; - -class MockThemeService implements Partial {} - -export default { - title: "Web/Integration Layout/Integration Grid", - component: IntegrationGridComponent, - decorators: [ - applicationConfig({ - providers: [importProvidersFrom(PreloadedEnglishI18nModule)], - }), - moduleMetadata({ - imports: [IntegrationCardComponent], - providers: [ - { - provide: ThemeStateService, - useClass: MockThemeService, - }, - { - provide: SYSTEM_THEME_OBSERVABLE, - useValue: of(ThemeTypes.Dark), - }, - ], - }), - ], -} as Meta; - -type Story = StoryObj; - -export const Default: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - - `, - }), - args: { - integrations: [ - { - name: "Card 1", - linkURL: "https://bitwarden.com", - image: "/integrations/bitwarden-vertical-blue.svg", - type: IntegrationType.SSO, - }, - { - name: "Card 2", - linkURL: "https://bitwarden.com", - image: "/integrations/bitwarden-vertical-blue.svg", - type: IntegrationType.SDK, - }, - { - name: "Card 3", - linkURL: "https://bitwarden.com", - image: "/integrations/bitwarden-vertical-blue.svg", - type: IntegrationType.SCIM, - }, - ], - }, -}; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts b/apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts index 765b1d44a2e..b3d24ffb3b0 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/integrations/models.ts @@ -17,4 +17,8 @@ export type Integration = { * @example "2024-12-31" */ newBadgeExpiration?: string; + description?: string; + isConnected?: boolean; + canSetupConnection?: boolean; + configuration?: string; }; diff --git a/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.spec.ts b/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.spec.ts index 120a58d6b1a..f9d389c979f 100644 --- a/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.spec.ts +++ b/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.spec.ts @@ -18,7 +18,7 @@ describe("freeOrgCollectionLimitValidator", () => { it("returns null if organization is not found", async () => { const orgs: Organization[] = []; - const validator = freeOrgCollectionLimitValidator(of(orgs), [], i18nService); + const validator = freeOrgCollectionLimitValidator(of(orgs), of([]), i18nService); const control = new FormControl("org-id"); const result: Observable = validator(control) as Observable; @@ -28,7 +28,7 @@ describe("freeOrgCollectionLimitValidator", () => { }); it("returns null if control is not an instance of FormControl", async () => { - const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService); + const validator = freeOrgCollectionLimitValidator(of([]), of([]), i18nService); const control = {} as AbstractControl; const result: Observable = validator( @@ -40,7 +40,7 @@ describe("freeOrgCollectionLimitValidator", () => { }); it("returns null if control is not provided", async () => { - const validator = freeOrgCollectionLimitValidator(of([]), [], i18nService); + const validator = freeOrgCollectionLimitValidator(of([]), of([]), i18nService); const result: Observable = validator( undefined as any, @@ -53,7 +53,7 @@ describe("freeOrgCollectionLimitValidator", () => { it("returns null if organization has not reached collection limit (Observable)", async () => { const org = { id: "org-id", maxCollections: 2 } as Organization; const collections = [{ organizationId: "org-id" } as Collection]; - const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService); + const validator = freeOrgCollectionLimitValidator(of([org]), of(collections), i18nService); const control = new FormControl("org-id"); const result$ = validator(control) as Observable; @@ -65,7 +65,7 @@ describe("freeOrgCollectionLimitValidator", () => { it("returns error if organization has reached collection limit (Observable)", async () => { const org = { id: "org-id", maxCollections: 1 } as Organization; const collections = [{ organizationId: "org-id" } as Collection]; - const validator = freeOrgCollectionLimitValidator(of([org]), collections, i18nService); + const validator = freeOrgCollectionLimitValidator(of([org]), of(collections), i18nService); const control = new FormControl("org-id"); const result$ = validator(control) as Observable; diff --git a/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.ts b/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.ts index 75919d31c1a..7132428c375 100644 --- a/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.ts +++ b/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.ts @@ -1,13 +1,14 @@ import { AbstractControl, AsyncValidatorFn, FormControl, ValidationErrors } from "@angular/forms"; -import { map, Observable, of } from "rxjs"; +import { combineLatest, map, Observable, of } from "rxjs"; import { Collection } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { getById } from "@bitwarden/common/platform/misc"; export function freeOrgCollectionLimitValidator( - orgs: Observable, - collections: Collection[], + organizations$: Observable, + collections$: Observable, i18nService: I18nService, ): AsyncValidatorFn { return (control: AbstractControl): Observable => { @@ -21,15 +22,16 @@ export function freeOrgCollectionLimitValidator( return of(null); } - return orgs.pipe( - map((organizations) => organizations.find((org) => org.id === orgId)), - map((org) => { - if (!org) { + return combineLatest([organizations$.pipe(getById(orgId)), collections$]).pipe( + map(([organization, collections]) => { + if (!organization) { return null; } - const orgCollections = collections.filter((c) => c.organizationId === org.id); - const hasReachedLimit = org.maxCollections === orgCollections.length; + const orgCollections = collections.filter( + (collection: Collection) => collection.organizationId === organization.id, + ); + const hasReachedLimit = organization.maxCollections === orgCollections.length; if (hasReachedLimit) { return { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 1bdb0c37728..203bad3754a 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -285,7 +285,6 @@ export class AppComponent implements OnDestroy, OnInit { this.keyService.clearKeys(userId), this.cipherService.clear(userId), this.folderService.clear(userId), - this.collectionService.clear(userId), this.biometricStateService.logout(userId), ]); diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index 8c556986225..02f13cd436b 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -3,7 +3,6 @@ export * from "./login"; export * from "./login-decryption-options"; export * from "./webauthn-login"; export * from "./password-management"; -export * from "./set-password-jit"; export * from "./registration"; export * from "./two-factor-auth"; export * from "./link-sso.service"; diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts index 4cc06baf32b..799e10bc15c 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts @@ -1,6 +1,5 @@ import { TestBed } from "@angular/core/testing"; import { MockProxy, mock } from "jest-mock-extended"; -import { of } from "rxjs"; import { DefaultLoginComponentService } from "@bitwarden/auth/angular"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; @@ -138,8 +137,8 @@ describe("WebLoginComponentService", () => { resetPasswordPolicyEnabled, ]); - internalPolicyService.masterPasswordPolicyOptions$.mockReturnValue( - of(masterPasswordPolicyOptions), + internalPolicyService.combinePoliciesIntoMasterPasswordPolicyOptions.mockReturnValue( + masterPasswordPolicyOptions, ); const result = await service.getOrgPoliciesFromOrgInvite(); diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts index cf0adb91144..4ee84ecfde2 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts @@ -2,7 +2,6 @@ // @ts-strict-ignore import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, switchMap } from "rxjs"; import { DefaultLoginComponentService, @@ -11,13 +10,10 @@ import { } from "@bitwarden/auth/angular"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -99,23 +95,8 @@ export class WebLoginComponentService const isPolicyAndAutoEnrollEnabled = resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled; - let enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions; - - if ( - await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor) - ) { - enforcedPasswordPolicyOptions = - this.policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies); - } else { - enforcedPasswordPolicyOptions = await firstValueFrom( - this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => - this.policyService.masterPasswordPolicyOptions$(userId, policies), - ), - ), - ); - } + const enforcedPasswordPolicyOptions = + this.policyService.combinePoliciesIntoMasterPasswordPolicyOptions(policies); return { policies, diff --git a/apps/web/src/app/auth/core/services/set-password-jit/index.ts b/apps/web/src/app/auth/core/services/set-password-jit/index.ts deleted file mode 100644 index fc119fd964f..00000000000 --- a/apps/web/src/app/auth/core/services/set-password-jit/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./web-set-password-jit.service"; diff --git a/apps/web/src/app/auth/core/services/set-password-jit/web-set-password-jit.service.ts b/apps/web/src/app/auth/core/services/set-password-jit/web-set-password-jit.service.ts deleted file mode 100644 index 3078b8e3b83..00000000000 --- a/apps/web/src/app/auth/core/services/set-password-jit/web-set-password-jit.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { inject } from "@angular/core"; - -import { - DefaultSetPasswordJitService, - SetPasswordCredentials, - SetPasswordJitService, -} from "@bitwarden/auth/angular"; -import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; - -import { RouterService } from "../../../../core/router.service"; - -export class WebSetPasswordJitService - extends DefaultSetPasswordJitService - implements SetPasswordJitService -{ - routerService = inject(RouterService); - organizationInviteService = inject(OrganizationInviteService); - - override async setPassword(credentials: SetPasswordCredentials) { - await super.setPassword(credentials); - - // SSO JIT accepts org invites when setting their MP, meaning - // we can clear the deep linked url for accepting it. - await this.routerService.getAndClearLoginRedirectUrl(); - await this.organizationInviteService.clearOrganizationInvitation(); - } -} 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 05373534ce7..ff062b31e6b 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 @@ -4,12 +4,10 @@ import { MockProxy } from "jest-mock-extended"; import mock from "jest-mock-extended/lib/Mock"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -38,11 +36,9 @@ describe("EmergencyAccessService", () => { let apiService: MockProxy; let keyService: MockProxy; let encryptService: MockProxy; - let bulkEncryptService: MockProxy; let cipherService: MockProxy; let logService: MockProxy; let emergencyAccessService: EmergencyAccessService; - let configService: ConfigService; const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")]; @@ -52,7 +48,6 @@ describe("EmergencyAccessService", () => { apiService = mock(); keyService = mock(); encryptService = mock(); - bulkEncryptService = mock(); cipherService = mock(); logService = mock(); @@ -61,10 +56,8 @@ describe("EmergencyAccessService", () => { apiService, keyService, encryptService, - bulkEncryptService, cipherService, logService, - configService, ); }); 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 a814af32505..9a31bd9c107 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 @@ -3,14 +3,11 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptedString, EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { UserId } from "@bitwarden/common/types/guid"; @@ -59,10 +56,8 @@ export class EmergencyAccessService private apiService: ApiService, private keyService: KeyService, private encryptService: EncryptService, - private bulkEncryptService: BulkEncryptService, private cipherService: CipherService, private logService: LogService, - private configService: ConfigService, ) {} /** @@ -258,17 +253,8 @@ export class EmergencyAccessService )) as UserKey; let ciphers: CipherView[] = []; - if (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) { - ciphers = await this.bulkEncryptService.decryptItems( - response.ciphers.map((c) => new Cipher(c)), - grantorUserKey, - ); - } else { - ciphers = await this.encryptService.decryptItems( - response.ciphers.map((c) => new Cipher(c)), - grantorUserKey, - ); - } + const ciphersEncrypted = response.ciphers.map((c) => new Cipher(c)); + ciphers = await Promise.all(ciphersEncrypted.map(async (c) => c.decrypt(grantorUserKey))); return ciphers.sort(this.cipherService.getLocaleSortingFunction()); } diff --git a/apps/web/src/app/auth/set-password.component.html b/apps/web/src/app/auth/set-password.component.html deleted file mode 100644 index 252893d22cb..00000000000 --- a/apps/web/src/app/auth/set-password.component.html +++ /dev/null @@ -1,130 +0,0 @@ -
-
-
-

{{ "setMasterPassword" | i18n }}

-
-
- - {{ "loading" | i18n }} -
-
-

- {{ "orgPermissionsUpdatedMustSetPassword" | i18n }} -

- - -

{{ "orgRequiresYouToSetPassword" | i18n }}

-
- - - {{ "resetPasswordAutoEnrollInviteWarning" | i18n }} - -
- - - -
-
- - - -
-
- - -
-
- {{ "masterPassDesc" | i18n }} -
-
- -
- - -
-
-
- - - {{ "masterPassHintDesc" | i18n }} -
-
-
- - -
-
-
-
-
-
diff --git a/apps/web/src/app/auth/set-password.component.ts b/apps/web/src/app/auth/set-password.component.ts deleted file mode 100644 index a2044e298a5..00000000000 --- a/apps/web/src/app/auth/set-password.component.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Component, inject } from "@angular/core"; - -import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component"; -import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { MasterKey, UserKey } from "@bitwarden/common/types/key"; - -import { RouterService } from "../core"; - -@Component({ - selector: "app-set-password", - templateUrl: "set-password.component.html", - standalone: false, -}) -export class SetPasswordComponent extends BaseSetPasswordComponent { - routerService = inject(RouterService); - organizationInviteService = inject(OrganizationInviteService); - - protected override async onSetPasswordSuccess( - masterKey: MasterKey, - userKey: [UserKey, EncString], - keyPair: [string, EncString], - ): Promise { - await super.onSetPasswordSuccess(masterKey, userKey, keyPair); - // SSO JIT accepts org invites when setting their MP, meaning - // we can clear the deep linked url for accepting it. - await this.routerService.getAndClearLoginRedirectUrl(); - await this.organizationInviteService.clearOrganizationInvitation(); - } -} diff --git a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts index f5c0733e5b0..bd0d9df9f06 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts @@ -89,7 +89,7 @@ describe("ChangeEmailComponent", () => { }); keyService.getOrDeriveMasterKey - .calledWith("password", "UserId") + .calledWith("password", "UserId" as UserId) .mockResolvedValue("getOrDeriveMasterKey" as any); keyService.hashMasterKey .calledWith("password", "getOrDeriveMasterKey" as any) diff --git a/apps/web/src/app/auth/settings/change-password.component.html b/apps/web/src/app/auth/settings/change-password.component.html deleted file mode 100644 index 34bb74ee473..00000000000 --- a/apps/web/src/app/auth/settings/change-password.component.html +++ /dev/null @@ -1,129 +0,0 @@ -
-

{{ "changeMasterPassword" | i18n }}

-
- -{{ "loggedOutWarning" | i18n }} - - - -
-
-
-
- - -
-
-
-
-
-
- - - - {{ "important" | i18n }} - {{ "masterPassImportant" | i18n }} {{ characterMinimumMessage }} - - - -
-
-
-
- - -
-
-
-
-
- - -
-
-
-
- - - - - -
-
-
- - -
- -
- - diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts deleted file mode 100644 index ce10a0e5a34..00000000000 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ /dev/null @@ -1,258 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; -import { firstValueFrom, map } from "rxjs"; - -import { ChangePasswordComponent as BaseChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { KdfConfigService, KeyService } from "@bitwarden/key-management"; - -import { UserKeyRotationService } from "../../key-management/key-rotation/user-key-rotation.service"; - -/** - * @deprecated use the auth `PasswordSettingsComponent` instead - */ -@Component({ - selector: "app-change-password", - templateUrl: "change-password.component.html", - standalone: false, -}) -export class ChangePasswordComponent - extends BaseChangePasswordComponent - implements OnInit, OnDestroy -{ - loading = false; - rotateUserKey = false; - currentMasterPassword: string; - masterPasswordHint: string; - checkForBreaches = true; - characterMinimumMessage = ""; - - constructor( - private auditService: AuditService, - private cipherService: CipherService, - private keyRotationService: UserKeyRotationService, - private masterPasswordApiService: MasterPasswordApiService, - private router: Router, - private syncService: SyncService, - private userVerificationService: UserVerificationService, - protected accountService: AccountService, - protected dialogService: DialogService, - protected i18nService: I18nService, - protected kdfConfigService: KdfConfigService, - protected keyService: KeyService, - protected masterPasswordService: InternalMasterPasswordServiceAbstraction, - protected messagingService: MessagingService, - protected platformUtilsService: PlatformUtilsService, - protected policyService: PolicyService, - protected toastService: ToastService, - ) { - super( - accountService, - dialogService, - i18nService, - kdfConfigService, - keyService, - masterPasswordService, - messagingService, - platformUtilsService, - policyService, - toastService, - ); - } - - async ngOnInit() { - if (!(await this.userVerificationService.hasMasterPassword())) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/settings/security/two-factor"]); - } - - await super.ngOnInit(); - - this.characterMinimumMessage = this.i18nService.t("characterMinimum", this.minimumLength); - } - - async rotateUserKeyClicked() { - if (this.rotateUserKey) { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - - const ciphers = await this.cipherService.getAllDecrypted(activeUserId); - let hasOldAttachments = false; - if (ciphers != null) { - for (let i = 0; i < ciphers.length; i++) { - if (ciphers[i].organizationId == null && ciphers[i].hasOldAttachments) { - hasOldAttachments = true; - break; - } - } - } - - if (hasOldAttachments) { - const learnMore = await this.dialogService.openSimpleDialog({ - title: { key: "warning" }, - content: { key: "oldAttachmentsNeedFixDesc" }, - acceptButtonText: { key: "learnMore" }, - cancelButtonText: { key: "close" }, - type: "warning", - }); - - if (learnMore) { - this.platformUtilsService.launchUri( - "https://bitwarden.com/help/attachments/#add-storage-space", - ); - } - this.rotateUserKey = false; - return; - } - - const result = await this.dialogService.openSimpleDialog({ - title: { key: "rotateEncKeyTitle" }, - content: - this.i18nService.t("updateEncryptionKeyWarning") + - " " + - this.i18nService.t("updateEncryptionKeyAccountExportWarning") + - " " + - this.i18nService.t("rotateEncKeyConfirmation"), - type: "warning", - }); - - if (!result) { - this.rotateUserKey = false; - } - } - } - - async submit() { - this.loading = true; - if (this.currentMasterPassword == null || this.currentMasterPassword === "") { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("masterPasswordRequired"), - }); - this.loading = false; - return; - } - - if ( - this.masterPasswordHint != null && - this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase() - ) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("hintEqualsPassword"), - }); - this.loading = false; - return; - } - - this.leakedPassword = false; - if (this.checkForBreaches) { - this.leakedPassword = (await this.auditService.passwordLeaked(this.masterPassword)) > 0; - } - - if (!(await this.strongPassword())) { - this.loading = false; - return; - } - - try { - if (this.rotateUserKey) { - await this.syncService.fullSync(true); - const user = await firstValueFrom(this.accountService.activeAccount$); - await this.keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( - this.currentMasterPassword, - this.masterPassword, - user, - this.masterPasswordHint, - ); - } else { - await this.updatePassword(this.masterPassword); - } - } catch (e) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: e.message, - }); - } finally { - this.loading = false; - } - } - - // todo: move this to a service - // https://bitwarden.atlassian.net/browse/PM-17108 - private async updatePassword(newMasterPassword: string) { - const currentMasterPassword = this.currentMasterPassword; - const { userId, email } = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => ({ userId: a?.id, email: a?.email }))), - ); - const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId)); - - const currentMasterKey = await this.keyService.makeMasterKey( - currentMasterPassword, - email, - kdfConfig, - ); - const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( - currentMasterKey, - userId, - ); - if (decryptedUserKey == null) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("invalidMasterPassword"), - }); - return; - } - - const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig); - const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey( - newMasterKey, - decryptedUserKey, - ); - - const request = new PasswordRequest(); - request.masterPasswordHash = await this.keyService.hashMasterKey( - this.currentMasterPassword, - currentMasterKey, - ); - request.masterPasswordHint = this.masterPasswordHint; - request.newMasterPasswordHash = await this.keyService.hashMasterKey( - newMasterPassword, - newMasterKey, - ); - request.key = newMasterKeyEncryptedUserKey[1].encryptedString; - try { - await this.masterPasswordApiService.postPassword(request); - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("masterPasswordChanged"), - }); - this.messagingService.send("logout"); - } catch { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("errorOccurred"), - }); - } - } -} diff --git a/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts b/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts index cd7a585f3b1..641dde66cc4 100644 --- a/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts @@ -8,6 +8,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { DialogConfig, DialogRef, DIALOG_DATA, DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { SharedModule } from "../../../../shared"; + // FIXME: update to use a const object instead of a typescript enum // eslint-disable-next-line @bitwarden/platform/no-enums export enum EmergencyAccessConfirmDialogResult { @@ -24,9 +26,8 @@ type EmergencyAccessConfirmDialogData = { publicKey: Uint8Array; }; @Component({ - selector: "emergency-access-confirm", templateUrl: "emergency-access-confirm.component.html", - standalone: false, + imports: [SharedModule], }) export class EmergencyAccessConfirmComponent implements OnInit { loading = true; diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts index 2f3f3a20b04..baa0f396fc5 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts @@ -14,6 +14,8 @@ import { ToastService, } from "@bitwarden/components"; +import { SharedModule } from "../../../shared/shared.module"; +import { PremiumBadgeComponent } from "../../../vault/components/premium-badge.component"; import { EmergencyAccessService } from "../../emergency-access"; import { EmergencyAccessType } from "../../emergency-access/enums/emergency-access-type"; @@ -34,9 +36,8 @@ export enum EmergencyAccessAddEditDialogResult { Deleted = "deleted", } @Component({ - selector: "emergency-access-add-edit", templateUrl: "emergency-access-add-edit.component.html", - standalone: false, + imports: [SharedModule, PremiumBadgeComponent], }) export class EmergencyAccessAddEditComponent implements OnInit { loading = true; diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts index 1d78bb7dd17..6de647dc5ce 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts @@ -10,8 +10,6 @@ import { OrganizationManagementPreferencesService } from "@bitwarden/common/admi import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -20,6 +18,9 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogService, ToastService } from "@bitwarden/components"; +import { HeaderModule } from "../../../layouts/header/header.module"; +import { SharedModule } from "../../../shared/shared.module"; +import { PremiumBadgeComponent } from "../../../vault/components/premium-badge.component"; import { EmergencyAccessService } from "../../emergency-access"; import { EmergencyAccessStatusType } from "../../emergency-access/enums/emergency-access-status-type"; import { EmergencyAccessType } from "../../emergency-access/enums/emergency-access-type"; @@ -40,15 +41,10 @@ import { EmergencyAccessTakeoverDialogComponent, EmergencyAccessTakeoverDialogResultType, } from "./takeover/emergency-access-takeover-dialog.component"; -import { - EmergencyAccessTakeoverComponent, - EmergencyAccessTakeoverResultType, -} from "./takeover/emergency-access-takeover.component"; @Component({ - selector: "emergency-access", templateUrl: "emergency-access.component.html", - standalone: false, + imports: [SharedModule, HeaderModule, PremiumBadgeComponent], }) export class EmergencyAccessComponent implements OnInit { loaded = false; @@ -75,7 +71,6 @@ export class EmergencyAccessComponent implements OnInit { private toastService: ToastService, private apiService: ApiService, private accountService: AccountService, - private configService: ConfigService, ) { this.canAccessPremium$ = this.accountService.activeAccount$.pipe( switchMap((account) => @@ -292,60 +287,36 @@ export class EmergencyAccessComponent implements OnInit { } takeover = async (details: GrantorEmergencyAccess) => { - const changePasswordRefactorFlag = await this.configService.getFeatureFlag( - FeatureFlag.PM16117_ChangeExistingPasswordRefactor, - ); - - if (changePasswordRefactorFlag) { - if (!details || !details.email || !details.id) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("grantorDetailsNotFound"), - }); - this.logService.error( - "Grantor details not found when attempting emergency access takeover", - ); - - return; - } - - const grantorName = this.userNamePipe.transform(details); - - const dialogRef = EmergencyAccessTakeoverDialogComponent.open(this.dialogService, { - data: { - grantorName, - grantorEmail: details.email, - emergencyAccessId: details.id, - }, + if (!details || !details.email || !details.id) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("grantorDetailsNotFound"), }); - const result = await lastValueFrom(dialogRef.closed); - if (result === EmergencyAccessTakeoverDialogResultType.Done) { - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("passwordResetFor", grantorName), - }); - } + this.logService.error("Grantor details not found when attempting emergency access takeover"); return; } - const dialogRef = EmergencyAccessTakeoverComponent.open(this.dialogService, { + const grantorName = this.userNamePipe.transform(details); + + const dialogRef = EmergencyAccessTakeoverDialogComponent.open(this.dialogService, { data: { - name: this.userNamePipe.transform(details), - email: details.email, - emergencyAccessId: details.id ?? null, + grantorName, + grantorEmail: details.email, + emergencyAccessId: details.id, }, }); const result = await lastValueFrom(dialogRef.closed); - if (result === EmergencyAccessTakeoverResultType.Done) { + if (result === EmergencyAccessTakeoverDialogResultType.Done) { this.toastService.showToast({ variant: "success", - title: null, - message: this.i18nService.t("passwordResetFor", this.userNamePipe.transform(details)), + title: "", + message: this.i18nService.t("passwordResetFor", grantorName), }); } + + return; }; private removeGrantee(details: GranteeEmergencyAccess) { diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts index 3ad9ce6b1fb..2619e6852b3 100644 --- a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts @@ -49,7 +49,6 @@ export type EmergencyAccessTakeoverDialogResultType = * @link https://bitwarden.com/help/emergency-access/ */ @Component({ - standalone: true, selector: "auth-emergency-access-takeover-dialog", templateUrl: "./emergency-access-takeover-dialog.component.html", imports: [ diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.html b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.html deleted file mode 100644 index 64b35344455..00000000000 --- a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.html +++ /dev/null @@ -1,54 +0,0 @@ -
- - - {{ "takeover" | i18n }} - {{ params.name }} - -
- {{ "loggedOutWarning" | i18n }} - - -
-
- - {{ "newMasterPass" | i18n }} - - - - - -
-
- - {{ "confirmNewMasterPass" | i18n }} - - - -
-
-
- - - - -
-
diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts deleted file mode 100644 index ede60887725..00000000000 --- a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts +++ /dev/null @@ -1,145 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, OnDestroy, OnInit, Inject, Input } from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { switchMap, takeUntil } from "rxjs"; - -import { ChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { - DialogConfig, - DialogRef, - DIALOG_DATA, - DialogService, - ToastService, -} from "@bitwarden/components"; -import { KdfType, KdfConfigService, KeyService } from "@bitwarden/key-management"; - -import { EmergencyAccessService } from "../../../emergency-access"; - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum EmergencyAccessTakeoverResultType { - Done = "done", -} -type EmergencyAccessTakeoverDialogData = { - /** display name of the account requesting emergency access takeover */ - name: string; - /** email of the account requesting emergency access takeover */ - email: string; - /** traces a unique emergency request */ - emergencyAccessId: string; -}; -@Component({ - selector: "emergency-access-takeover", - templateUrl: "emergency-access-takeover.component.html", - standalone: false, -}) -export class EmergencyAccessTakeoverComponent - extends ChangePasswordComponent - implements OnInit, OnDestroy -{ - @Input() kdf: KdfType; - @Input() kdfIterations: number; - takeoverForm = this.formBuilder.group({ - masterPassword: ["", [Validators.required]], - masterPasswordRetype: ["", [Validators.required]], - }); - - constructor( - @Inject(DIALOG_DATA) protected params: EmergencyAccessTakeoverDialogData, - private formBuilder: FormBuilder, - i18nService: I18nService, - keyService: KeyService, - messagingService: MessagingService, - platformUtilsService: PlatformUtilsService, - policyService: PolicyService, - private emergencyAccessService: EmergencyAccessService, - private logService: LogService, - dialogService: DialogService, - private dialogRef: DialogRef, - kdfConfigService: KdfConfigService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - accountService: AccountService, - protected toastService: ToastService, - ) { - super( - accountService, - dialogService, - i18nService, - kdfConfigService, - keyService, - masterPasswordService, - messagingService, - platformUtilsService, - policyService, - toastService, - ); - } - - async ngOnInit() { - const policies = await this.emergencyAccessService.getGrantorPolicies( - this.params.emergencyAccessId, - ); - this.accountService.activeAccount$ - .pipe( - getUserId, - switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId, policies)), - takeUntil(this.destroy$), - ) - .subscribe((enforcedPolicyOptions) => (this.enforcedPolicyOptions = enforcedPolicyOptions)); - } - - ngOnDestroy(): void { - super.ngOnDestroy(); - } - - submit = async () => { - if (this.takeoverForm.invalid) { - this.takeoverForm.markAllAsTouched(); - return; - } - this.masterPassword = this.takeoverForm.get("masterPassword").value; - this.masterPasswordRetype = this.takeoverForm.get("masterPasswordRetype").value; - if (!(await this.strongPassword())) { - return; - } - - try { - await this.emergencyAccessService.takeover( - this.params.emergencyAccessId, - this.masterPassword, - this.params.email, - ); - } catch (e) { - this.logService.error(e); - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("unexpectedError"), - }); - } - this.dialogRef.close(EmergencyAccessTakeoverResultType.Done); - }; - /** - * Strongly typed helper to open a EmergencyAccessTakeoverComponent - * @param dialogService Instance of the dialog service that will be used to open the dialog - * @param config Configuration for the dialog - */ - static open = ( - dialogService: DialogService, - config: DialogConfig, - ) => { - return dialogService.open( - EmergencyAccessTakeoverComponent, - config, - ); - }; -} diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts index 607e6e6a2c7..ce46e624972 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts @@ -7,15 +7,15 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService } from "@bitwarden/components"; import { CipherFormConfigService, DefaultCipherFormConfigService } from "@bitwarden/vault"; +import { SharedModule } from "../../../../shared/shared.module"; import { EmergencyAccessService } from "../../../emergency-access"; import { EmergencyViewDialogComponent } from "./emergency-view-dialog.component"; @Component({ - selector: "emergency-access-view", templateUrl: "emergency-access-view.component.html", providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }], - standalone: false, + imports: [SharedModule], }) export class EmergencyAccessViewComponent implements OnInit { id: EmergencyAccessId | null = null; diff --git a/apps/web/src/app/auth/settings/security/change-kdf/change-kdf-confirmation.component.ts b/apps/web/src/app/auth/settings/security/change-kdf/change-kdf-confirmation.component.ts index 0bfc46eea96..5aa8eeb907c 100644 --- a/apps/web/src/app/auth/settings/security/change-kdf/change-kdf-confirmation.component.ts +++ b/apps/web/src/app/auth/settings/security/change-kdf/change-kdf-confirmation.component.ts @@ -2,14 +2,13 @@ // @ts-strict-ignore import { Component, Inject } from "@angular/core"; import { FormGroup, FormControl, Validators } from "@angular/forms"; -import { firstValueFrom, map } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { KdfRequest } from "@bitwarden/common/models/request/kdf.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DIALOG_DATA, ToastService } from "@bitwarden/components"; import { KdfConfig, KdfType, KeyService } from "@bitwarden/key-management"; @@ -31,7 +30,6 @@ export class ChangeKdfConfirmationComponent { constructor( private apiService: ApiService, private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, private keyService: KeyService, private messagingService: MessagingService, @Inject(DIALOG_DATA) params: { kdf: KdfType; kdfConfig: KdfConfig }, @@ -58,6 +56,10 @@ export class ChangeKdfConfirmationComponent { }; private async makeKeyAndSaveAsync() { + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (activeAccount == null) { + throw new Error("No active account found."); + } const masterPassword = this.form.value.masterPassword; // Ensure the KDF config is valid. @@ -70,13 +72,14 @@ export class ChangeKdfConfirmationComponent { request.kdfMemory = this.kdfConfig.memory; request.kdfParallelism = this.kdfConfig.parallelism; } - const masterKey = await this.keyService.getOrDeriveMasterKey(masterPassword); + const masterKey = await this.keyService.getOrDeriveMasterKey(masterPassword, activeAccount.id); request.masterPasswordHash = await this.keyService.hashMasterKey(masterPassword, masterKey); - const email = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.email)), - ); - const newMasterKey = await this.keyService.makeMasterKey(masterPassword, email, this.kdfConfig); + const newMasterKey = await this.keyService.makeMasterKey( + masterPassword, + activeAccount.email, + this.kdfConfig, + ); request.newMasterPasswordHash = await this.keyService.hashMasterKey( masterPassword, newMasterKey, diff --git a/apps/web/src/app/auth/settings/security/device-management-old.component.html b/apps/web/src/app/auth/settings/security/device-management-old.component.html index 587703c7389..da01d0fe8f4 100644 --- a/apps/web/src/app/auth/settings/security/device-management-old.component.html +++ b/apps/web/src/app/auth/settings/security/device-management-old.component.html @@ -11,7 +11,7 @@ -

{{ "aDeviceIs" | i18n }}

+

{{ "aDeviceIs" | i18n }}

-
-
-

{{ "updateMasterPassword" | i18n }}

-
-
- {{ "masterPasswordInvalidWarning" | i18n }} - - -
-
-
-
- - -
-
-
-
-
-
- - - -
-
-
-
- - -
-
-
- - -
-
-
-
-
- diff --git a/apps/web/src/app/auth/update-password.component.ts b/apps/web/src/app/auth/update-password.component.ts deleted file mode 100644 index bc53f824228..00000000000 --- a/apps/web/src/app/auth/update-password.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, inject } from "@angular/core"; - -import { UpdatePasswordComponent as BaseUpdatePasswordComponent } from "@bitwarden/angular/auth/components/update-password.component"; -import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; - -import { RouterService } from "../core"; - -@Component({ - selector: "app-update-password", - templateUrl: "update-password.component.html", - standalone: false, -}) -export class UpdatePasswordComponent extends BaseUpdatePasswordComponent { - private routerService = inject(RouterService); - private organizationInviteService = inject(OrganizationInviteService); - - override async cancel() { - // clearing the login redirect url so that the user - // does not join the organization if they cancel - await this.routerService.getAndClearLoginRedirectUrl(); - await this.organizationInviteService.clearOrganizationInvitation(); - await super.cancel(); - } -} diff --git a/apps/web/src/app/auth/update-temp-password.component.html b/apps/web/src/app/auth/update-temp-password.component.html deleted file mode 100644 index 4fd0ea72b5f..00000000000 --- a/apps/web/src/app/auth/update-temp-password.component.html +++ /dev/null @@ -1,96 +0,0 @@ -
-
-
-

{{ "updateMasterPassword" | i18n }}

-
- {{ masterPasswordWarningText }} - - - - {{ "currentMasterPass" | i18n }} - - - -
- - {{ "newMasterPass" | i18n }} - - - - - -
- - {{ "confirmNewMasterPass" | i18n }} - - - - - {{ "masterPassHint" | i18n }} - - {{ "masterPassHintDesc" | i18n }} - -
-
- - -
-
-
-
-
diff --git a/apps/web/src/app/auth/update-temp-password.component.ts b/apps/web/src/app/auth/update-temp-password.component.ts deleted file mode 100644 index ead10660b92..00000000000 --- a/apps/web/src/app/auth/update-temp-password.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from "@angular/core"; - -import { UpdateTempPasswordComponent as BaseUpdateTempPasswordComponent } from "@bitwarden/angular/auth/components/update-temp-password.component"; - -@Component({ - selector: "app-update-temp-password", - templateUrl: "update-temp-password.component.html", - standalone: false, -}) -export class UpdateTempPasswordComponent extends BaseUpdateTempPasswordComponent {} diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts index fda7faeeb25..e13fac41f75 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts +++ b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts @@ -304,6 +304,7 @@ export class TrialBillingStepComponent implements OnInit, OnDestroy { this.fetchingTaxAmount = true; if (!this.taxInfoComponent.validate()) { + this.fetchingTaxAmount = false; return 0; } @@ -326,7 +327,7 @@ export class TrialBillingStepComponent implements OnInit, OnDestroy { const response = await this.taxService.previewTaxAmountForOrganizationTrial(request); this.fetchingTaxAmount = false; - return response.taxAmount; + return response; }; get price() { diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 91e2e83a92c..ace3d749a3f 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -11,24 +11,6 @@ }}
- {{ - "upgradeDiscount" - | i18n - : (selectedInterval === planIntervals.Annually && discountPercentageFromSub == 0 - ? this.discountPercentage - : this.discountPercentageFromSub) - }}
diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index dc8474d24d6..129d8e1de48 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -54,6 +54,7 @@ import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.res import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DIALOG_DATA, @@ -149,7 +150,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { @Output() onCanceled = new EventEmitter(); @Output() onTrialBillingSuccess = new EventEmitter(); - protected discountPercentage: number = 20; protected discountPercentageFromSub: number; protected loading = true; protected planCards: PlanCard[]; @@ -892,7 +892,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { // Backfill pub/priv key if necessary if (!this.organization.hasPublicAndPrivateKeys) { - const orgShareKey = await this.keyService.getOrgKey(this.organizationId); + const userId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const orgShareKey = await firstValueFrom( + this.keyService + .orgKeys$(userId) + .pipe(map((orgKeys) => orgKeys?.[this.organizationId as OrganizationId] ?? null)), + ); const orgKeys = await this.keyService.makeKeyPair(orgShareKey); request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); } diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 570e243933e..0d2c3a1d03f 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -54,6 +54,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; @@ -756,7 +757,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { // Backfill pub/priv key if necessary if (!this.organization.hasPublicAndPrivateKeys) { - const orgShareKey = await this.keyService.getOrgKey(this.organizationId); + const userId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + const orgShareKey = await firstValueFrom( + this.keyService + .orgKeys$(userId) + .pipe(map((orgKeys) => orgKeys?.[this.organizationId as OrganizationId] ?? null)), + ); const orgKeys = await this.keyService.makeKeyPair(orgShareKey); request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); } diff --git a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts index ff5156ba636..15c63d8f99f 100644 --- a/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/change-payment-method-dialog.component.ts @@ -1,5 +1,5 @@ import { DIALOG_DATA } from "@angular/cdk/dialog"; -import { Component, Inject, ViewChild } from "@angular/core"; +import { Component, Inject } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; @@ -7,19 +7,17 @@ import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden import { SharedModule } from "../../../shared"; import { BillingClient } from "../../services"; import { BillableEntity } from "../../types"; -import { MaskedPaymentMethod } from "../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; +import { + SubmitPaymentMethodDialogComponent, + SubmitPaymentMethodDialogResult, +} from "./submit-payment-method-dialog.component"; type DialogParams = { owner: BillableEntity; }; -type DialogResult = - | { type: "cancelled" } - | { type: "error" } - | { type: "success"; paymentMethod: MaskedPaymentMethod }; - @Component({ template: `
@@ -55,63 +53,23 @@ type DialogResult = imports: [EnterPaymentMethodComponent, SharedModule], providers: [BillingClient], }) -export class ChangePaymentMethodDialogComponent { - @ViewChild(EnterPaymentMethodComponent) - private enterPaymentMethodComponent!: EnterPaymentMethodComponent; - protected formGroup = EnterPaymentMethodComponent.getFormGroup(); +export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { + protected override owner: BillableEntity; constructor( - private billingClient: BillingClient, + billingClient: BillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, - private dialogRef: DialogRef, - private i18nService: I18nService, - private toastService: ToastService, - ) {} - - submit = async () => { - this.formGroup.markAllAsTouched(); - - if (!this.formGroup.valid) { - return; - } - - const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); - const billingAddress = - this.formGroup.value.type !== "payPal" - ? this.formGroup.controls.billingAddress.getRawValue() - : null; - - const result = await this.billingClient.updatePaymentMethod( - this.dialogParams.owner, - paymentMethod, - billingAddress, - ); - - switch (result.type) { - case "success": { - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("paymentMethodUpdated"), - }); - this.dialogRef.close({ - type: "success", - paymentMethod: result.value, - }); - break; - } - case "error": { - this.toastService.showToast({ - variant: "error", - title: "", - message: result.message, - }); - this.dialogRef.close({ type: "error" }); - break; - } - } - }; + dialogRef: DialogRef, + i18nService: I18nService, + toastService: ToastService, + ) { + super(billingClient, dialogRef, i18nService, toastService); + this.owner = this.dialogParams.owner; + } static open = (dialogService: DialogService, dialogConfig: DialogConfig) => - dialogService.open(ChangePaymentMethodDialogComponent, dialogConfig); + dialogService.open( + ChangePaymentMethodDialogComponent, + dialogConfig, + ); } diff --git a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts index b73c3297e9e..b5d732031c0 100644 --- a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts @@ -108,7 +108,7 @@ type PaymentMethodFormGroup = FormGroup<{ -

{{ "cardSecurityCodeDescription" | i18n }}

+

{{ "cardSecurityCodeDescription" | i18n }}

diff --git a/apps/web/src/app/billing/payment/components/index.ts b/apps/web/src/app/billing/payment/components/index.ts index 3bf7f5ecd36..7e500d2119e 100644 --- a/apps/web/src/app/billing/payment/components/index.ts +++ b/apps/web/src/app/billing/payment/components/index.ts @@ -6,4 +6,6 @@ export * from "./display-payment-method.component"; export * from "./edit-billing-address-dialog.component"; export * from "./enter-billing-address.component"; export * from "./enter-payment-method.component"; +export * from "./require-payment-method-dialog.component"; +export * from "./submit-payment-method-dialog.component"; export * from "./verify-bank-account.component"; diff --git a/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts new file mode 100644 index 00000000000..72585badca0 --- /dev/null +++ b/apps/web/src/app/billing/payment/components/require-payment-method-dialog.component.ts @@ -0,0 +1,77 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + CalloutTypes, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; +import { BillingClient } from "../../services"; +import { BillableEntity } from "../../types"; + +import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; +import { + SubmitPaymentMethodDialogComponent, + SubmitPaymentMethodDialogResult, +} from "./submit-payment-method-dialog.component"; + +type DialogParams = { + owner: BillableEntity; + callout: { + type: CalloutTypes; + title: string; + message: string; + }; +}; + +@Component({ + template: ` + + + + {{ "addPaymentMethod" | i18n }} + +
+ + {{ dialogParams.callout.message }} + + + +
+ + + +
+ + `, + standalone: true, + imports: [EnterPaymentMethodComponent, SharedModule], + providers: [BillingClient], +}) +export class RequirePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { + protected override owner: BillableEntity; + + constructor( + billingClient: BillingClient, + @Inject(DIALOG_DATA) protected dialogParams: DialogParams, + dialogRef: DialogRef, + i18nService: I18nService, + toastService: ToastService, + ) { + super(billingClient, dialogRef, i18nService, toastService); + this.owner = this.dialogParams.owner; + } + + static open = (dialogService: DialogService, dialogConfig: DialogConfig) => + dialogService.open(RequirePaymentMethodDialogComponent, { + ...dialogConfig, + disableClose: true, + }); +} diff --git a/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts new file mode 100644 index 00000000000..0a0a5bf26d9 --- /dev/null +++ b/apps/web/src/app/billing/payment/components/submit-payment-method-dialog.component.ts @@ -0,0 +1,75 @@ +import { Component, ViewChild } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogRef, ToastService } from "@bitwarden/components"; + +import { BillingClient } from "../../services"; +import { BillableEntity } from "../../types"; +import { MaskedPaymentMethod } from "../types"; + +import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; + +export type SubmitPaymentMethodDialogResult = + | { type: "cancelled" } + | { type: "error" } + | { type: "success"; paymentMethod: MaskedPaymentMethod }; + +@Component({ template: "" }) +export abstract class SubmitPaymentMethodDialogComponent { + @ViewChild(EnterPaymentMethodComponent) + private enterPaymentMethodComponent!: EnterPaymentMethodComponent; + protected formGroup = EnterPaymentMethodComponent.getFormGroup(); + + protected abstract owner: BillableEntity; + + protected constructor( + protected billingClient: BillingClient, + protected dialogRef: DialogRef, + protected i18nService: I18nService, + protected toastService: ToastService, + ) {} + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (!this.formGroup.valid) { + return; + } + + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + const billingAddress = + this.formGroup.value.type !== "payPal" + ? this.formGroup.controls.billingAddress.getRawValue() + : null; + + const result = await this.billingClient.updatePaymentMethod( + this.owner, + paymentMethod, + billingAddress, + ); + + switch (result.type) { + case "success": { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("paymentMethodUpdated"), + }); + this.dialogRef.close({ + type: "success", + paymentMethod: result.value, + }); + break; + } + case "error": { + this.toastService.showToast({ + variant: "error", + title: "", + message: result.message, + }); + this.dialogRef.close({ type: "error" }); + break; + } + } + }; +} diff --git a/apps/web/src/app/billing/shared/plan-card/plan-card.component.html b/apps/web/src/app/billing/shared/plan-card/plan-card.component.html index 08fd3b435f6..af228842720 100644 --- a/apps/web/src/app/billing/shared/plan-card/plan-card.component.html +++ b/apps/web/src/app/billing/shared/plan-card/plan-card.component.html @@ -31,10 +31,6 @@ class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center" > {{ plan().title }} - - - {{ "upgradeDiscount" | i18n: plan().discount }} {{ plan().costPerMember | currency: "$" }} diff --git a/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts b/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts index 9e3f03a5e7d..4150ddc25ba 100644 --- a/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts +++ b/apps/web/src/app/billing/shared/plan-card/plan-card.component.ts @@ -5,7 +5,6 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; export interface PlanCard { title: string; costPerMember: number; - discount?: number; isDisabled: boolean; isAnnual: boolean; isSelected: boolean; diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html index 98fe4032b55..e74997cb9f5 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html @@ -12,6 +12,7 @@ - // Web chooses to have Light as the default theme - new DefaultThemeStateService(globalStateProvider, ThemeTypes.Light), + useClass: DefaultThemeStateService, deps: [GlobalStateProvider], }), safeProvider({ @@ -277,21 +274,6 @@ const safeProviders: SafeProvider[] = [ useClass: WebLockComponentService, deps: [], }), - safeProvider({ - provide: SetPasswordJitService, - useClass: WebSetPasswordJitService, - deps: [ - EncryptService, - I18nServiceAbstraction, - KdfConfigService, - KeyServiceAbstraction, - MasterPasswordApiService, - InternalMasterPasswordServiceAbstraction, - OrganizationApiServiceAbstraction, - OrganizationUserApiService, - InternalUserDecryptionOptionsServiceAbstraction, - ], - }), safeProvider({ provide: SetInitialPasswordService, useClass: WebSetInitialPasswordService, @@ -412,6 +394,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultDeviceManagementComponentService, deps: [], }), + safeProvider({ + provide: OrganizationIntegrationApiService, + useClass: OrganizationIntegrationApiService, + deps: [ApiService], + }), ]; @NgModule({ diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index 72951658b23..ecf10bfa723 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -7,10 +7,8 @@ import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; @@ -42,8 +40,6 @@ export class InitService { private versionService: VersionService, private ipcService: IpcService, private sdkLoadService: SdkLoadService, - private configService: ConfigService, - private bulkEncryptService: BulkEncryptService, private taskService: TaskService, @Inject(DOCUMENT) private document: Document, ) {} @@ -53,13 +49,6 @@ export class InitService { await this.sdkLoadService.loadAndInit(); await this.stateService.init(); - this.configService.serverConfig$.subscribe((newConfig) => { - if (newConfig != null) { - this.encryptService.onServerConfigChange(newConfig); - this.bulkEncryptService.onServerConfigChange(newConfig); - } - }); - const activeAccount = await firstValueFrom(this.accountService.activeAccount$); if (activeAccount) { // If there is an active account, we must await the process of setting the user key in memory diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 1fb19757d60..4c1ed1a7472 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -12,14 +12,12 @@ import { } from "@bitwarden/angular/auth/guards"; import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password"; import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component"; -import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { PasswordHintComponent, RegistrationFinishComponent, RegistrationStartComponent, RegistrationStartSecondaryComponent, RegistrationStartSecondaryComponentData, - SetPasswordJitComponent, RegistrationLinkExpiredComponent, LoginComponent, LoginSecondaryContentComponent, @@ -39,7 +37,6 @@ import { NewDeviceVerificationComponent, DeviceVerificationIcon, } from "@bitwarden/auth/angular"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, Icons } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { VaultIcons } from "@bitwarden/vault"; @@ -55,13 +52,10 @@ import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "./auth/recover-two-factor.component"; -import { SetPasswordComponent } from "./auth/set-password.component"; import { AccountComponent } from "./auth/settings/account/account.component"; import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emergency-access.component"; import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component"; import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module"; -import { UpdatePasswordComponent } from "./auth/update-password.component"; -import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "./auth/verify-email-token.component"; import { VerifyRecoverDeleteComponent } from "./auth/verify-recover-delete.component"; import { SponsoredFamiliesComponent } from "./billing/settings/sponsored-families.component"; @@ -115,11 +109,6 @@ const routes: Routes = [ component: LoginViaWebAuthnComponent, data: { titleId: "logInWithPasskey" } satisfies RouteDataProperties, }, - { - path: "set-password", - component: SetPasswordComponent, - data: { titleId: "setMasterPassword" } satisfies RouteDataProperties, - }, { path: "verify-email", component: VerifyEmailTokenComponent }, { path: "accept-organization", @@ -143,34 +132,6 @@ const routes: Routes = [ canActivate: [unauthGuardFn()], data: { titleId: "deleteOrganization" }, }, - { - path: "update-temp-password", - component: UpdateTempPasswordComponent, - canActivate: [ - canAccessFeature( - FeatureFlag.PM16117_ChangeExistingPasswordRefactor, - false, - "change-password", - false, - ), - authGuard, - ], - data: { titleId: "updateTempPassword" } satisfies RouteDataProperties, - }, - { - path: "update-password", - component: UpdatePasswordComponent, - canActivate: [ - canAccessFeature( - FeatureFlag.PM16117_ChangeExistingPasswordRefactor, - false, - "change-password", - false, - ), - authGuard, - ], - data: { titleId: "updatePassword" } satisfies RouteDataProperties, - }, ], }, { @@ -329,24 +290,12 @@ const routes: Routes = [ }, { path: "set-initial-password", - canActivate: [canAccessFeature(FeatureFlag.PM16117_SetInitialPasswordRefactor), authGuard], + canActivate: [authGuard], component: SetInitialPasswordComponent, data: { maxWidth: "lg", } satisfies AnonLayoutWrapperData, }, - { - path: "set-password-jit", - component: SetPasswordJitComponent, - data: { - pageTitle: { - key: "joinOrganization", - }, - pageSubtitle: { - key: "finishJoiningThisOrganizationBySettingAMasterPassword", - }, - } satisfies AnonLayoutWrapperData, - }, { path: "signup-link-expired", canActivate: [unauthGuardFn()], @@ -601,10 +550,7 @@ const routes: Routes = [ { path: "change-password", component: ChangePasswordComponent, - canActivate: [ - canAccessFeature(FeatureFlag.PM16117_ChangeExistingPasswordRefactor), - authGuard, - ], + canActivate: [authGuard], }, { path: "setup-extension", diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 97c3fa0375c..637e1b77ce0 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -14,16 +14,8 @@ import { VerifyRecoverDeleteOrgComponent } from "../admin-console/organizations/ import { AcceptFamilySponsorshipComponent } from "../admin-console/organizations/sponsorships/accept-family-sponsorship.component"; import { RecoverDeleteComponent } from "../auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component"; -import { SetPasswordComponent } from "../auth/set-password.component"; import { DangerZoneComponent } from "../auth/settings/account/danger-zone.component"; -import { EmergencyAccessConfirmComponent } from "../auth/settings/emergency-access/confirm/emergency-access-confirm.component"; -import { EmergencyAccessAddEditComponent } from "../auth/settings/emergency-access/emergency-access-add-edit.component"; -import { EmergencyAccessComponent } from "../auth/settings/emergency-access/emergency-access.component"; -import { EmergencyAccessTakeoverComponent } from "../auth/settings/emergency-access/takeover/emergency-access-takeover.component"; -import { EmergencyAccessViewComponent } from "../auth/settings/emergency-access/view/emergency-access-view.component"; import { UserVerificationModule } from "../auth/shared/components/user-verification"; -import { UpdatePasswordComponent } from "../auth/update-password.component"; -import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component"; import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component"; import { FreeBitwardenFamiliesComponent } from "../billing/members/free-bitwarden-families.component"; @@ -70,11 +62,6 @@ import { SharedModule } from "./shared.module"; ], declarations: [ AcceptFamilySponsorshipComponent, - EmergencyAccessAddEditComponent, - EmergencyAccessComponent, - EmergencyAccessConfirmComponent, - EmergencyAccessTakeoverComponent, - EmergencyAccessViewComponent, OrgEventsComponent, OrgExposedPasswordsReportComponent, OrgInactiveTwoFactorReportComponent, @@ -85,23 +72,15 @@ import { SharedModule } from "./shared.module"; RecoverDeleteComponent, RecoverTwoFactorComponent, RemovePasswordComponent, - SetPasswordComponent, SponsoredFamiliesComponent, FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, - UpdatePasswordComponent, - UpdateTempPasswordComponent, VerifyEmailTokenComponent, VerifyRecoverDeleteComponent, ], exports: [ UserVerificationModule, PremiumBadgeComponent, - EmergencyAccessAddEditComponent, - EmergencyAccessComponent, - EmergencyAccessConfirmComponent, - EmergencyAccessTakeoverComponent, - EmergencyAccessViewComponent, OrganizationLayoutComponent, OrgEventsComponent, OrgExposedPasswordsReportComponent, @@ -114,12 +93,9 @@ import { SharedModule } from "./shared.module"; RecoverDeleteComponent, RecoverTwoFactorComponent, RemovePasswordComponent, - SetPasswordComponent, SponsoredFamiliesComponent, FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, - UpdateTempPasswordComponent, - UpdatePasswordComponent, VerifyEmailTokenComponent, VerifyRecoverDeleteComponent, HeaderModule, diff --git a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts index 3b1bf427a0b..239861dd244 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts @@ -63,7 +63,7 @@ export class SendAccessFileComponent { try { const encBuf = await EncArrayBuffer.fromResponse(response); - const decBuf = await this.encryptService.decryptToBytes(encBuf, this.decKey); + const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey); this.fileDownloadService.download({ fileName: this.send.file.fileName, blobData: decBuf, diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html index 3b9ec19fd34..c23fa0aac35 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -45,11 +45,11 @@

{{ "gettingStartedWithBitwardenPart1" | i18n }} - + {{ "gettingStartedWithBitwardenPart2" | i18n }} {{ "and" | i18n }} - + {{ "gettingStartedWithBitwardenPart3" | i18n }}

diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index ebee57878db..e63b353be9c 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -285,7 +285,7 @@ export class VaultItemsComponent { protected canClone(vaultItem: VaultItem) { // This will check for restrictions from org policies before allowing cloning. const isItemRestricted = this.restrictedPolicies().some( - (rt) => rt.cipherType === vaultItem.cipher.type, + (rt) => rt.cipherType === CipherViewLikeUtils.getType(vaultItem.cipher), ); if (isItemRestricted) { return false; @@ -566,7 +566,7 @@ export class VaultItemsComponent { } private hasPersonalItems(): boolean { - return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null); + return this.selection.selected.some(({ cipher }) => !cipher?.organizationId); } private allCiphersHaveEditAccess(): boolean { diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts index 128afdcccfc..78abad1ebf8 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts @@ -9,7 +9,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; @@ -68,7 +68,6 @@ export class BulkDeleteDialogComponent { @Inject(DIALOG_DATA) params: BulkDeleteDialogParams, private dialogRef: DialogRef, private cipherService: CipherService, - private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private apiService: ApiService, private collectionService: CollectionService, @@ -116,7 +115,11 @@ export class BulkDeleteDialogComponent { }); } if (this.collections.length) { - await this.collectionService.delete(this.collections.map((c) => c.id)); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await this.collectionService.delete( + this.collections.map((c) => c.id as CollectionId), + userId, + ); this.toastService.showToast({ variant: "success", title: null, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts index 93189f2bf1c..b7a19bf2e76 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts @@ -78,7 +78,7 @@ describe("vault filter service", () => { configService.getFeatureFlag$.mockReturnValue(of(true)); organizationService.memberOrganizations$.mockReturnValue(organizations); folderService.folderViews$.mockReturnValue(folderViews); - collectionService.decryptedCollections$ = collectionViews; + collectionService.decryptedCollections$.mockReturnValue(collectionViews); policyService.policyAppliesToUser$ .calledWith(PolicyType.OrganizationDataOwnership, mockUserId) .mockReturnValue(organizationDataOwnershipPolicy); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts index 1fe618c6c4e..266676e418b 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts @@ -4,7 +4,6 @@ import { Injectable } from "@angular/core"; import { BehaviorSubject, combineLatest, - combineLatestWith, filter, firstValueFrom, map, @@ -100,13 +99,13 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { map((folders) => this.buildFolderTree(folders)), ); - filteredCollections$: Observable = - this.collectionService.decryptedCollections$.pipe( - combineLatestWith(this._organizationFilter), - switchMap(([collections, org]) => { - return this.filterCollections(collections, org); - }), - ); + filteredCollections$: Observable = combineLatest([ + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.collectionService.decryptedCollections$(userId)), + ), + this._organizationFilter, + ]).pipe(switchMap(([collections, org]) => this.filterCollections(collections, org))); collectionTree$: Observable> = combineLatest([ this.filteredCollections$, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html index 1485c1f5343..01a38a02d51 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html @@ -1,3 +1,4 @@ +
- - @@ -73,26 +89,41 @@ appA11yTitle="{{ 'options' | i18n }}" > - - - + @if (provider?.type === ProviderUserType.ProviderAdmin) { + + }
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts index de9e63cd509..27f45cb250e 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-clients.component.ts @@ -2,8 +2,16 @@ import { Component } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom, from, lastValueFrom, map } from "rxjs"; -import { debounceTime, first, switchMap } from "rxjs/operators"; +import { + firstValueFrom, + from, + lastValueFrom, + map, + combineLatest, + switchMap, + Observable, +} from "rxjs"; +import { debounceTime, first } from "rxjs/operators"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { @@ -15,6 +23,8 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { @@ -61,20 +71,49 @@ import { ReplacePipe } from "./replace.pipe"; ], }) export class ManageClientsComponent { - providerId: string = ""; - provider: Provider | undefined; loading = true; - isProviderAdmin = false; dataSource: TableDataSource = new TableDataSource(); protected searchControl = new FormControl("", { nonNullable: true }); protected plans: PlanResponse[] = []; + protected ProviderUserType = ProviderUserType; pageTitle = this.i18nService.t("clients"); clientColumnHeader = this.i18nService.t("client"); newClientButtonLabel = this.i18nService.t("newClient"); + protected providerId$: Observable = + this.activatedRoute.parent?.params.pipe(map((params) => params.providerId as string)) ?? + new Observable(); + + protected provider$ = this.providerId$.pipe( + switchMap((providerId) => this.providerService.get$(providerId)), + ); + + protected isAdminOrServiceUser$ = this.provider$.pipe( + map( + (provider) => + provider?.type === ProviderUserType.ProviderAdmin || + provider?.type === ProviderUserType.ServiceUser, + ), + ); + + protected providerPortalTakeover$ = this.configService.getFeatureFlag$( + FeatureFlag.PM21821_ProviderPortalTakeover, + ); + + protected suspensionActive$ = combineLatest([ + this.isAdminOrServiceUser$, + this.providerPortalTakeover$, + this.provider$.pipe(map((provider) => provider?.enabled ?? false)), + ]).pipe( + map( + ([isAdminOrServiceUser, portalTakeoverEnabled, providerEnabled]) => + isAdminOrServiceUser && portalTakeoverEnabled && !providerEnabled, + ), + ); + constructor( private billingApiService: BillingApiServiceAbstraction, private providerService: ProviderService, @@ -86,29 +125,23 @@ export class ManageClientsComponent { private validationService: ValidationService, private webProviderService: WebProviderService, private billingNotificationService: BillingNotificationService, + private configService: ConfigService, ) { this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => { this.searchControl.setValue(queryParams.search); }); - this.activatedRoute.parent?.params - ?.pipe( - switchMap((params) => { - this.providerId = params.providerId; - return this.providerService.get$(this.providerId).pipe( - map((provider: Provider) => provider?.providerStatus === ProviderStatusType.Billable), - map((isBillable) => { - if (!isBillable) { - return from( - this.router.navigate(["../clients"], { - relativeTo: this.activatedRoute, - }), - ); - } else { - return from(this.load()); - } - }), - ); + this.provider$ + .pipe( + map((provider: Provider) => { + if (provider?.providerStatus !== ProviderStatusType.Billable) { + return from( + this.router.navigate(["../clients"], { + relativeTo: this.activatedRoute, + }), + ); + } + return from(this.load()); }), takeUntilDestroyed(), ) @@ -124,15 +157,15 @@ export class ManageClientsComponent { async load() { try { - this.provider = await firstValueFrom(this.providerService.get$(this.providerId)); - if (this.provider?.providerType === ProviderType.BusinessUnit) { + const providerId = await firstValueFrom(this.providerId$); + const provider = await firstValueFrom(this.providerService.get$(providerId)); + if (provider?.providerType === ProviderType.BusinessUnit) { this.pageTitle = this.i18nService.t("businessUnits"); this.clientColumnHeader = this.i18nService.t("businessUnit"); this.newClientButtonLabel = this.i18nService.t("newBusinessUnit"); } - this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin; this.dataSource.data = ( - await this.billingApiService.getProviderClientOrganizations(this.providerId) + await this.billingApiService.getProviderClientOrganizations(providerId) ).data; this.plans = (await this.billingApiService.getPlans()).data; this.loading = false; @@ -142,10 +175,11 @@ export class ManageClientsComponent { } addExistingOrganization = async () => { - if (this.provider) { + const provider = await firstValueFrom(this.provider$); + if (provider) { const reference = AddExistingOrganizationDialogComponent.open(this.dialogService, { data: { - provider: this.provider, + provider: provider, }, }); @@ -158,9 +192,10 @@ export class ManageClientsComponent { }; createClient = async () => { + const providerId = await firstValueFrom(this.providerId$); const reference = openCreateClientDialog(this.dialogService, { data: { - providerId: this.providerId, + providerId: providerId, plans: this.plans, }, }); @@ -173,9 +208,10 @@ export class ManageClientsComponent { }; manageClientName = async (organization: ProviderOrganizationOrganizationDetailsResponse) => { + const providerId = await firstValueFrom(this.providerId$); const dialogRef = openManageClientNameDialog(this.dialogService, { data: { - providerId: this.providerId, + providerId: providerId, organization: { id: organization.id, name: organization.organizationName, @@ -194,10 +230,11 @@ export class ManageClientsComponent { manageClientSubscription = async ( organization: ProviderOrganizationOrganizationDetailsResponse, ) => { + const provider = await firstValueFrom(this.provider$); const dialogRef = openManageClientSubscriptionDialog(this.dialogService, { data: { organization, - provider: this.provider!, + provider: provider!, }, }); @@ -220,7 +257,8 @@ export class ManageClientsComponent { } try { - await this.webProviderService.detachOrganization(this.providerId, organization.id); + const providerId = await firstValueFrom(this.providerId$); + await this.webProviderService.detachOrganization(providerId, organization.id); this.toastService.showToast({ variant: "success", title: "", diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts index 768f22c5738..039c42de302 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts @@ -30,6 +30,7 @@ const gearIcon = svgIcon`

{{ "noClients" | i18n }}

this.addNewOrganizationClicked.emit(); diff --git a/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts b/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts new file mode 100644 index 00000000000..752df86ed7e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.spec.ts @@ -0,0 +1,187 @@ +import { TestBed } from "@angular/core/testing"; +import { ActivatedRoute, Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { ProviderSubscriptionResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { DialogRef, DialogService } from "@bitwarden/components"; +import { + RequirePaymentMethodDialogComponent, + SubmitPaymentMethodDialogResult, +} from "@bitwarden/web-vault/app/billing/payment/components"; + +import { ProviderWarningsService } from "./provider-warnings.service"; + +describe("ProviderWarningsService", () => { + let service: ProviderWarningsService; + let configService: MockProxy; + let dialogService: MockProxy; + let providerService: MockProxy; + let billingApiService: MockProxy; + let i18nService: MockProxy; + let router: MockProxy; + let syncService: MockProxy; + + beforeEach(() => { + billingApiService = mock(); + configService = mock(); + dialogService = mock(); + i18nService = mock(); + providerService = mock(); + router = mock(); + syncService = mock(); + + TestBed.configureTestingModule({ + providers: [ + ProviderWarningsService, + { provide: ActivatedRoute, useValue: {} }, + { provide: BillingApiServiceAbstraction, useValue: billingApiService }, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, + { provide: I18nService, useValue: i18nService }, + { provide: ProviderService, useValue: providerService }, + { provide: Router, useValue: router }, + { provide: SyncService, useValue: syncService }, + ], + }); + + service = TestBed.inject(ProviderWarningsService); + }); + + it("should create the service", () => { + expect(service).toBeTruthy(); + }); + + describe("showProviderSuspendedDialog$", () => { + const providerId = "test-provider-id"; + + it("should not show any dialog when the 'pm-21821-provider-portal-takeover' flag is disabled", (done) => { + const provider = { enabled: false } as Provider; + const subscription = { status: "unpaid" } as ProviderSubscriptionResponse; + + providerService.get$.mockReturnValue(of(provider)); + billingApiService.getProviderSubscription.mockResolvedValue(subscription); + configService.getFeatureFlag$.mockReturnValue(of(false)); + + const requirePaymentMethodDialogComponentOpenSpy = jest.spyOn( + RequirePaymentMethodDialogComponent, + "open", + ); + + service.showProviderSuspendedDialog$(providerId).subscribe(() => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(requirePaymentMethodDialogComponentOpenSpy).not.toHaveBeenCalled(); + done(); + }); + }); + + it("should not show any dialog when the provider is enabled", (done) => { + const provider = { enabled: true } as Provider; + const subscription = { status: "unpaid" } as ProviderSubscriptionResponse; + + providerService.get$.mockReturnValue(of(provider)); + billingApiService.getProviderSubscription.mockResolvedValue(subscription); + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const requirePaymentMethodDialogComponentOpenSpy = jest.spyOn( + RequirePaymentMethodDialogComponent, + "open", + ); + + service.showProviderSuspendedDialog$(providerId).subscribe(() => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(requirePaymentMethodDialogComponentOpenSpy).not.toHaveBeenCalled(); + done(); + }); + }); + + it("should show the require payment method dialog for an admin of a provider with an unpaid subscription", (done) => { + const provider = { + enabled: false, + type: ProviderUserType.ProviderAdmin, + name: "Test Provider", + } as Provider; + const subscription = { + status: "unpaid", + cancelAt: "2024-12-31", + } as ProviderSubscriptionResponse; + + providerService.get$.mockReturnValue(of(provider)); + billingApiService.getProviderSubscription.mockResolvedValue(subscription); + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const dialogRef = { + closed: of({ type: "success" }), + } as DialogRef; + jest.spyOn(RequirePaymentMethodDialogComponent, "open").mockReturnValue(dialogRef); + + service.showProviderSuspendedDialog$(providerId).subscribe(() => { + expect(RequirePaymentMethodDialogComponent.open).toHaveBeenCalled(); + expect(syncService.fullSync).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalled(); + done(); + }); + }); + + it("should show the simple, unpaid invoices dialog for a service user of a provider with an unpaid subscription", (done) => { + const provider = { + enabled: false, + type: ProviderUserType.ServiceUser, + name: "Test Provider", + } as Provider; + const subscription = { status: "unpaid" } as ProviderSubscriptionResponse; + + providerService.get$.mockReturnValue(of(provider)); + billingApiService.getProviderSubscription.mockResolvedValue(subscription); + dialogService.openSimpleDialog.mockResolvedValue(true); + configService.getFeatureFlag$.mockReturnValue(of(true)); + + i18nService.t.mockImplementation((key: string) => key); + + service.showProviderSuspendedDialog$(providerId).subscribe(() => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + type: "danger", + title: "unpaidInvoices", + content: "unpaidInvoicesForServiceUser", + disableClose: true, + }); + done(); + }); + }); + + it("should show the provider suspended dialog to all users of a provider that's suspended, but not unpaid", (done) => { + const provider = { + enabled: false, + name: "Test Provider", + } as Provider; + const subscription = { status: "active" } as ProviderSubscriptionResponse; + + providerService.get$.mockReturnValue(of(provider)); + billingApiService.getProviderSubscription.mockResolvedValue(subscription); + dialogService.openSimpleDialog.mockResolvedValue(true); + configService.getFeatureFlag$.mockReturnValue(of(true)); + + i18nService.t.mockImplementation((key: string) => key); + + service.showProviderSuspendedDialog$(providerId).subscribe(() => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + type: "danger", + title: "providerSuspended", + content: "restoreProviderPortalAccessViaCustomerSupport", + disableClose: true, + acceptButtonText: "contactSupportShort", + cancelButtonText: null, + acceptAction: expect.any(Function), + }); + done(); + }); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts b/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts new file mode 100644 index 00000000000..d888cc6b8d9 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/services/provider-warnings.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { combineLatest, from, lastValueFrom, Observable, switchMap } from "rxjs"; + +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { DialogService } from "@bitwarden/components"; +import { RequirePaymentMethodDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components"; + +@Injectable() +export class ProviderWarningsService { + constructor( + private activatedRoute: ActivatedRoute, + private billingApiService: BillingApiServiceAbstraction, + private configService: ConfigService, + private dialogService: DialogService, + private i18nService: I18nService, + private providerService: ProviderService, + private router: Router, + private syncService: SyncService, + ) {} + + showProviderSuspendedDialog$ = (providerId: string): Observable => + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.PM21821_ProviderPortalTakeover), + this.providerService.get$(providerId), + from(this.billingApiService.getProviderSubscription(providerId)), + ]).pipe( + switchMap(async ([providerPortalTakeover, provider, subscription]) => { + if (!providerPortalTakeover || provider.enabled) { + return; + } + + if (subscription.status === "unpaid") { + switch (provider.type) { + case ProviderUserType.ProviderAdmin: { + const cancelAt = subscription.cancelAt + ? new Date(subscription.cancelAt).toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }) + : null; + + const dialogRef = RequirePaymentMethodDialogComponent.open(this.dialogService, { + data: { + owner: { + type: "provider", + data: provider, + }, + callout: { + type: "danger", + title: this.i18nService.t("unpaidInvoices"), + message: this.i18nService.t( + "restoreProviderPortalAccessViaPaymentMethod", + cancelAt ?? undefined, + ), + }, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result?.type === "success") { + await this.syncService.fullSync(true); + await this.router.navigate(["."], { + relativeTo: this.activatedRoute, + onSameUrlNavigation: "reload", + }); + } + break; + } + case ProviderUserType.ServiceUser: { + await this.dialogService.openSimpleDialog({ + type: "danger", + title: this.i18nService.t("unpaidInvoices"), + content: this.i18nService.t("unpaidInvoicesForServiceUser"), + disableClose: true, + }); + break; + } + } + } else { + await this.dialogService.openSimpleDialog({ + type: "danger", + title: this.i18nService.t("providerSuspended", provider.name), + content: this.i18nService.t("restoreProviderPortalAccessViaCustomerSupport"), + disableClose: true, + acceptButtonText: this.i18nService.t("contactSupportShort"), + cancelButtonText: null, + acceptAction: async () => { + window.open("https://bitwarden.com/contact/", "_blank"); + return Promise.resolve(); + }, + }); + } + }), + ); +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts index b563591f32f..be4b5725ecc 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts @@ -1,6 +1,7 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { ActivatedRoute } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of } from "rxjs"; @@ -8,9 +9,12 @@ import {} from "@bitwarden/web-vault/app/shared"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; +import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/integrations"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { ToastService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { IntegrationCardComponent } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/integration-card/integration-card.component"; import { IntegrationGridComponent } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/integrations/integration-grid/integration-grid.component"; @@ -33,23 +37,25 @@ class MockNewMenuComponent {} describe("IntegrationsComponent", () => { let fixture: ComponentFixture; + const mockOrgIntegrationApiService = mock(); + const activatedRouteMock = { + snapshot: { paramMap: { get: jest.fn() } }, + }; + const mockI18nService = mock(); + beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [IntegrationsComponent, MockHeaderComponent, MockNewMenuComponent], imports: [JslibModule, IntegrationGridComponent, IntegrationCardComponent], providers: [ - { - provide: I18nService, - useValue: mock(), - }, - { - provide: ThemeStateService, - useValue: mock(), - }, - { - provide: SYSTEM_THEME_OBSERVABLE, - useValue: of(ThemeType.Light), - }, + { provide: I18nService, useValue: mock() }, + { provide: ThemeStateService, useValue: mock() }, + { provide: SYSTEM_THEME_OBSERVABLE, useValue: of(ThemeType.Light) }, + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { provide: OrganizationIntegrationApiService, useValue: mockOrgIntegrationApiService }, + { provide: ToastService, useValue: mock() }, + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, ], }).compileComponents(); fixture = TestBed.createComponent(IntegrationsComponent); diff --git a/bitwarden_license/bit-web/tsconfig.build.json b/bitwarden_license/bit-web/tsconfig.build.json index 6313ce27863..66c475051ed 100644 --- a/bitwarden_license/bit-web/tsconfig.build.json +++ b/bitwarden_license/bit-web/tsconfig.build.json @@ -7,8 +7,5 @@ "../../bitwarden_license/bit-web/src/main.ts" ], - "include": [ - "../../apps/web/src/connectors/*.ts", - "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts" - ] + "include": ["../../apps/web/src/connectors/*.ts"] } diff --git a/bitwarden_license/bit-web/tsconfig.json b/bitwarden_license/bit-web/tsconfig.json index 7ec0441f4c1..0836d3d54ad 100644 --- a/bitwarden_license/bit-web/tsconfig.json +++ b/bitwarden_license/bit-web/tsconfig.json @@ -11,8 +11,6 @@ "../../apps/web/src/connectors/*.ts", "../../apps/web/src/**/*.stories.ts", "../../apps/web/src/**/*.spec.ts", - "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts", - "src/**/*.stories.ts", "src/**/*.spec.ts" ] diff --git a/eslint.config.mjs b/eslint.config.mjs index f08523d5878..c4018b7625e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -310,6 +310,26 @@ export default tseslint.config( "no-console": "off", }, }, + // Tailwind migrated clients & libs + { + files: ["apps/web/**/*.html", "bitwarden_license/bit-web/**/*.html", "libs/**/*.html"], + rules: { + "tailwindcss/no-custom-classname": [ + "error", + { + // In migrated clients we only allow tailwind classes plus the following exceptions + whitelist: [ + "((bwi)\\-?).*", // Font icons + "logo", + "logo-themed", + "file-selector", + "mfaType.*", + "filter.*", // Temporary until filters are migrated + ], + }, + ], + }, + }, /// Bandaids for keeping existing circular dependencies from getting worse and new ones from being created /// Will be removed after Nx is implemented and existing circular dependencies are removed. { diff --git a/libs/admin-console/src/common/collections/abstractions/collection-admin.service.ts b/libs/admin-console/src/common/collections/abstractions/collection-admin.service.ts index 36222b16794..61faabb16b8 100644 --- a/libs/admin-console/src/common/collections/abstractions/collection-admin.service.ts +++ b/libs/admin-console/src/common/collections/abstractions/collection-admin.service.ts @@ -1,18 +1,23 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CollectionDetailsResponse } from "@bitwarden/admin-console/common"; +import { UserId } from "@bitwarden/common/types/guid"; import { CollectionAccessSelectionView, CollectionAdminView } from "../models"; export abstract class CollectionAdminService { - getAll: (organizationId: string) => Promise; - get: (organizationId: string, collectionId: string) => Promise; - save: (collection: CollectionAdminView) => Promise; - delete: (organizationId: string, collectionId: string) => Promise; - bulkAssignAccess: ( + abstract getAll(organizationId: string): Promise; + abstract get( + organizationId: string, + collectionId: string, + ): Promise; + abstract save( + collection: CollectionAdminView, + userId: UserId, + ): Promise; + abstract delete(organizationId: string, collectionId: string): Promise; + abstract bulkAssignAccess( organizationId: string, collectionIds: string[], users: CollectionAccessSelectionView[], groups: CollectionAccessSelectionView[], - ) => Promise; + ): Promise; } diff --git a/libs/admin-console/src/common/collections/abstractions/collection.service.ts b/libs/admin-console/src/common/collections/abstractions/collection.service.ts index 61fc94b271c..dabaf078e16 100644 --- a/libs/admin-console/src/common/collections/abstractions/collection.service.ts +++ b/libs/admin-console/src/common/collections/abstractions/collection.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -9,27 +7,25 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CollectionData, Collection, CollectionView } from "../models"; export abstract class CollectionService { - encryptedCollections$: Observable; - decryptedCollections$: Observable; - - clearActiveUserCache: () => Promise; - encrypt: (model: CollectionView) => Promise; - decryptedCollectionViews$: (ids: CollectionId[]) => Observable; + abstract encryptedCollections$(userId: UserId): Observable; + abstract decryptedCollections$(userId: UserId): Observable; + abstract upsert(collection: CollectionData, userId: UserId): Promise; + abstract replace(collections: { [id: string]: CollectionData }, userId: UserId): Promise; /** - * @deprecated This method will soon be made private - * See PM-12375 + * @deprecated This method will soon be made private, use `decryptedCollections$` instead. */ - decryptMany: ( + abstract decryptMany$( collections: Collection[], - orgKeys?: Record, - ) => Promise; - get: (id: string) => Promise; - getAll: () => Promise; - getAllDecrypted: () => Promise; - getAllNested: (collections?: CollectionView[]) => Promise[]>; - getNested: (id: string) => Promise>; - upsert: (collection: CollectionData | CollectionData[]) => Promise; - replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise; - clear: (userId?: string) => Promise; - delete: (id: string | string[]) => Promise; + orgKeys: Record, + ): Observable; + abstract delete(ids: CollectionId[], userId: UserId): Promise; + abstract encrypt(model: CollectionView, userId: UserId): Promise; + /** + * Transforms the input CollectionViews into TreeNodes + */ + abstract getAllNested(collections: CollectionView[]): TreeNode[]; + /* + * Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id + */ + abstract getNested(collections: CollectionView[], id: string): TreeNode; } diff --git a/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts b/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts deleted file mode 100644 index e1b2a5759a1..00000000000 --- a/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Observable } from "rxjs"; - -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { OrgKey } from "@bitwarden/common/types/key"; -import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; - -import { CollectionData, Collection, CollectionView } from "../models"; - -export abstract class vNextCollectionService { - encryptedCollections$: (userId: UserId) => Observable; - decryptedCollections$: (userId: UserId) => Observable; - upsert: (collection: CollectionData | CollectionData[], userId: UserId) => Promise; - replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise; - /** - * Clear decrypted state without affecting encrypted state. - * Used for locking the vault. - */ - clearDecryptedState: (userId: UserId) => Promise; - /** - * Clear decrypted and encrypted state. - * Used for logging out. - */ - clear: (userId: UserId) => Promise; - delete: (id: string | string[], userId: UserId) => Promise; - encrypt: (model: CollectionView) => Promise; - /** - * @deprecated This method will soon be made private, use `decryptedCollections$` instead. - */ - decryptMany: ( - collections: Collection[], - orgKeys?: Record | null, - ) => Promise; - /** - * Transforms the input CollectionViews into TreeNodes - */ - getAllNested: (collections: CollectionView[]) => TreeNode[]; - /** - * Transforms the input CollectionViews into TreeNodes and then returns the Treenode with the specified id - */ - getNested: (collections: CollectionView[], id: string) => TreeNode; -} diff --git a/libs/admin-console/src/common/collections/models/collection.data.ts b/libs/admin-console/src/common/collections/models/collection.data.ts index b28a066509c..27c5c0c0efa 100644 --- a/libs/admin-console/src/common/collections/models/collection.data.ts +++ b/libs/admin-console/src/common/collections/models/collection.data.ts @@ -26,7 +26,10 @@ export class CollectionData { this.type = response.type; } - static fromJSON(obj: Jsonify) { + static fromJSON(obj: Jsonify): CollectionData | null { + if (obj == null) { + return null; + } return Object.assign(new CollectionData(new CollectionDetailsResponse({})), obj); } } diff --git a/libs/admin-console/src/common/collections/models/collection.ts b/libs/admin-console/src/common/collections/models/collection.ts index 75d68222b38..7bbd018fa96 100644 --- a/libs/admin-console/src/common/collections/models/collection.ts +++ b/libs/admin-console/src/common/collections/models/collection.ts @@ -1,7 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import Domain from "@bitwarden/common/platform/models/domain/domain-base"; +import Domain, { EncryptableKeys } from "@bitwarden/common/platform/models/domain/domain-base"; import { OrgKey } from "@bitwarden/common/types/key"; import { CollectionData } from "./collection.data"; @@ -15,16 +13,16 @@ export const CollectionTypes = { export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes]; export class Collection extends Domain { - id: string; - organizationId: string; - name: EncString; - externalId: string; - readOnly: boolean; - hidePasswords: boolean; - manage: boolean; - type: CollectionType; + id: string | undefined; + organizationId: string | undefined; + name: EncString | undefined; + externalId: string | undefined; + readOnly: boolean = false; + hidePasswords: boolean = false; + manage: boolean = false; + type: CollectionType = CollectionTypes.SharedCollection; - constructor(obj?: CollectionData) { + constructor(obj?: CollectionData | null) { super(); if (obj == null) { return; @@ -51,8 +49,8 @@ export class Collection extends Domain { return this.decryptObj( this, new CollectionView(this), - ["name"], - this.organizationId, + ["name"] as EncryptableKeys[], + this.organizationId ?? null, orgKey, ); } diff --git a/libs/admin-console/src/common/collections/models/collection.view.ts b/libs/admin-console/src/common/collections/models/collection.view.ts index bce1d558f96..f75ff565100 100644 --- a/libs/admin-console/src/common/collections/models/collection.view.ts +++ b/libs/admin-console/src/common/collections/models/collection.view.ts @@ -12,7 +12,7 @@ export const NestingDelimiter = "/"; export class CollectionView implements View, ITreeNodeObject { id: string | undefined; organizationId: string | undefined; - name: string | undefined; + name: string = ""; externalId: string | undefined; // readOnly applies to the items within a collection readOnly: boolean = false; diff --git a/libs/admin-console/src/common/collections/services/collection.state.ts b/libs/admin-console/src/common/collections/services/collection.state.ts new file mode 100644 index 00000000000..ebb620c2354 --- /dev/null +++ b/libs/admin-console/src/common/collections/services/collection.state.ts @@ -0,0 +1,28 @@ +import { Jsonify } from "type-fest"; + +import { + COLLECTION_DISK, + COLLECTION_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; +import { CollectionId } from "@bitwarden/common/types/guid"; + +import { CollectionData, CollectionView } from "../models"; + +export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record< + CollectionData | null, + CollectionId +>(COLLECTION_DISK, "collections", { + deserializer: (jsonData: Jsonify) => CollectionData.fromJSON(jsonData), + clearOn: ["logout"], +}); + +export const DECRYPTED_COLLECTION_DATA_KEY = new UserKeyDefinition( + COLLECTION_MEMORY, + "decryptedCollections", + { + deserializer: (obj: Jsonify) => + obj?.map((f) => CollectionView.fromJSON(f)) ?? null, + clearOn: ["logout", "lock"], + }, +); diff --git a/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts b/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts index 325b17cbd56..a00be4e5174 100644 --- a/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts +++ b/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts @@ -1,9 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { CollectionId, UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; import { CollectionAdminService, CollectionService } from "../abstractions"; @@ -55,7 +57,7 @@ export class DefaultCollectionAdminService implements CollectionAdminService { return view; } - async save(collection: CollectionAdminView): Promise { + async save(collection: CollectionAdminView, userId: UserId): Promise { const request = await this.encrypt(collection); let response: CollectionDetailsResponse; @@ -71,9 +73,9 @@ export class DefaultCollectionAdminService implements CollectionAdminService { } if (response.assigned) { - await this.collectionService.upsert(new CollectionData(response)); + await this.collectionService.upsert(new CollectionData(response), userId); } else { - await this.collectionService.delete(collection.id); + await this.collectionService.delete([collection.id as CollectionId], userId); } return response; diff --git a/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts b/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts index 57bed5e4ca5..c2c0332a486 100644 --- a/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts +++ b/libs/admin-console/src/common/collections/services/default-collection.service.spec.ts @@ -1,10 +1,11 @@ -import { mock } from "jest-mock-extended"; -import { firstValueFrom, of } from "rxjs"; +import { mock, MockProxy } from "jest-mock-extended"; +import { combineLatest, first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { FakeStateProvider, @@ -16,124 +17,382 @@ import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/gu import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; -import { CollectionData } from "../models"; +import { CollectionData, CollectionView } from "../models"; -import { - DefaultCollectionService, - ENCRYPTED_COLLECTION_DATA_KEY, -} from "./default-collection.service"; +import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state"; +import { DefaultCollectionService } from "./default-collection.service"; describe("DefaultCollectionService", () => { + let keyService: MockProxy; + let encryptService: MockProxy; + let i18nService: MockProxy; + let stateProvider: FakeStateProvider; + + let userId: UserId; + + let cryptoKeys: ReplaySubject | null>; + + let collectionService: DefaultCollectionService; + + beforeEach(() => { + userId = Utils.newGuid() as UserId; + + keyService = mock(); + encryptService = mock(); + i18nService = mock(); + stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); + + cryptoKeys = new ReplaySubject(1); + keyService.orgKeys$.mockReturnValue(cryptoKeys); + + // Set up mock decryption + encryptService.decryptString + .calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey)) + .mockImplementation((encString, key) => + Promise.resolve(encString.data.replace("ENC_", "DEC_")), + ); + + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + + // Arrange i18nService so that sorting algorithm doesn't throw + i18nService.collator = null; + + collectionService = new DefaultCollectionService( + keyService, + encryptService, + i18nService, + stateProvider, + ); + }); + afterEach(() => { delete (window as any).bitwardenContainerService; }); describe("decryptedCollections$", () => { it("emits decrypted collections from state", async () => { - // Arrange test collections + // Arrange test data const org1 = Utils.newGuid() as OrganizationId; - const org2 = Utils.newGuid() as OrganizationId; - + const orgKey1 = makeSymmetricCryptoKey(64, 1); const collection1 = collectionDataFactory(org1); + + const org2 = Utils.newGuid() as OrganizationId; + const orgKey2 = makeSymmetricCryptoKey(64, 2); const collection2 = collectionDataFactory(org2); - // Arrange state provider - const fakeStateProvider = mockStateProvider(); - await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, { - [collection1.id]: collection1, - [collection2.id]: collection2, + // Arrange dependencies + await setEncryptedState([collection1, collection2]); + cryptoKeys.next({ + [org1]: orgKey1, + [org2]: orgKey2, }); - // Arrange cryptoService - orgKeys and mock decryption - const cryptoService = mockCryptoService(); - cryptoService.orgKeys$.mockReturnValue( - of({ - [org1]: makeSymmetricCryptoKey(), - [org2]: makeSymmetricCryptoKey(), - }), - ); + const result = await firstValueFrom(collectionService.decryptedCollections$(userId)); - const collectionService = new DefaultCollectionService( - cryptoService, - mock(), - mockI18nService(), - fakeStateProvider, - ); - - const result = await firstValueFrom(collectionService.decryptedCollections$); + // Assert emitted values expect(result.length).toBe(2); - expect(result[0]).toMatchObject({ - id: collection1.id, - name: "DECRYPTED_STRING", - }); - expect(result[1]).toMatchObject({ - id: collection2.id, - name: "DECRYPTED_STRING", - }); + expect(result).toContainPartialObjects([ + { + id: collection1.id, + name: "DEC_NAME_" + collection1.id, + }, + { + id: collection2.id, + name: "DEC_NAME_" + collection2.id, + }, + ]); + + // Assert that the correct org keys were used for each encrypted string + // This should be replaced with decryptString when the platform PR (https://github.com/bitwarden/clients/pull/14544) is merged + expect(encryptService.decryptString).toHaveBeenCalledWith( + expect.objectContaining(new EncString(collection1.name)), + orgKey1, + ); + expect(encryptService.decryptString).toHaveBeenCalledWith( + expect.objectContaining(new EncString(collection2.name)), + orgKey2, + ); + }); + + it("emits decrypted collections from in-memory state when available", async () => { + // Arrange test data + const org1 = Utils.newGuid() as OrganizationId; + const collection1 = collectionViewDataFactory(org1); + + const org2 = Utils.newGuid() as OrganizationId; + const collection2 = collectionViewDataFactory(org2); + + await setDecryptedState([collection1, collection2]); + + const result = await firstValueFrom(collectionService.decryptedCollections$(userId)); + + // Assert emitted values + expect(result.length).toBe(2); + expect(result).toContainPartialObjects([ + { + id: collection1.id, + name: "DEC_NAME_" + collection1.id, + }, + { + id: collection2.id, + name: "DEC_NAME_" + collection2.id, + }, + ]); + + // Ensure that the returned data came from the in-memory state, rather than from decryption. + expect(encryptService.decryptString).not.toHaveBeenCalled(); }); it("handles null collection state", async () => { - // Arrange test collections + // Arrange dependencies + await setEncryptedState(null); + cryptoKeys.next({}); + + const encryptedCollections = await firstValueFrom( + collectionService.encryptedCollections$(userId), + ); + + expect(encryptedCollections).toBe(null); + }); + + it("handles undefined orgKeys", (done) => { + // Arrange test data const org1 = Utils.newGuid() as OrganizationId; + const collection1 = collectionDataFactory(org1); + const org2 = Utils.newGuid() as OrganizationId; + const collection2 = collectionDataFactory(org2); - // Arrange state provider - const fakeStateProvider = mockStateProvider(); - await fakeStateProvider.setUserState(ENCRYPTED_COLLECTION_DATA_KEY, null); + // Emit a non-null value after the first undefined value has propagated + // This will cause the collections to emit, calling done() + cryptoKeys.pipe(first()).subscribe((val) => { + cryptoKeys.next({}); + }); - // Arrange cryptoService - orgKeys and mock decryption - const cryptoService = mockCryptoService(); - cryptoService.orgKeys$.mockReturnValue( - of({ - [org1]: makeSymmetricCryptoKey(), - [org2]: makeSymmetricCryptoKey(), - }), - ); + collectionService + .decryptedCollections$(userId) + .pipe(takeWhile((val) => val.length != 2)) + .subscribe({ complete: () => done() }); - const collectionService = new DefaultCollectionService( - cryptoService, - mock(), - mockI18nService(), - fakeStateProvider, - ); + // Arrange dependencies + void setEncryptedState([collection1, collection2]).then(() => { + // Act: emit undefined + cryptoKeys.next(undefined); + keyService.activeUserOrgKeys$ = of(undefined); + }); + }); - const decryptedCollections = await firstValueFrom(collectionService.decryptedCollections$); - expect(decryptedCollections.length).toBe(0); + it("Decrypts one time for multiple simultaneous callers", async () => { + const decryptedMock: CollectionView[] = [{ id: "col1" }] as CollectionView[]; + const decryptManySpy = jest + .spyOn(collectionService, "decryptMany$") + .mockReturnValue(of(decryptedMock)); - const encryptedCollections = await firstValueFrom(collectionService.encryptedCollections$); - expect(encryptedCollections.length).toBe(0); + jest + .spyOn(collectionService as any, "encryptedCollections$") + .mockReturnValue(of([{ id: "enc1" }])); + jest.spyOn(keyService, "orgKeys$").mockReturnValue(of({ key: "fake-key" })); + + // Simulate multiple subscribers + const obs1 = collectionService.decryptedCollections$(userId); + const obs2 = collectionService.decryptedCollections$(userId); + const obs3 = collectionService.decryptedCollections$(userId); + + await firstValueFrom(combineLatest([obs1, obs2, obs3])); + + // Expect decryptMany$ to be called only once + expect(decryptManySpy).toHaveBeenCalledTimes(1); }); }); + + describe("encryptedCollections$", () => { + it("emits encrypted collections from state", async () => { + // Arrange test data + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + + // Arrange dependencies + await setEncryptedState([collection1, collection2]); + + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); + + expect(result!.length).toBe(2); + expect(result).toContainPartialObjects([ + { + id: collection1.id, + name: makeEncString("ENC_NAME_" + collection1.id), + }, + { + id: collection2.id, + name: makeEncString("ENC_NAME_" + collection2.id), + }, + ]); + }); + + it("handles null collection state", async () => { + await setEncryptedState(null); + + const decryptedCollections = await firstValueFrom( + collectionService.encryptedCollections$(userId), + ); + expect(decryptedCollections).toBe(null); + }); + }); + + describe("upsert", () => { + it("upserts to existing collections", async () => { + const org1 = Utils.newGuid() as OrganizationId; + const orgKey1 = makeSymmetricCryptoKey(64, 1); + const collection1 = collectionDataFactory(org1); + + await setEncryptedState([collection1]); + cryptoKeys.next({ + [collection1.organizationId]: orgKey1, + }); + + const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, { + name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString, + }); + + await collectionService.upsert(updatedCollection1, userId); + + const encryptedResult = await firstValueFrom(collectionService.encryptedCollections$(userId)); + + expect(encryptedResult!.length).toBe(1); + expect(encryptedResult).toContainPartialObjects([ + { + id: collection1.id, + name: makeEncString("UPDATED_ENC_NAME_" + collection1.id), + }, + ]); + + const decryptedResult = await firstValueFrom(collectionService.decryptedCollections$(userId)); + expect(decryptedResult.length).toBe(1); + expect(decryptedResult).toContainPartialObjects([ + { + id: collection1.id, + name: "UPDATED_DEC_NAME_" + collection1.id, + }, + ]); + }); + + it("upserts to a null state", async () => { + const org1 = Utils.newGuid() as OrganizationId; + const orgKey1 = makeSymmetricCryptoKey(64, 1); + const collection1 = collectionDataFactory(org1); + + cryptoKeys.next({ + [collection1.organizationId]: orgKey1, + }); + + await setEncryptedState(null); + + await collectionService.upsert(collection1, userId); + + const encryptedResult = await firstValueFrom(collectionService.encryptedCollections$(userId)); + expect(encryptedResult!.length).toBe(1); + expect(encryptedResult).toContainPartialObjects([ + { + id: collection1.id, + name: makeEncString("ENC_NAME_" + collection1.id), + }, + ]); + + const decryptedResult = await firstValueFrom(collectionService.decryptedCollections$(userId)); + expect(decryptedResult.length).toBe(1); + expect(decryptedResult).toContainPartialObjects([ + { + id: collection1.id, + name: "DEC_NAME_" + collection1.id, + }, + ]); + }); + }); + + describe("replace", () => { + it("replaces all collections", async () => { + await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); + + const newCollection3 = collectionDataFactory(); + await collectionService.replace( + { + [newCollection3.id]: newCollection3, + }, + userId, + ); + + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); + expect(result!.length).toBe(1); + expect(result).toContainPartialObjects([ + { + id: newCollection3.id, + name: makeEncString("ENC_NAME_" + newCollection3.id), + }, + ]); + }); + }); + + describe("delete", () => { + it("deletes a collection", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + await setEncryptedState([collection1, collection2]); + + await collectionService.delete([collection1.id], userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); + expect(result!.length).toEqual(1); + expect(result![0]).toMatchObject({ id: collection2.id }); + }); + + it("deletes several collections", async () => { + const collection1 = collectionDataFactory(); + const collection2 = collectionDataFactory(); + const collection3 = collectionDataFactory(); + await setEncryptedState([collection1, collection2, collection3]); + + await collectionService.delete([collection1.id, collection3.id], userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); + expect(result!.length).toEqual(1); + expect(result![0]).toMatchObject({ id: collection2.id }); + }); + + it("handles null collections", async () => { + const collection1 = collectionDataFactory(); + await setEncryptedState(null); + + await collectionService.delete([collection1.id], userId); + + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); + expect(result!.length).toEqual(0); + }); + }); + + const setEncryptedState = (collectionData: CollectionData[] | null) => + stateProvider.setUserState( + ENCRYPTED_COLLECTION_DATA_KEY, + collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])), + userId, + ); + + const setDecryptedState = (collectionViews: CollectionView[] | null) => + stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collectionViews, userId); }); -const mockI18nService = () => { - const i18nService = mock(); - i18nService.collator = null; // this is a mock only, avoid use of this object - return i18nService; -}; - -const mockStateProvider = () => { - const userId = Utils.newGuid() as UserId; - return new FakeStateProvider(mockAccountServiceWith(userId)); -}; - -const mockCryptoService = () => { - const keyService = mock(); - const encryptService = mock(); - encryptService.decryptString - .calledWith(expect.any(EncString), expect.anything()) - .mockResolvedValue("DECRYPTED_STRING"); - - (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); - - return keyService; -}; - -const collectionDataFactory = (orgId: OrganizationId) => { +const collectionDataFactory = (orgId?: OrganizationId) => { const collection = new CollectionData({} as any); collection.id = Utils.newGuid() as CollectionId; - collection.organizationId = orgId; - collection.name = makeEncString("ENC_STRING").encryptedString; + collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId); + collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString ?? ""; return collection; }; + +function collectionViewDataFactory(orgId?: OrganizationId): CollectionView { + const collectionView = new CollectionView(); + collectionView.id = Utils.newGuid() as CollectionId; + collectionView.organizationId = orgId ?? (Utils.newGuid() as OrganizationId); + collectionView.name = "DEC_NAME_" + collectionView.id; + return collectionView; +} diff --git a/libs/admin-console/src/common/collections/services/default-collection.service.ts b/libs/admin-console/src/common/collections/services/default-collection.service.ts index a1dd0419e2c..4978b06df35 100644 --- a/libs/admin-console/src/common/collections/services/default-collection.service.ts +++ b/libs/admin-console/src/common/collections/services/default-collection.service.ts @@ -1,113 +1,193 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs"; -import { Jsonify } from "type-fest"; +import { + combineLatest, + delayWhen, + filter, + firstValueFrom, + from, + map, + NEVER, + Observable, + of, + shareReplay, + switchMap, +} from "rxjs"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { - ActiveUserState, - COLLECTION_DATA, - DeriveDefinition, - DerivedState, - StateProvider, - UserKeyDefinition, -} from "@bitwarden/common/platform/state"; +import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { KeyService } from "@bitwarden/key-management"; -import { CollectionService } from "../abstractions"; +import { CollectionService } from "../abstractions/collection.service"; import { Collection, CollectionData, CollectionView } from "../models"; -export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record( - COLLECTION_DATA, - "collections", - { - deserializer: (jsonData: Jsonify) => CollectionData.fromJSON(jsonData), - clearOn: ["logout"], - }, -); - -const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition< - [Record, Record], - CollectionView[], - { collectionService: DefaultCollectionService } ->(COLLECTION_DATA, "decryptedCollections", { - deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)), - derive: async ([collections, orgKeys], { collectionService }) => { - if (collections == null) { - return []; - } - - const data = Object.values(collections).map((c) => new Collection(c)); - return await collectionService.decryptMany(data, orgKeys); - }, -}); +import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state"; const NestingDelimiter = "/"; export class DefaultCollectionService implements CollectionService { - private encryptedCollectionDataState: ActiveUserState>; - encryptedCollections$: Observable; - private decryptedCollectionDataState: DerivedState; - decryptedCollections$: Observable; - - decryptedCollectionViews$(ids: CollectionId[]): Observable { - return this.decryptedCollections$.pipe( - map((collections) => collections.filter((c) => ids.includes(c.id as CollectionId))), - ); - } - constructor( private keyService: KeyService, private encryptService: EncryptService, private i18nService: I18nService, protected stateProvider: StateProvider, - ) { - this.encryptedCollectionDataState = this.stateProvider.getActive(ENCRYPTED_COLLECTION_DATA_KEY); + ) {} - this.encryptedCollections$ = this.encryptedCollectionDataState.state$.pipe( + private collectionViewCache = new Map>(); + + /** + * @returns a SingleUserState for encrypted collection data. + */ + private encryptedState( + userId: UserId, + ): SingleUserState> { + return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY); + } + + /** + * @returns a SingleUserState for decrypted collection data. + */ + private decryptedState(userId: UserId): SingleUserState { + return this.stateProvider.getUser(userId, DECRYPTED_COLLECTION_DATA_KEY); + } + + encryptedCollections$(userId: UserId): Observable { + return this.encryptedState(userId).state$.pipe( map((collections) => { if (collections == null) { - return []; + return null; } return Object.values(collections).map((c) => new Collection(c)); }), ); + } - const encryptedCollectionsWithKeys = this.encryptedCollectionDataState.combinedState$.pipe( - switchMap(([userId, collectionData]) => - combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]), + decryptedCollections$(userId: UserId): Observable { + const cachedResult = this.collectionViewCache.get(userId); + if (cachedResult) { + return cachedResult; + } + + const result$ = this.decryptedState(userId).state$.pipe( + switchMap((decryptedState) => { + // If decrypted state is already populated, return that + if (decryptedState !== null) { + return of(decryptedState ?? []); + } + + return this.initializeDecryptedState(userId).pipe(switchMap(() => NEVER)); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.collectionViewCache.set(userId, result$); + return result$; + } + + private initializeDecryptedState(userId: UserId): Observable { + return combineLatest([ + this.encryptedCollections$(userId), + this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => !!orgKeys)), + ]).pipe( + switchMap(([collections, orgKeys]) => + this.decryptMany$(collections, orgKeys).pipe( + delayWhen((collections) => this.setDecryptedCollections(collections, userId)), + ), ), - shareReplay({ refCount: false, bufferSize: 1 }), ); - - this.decryptedCollectionDataState = this.stateProvider.getDerived( - encryptedCollectionsWithKeys, - DECRYPTED_COLLECTION_DATA_KEY, - { collectionService: this }, - ); - - this.decryptedCollections$ = this.decryptedCollectionDataState.state$; } - async clearActiveUserCache(): Promise { - await this.decryptedCollectionDataState.forceValue(null); + async upsert(toUpdate: CollectionData, userId: UserId): Promise { + if (toUpdate == null) { + return; + } + await this.encryptedState(userId).update((collections) => { + if (collections == null) { + collections = {}; + } + collections[toUpdate.id] = toUpdate; + + return collections; + }); + + const decryptedCollections = await firstValueFrom( + this.keyService.orgKeys$(userId).pipe( + switchMap((orgKeys) => { + if (!orgKeys) { + throw new Error("No key for this collection's organization."); + } + return this.decryptMany$([new Collection(toUpdate)], orgKeys); + }), + ), + ); + + await this.decryptedState(userId).update((collections) => { + if (collections == null) { + collections = []; + } + + if (!decryptedCollections?.length) { + return collections; + } + + const decryptedCollection = decryptedCollections[0]; + const existingIndex = collections.findIndex((collection) => collection.id == toUpdate.id); + if (existingIndex >= 0) { + collections[existingIndex] = decryptedCollection; + } else { + collections.push(decryptedCollection); + } + + return collections; + }); } - async encrypt(model: CollectionView): Promise { + async replace(collections: Record, userId: UserId): Promise { + await this.encryptedState(userId).update(() => collections); + await this.decryptedState(userId).update(() => null); + } + + async delete(ids: CollectionId[], userId: UserId): Promise { + await this.encryptedState(userId).update((collections) => { + if (collections == null) { + collections = {}; + } + ids.forEach((i) => { + delete collections[i]; + }); + return collections; + }); + + await this.decryptedState(userId).update((collections) => { + if (collections == null) { + collections = []; + } + ids.forEach((i) => { + if (collections?.length) { + collections = collections.filter((c) => c.id != i) ?? []; + } + }); + return collections; + }); + } + + async encrypt(model: CollectionView, userId: UserId): Promise { if (model.organizationId == null) { throw new Error("Collection has no organization id."); } - const key = await this.keyService.getOrgKey(model.organizationId); - if (key == null) { - throw new Error("No key for this collection's organization."); - } + + const key = await firstValueFrom( + this.keyService.orgKeys$(userId).pipe( + filter((orgKeys) => !!orgKeys), + map((k) => k[model.organizationId as OrganizationId]), + ), + ); + const collection = new Collection(); collection.id = model.id; collection.organizationId = model.organizationId; @@ -117,58 +197,37 @@ export class DefaultCollectionService implements CollectionService { return collection; } - // TODO: this should be private and orgKeys should be required. + // TODO: this should be private. // See https://bitwarden.atlassian.net/browse/PM-12375 - async decryptMany( - collections: Collection[], - orgKeys?: Record, - ): Promise { - if (collections == null || collections.length === 0) { - return []; + decryptMany$( + collections: Collection[] | null, + orgKeys: Record, + ): Observable { + if (collections === null || collections.length == 0 || orgKeys === null) { + return of([]); } - const decCollections: CollectionView[] = []; - orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$); + const decCollections: Observable[] = []; - const promises: Promise[] = []; collections.forEach((collection) => { - promises.push( - collection - .decrypt(orgKeys[collection.organizationId as OrganizationId]) - .then((c) => decCollections.push(c)), + decCollections.push( + from(collection.decrypt(orgKeys[collection.organizationId as OrganizationId])), ); }); - await Promise.all(promises); - return decCollections.sort(Utils.getSortFunction(this.i18nService, "name")); - } - async get(id: string): Promise { - return ( - (await firstValueFrom( - this.encryptedCollections$.pipe(map((cs) => cs.find((c) => c.id === id))), - )) ?? null + return combineLatest(decCollections).pipe( + map((collections) => collections.sort(Utils.getSortFunction(this.i18nService, "name"))), ); } - async getAll(): Promise { - return await firstValueFrom(this.encryptedCollections$); - } - - async getAllDecrypted(): Promise { - return await firstValueFrom(this.decryptedCollections$); - } - - async getAllNested(collections: CollectionView[] = null): Promise[]> { - if (collections == null) { - collections = await this.getAllDecrypted(); - } + getAllNested(collections: CollectionView[]): TreeNode[] { const nodes: TreeNode[] = []; collections.forEach((c) => { const collectionCopy = new CollectionView(); collectionCopy.id = c.id; collectionCopy.organizationId = c.organizationId; const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; - ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); + ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter); }); return nodes; } @@ -177,58 +236,23 @@ export class DefaultCollectionService implements CollectionService { * @deprecated August 30 2022: Moved to new Vault Filter Service * Remove when Desktop and Browser are updated */ - async getNested(id: string): Promise> { - const collections = await this.getAllNested(); - return ServiceUtils.getTreeNodeObjectFromList(collections, id) as TreeNode; + getNested(collections: CollectionView[], id: string): TreeNode { + const nestedCollections = this.getAllNested(collections); + return ServiceUtils.getTreeNodeObjectFromList( + nestedCollections, + id, + ) as TreeNode; } - async upsert(toUpdate: CollectionData | CollectionData[]): Promise { - if (toUpdate == null) { - return; - } - await this.encryptedCollectionDataState.update((collections) => { - if (collections == null) { - collections = {}; - } - if (Array.isArray(toUpdate)) { - toUpdate.forEach((c) => { - collections[c.id] = c; - }); - } else { - collections[toUpdate.id] = toUpdate; - } - return collections; - }); - } - - async replace(collections: Record, userId: UserId): Promise { - await this.stateProvider - .getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY) - .update(() => collections); - } - - async clear(userId?: UserId): Promise { - if (userId == null) { - await this.encryptedCollectionDataState.update(() => null); - await this.decryptedCollectionDataState.forceValue(null); - } else { - await this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY).update(() => null); - } - } - - async delete(id: CollectionId | CollectionId[]): Promise { - await this.encryptedCollectionDataState.update((collections) => { - if (collections == null) { - collections = {}; - } - if (typeof id === "string") { - delete collections[id]; - } else { - (id as CollectionId[]).forEach((i) => { - delete collections[i]; - }); - } - return collections; - }); + /** + * Sets the decrypted collections state for a user. + * @param collections the decrypted collections + * @param userId the user id + */ + private async setDecryptedCollections( + collections: CollectionView[], + userId: UserId, + ): Promise { + await this.stateProvider.setUserState(DECRYPTED_COLLECTION_DATA_KEY, collections, userId); } } diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts deleted file mode 100644 index 256157a03c1..00000000000 --- a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { mock, MockProxy } from "jest-mock-extended"; -import { first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs"; - -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { ContainerService } from "@bitwarden/common/platform/services/container.service"; -import { - FakeStateProvider, - makeEncString, - makeSymmetricCryptoKey, - mockAccountServiceWith, -} from "@bitwarden/common/spec"; -import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { OrgKey } from "@bitwarden/common/types/key"; -import { KeyService } from "@bitwarden/key-management"; - -import { CollectionData } from "../models"; - -import { DefaultvNextCollectionService } from "./default-vnext-collection.service"; -import { ENCRYPTED_COLLECTION_DATA_KEY } from "./vnext-collection.state"; - -describe("DefaultvNextCollectionService", () => { - let keyService: MockProxy; - let encryptService: MockProxy; - let i18nService: MockProxy; - let stateProvider: FakeStateProvider; - - let userId: UserId; - - let cryptoKeys: ReplaySubject | null>; - - let collectionService: DefaultvNextCollectionService; - - beforeEach(() => { - userId = Utils.newGuid() as UserId; - - keyService = mock(); - encryptService = mock(); - i18nService = mock(); - stateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); - - cryptoKeys = new ReplaySubject(1); - keyService.orgKeys$.mockReturnValue(cryptoKeys); - - // Set up mock decryption - encryptService.decryptString - .calledWith(expect.any(EncString), expect.any(SymmetricCryptoKey)) - .mockImplementation((encString, key) => - Promise.resolve(encString.data.replace("ENC_", "DEC_")), - ); - - (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); - - // Arrange i18nService so that sorting algorithm doesn't throw - i18nService.collator = null; - - collectionService = new DefaultvNextCollectionService( - keyService, - encryptService, - i18nService, - stateProvider, - ); - }); - - afterEach(() => { - delete (window as any).bitwardenContainerService; - }); - - describe("decryptedCollections$", () => { - it("emits decrypted collections from state", async () => { - // Arrange test data - const org1 = Utils.newGuid() as OrganizationId; - const orgKey1 = makeSymmetricCryptoKey(64, 1); - const collection1 = collectionDataFactory(org1); - - const org2 = Utils.newGuid() as OrganizationId; - const orgKey2 = makeSymmetricCryptoKey(64, 2); - const collection2 = collectionDataFactory(org2); - - // Arrange dependencies - await setEncryptedState([collection1, collection2]); - cryptoKeys.next({ - [org1]: orgKey1, - [org2]: orgKey2, - }); - - const result = await firstValueFrom(collectionService.decryptedCollections$(userId)); - - // Assert emitted values - expect(result.length).toBe(2); - expect(result).toContainPartialObjects([ - { - id: collection1.id, - name: "DEC_NAME_" + collection1.id, - }, - { - id: collection2.id, - name: "DEC_NAME_" + collection2.id, - }, - ]); - - // Assert that the correct org keys were used for each encrypted string - // This should be replaced with decryptString when the platform PR (https://github.com/bitwarden/clients/pull/14544) is merged - expect(encryptService.decryptString).toHaveBeenCalledWith( - expect.objectContaining(new EncString(collection1.name)), - orgKey1, - ); - expect(encryptService.decryptString).toHaveBeenCalledWith( - expect.objectContaining(new EncString(collection2.name)), - orgKey2, - ); - }); - - it("handles null collection state", async () => { - // Arrange dependencies - await setEncryptedState(null); - cryptoKeys.next({}); - - const encryptedCollections = await firstValueFrom( - collectionService.encryptedCollections$(userId), - ); - - expect(encryptedCollections.length).toBe(0); - }); - - it("handles undefined orgKeys", (done) => { - // Arrange test data - const org1 = Utils.newGuid() as OrganizationId; - const collection1 = collectionDataFactory(org1); - - const org2 = Utils.newGuid() as OrganizationId; - const collection2 = collectionDataFactory(org2); - - // Emit a non-null value after the first undefined value has propagated - // This will cause the collections to emit, calling done() - cryptoKeys.pipe(first()).subscribe((val) => { - cryptoKeys.next({}); - }); - - collectionService - .decryptedCollections$(userId) - .pipe(takeWhile((val) => val.length != 2)) - .subscribe({ complete: () => done() }); - - // Arrange dependencies - void setEncryptedState([collection1, collection2]).then(() => { - // Act: emit undefined - cryptoKeys.next(undefined); - keyService.activeUserOrgKeys$ = of(undefined); - }); - }); - }); - - describe("encryptedCollections$", () => { - it("emits encrypted collections from state", async () => { - // Arrange test data - const collection1 = collectionDataFactory(); - const collection2 = collectionDataFactory(); - - // Arrange dependencies - await setEncryptedState([collection1, collection2]); - - const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); - - expect(result.length).toBe(2); - expect(result).toContainPartialObjects([ - { - id: collection1.id, - name: makeEncString("ENC_NAME_" + collection1.id), - }, - { - id: collection2.id, - name: makeEncString("ENC_NAME_" + collection2.id), - }, - ]); - }); - - it("handles null collection state", async () => { - await setEncryptedState(null); - - const decryptedCollections = await firstValueFrom( - collectionService.encryptedCollections$(userId), - ); - expect(decryptedCollections.length).toBe(0); - }); - }); - - describe("upsert", () => { - it("upserts to existing collections", async () => { - const collection1 = collectionDataFactory(); - const collection2 = collectionDataFactory(); - - await setEncryptedState([collection1, collection2]); - - const updatedCollection1 = Object.assign(new CollectionData({} as any), collection1, { - name: makeEncString("UPDATED_ENC_NAME_" + collection1.id).encryptedString, - }); - const newCollection3 = collectionDataFactory(); - - await collectionService.upsert([updatedCollection1, newCollection3], userId); - - const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); - expect(result.length).toBe(3); - expect(result).toContainPartialObjects([ - { - id: collection1.id, - name: makeEncString("UPDATED_ENC_NAME_" + collection1.id), - }, - { - id: collection2.id, - name: makeEncString("ENC_NAME_" + collection2.id), - }, - { - id: newCollection3.id, - name: makeEncString("ENC_NAME_" + newCollection3.id), - }, - ]); - }); - - it("upserts to a null state", async () => { - const collection1 = collectionDataFactory(); - - await setEncryptedState(null); - - await collectionService.upsert(collection1, userId); - - const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); - expect(result.length).toBe(1); - expect(result).toContainPartialObjects([ - { - id: collection1.id, - name: makeEncString("ENC_NAME_" + collection1.id), - }, - ]); - }); - }); - - describe("replace", () => { - it("replaces all collections", async () => { - await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); - - const newCollection3 = collectionDataFactory(); - await collectionService.replace( - { - [newCollection3.id]: newCollection3, - }, - userId, - ); - - const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); - expect(result.length).toBe(1); - expect(result).toContainPartialObjects([ - { - id: newCollection3.id, - name: makeEncString("ENC_NAME_" + newCollection3.id), - }, - ]); - }); - }); - - it("clearDecryptedState", async () => { - await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); - - await collectionService.clearDecryptedState(userId); - - // Encrypted state remains - const encryptedState = await firstValueFrom(collectionService.encryptedCollections$(userId)); - expect(encryptedState.length).toEqual(2); - - // Decrypted state is cleared - const decryptedState = await firstValueFrom(collectionService.decryptedCollections$(userId)); - expect(decryptedState.length).toEqual(0); - }); - - it("clear", async () => { - await setEncryptedState([collectionDataFactory(), collectionDataFactory()]); - cryptoKeys.next({}); - - await collectionService.clear(userId); - - // Encrypted state is cleared - const encryptedState = await firstValueFrom(collectionService.encryptedCollections$(userId)); - expect(encryptedState.length).toEqual(0); - - // Decrypted state is cleared - const decryptedState = await firstValueFrom(collectionService.decryptedCollections$(userId)); - expect(decryptedState.length).toEqual(0); - }); - - describe("delete", () => { - it("deletes a collection", async () => { - const collection1 = collectionDataFactory(); - const collection2 = collectionDataFactory(); - await setEncryptedState([collection1, collection2]); - - await collectionService.delete(collection1.id, userId); - - const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); - expect(result.length).toEqual(1); - expect(result[0]).toMatchObject({ id: collection2.id }); - }); - - it("deletes several collections", async () => { - const collection1 = collectionDataFactory(); - const collection2 = collectionDataFactory(); - const collection3 = collectionDataFactory(); - await setEncryptedState([collection1, collection2, collection3]); - - await collectionService.delete([collection1.id, collection3.id], userId); - - const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); - expect(result.length).toEqual(1); - expect(result[0]).toMatchObject({ id: collection2.id }); - }); - - it("handles null collections", async () => { - const collection1 = collectionDataFactory(); - await setEncryptedState(null); - - await collectionService.delete(collection1.id, userId); - - const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); - expect(result.length).toEqual(0); - }); - }); - - const setEncryptedState = (collectionData: CollectionData[] | null) => - stateProvider.setUserState( - ENCRYPTED_COLLECTION_DATA_KEY, - collectionData == null ? null : Object.fromEntries(collectionData.map((c) => [c.id, c])), - userId, - ); -}); - -const collectionDataFactory = (orgId?: OrganizationId) => { - const collection = new CollectionData({} as any); - collection.id = Utils.newGuid() as CollectionId; - collection.organizationId = orgId ?? (Utils.newGuid() as OrganizationId); - collection.name = makeEncString("ENC_NAME_" + collection.id).encryptedString; - - return collection; -}; diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts deleted file mode 100644 index 4dcda795afe..00000000000 --- a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts +++ /dev/null @@ -1,194 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { combineLatest, filter, firstValueFrom, map } from "rxjs"; - -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { StateProvider, DerivedState } from "@bitwarden/common/platform/state"; -import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { OrgKey } from "@bitwarden/common/types/key"; -import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; -import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; -import { KeyService } from "@bitwarden/key-management"; - -import { vNextCollectionService } from "../abstractions/vnext-collection.service"; -import { Collection, CollectionData, CollectionView } from "../models"; - -import { - DECRYPTED_COLLECTION_DATA_KEY, - ENCRYPTED_COLLECTION_DATA_KEY, -} from "./vnext-collection.state"; - -const NestingDelimiter = "/"; - -export class DefaultvNextCollectionService implements vNextCollectionService { - constructor( - private keyService: KeyService, - private encryptService: EncryptService, - private i18nService: I18nService, - protected stateProvider: StateProvider, - ) {} - - encryptedCollections$(userId: UserId) { - return this.encryptedState(userId).state$.pipe( - map((collections) => { - if (collections == null) { - return []; - } - - return Object.values(collections).map((c) => new Collection(c)); - }), - ); - } - - decryptedCollections$(userId: UserId) { - return this.decryptedState(userId).state$.pipe(map((collections) => collections ?? [])); - } - - async upsert(toUpdate: CollectionData | CollectionData[], userId: UserId): Promise { - if (toUpdate == null) { - return; - } - await this.encryptedState(userId).update((collections) => { - if (collections == null) { - collections = {}; - } - if (Array.isArray(toUpdate)) { - toUpdate.forEach((c) => { - collections[c.id] = c; - }); - } else { - collections[toUpdate.id] = toUpdate; - } - return collections; - }); - } - - async replace(collections: Record, userId: UserId): Promise { - await this.encryptedState(userId).update(() => collections); - } - - async clearDecryptedState(userId: UserId): Promise { - if (userId == null) { - throw new Error("User ID is required."); - } - - await this.decryptedState(userId).forceValue([]); - } - - async clear(userId: UserId): Promise { - await this.encryptedState(userId).update(() => null); - // This will propagate from the encrypted state update, but by doing it explicitly - // the promise doesn't resolve until the update is complete. - await this.decryptedState(userId).forceValue([]); - } - - async delete(id: CollectionId | CollectionId[], userId: UserId): Promise { - await this.encryptedState(userId).update((collections) => { - if (collections == null) { - collections = {}; - } - if (typeof id === "string") { - delete collections[id]; - } else { - (id as CollectionId[]).forEach((i) => { - delete collections[i]; - }); - } - return collections; - }); - } - - async encrypt(model: CollectionView): Promise { - if (model.organizationId == null) { - throw new Error("Collection has no organization id."); - } - const key = await this.keyService.getOrgKey(model.organizationId); - if (key == null) { - throw new Error("No key for this collection's organization."); - } - const collection = new Collection(); - collection.id = model.id; - collection.organizationId = model.organizationId; - collection.readOnly = model.readOnly; - collection.externalId = model.externalId; - collection.name = await this.encryptService.encryptString(model.name, key); - return collection; - } - - // TODO: this should be private and orgKeys should be required. - // See https://bitwarden.atlassian.net/browse/PM-12375 - async decryptMany( - collections: Collection[], - orgKeys?: Record | null, - ): Promise { - if (collections == null || collections.length === 0) { - return []; - } - const decCollections: CollectionView[] = []; - - orgKeys ??= await firstValueFrom(this.keyService.activeUserOrgKeys$); - - const promises: Promise[] = []; - collections.forEach((collection) => { - promises.push( - collection - .decrypt(orgKeys[collection.organizationId as OrganizationId]) - .then((c) => decCollections.push(c)), - ); - }); - await Promise.all(promises); - return decCollections.sort(Utils.getSortFunction(this.i18nService, "name")); - } - - getAllNested(collections: CollectionView[]): TreeNode[] { - const nodes: TreeNode[] = []; - collections.forEach((c) => { - const collectionCopy = new CollectionView(); - collectionCopy.id = c.id; - collectionCopy.organizationId = c.organizationId; - const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; - ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter); - }); - return nodes; - } - - /** - * @deprecated August 30 2022: Moved to new Vault Filter Service - * Remove when Desktop and Browser are updated - */ - getNested(collections: CollectionView[], id: string): TreeNode { - const nestedCollections = this.getAllNested(collections); - return ServiceUtils.getTreeNodeObjectFromList( - nestedCollections, - id, - ) as TreeNode; - } - - /** - * @returns a SingleUserState for encrypted collection data. - */ - private encryptedState(userId: UserId) { - return this.stateProvider.getUser(userId, ENCRYPTED_COLLECTION_DATA_KEY); - } - - /** - * @returns a SingleUserState for decrypted collection data. - */ - private decryptedState(userId: UserId): DerivedState { - const encryptedCollectionsWithKeys$ = combineLatest([ - this.encryptedCollections$(userId), - // orgKeys$ can emit null during brief moments on unlock and lock/logout, we want to ignore those intermediate states - this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)), - ]); - - return this.stateProvider.getDerived( - encryptedCollectionsWithKeys$, - DECRYPTED_COLLECTION_DATA_KEY, - { - collectionService: this, - }, - ); - } -} diff --git a/libs/admin-console/src/common/collections/services/vnext-collection.state.ts b/libs/admin-console/src/common/collections/services/vnext-collection.state.ts deleted file mode 100644 index 331c80436f7..00000000000 --- a/libs/admin-console/src/common/collections/services/vnext-collection.state.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Jsonify } from "type-fest"; - -import { - COLLECTION_DATA, - DeriveDefinition, - UserKeyDefinition, -} from "@bitwarden/common/platform/state"; -import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; -import { OrgKey } from "@bitwarden/common/types/key"; - -import { vNextCollectionService } from "../abstractions/vnext-collection.service"; -import { Collection, CollectionData, CollectionView } from "../models"; - -export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record( - COLLECTION_DATA, - "collections", - { - deserializer: (jsonData: Jsonify) => CollectionData.fromJSON(jsonData), - clearOn: ["logout"], - }, -); - -export const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition< - [Collection[], Record | null], - CollectionView[], - { collectionService: vNextCollectionService } ->(COLLECTION_DATA, "decryptedCollections", { - deserializer: (obj) => obj.map((collection) => CollectionView.fromJSON(collection)), - derive: async ([collections, orgKeys], { collectionService }) => { - if (collections == null) { - return []; - } - - return await collectionService.decryptMany(collections, orgKeys); - }, -}); diff --git a/libs/angular/src/auth/components/change-password.component.ts b/libs/angular/src/auth/components/change-password.component.ts deleted file mode 100644 index 8a564d68fd4..00000000000 --- a/libs/angular/src/auth/components/change-password.component.ts +++ /dev/null @@ -1,232 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, OnDestroy, OnInit } from "@angular/core"; -import { Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs"; - -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { UserKey, MasterKey } from "@bitwarden/common/types/key"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management"; - -import { PasswordColorText } from "../../tools/password-strength/password-strength.component"; - -@Directive() -export class ChangePasswordComponent implements OnInit, OnDestroy { - masterPassword: string; - masterPasswordRetype: string; - formPromise: Promise; - enforcedPolicyOptions: MasterPasswordPolicyOptions; - passwordStrengthResult: any; - color: string; - text: string; - leakedPassword: boolean; - minimumLength = Utils.minimumPasswordLength; - - protected email: string; - protected kdfConfig: KdfConfig; - - protected destroy$ = new Subject(); - - constructor( - protected accountService: AccountService, - protected dialogService: DialogService, - protected i18nService: I18nService, - protected kdfConfigService: KdfConfigService, - protected keyService: KeyService, - protected masterPasswordService: InternalMasterPasswordServiceAbstraction, - protected messagingService: MessagingService, - protected platformUtilsService: PlatformUtilsService, - protected policyService: PolicyService, - protected toastService: ToastService, - ) {} - - async ngOnInit() { - this.email = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.email)), - ); - this.accountService.activeAccount$ - .pipe( - getUserId, - switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)), - takeUntil(this.destroy$), - ) - .subscribe( - (enforcedPasswordPolicyOptions) => - (this.enforcedPolicyOptions ??= enforcedPasswordPolicyOptions), - ); - - if (this.enforcedPolicyOptions?.minLength) { - this.minimumLength = this.enforcedPolicyOptions.minLength; - } - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - async submit() { - if (!(await this.strongPassword())) { - return; - } - - if (!(await this.setupSubmitActions())) { - return; - } - - const [userId, email] = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])), - ); - - if (this.kdfConfig == null) { - this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId); - } - - // Create new master key - const newMasterKey = await this.keyService.makeMasterKey( - this.masterPassword, - email.trim().toLowerCase(), - this.kdfConfig, - ); - const newMasterKeyHash = await this.keyService.hashMasterKey(this.masterPassword, newMasterKey); - - let newProtectedUserKey: [UserKey, EncString] = null; - const userKey = await this.keyService.getUserKey(); - if (userKey == null) { - newProtectedUserKey = await this.keyService.makeUserKey(newMasterKey); - } else { - newProtectedUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey); - } - - await this.performSubmitActions(newMasterKeyHash, newMasterKey, newProtectedUserKey); - } - - async setupSubmitActions(): Promise { - // Override in sub-class - // Can be used for additional validation and/or other processes the should occur before changing passwords - return true; - } - - async performSubmitActions( - newMasterKeyHash: string, - newMasterKey: MasterKey, - newUserKey: [UserKey, EncString], - ) { - // Override in sub-class - } - - async strongPassword(): Promise { - if (this.masterPassword == null || this.masterPassword === "") { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("masterPasswordRequired"), - }); - return false; - } - if (this.masterPassword.length < this.minimumLength) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("masterPasswordMinimumlength", this.minimumLength), - }); - return false; - } - if (this.masterPassword !== this.masterPasswordRetype) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("masterPassDoesntMatch"), - }); - return false; - } - - const strengthResult = this.passwordStrengthResult; - - if ( - this.enforcedPolicyOptions != null && - !this.policyService.evaluateMasterPassword( - strengthResult.score, - this.masterPassword, - this.enforcedPolicyOptions, - ) - ) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"), - }); - return false; - } - - const weakPassword = strengthResult != null && strengthResult.score < 3; - - if (weakPassword && this.leakedPassword) { - const result = await this.dialogService.openSimpleDialog({ - title: { key: "weakAndExposedMasterPassword" }, - content: { key: "weakAndBreachedMasterPasswordDesc" }, - type: "warning", - }); - - if (!result) { - return false; - } - } else { - if (weakPassword) { - const result = await this.dialogService.openSimpleDialog({ - title: { key: "weakMasterPassword" }, - content: { key: "weakMasterPasswordDesc" }, - type: "warning", - }); - - if (!result) { - return false; - } - } - if (this.leakedPassword) { - const result = await this.dialogService.openSimpleDialog({ - title: { key: "exposedMasterPassword" }, - content: { key: "exposedMasterPasswordDesc" }, - type: "warning", - }); - - if (!result) { - return false; - } - } - } - - return true; - } - - async logOut() { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "logOut" }, - content: { key: "logOutConfirmation" }, - acceptButtonText: { key: "logOut" }, - type: "warning", - }); - - if (confirmed) { - this.messagingService.send("logout"); - } - } - - getStrengthResult(result: any) { - this.passwordStrengthResult = result; - } - - getPasswordScoreText(event: PasswordColorText) { - this.color = event.color; - this.text = event.text; - } -} diff --git a/libs/angular/src/auth/components/set-password.component.ts b/libs/angular/src/auth/components/set-password.component.ts deleted file mode 100644 index 03cbdbc625a..00000000000 --- a/libs/angular/src/auth/components/set-password.component.ts +++ /dev/null @@ -1,300 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom, of } from "rxjs"; -import { filter, first, switchMap, tap } from "rxjs/operators"; - -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { - OrganizationUserApiService, - OrganizationUserResetPasswordEnrollmentRequest, -} from "@bitwarden/admin-console/common"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { OrganizationAutoEnrollStatusResponse } from "@bitwarden/common/admin-console/models/response/organization-auto-enroll-status.response"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { HashPurpose } from "@bitwarden/common/platform/enums"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { UserId } from "@bitwarden/common/types/guid"; -import { MasterKey, UserKey } from "@bitwarden/common/types/key"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; - -import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component"; - -@Directive() -export class SetPasswordComponent extends BaseChangePasswordComponent implements OnInit { - syncLoading = true; - showPassword = false; - hint = ""; - orgSsoIdentifier: string = null; - orgId: string; - resetPasswordAutoEnroll = false; - onSuccessfulChangePassword: () => Promise; - successRoute = "vault"; - activeUserId: UserId; - - forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None; - ForceSetPasswordReason = ForceSetPasswordReason; - - constructor( - protected accountService: AccountService, - protected dialogService: DialogService, - protected encryptService: EncryptService, - protected i18nService: I18nService, - protected kdfConfigService: KdfConfigService, - protected keyService: KeyService, - protected masterPasswordApiService: MasterPasswordApiService, - protected masterPasswordService: InternalMasterPasswordServiceAbstraction, - protected messagingService: MessagingService, - protected organizationApiService: OrganizationApiServiceAbstraction, - protected organizationUserApiService: OrganizationUserApiService, - protected platformUtilsService: PlatformUtilsService, - protected policyApiService: PolicyApiServiceAbstraction, - protected policyService: PolicyService, - protected route: ActivatedRoute, - protected router: Router, - protected ssoLoginService: SsoLoginServiceAbstraction, - protected syncService: SyncService, - protected toastService: ToastService, - protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, - ) { - super( - accountService, - dialogService, - i18nService, - kdfConfigService, - keyService, - masterPasswordService, - messagingService, - platformUtilsService, - policyService, - toastService, - ); - } - - async ngOnInit() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - super.ngOnInit(); - - await this.syncService.fullSync(true); - this.syncLoading = false; - - this.activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - - this.forceSetPasswordReason = await firstValueFrom( - this.masterPasswordService.forceSetPasswordReason$(this.activeUserId), - ); - - this.route.queryParams - .pipe( - first(), - switchMap((qParams) => { - if (qParams.identifier != null) { - return of(qParams.identifier); - } else { - // Try to get orgSsoId from state as fallback - // Note: this is primarily for the TDE user w/out MP obtains admin MP reset permission scenario. - return this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(this.activeUserId); - } - }), - filter((orgSsoId) => orgSsoId != null), - tap((orgSsoId: string) => { - this.orgSsoIdentifier = orgSsoId; - }), - switchMap((orgSsoId: string) => this.organizationApiService.getAutoEnrollStatus(orgSsoId)), - tap((orgAutoEnrollStatusResponse: OrganizationAutoEnrollStatusResponse) => { - this.orgId = orgAutoEnrollStatusResponse.id; - this.resetPasswordAutoEnroll = orgAutoEnrollStatusResponse.resetPasswordEnabled; - }), - switchMap((orgAutoEnrollStatusResponse: OrganizationAutoEnrollStatusResponse) => - // Must get org id from response to get master password policy options - this.policyApiService.getMasterPasswordPolicyOptsForOrgUser( - orgAutoEnrollStatusResponse.id, - ), - ), - tap((masterPasswordPolicyOptions: MasterPasswordPolicyOptions) => { - this.enforcedPolicyOptions = masterPasswordPolicyOptions; - }), - ) - .subscribe({ - error: () => { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("errorOccurred"), - }); - }, - }); - } - - async setupSubmitActions() { - this.kdfConfig = DEFAULT_KDF_CONFIG; - return true; - } - - async performSubmitActions( - masterPasswordHash: string, - masterKey: MasterKey, - userKey: [UserKey, EncString], - ) { - let keysRequest: KeysRequest | null = null; - let newKeyPair: [string, EncString] | null = null; - - if ( - this.forceSetPasswordReason != - ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission - ) { - // Existing JIT provisioned user in a MP encryption org setting first password - // Users in this state will not already have a user asymmetric key pair so must create it for them - // We don't want to re-create the user key pair if the user already has one (TDE user case) - - // in case we have a local private key, and are not sure whether it has been posted to the server, we post the local private key instead of generating a new one - const existingUserPrivateKey = (await firstValueFrom( - this.keyService.userPrivateKey$(this.activeUserId), - )) as Uint8Array; - const existingUserPublicKey = await firstValueFrom( - this.keyService.userPublicKey$(this.activeUserId), - ); - if (existingUserPrivateKey != null && existingUserPublicKey != null) { - const existingUserPublicKeyB64 = Utils.fromBufferToB64(existingUserPublicKey); - newKeyPair = [ - existingUserPublicKeyB64, - await this.encryptService.wrapDecapsulationKey(existingUserPrivateKey, userKey[0]), - ]; - } else { - newKeyPair = await this.keyService.makeKeyPair(userKey[0]); - } - keysRequest = new KeysRequest(newKeyPair[0], newKeyPair[1].encryptedString); - } - - const request = new SetPasswordRequest( - masterPasswordHash, - userKey[1].encryptedString, - this.hint, - this.orgSsoIdentifier, - keysRequest, - this.kdfConfig.kdfType, //always PBKDF2 --> see this.setupSubmitActions - this.kdfConfig.iterations, - ); - try { - if (this.resetPasswordAutoEnroll) { - this.formPromise = this.masterPasswordApiService - .setPassword(request) - .then(async () => { - await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair); - return this.organizationApiService.getKeys(this.orgId); - }) - .then(async (response) => { - if (response == null) { - throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); - } - const publicKey = Utils.fromB64ToArray(response.publicKey); - - // RSA Encrypt user key with organization public key - const userKey = await this.keyService.getUserKey(); - const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned( - userKey, - publicKey, - ); - - const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest(); - resetRequest.masterPasswordHash = masterPasswordHash; - resetRequest.resetPasswordKey = encryptedUserKey.encryptedString; - - return this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment( - this.orgId, - this.activeUserId, - resetRequest, - ); - }); - } else { - this.formPromise = this.masterPasswordApiService.setPassword(request).then(async () => { - await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair); - }); - } - - await this.formPromise; - - if (this.onSuccessfulChangePassword != null) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.onSuccessfulChangePassword(); - } else { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate([this.successRoute]); - } - } catch { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("errorOccurred"), - }); - } - } - - togglePassword(confirmField: boolean) { - this.showPassword = !this.showPassword; - document.getElementById(confirmField ? "masterPasswordRetype" : "masterPassword").focus(); - } - - protected async onSetPasswordSuccess( - masterKey: MasterKey, - userKey: [UserKey, EncString], - keyPair: [string, EncString] | null, - ) { - // Clear force set password reason to allow navigation back to vault. - await this.masterPasswordService.setForceSetPasswordReason( - ForceSetPasswordReason.None, - this.activeUserId, - ); - - // User now has a password so update account decryption options in state - const userDecryptionOpts = await firstValueFrom( - this.userDecryptionOptionsService.userDecryptionOptions$, - ); - userDecryptionOpts.hasMasterPassword = true; - await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts); - await this.kdfConfigService.setKdfConfig(this.activeUserId, this.kdfConfig); - await this.masterPasswordService.setMasterKey(masterKey, this.activeUserId); - await this.keyService.setUserKey(userKey[0], this.activeUserId); - - // Set private key only for new JIT provisioned users in MP encryption orgs - // Existing TDE users will have private key set on sync or on login - if ( - keyPair !== null && - this.forceSetPasswordReason != - ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission - ) { - await this.keyService.setPrivateKey(keyPair[1].encryptedString, this.activeUserId); - } - - const localMasterKeyHash = await this.keyService.hashMasterKey( - this.masterPassword, - masterKey, - HashPurpose.LocalAuthorization, - ); - await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.activeUserId); - } -} diff --git a/libs/angular/src/auth/components/set-pin.component.ts b/libs/angular/src/auth/components/set-pin.component.ts index fba6ce2da86..9e351990fff 100644 --- a/libs/angular/src/auth/components/set-pin.component.ts +++ b/libs/angular/src/auth/components/set-pin.component.ts @@ -4,11 +4,9 @@ import { Directive, OnInit } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { firstValueFrom } from "rxjs"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogRef } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; diff --git a/libs/angular/src/auth/components/update-password.component.ts b/libs/angular/src/auth/components/update-password.component.ts deleted file mode 100644 index fa3ab58db69..00000000000 --- a/libs/angular/src/auth/components/update-password.component.ts +++ /dev/null @@ -1,141 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive } from "@angular/core"; -import { Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; - -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; -import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { Verification } from "@bitwarden/common/auth/types/verification"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { MasterKey, UserKey } from "@bitwarden/common/types/key"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { KdfConfigService, KeyService } from "@bitwarden/key-management"; - -import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component"; - -@Directive() -export class UpdatePasswordComponent extends BaseChangePasswordComponent { - hint: string; - key: string; - enforcedPolicyOptions: MasterPasswordPolicyOptions; - showPassword = false; - currentMasterPassword: string; - - onSuccessfulChangePassword: () => Promise; - - constructor( - protected router: Router, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - policyService: PolicyService, - keyService: KeyService, - messagingService: MessagingService, - private masterPasswordApiService: MasterPasswordApiService, - private userVerificationService: UserVerificationService, - private logService: LogService, - dialogService: DialogService, - kdfConfigService: KdfConfigService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - accountService: AccountService, - toastService: ToastService, - ) { - super( - accountService, - dialogService, - i18nService, - kdfConfigService, - keyService, - masterPasswordService, - messagingService, - platformUtilsService, - policyService, - toastService, - ); - } - - togglePassword(confirmField: boolean) { - this.showPassword = !this.showPassword; - document.getElementById(confirmField ? "masterPasswordRetype" : "masterPassword").focus(); - } - - async cancel() { - await this.router.navigate(["/vault"]); - } - - async setupSubmitActions(): Promise { - if (this.currentMasterPassword == null || this.currentMasterPassword === "") { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("masterPasswordRequired"), - }); - return false; - } - - const secret: Verification = { - type: VerificationType.MasterPassword, - secret: this.currentMasterPassword, - }; - try { - await this.userVerificationService.verifyUser(secret); - } catch (e) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: e.message, - }); - return false; - } - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId); - return true; - } - - async performSubmitActions( - newMasterKeyHash: string, - newMasterKey: MasterKey, - newUserKey: [UserKey, EncString], - ) { - try { - // Create Request - const request = new PasswordRequest(); - request.masterPasswordHash = await this.keyService.hashMasterKey( - this.currentMasterPassword, - await this.keyService.getOrDeriveMasterKey(this.currentMasterPassword), - ); - request.newMasterPasswordHash = newMasterKeyHash; - request.key = newUserKey[1].encryptedString; - - // Update user's password - await this.masterPasswordApiService.postPassword(request); - - this.toastService.showToast({ - variant: "success", - title: this.i18nService.t("masterPasswordChanged"), - message: this.i18nService.t("logBackIn"), - }); - - if (this.onSuccessfulChangePassword != null) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.onSuccessfulChangePassword(); - } else { - this.messagingService.send("logout"); - } - } catch (e) { - this.logService.error(e); - } - } -} diff --git a/libs/angular/src/auth/components/update-temp-password.component.ts b/libs/angular/src/auth/components/update-temp-password.component.ts deleted file mode 100644 index 681f69b083a..00000000000 --- a/libs/angular/src/auth/components/update-temp-password.component.ts +++ /dev/null @@ -1,232 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; -import { firstValueFrom, map } from "rxjs"; - -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; -import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request"; -import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request"; -import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { MasterKey, UserKey } from "@bitwarden/common/types/key"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { KdfConfigService, KeyService } from "@bitwarden/key-management"; - -import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component"; - -@Directive() -export class UpdateTempPasswordComponent extends BaseChangePasswordComponent implements OnInit { - hint: string; - key: string; - enforcedPolicyOptions: MasterPasswordPolicyOptions; - showPassword = false; - reason: ForceSetPasswordReason = ForceSetPasswordReason.None; - verification: MasterPasswordVerification = { - type: VerificationType.MasterPassword, - secret: "", - }; - - onSuccessfulChangePassword: () => Promise; - - get requireCurrentPassword(): boolean { - return this.reason === ForceSetPasswordReason.WeakMasterPassword; - } - - constructor( - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - policyService: PolicyService, - keyService: KeyService, - messagingService: MessagingService, - private masterPasswordApiService: MasterPasswordApiService, - private syncService: SyncService, - private logService: LogService, - private userVerificationService: UserVerificationService, - protected router: Router, - dialogService: DialogService, - kdfConfigService: KdfConfigService, - accountService: AccountService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - toastService: ToastService, - ) { - super( - accountService, - dialogService, - i18nService, - kdfConfigService, - keyService, - masterPasswordService, - messagingService, - platformUtilsService, - policyService, - toastService, - ); - } - - async ngOnInit() { - await this.syncService.fullSync(true); - - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - this.reason = await firstValueFrom(this.masterPasswordService.forceSetPasswordReason$(userId)); - - // If we somehow end up here without a reason, go back to the home page - if (this.reason == ForceSetPasswordReason.None) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/"]); - return; - } - - await super.ngOnInit(); - } - - get masterPasswordWarningText(): string { - if (this.reason == ForceSetPasswordReason.WeakMasterPassword) { - return this.i18nService.t("updateWeakMasterPasswordWarning"); - } else if (this.reason == ForceSetPasswordReason.TdeOffboarding) { - return this.i18nService.t("tdeDisabledMasterPasswordRequired"); - } else { - return this.i18nService.t("updateMasterPasswordWarning"); - } - } - - togglePassword(confirmField: boolean) { - this.showPassword = !this.showPassword; - document.getElementById(confirmField ? "masterPasswordRetype" : "masterPassword").focus(); - } - - async setupSubmitActions(): Promise { - const [userId, email] = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])), - ); - this.email = email; - this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId); - return true; - } - - async submit() { - // Validation - if (!(await this.strongPassword())) { - return; - } - - if (!(await this.setupSubmitActions())) { - return; - } - - try { - // Create new key and hash new password - const newMasterKey = await this.keyService.makeMasterKey( - this.masterPassword, - this.email.trim().toLowerCase(), - this.kdfConfig, - ); - const newPasswordHash = await this.keyService.hashMasterKey( - this.masterPassword, - newMasterKey, - ); - - // Grab user key - const userKey = await this.keyService.getUserKey(); - - // Encrypt user key with new master key - const newProtectedUserKey = await this.keyService.encryptUserKeyWithMasterKey( - newMasterKey, - userKey, - ); - - await this.performSubmitActions(newPasswordHash, newMasterKey, newProtectedUserKey); - } catch (e) { - this.logService.error(e); - } - } - - async performSubmitActions( - masterPasswordHash: string, - masterKey: MasterKey, - userKey: [UserKey, EncString], - ) { - try { - switch (this.reason) { - case ForceSetPasswordReason.AdminForcePasswordReset: - this.formPromise = this.updateTempPassword(masterPasswordHash, userKey); - break; - case ForceSetPasswordReason.WeakMasterPassword: - this.formPromise = this.updatePassword(masterPasswordHash, userKey); - break; - case ForceSetPasswordReason.TdeOffboarding: - this.formPromise = this.updateTdeOffboardingPassword(masterPasswordHash, userKey); - break; - } - - await this.formPromise; - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("updatedMasterPassword"), - }); - - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.masterPasswordService.setForceSetPasswordReason( - ForceSetPasswordReason.None, - userId, - ); - - if (this.onSuccessfulChangePassword != null) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.onSuccessfulChangePassword(); - } else { - this.messagingService.send("logout"); - } - } catch (e) { - this.logService.error(e); - } - } - private async updateTempPassword(masterPasswordHash: string, userKey: [UserKey, EncString]) { - const request = new UpdateTempPasswordRequest(); - request.key = userKey[1].encryptedString; - request.newMasterPasswordHash = masterPasswordHash; - request.masterPasswordHint = this.hint; - - return this.masterPasswordApiService.putUpdateTempPassword(request); - } - - private async updatePassword(newMasterPasswordHash: string, userKey: [UserKey, EncString]) { - const request = await this.userVerificationService.buildRequest( - this.verification, - PasswordRequest, - ); - request.masterPasswordHint = this.hint; - request.newMasterPasswordHash = newMasterPasswordHash; - request.key = userKey[1].encryptedString; - - return this.masterPasswordApiService.postPassword(request); - } - - private async updateTdeOffboardingPassword( - masterPasswordHash: string, - userKey: [UserKey, EncString], - ) { - const request = new UpdateTdeOffboardingPasswordRequest(); - request.key = userKey[1].encryptedString; - request.newMasterPasswordHash = masterPasswordHash; - request.masterPasswordHint = this.hint; - - return this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request); - } -} diff --git a/libs/angular/src/auth/device-management/device-management.component.html b/libs/angular/src/auth/device-management/device-management.component.html index 8b82140a508..6ee50a32e8e 100644 --- a/libs/angular/src/auth/device-management/device-management.component.html +++ b/libs/angular/src/auth/device-management/device-management.component.html @@ -12,7 +12,7 @@ -

{{ "aDeviceIs" | i18n }}

+

{{ "aDeviceIs" | i18n }}

diff --git a/libs/angular/src/auth/guards/auth.guard.spec.ts b/libs/angular/src/auth/guards/auth.guard.spec.ts index 1681fa2b4ea..fccfcd58874 100644 --- a/libs/angular/src/auth/guards/auth.guard.spec.ts +++ b/libs/angular/src/auth/guards/auth.guard.spec.ts @@ -68,10 +68,7 @@ describe("AuthGuard", () => { { path: "", component: EmptyComponent }, { path: "guarded-route", component: EmptyComponent, canActivate: [authGuard] }, { path: "lock", component: EmptyComponent }, - { path: "set-password", component: EmptyComponent }, - { path: "set-password-jit", component: EmptyComponent }, { path: "set-initial-password", component: EmptyComponent, canActivate: [authGuard] }, - { path: "update-temp-password", component: EmptyComponent, canActivate: [authGuard] }, { path: "change-password", component: EmptyComponent }, { path: "remove-password", component: EmptyComponent, canActivate: [authGuard] }, ]), @@ -125,109 +122,58 @@ describe("AuthGuard", () => { }); describe("given user is Locked", () => { - describe("given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => { - it("should redirect to /set-initial-password when the user has ForceSetPasswordReaason.TdeOffboardingUntrustedDevice", async () => { - const { router } = setup( - AuthenticationStatus.Locked, - ForceSetPasswordReason.TdeOffboardingUntrustedDevice, - false, - FeatureFlag.PM16117_SetInitialPasswordRefactor, - ); + it("should redirect to /set-initial-password when the user has ForceSetPasswordReaason.TdeOffboardingUntrustedDevice", async () => { + const { router } = setup( + AuthenticationStatus.Locked, + ForceSetPasswordReason.TdeOffboardingUntrustedDevice, + false, + ); - await router.navigate(["guarded-route"]); - expect(router.url).toBe("/set-initial-password"); - }); + await router.navigate(["guarded-route"]); + expect(router.url).toBe("/set-initial-password"); + }); - it("should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.TdeOffboardingUntrustedDevice", async () => { - const { router } = setup( - AuthenticationStatus.Unlocked, - ForceSetPasswordReason.TdeOffboardingUntrustedDevice, - false, - FeatureFlag.PM16117_SetInitialPasswordRefactor, - ); + it("should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.TdeOffboardingUntrustedDevice", async () => { + const { router } = setup( + AuthenticationStatus.Unlocked, + ForceSetPasswordReason.TdeOffboardingUntrustedDevice, + false, + ); - await router.navigate(["/set-initial-password"]); - expect(router.url).toContain("/set-initial-password"); - }); + await router.navigate(["/set-initial-password"]); + expect(router.url).toContain("/set-initial-password"); }); }); - describe("given user is Unlocked", () => { - describe("given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => { - const tests = [ - ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, - ForceSetPasswordReason.TdeOffboarding, - ]; + describe("given user is Unlocked and ForceSetPasswordReason requires setting an initial password", () => { + const tests = [ + ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + ForceSetPasswordReason.TdeOffboarding, + ]; - describe("given user attempts to navigate to an auth guarded route", () => { - tests.forEach((reason) => { - it(`should redirect to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => { - const { router } = setup( - AuthenticationStatus.Unlocked, - reason, - false, - FeatureFlag.PM16117_SetInitialPasswordRefactor, - ); + describe("given user attempts to navigate to an auth guarded route", () => { + tests.forEach((reason) => { + it(`should redirect to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => { + const { router } = setup(AuthenticationStatus.Unlocked, reason, false); - await router.navigate(["guarded-route"]); - expect(router.url).toContain("/set-initial-password"); - }); - }); - }); - - describe("given user attempts to navigate to /set-initial-password", () => { - tests.forEach((reason) => { - it(`should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => { - const { router } = setup( - AuthenticationStatus.Unlocked, - reason, - false, - FeatureFlag.PM16117_SetInitialPasswordRefactor, - ); - - await router.navigate(["/set-initial-password"]); - expect(router.url).toContain("/set-initial-password"); - }); + await router.navigate(["guarded-route"]); + expect(router.url).toContain("/set-initial-password"); }); }); }); - describe("given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => { - const tests = [ - { - reason: ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, - url: "/set-password", - }, - { - reason: ForceSetPasswordReason.TdeOffboarding, - url: "/update-temp-password", - }, - ]; + describe("given user attempts to navigate to /set-initial-password", () => { + tests.forEach((reason) => { + it(`should allow navigation to continue to /set-initial-password when the user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => { + const { router } = setup(AuthenticationStatus.Unlocked, reason, false); - describe("given user attempts to navigate to an auth guarded route", () => { - tests.forEach(({ reason, url }) => { - it(`should redirect to ${url} when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => { - const { router } = setup(AuthenticationStatus.Unlocked, reason); - - await router.navigate(["/guarded-route"]); - expect(router.url).toContain(url); - }); - }); - }); - - describe("given user attempts to navigate to the set- or update- password route itself", () => { - tests.forEach(({ reason, url }) => { - it(`should allow navigation to continue to ${url} when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => { - const { router } = setup(AuthenticationStatus.Unlocked, reason); - - await router.navigate([url]); - expect(router.url).toContain(url); - }); + await router.navigate(["/set-initial-password"]); + expect(router.url).toContain("/set-initial-password"); }); }); }); - describe("given the PM16117_ChangeExistingPasswordRefactor feature flag is ON", () => { + describe("given user is Unlocked and ForceSetPasswordReason requires changing an existing password", () => { const tests = [ ForceSetPasswordReason.AdminForcePasswordReset, ForceSetPasswordReason.WeakMasterPassword, @@ -236,12 +182,7 @@ describe("AuthGuard", () => { describe("given user attempts to navigate to an auth guarded route", () => { tests.forEach((reason) => { it(`should redirect to /change-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => { - const { router } = setup( - AuthenticationStatus.Unlocked, - reason, - false, - FeatureFlag.PM16117_ChangeExistingPasswordRefactor, - ); + const { router } = setup(AuthenticationStatus.Unlocked, reason, false); await router.navigate(["guarded-route"]); expect(router.url).toContain("/change-password"); @@ -256,7 +197,6 @@ describe("AuthGuard", () => { AuthenticationStatus.Unlocked, ForceSetPasswordReason.AdminForcePasswordReset, false, - FeatureFlag.PM16117_ChangeExistingPasswordRefactor, ); await router.navigate(["/change-password"]); @@ -265,34 +205,5 @@ describe("AuthGuard", () => { }); }); }); - - describe("given the PM16117_ChangeExistingPasswordRefactor feature flag is OFF", () => { - const tests = [ - ForceSetPasswordReason.AdminForcePasswordReset, - ForceSetPasswordReason.WeakMasterPassword, - ]; - - describe("given user attempts to navigate to an auth guarded route", () => { - tests.forEach((reason) => { - it(`should redirect to /update-temp-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => { - const { router } = setup(AuthenticationStatus.Unlocked, reason); - - await router.navigate(["guarded-route"]); - expect(router.url).toContain("/update-temp-password"); - }); - }); - }); - - describe("given user attempts to navigate to /update-temp-password", () => { - tests.forEach((reason) => { - it(`should allow navigation to continue to /update-temp-password when user has ForceSetPasswordReason.${ForceSetPasswordReason[reason]}`, async () => { - const { router } = setup(AuthenticationStatus.Unlocked, reason); - - await router.navigate(["/update-temp-password"]); - expect(router.url).toContain("/update-temp-password"); - }); - }); - }); - }); }); }); diff --git a/libs/angular/src/auth/guards/auth.guard.ts b/libs/angular/src/auth/guards/auth.guard.ts index 3722a7c802a..8e8e70a6d29 100644 --- a/libs/angular/src/auth/guards/auth.guard.ts +++ b/libs/angular/src/auth/guards/auth.guard.ts @@ -14,10 +14,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; export const authGuard: CanActivateFn = async ( @@ -30,7 +28,6 @@ export const authGuard: CanActivateFn = async ( const keyConnectorService = inject(KeyConnectorService); const accountService = inject(AccountService); const masterPasswordService = inject(MasterPasswordServiceAbstraction); - const configService = inject(ConfigService); const authStatus = await authService.getAuthStatus(); @@ -44,16 +41,11 @@ export const authGuard: CanActivateFn = async ( masterPasswordService.forceSetPasswordReason$(userId), ); - const isSetInitialPasswordFlagOn = await configService.getFeatureFlag( - FeatureFlag.PM16117_SetInitialPasswordRefactor, - ); - // User JIT provisioned into a master-password-encryption org if ( authStatus === AuthenticationStatus.Locked && forceSetPasswordReason === ForceSetPasswordReason.SsoNewJitProvisionedUser && - !routerState.url.includes("set-initial-password") && - isSetInitialPasswordFlagOn + !routerState.url.includes("set-initial-password") ) { return router.createUrlTree(["/set-initial-password"]); } @@ -62,8 +54,7 @@ export const authGuard: CanActivateFn = async ( if ( authStatus === AuthenticationStatus.Locked && forceSetPasswordReason === ForceSetPasswordReason.TdeOffboardingUntrustedDevice && - !routerState.url.includes("set-initial-password") && - isSetInitialPasswordFlagOn + !routerState.url.includes("set-initial-password") ) { return router.createUrlTree(["/set-initial-password"]); } @@ -90,39 +81,28 @@ export const authGuard: CanActivateFn = async ( return router.createUrlTree(["/remove-password"]); } - // TDE org user has "manage account recovery" permission + // Handle cases where a user needs to set a password when they don't already have one: + // - TDE org user has been given "manage account recovery" permission + // - TDE offboarding on a trusted device, where we have access to their encryption key wrap with their new password if ( - forceSetPasswordReason === - ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission && - !routerState.url.includes("set-password") && + (forceSetPasswordReason === + ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission || + forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding) && !routerState.url.includes("set-initial-password") ) { - const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/set-password"; + const route = "/set-initial-password"; return router.createUrlTree([route]); } - // TDE Offboarding on trusted device - if ( - forceSetPasswordReason === ForceSetPasswordReason.TdeOffboarding && - !routerState.url.includes("update-temp-password") && - !routerState.url.includes("set-initial-password") - ) { - const route = isSetInitialPasswordFlagOn ? "/set-initial-password" : "/update-temp-password"; - return router.createUrlTree([route]); - } - - const isChangePasswordFlagOn = await configService.getFeatureFlag( - FeatureFlag.PM16117_ChangeExistingPasswordRefactor, - ); - - // Post- Account Recovery or Weak Password on login + // Handle cases where a user has a password but needs to set a new one: + // - Account recovery + // - Weak Password on login if ( (forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset || forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword) && - !routerState.url.includes("update-temp-password") && !routerState.url.includes("change-password") ) { - const route = isChangePasswordFlagOn ? "/change-password" : "/update-temp-password"; + const route = "/change-password"; return router.createUrlTree([route]); } diff --git a/libs/angular/src/auth/guards/lock.guard.spec.ts b/libs/angular/src/auth/guards/lock.guard.spec.ts index 2085e0f3486..53491bace00 100644 --- a/libs/angular/src/auth/guards/lock.guard.spec.ts +++ b/libs/angular/src/auth/guards/lock.guard.spec.ts @@ -26,7 +26,6 @@ import { lockGuard } from "./lock.guard"; interface SetupParams { authStatus: AuthenticationStatus; canLock?: boolean; - isLegacyUser?: boolean; clientType?: ClientType; everHadUserKey?: boolean; supportsDeviceTrust?: boolean; @@ -43,7 +42,6 @@ describe("lockGuard", () => { vaultTimeoutSettingsService.canLock.mockResolvedValue(setupParams.canLock); const keyService: MockProxy = mock(); - keyService.isLegacyUser.mockResolvedValue(setupParams.isLegacyUser); keyService.everHadUserKey$.mockReturnValue(of(setupParams.everHadUserKey)); const platformUtilService: MockProxy = mock(); @@ -155,37 +153,10 @@ describe("lockGuard", () => { expect(router.url).toBe("/"); }); - it("should log user out if they are a legacy user on a desktop client", async () => { - const { router, messagingService } = setup({ - authStatus: AuthenticationStatus.Locked, - canLock: true, - isLegacyUser: true, - clientType: ClientType.Desktop, - }); - - await router.navigate(["lock"]); - expect(router.url).toBe("/"); - expect(messagingService.send).toHaveBeenCalledWith("logout"); - }); - - it("should log user out if they are a legacy user on a browser extension client", async () => { - const { router, messagingService } = setup({ - authStatus: AuthenticationStatus.Locked, - canLock: true, - isLegacyUser: true, - clientType: ClientType.Browser, - }); - - await router.navigate(["lock"]); - expect(router.url).toBe("/"); - expect(messagingService.send).toHaveBeenCalledWith("logout"); - }); - it("should allow navigation to the lock route when device trust is supported, the user has a MP, and the user is coming from the login-initiated page", async () => { const { router } = setup({ authStatus: AuthenticationStatus.Locked, canLock: true, - isLegacyUser: false, clientType: ClientType.Web, everHadUserKey: false, supportsDeviceTrust: true, @@ -213,7 +184,6 @@ describe("lockGuard", () => { const { router } = setup({ authStatus: AuthenticationStatus.Locked, canLock: true, - isLegacyUser: false, clientType: ClientType.Web, everHadUserKey: false, supportsDeviceTrust: true, diff --git a/libs/angular/src/auth/guards/lock.guard.ts b/libs/angular/src/auth/guards/lock.guard.ts index 4b09ddeee18..8acdadeb87c 100644 --- a/libs/angular/src/auth/guards/lock.guard.ts +++ b/libs/angular/src/auth/guards/lock.guard.ts @@ -13,7 +13,6 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { KeyService } from "@bitwarden/key-management"; /** @@ -31,7 +30,6 @@ export function lockGuard(): CanActivateFn { const authService = inject(AuthService); const keyService = inject(KeyService); const deviceTrustService = inject(DeviceTrustServiceAbstraction); - const messagingService = inject(MessagingService); const router = inject(Router); const userVerificationService = inject(UserVerificationService); const vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService); @@ -56,11 +54,6 @@ export function lockGuard(): CanActivateFn { return false; } - if (await keyService.isLegacyUser()) { - messagingService.send("logout"); - return false; - } - // User is authN and in locked state. const tdeEnabled = await firstValueFrom(deviceTrustService.supportsDeviceTrust$); diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index 89e6cfeacb7..86042f4b779 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -54,7 +54,6 @@ import { UserTypePipe } from "./pipes/user-type.pipe"; import { EllipsisPipe } from "./platform/pipes/ellipsis.pipe"; import { FingerprintPipe } from "./platform/pipes/fingerprint.pipe"; import { I18nPipe } from "./platform/pipes/i18n.pipe"; -import { PasswordStrengthComponent } from "./tools/password-strength/password-strength.component"; import { IconComponent } from "./vault/components/icon.component"; @NgModule({ @@ -108,7 +107,6 @@ import { IconComponent } from "./vault/components/icon.component"; TrueFalseValueDirective, LaunchClickDirective, UserNamePipe, - PasswordStrengthComponent, UserTypePipe, IfFeatureDirective, FingerprintPipe, @@ -143,7 +141,6 @@ import { IconComponent } from "./vault/components/icon.component"; CopyClickDirective, LaunchClickDirective, UserNamePipe, - PasswordStrengthComponent, UserTypePipe, IfFeatureDirective, FingerprintPipe, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 717ff54b1ca..2eda60984b4 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -22,13 +22,11 @@ import { DefaultLoginComponentService, DefaultLoginDecryptionOptionsService, DefaultRegistrationFinishService, - DefaultSetPasswordJitService, DefaultTwoFactorAuthComponentService, DefaultTwoFactorAuthWebAuthnComponentService, LoginComponentService, LoginDecryptionOptionsService, RegistrationFinishService as RegistrationFinishServiceAbstraction, - SetPasswordJitService, TwoFactorAuthComponentService, TwoFactorAuthWebAuthnComponentService, } from "@bitwarden/auth/angular"; @@ -50,8 +48,6 @@ import { LoginSuccessHandlerService, LogoutReason, LogoutService, - PinService, - PinServiceAbstraction, UserDecryptionOptionsService, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; @@ -112,6 +108,7 @@ import { AccountServiceImplementation } from "@bitwarden/common/auth/services/ac import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; +import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/default-active-user.accessor"; import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation"; @@ -154,11 +151,9 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { TaxService } from "@bitwarden/common/billing/services/tax.service"; -import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { BulkEncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/bulk-encrypt.service.implementation"; -import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/multithread-encrypt.service.implementation"; +import { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation"; @@ -169,6 +164,8 @@ import { MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; import { SendPasswordService, DefaultSendPasswordService, @@ -238,6 +235,7 @@ import { StorageServiceProvider } from "@bitwarden/common/platform/services/stor import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { + ActiveUserAccessor, ActiveUserStateProvider, DerivedStateProvider, GlobalStateProvider, @@ -535,7 +533,6 @@ const safeProviders: SafeProvider[] = [ stateService: StateServiceAbstraction, autofillSettingsService: AutofillSettingsServiceAbstraction, encryptService: EncryptService, - bulkEncryptService: BulkEncryptService, fileUploadService: CipherFileUploadServiceAbstraction, configService: ConfigService, stateProvider: StateProvider, @@ -552,7 +549,6 @@ const safeProviders: SafeProvider[] = [ stateService, autofillSettingsService, encryptService, - bulkEncryptService, fileUploadService, configService, stateProvider, @@ -569,7 +565,6 @@ const safeProviders: SafeProvider[] = [ StateServiceAbstraction, AutofillSettingsServiceAbstraction, EncryptService, - BulkEncryptService, CipherFileUploadServiceAbstraction, ConfigService, StateProvider, @@ -976,14 +971,9 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: EncryptService, - useClass: MultithreadEncryptServiceImplementation, + useClass: EncryptServiceImplementation, deps: [CryptoFunctionServiceAbstraction, LogService, LOG_MAC_FAILURES], }), - safeProvider({ - provide: BulkEncryptService, - useClass: BulkEncryptServiceImplementation, - deps: [CryptoFunctionServiceAbstraction, LogService], - }), safeProvider({ provide: EventUploadServiceAbstraction, useClass: EventUploadService, @@ -1030,6 +1020,8 @@ const safeProviders: SafeProvider[] = [ KeyGenerationServiceAbstraction, EncryptService, LogService, + CryptoFunctionServiceAbstraction, + AccountServiceAbstraction, ], }), safeProvider({ @@ -1288,10 +1280,15 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultGlobalStateProvider, deps: [StorageServiceProvider, LogService], }), + safeProvider({ + provide: ActiveUserAccessor, + useClass: DefaultActiveUserAccessor, + deps: [AccountServiceAbstraction], + }), safeProvider({ provide: ActiveUserStateProvider, useClass: DefaultActiveUserStateProvider, - deps: [AccountServiceAbstraction, SingleUserStateProvider], + deps: [ActiveUserAccessor, SingleUserStateProvider], }), safeProvider({ provide: SingleUserStateProvider, @@ -1424,21 +1421,6 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultOrganizationInviteService, deps: [], }), - safeProvider({ - provide: SetPasswordJitService, - useClass: DefaultSetPasswordJitService, - deps: [ - EncryptService, - I18nServiceAbstraction, - KdfConfigService, - KeyService, - MasterPasswordApiServiceAbstraction, - InternalMasterPasswordServiceAbstraction, - OrganizationApiServiceAbstraction, - OrganizationUserApiService, - InternalUserDecryptionOptionsServiceAbstraction, - ], - }), safeProvider({ provide: SetInitialPasswordService, useClass: DefaultSetInitialPasswordService, diff --git a/libs/angular/src/tools/password-strength/password-strength.component.html b/libs/angular/src/tools/password-strength/password-strength.component.html deleted file mode 100644 index c9eec73899b..00000000000 --- a/libs/angular/src/tools/password-strength/password-strength.component.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
- - {{ text }} - -
-
diff --git a/libs/angular/src/tools/password-strength/password-strength.component.ts b/libs/angular/src/tools/password-strength/password-strength.component.ts deleted file mode 100644 index ca9892d9c6c..00000000000 --- a/libs/angular/src/tools/password-strength/password-strength.component.ts +++ /dev/null @@ -1,118 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, EventEmitter, Input, OnChanges, Output } from "@angular/core"; - -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; - -export interface PasswordColorText { - color: string; - text: string; -} - -/** - * @deprecated July 2024: Use new PasswordStrengthV2Component instead - */ -@Component({ - selector: "app-password-strength", - templateUrl: "password-strength.component.html", - standalone: false, -}) -export class PasswordStrengthComponent implements OnChanges { - @Input() showText = false; - @Input() email: string; - @Input() name: string; - @Input() set password(value: string) { - this.updatePasswordStrength(value); - } - @Output() passwordStrengthResult = new EventEmitter(); - @Output() passwordScoreColor = new EventEmitter(); - - masterPasswordScore: number; - scoreWidth = 0; - color = "bg-danger"; - text: string; - - private masterPasswordStrengthTimeout: any; - - //used by desktop and browser to display strength text color - get masterPasswordScoreColor() { - switch (this.masterPasswordScore) { - case 4: - return "success"; - case 3: - return "primary"; - case 2: - return "warning"; - default: - return "danger"; - } - } - - //used by desktop and browser to display strength text - get masterPasswordScoreText() { - switch (this.masterPasswordScore) { - case 4: - return this.i18nService.t("strong"); - case 3: - return this.i18nService.t("good"); - case 2: - return this.i18nService.t("weak"); - default: - return this.masterPasswordScore != null ? this.i18nService.t("weak") : null; - } - } - - constructor( - private i18nService: I18nService, - private passwordStrengthService: PasswordStrengthServiceAbstraction, - ) {} - - ngOnChanges(): void { - this.masterPasswordStrengthTimeout = setTimeout(() => { - this.scoreWidth = this.masterPasswordScore == null ? 0 : (this.masterPasswordScore + 1) * 20; - - switch (this.masterPasswordScore) { - case 4: - this.color = "bg-success"; - this.text = this.i18nService.t("strong"); - break; - case 3: - this.color = "bg-primary"; - this.text = this.i18nService.t("good"); - break; - case 2: - this.color = "bg-warning"; - this.text = this.i18nService.t("weak"); - break; - default: - this.color = "bg-danger"; - this.text = this.masterPasswordScore != null ? this.i18nService.t("weak") : null; - break; - } - - this.setPasswordScoreText(this.color, this.text); - }, 300); - } - - updatePasswordStrength(password: string) { - const masterPassword = password; - - if (this.masterPasswordStrengthTimeout != null) { - clearTimeout(this.masterPasswordStrengthTimeout); - } - - const strengthResult = this.passwordStrengthService.getPasswordStrength( - masterPassword, - this.email, - this.name?.trim().toLowerCase().split(" "), - ); - this.passwordStrengthResult.emit(strengthResult); - this.masterPasswordScore = strengthResult == null ? null : strengthResult.score; - } - - setPasswordScoreText(color: string, text: string) { - color = color.slice(3); - this.passwordScoreColor.emit({ color: color, text: text }); - } -} diff --git a/libs/angular/src/vault/components/folder-add-edit.component.ts b/libs/angular/src/vault/components/folder-add-edit.component.ts index 28ed0dc2aed..acf7511284d 100644 --- a/libs/angular/src/vault/components/folder-add-edit.component.ts +++ b/libs/angular/src/vault/components/folder-add-edit.component.ts @@ -63,7 +63,7 @@ export class FolderAddEditComponent implements OnInit { try { const activeUserId = await firstValueFrom(this.activeUserId$); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId); + const userKey = await this.keyService.getUserKey(activeUserId); const folder = await this.folderService.encrypt(this.folder, userKey); this.formPromise = this.folderApiService.save(folder, activeUserId); await this.formPromise; diff --git a/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts index dc3f39793d3..835c9e35ac7 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts @@ -3,12 +3,10 @@ import { Observable, combineLatest, from, of } from "rxjs"; import { catchError, switchMap } from "rxjs/operators"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; import { BiometricStateService } from "@bitwarden/key-management"; diff --git a/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts index d90ae06a75f..57df2d03398 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/empty-vault-nudge.service.ts @@ -27,7 +27,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService { this.getNudgeStatus$(nudgeType, userId), this.cipherService.cipherListViews$(userId), this.organizationService.organizations$(userId), - this.collectionService.decryptedCollections$, + this.collectionService.decryptedCollections$(userId), ]).pipe( switchMap(([nudgeStatus, ciphers, orgs, collections]) => { const vaultHasContents = !(ciphers == null || ciphers.length === 0); diff --git a/libs/angular/src/vault/services/custom-nudges-services/vault-settings-import-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/vault-settings-import-nudge.service.ts index df0403ba4ab..2529fc40b73 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/vault-settings-import-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/vault-settings-import-nudge.service.ts @@ -27,7 +27,7 @@ export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService { this.getNudgeStatus$(nudgeType, userId), this.cipherService.cipherViews$(userId), this.organizationService.organizations$(userId), - this.collectionService.decryptedCollections$, + this.collectionService.decryptedCollections$(userId), ]).pipe( switchMap(([nudgeStatus, ciphers, orgs, collections]) => { const vaultHasMoreThanOneItem = (ciphers?.length ?? 0) > 1; diff --git a/libs/angular/src/vault/services/nudges.service.spec.ts b/libs/angular/src/vault/services/nudges.service.spec.ts index a72ea2f581c..7c4c8ba8b74 100644 --- a/libs/angular/src/vault/services/nudges.service.spec.ts +++ b/libs/angular/src/vault/services/nudges.service.spec.ts @@ -2,13 +2,11 @@ import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; diff --git a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts index 0d633be868e..9bc10e5ffc5 100644 --- a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts +++ b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts @@ -109,7 +109,12 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti } async buildCollections(organizationId?: string): Promise> { - const storedCollections = await this.collectionService.getAllDecrypted(); + const storedCollections = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.collectionService.decryptedCollections$(userId)), + ), + ); const orgs = await this.buildOrganizations(); const defaulCollectionsFlagEnabled = await this.configService.getFeatureFlag( FeatureFlag.CreateDefaultLocation, diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index aa0041c7ec3..3e8dc575c7b 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -41,11 +41,6 @@ export * from "./registration/registration-env-selector/registration-env-selecto export * from "./registration/registration-finish/registration-finish.service"; export * from "./registration/registration-finish/default-registration-finish.service"; -// set password (JIT user) -export * from "./set-password-jit/set-password-jit.component"; -export * from "./set-password-jit/set-password-jit.service.abstraction"; -export * from "./set-password-jit/default-set-password-jit.service"; - // user verification export * from "./user-verification/user-verification-dialog.component"; export * from "./user-verification/user-verification-dialog.types"; 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 5e410c538f0..10b19567946 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 @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { IsActiveMatchOptions, Router, RouterModule } from "@angular/router"; -import { Observable, filter, firstValueFrom, map, merge, race, take, timer } from "rxjs"; +import { Observable, firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -185,17 +185,15 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy { this.accountService.activeAccount$.pipe(map((a) => a?.email)); const loginEmail$: Observable = this.loginEmailService.loginEmail$; - // Use merge as we want to get the first value from either observable. - const firstEmail$ = merge(loginEmail$, activeAccountEmail$).pipe( - filter((e): e is string => !!e), // convert null/undefined to false and filter out so we narrow type to string - take(1), // complete after first value - ); + let loginEmail: string | undefined = (await firstValueFrom(loginEmail$)) ?? undefined; - const emailRetrievalTimeout$ = timer(2500).pipe(map(() => undefined as undefined)); + if (!loginEmail) { + loginEmail = (await firstValueFrom(activeAccountEmail$)) ?? undefined; + } // Wait for either the first email or the timeout to occur so we can proceed // neither above observable will complete, so we have to add a timeout - this.email = await firstValueFrom(race(firstEmail$, emailRetrievalTimeout$)); + this.email = loginEmail; if (!this.email) { await this.handleMissingEmail(); diff --git a/libs/auth/src/angular/login/login.component.html b/libs/auth/src/angular/login/login.component.html index 35ef1fa9b50..e74d3a2f5a1 100644 --- a/libs/auth/src/angular/login/login.component.html +++ b/libs/auth/src/angular/login/login.component.html @@ -53,14 +53,14 @@ buttonType="secondary" (click)="handleLoginWithPasskeyClick()" > - + {{ "logInWithPasskey" | i18n }}
@@ -96,7 +96,7 @@ buttonType="secondary" (click)="startAuthRequestLogin()" > - + {{ "loginWithDevice" | i18n }} diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index b3509850ac0..2a2be148a86 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -18,7 +18,6 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ClientType, HttpStatusCode } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -230,29 +229,21 @@ export class LoginComponent implements OnInit, OnDestroy { return; } - let credentials: PasswordLoginCredentials; + // Try to retrieve any org policies from an org invite now so we can send it to the + // login strategies. Since it is optional and we only want to be doing this on the + // web we will only send in content in the right context. + const orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite + ? await this.loginComponentService.getOrgPoliciesFromOrgInvite() + : null; - if ( - await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor) - ) { - // Try to retrieve any org policies from an org invite now so we can send it to the - // login strategies. Since it is optional and we only want to be doing this on the - // web we will only send in content in the right context. - const orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite - ? await this.loginComponentService.getOrgPoliciesFromOrgInvite() - : null; + const orgMasterPasswordPolicyOptions = orgPoliciesFromInvite?.enforcedPasswordPolicyOptions; - const orgMasterPasswordPolicyOptions = orgPoliciesFromInvite?.enforcedPasswordPolicyOptions; - - credentials = new PasswordLoginCredentials( - email, - masterPassword, - undefined, - orgMasterPasswordPolicyOptions, - ); - } else { - credentials = new PasswordLoginCredentials(email, masterPassword); - } + const credentials = new PasswordLoginCredentials( + email, + masterPassword, + undefined, + orgMasterPasswordPolicyOptions, + ); try { const authResult = await this.loginStrategyService.logIn(credentials); @@ -332,7 +323,7 @@ export class LoginComponent implements OnInit, OnDestroy { await this.loginSuccessHandlerService.run(authResult.userId); // Determine where to send the user next - // The AuthGuard will handle routing to update-temp-password based on state + // The AuthGuard will handle routing to change-password based on state // TODO: PM-18269 - evaluate if we can combine this with the // password evaluation done in the password login strategy. @@ -344,7 +335,7 @@ export class LoginComponent implements OnInit, OnDestroy { if (orgPolicies) { // Since we have retrieved the policies, we can go ahead and set them into state for future use - // e.g., the update-password page currently only references state for policy data and + // e.g., the change-password page currently only references state for policy data and // doesn't fallback to pulling them from the server like it should if they are null. await this.setPoliciesIntoState(authResult.userId, orgPolicies.policies); @@ -352,13 +343,7 @@ export class LoginComponent implements OnInit, OnDestroy { orgPolicies.enforcedPasswordPolicyOptions, ); if (isPasswordChangeRequired) { - const changePasswordFeatureFlagOn = await this.configService.getFeatureFlag( - FeatureFlag.PM16117_ChangeExistingPasswordRefactor, - ); - - await this.router.navigate( - changePasswordFeatureFlagOn ? ["change-password"] : ["update-password"], - ); + await this.router.navigate(["change-password"]); return; } } diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts index 4325b4bcbc1..6362b901fc8 100644 --- a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts @@ -10,7 +10,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -151,25 +150,17 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy { this.loginSuccessHandlerService.run(authResult.userId); // TODO: PM-22663 use the new service to handle routing. + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + const forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(activeUserId), + ); + if ( - await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor) + forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword || + forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset ) { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(getUserId), - ); - - const forceSetPasswordReason = await firstValueFrom( - this.masterPasswordService.forceSetPasswordReason$(activeUserId), - ); - - if ( - forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword || - forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset - ) { - await this.router.navigate(["/change-password"]); - } else { - await this.router.navigate(["/vault"]); - } + await this.router.navigate(["/change-password"]); } else { await this.router.navigate(["/vault"]); } diff --git a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts deleted file mode 100644 index 6a9a37700cc..00000000000 --- a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { MockProxy, mock } from "jest-mock-extended"; -import { BehaviorSubject, of } from "rxjs"; - -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; -import { - FakeUserDecryptionOptions as UserDecryptionOptions, - InternalUserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response"; -import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; -import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { UserId } from "@bitwarden/common/types/guid"; -import { MasterKey, UserKey } from "@bitwarden/common/types/key"; -import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; - -import { PasswordInputResult } from "../input-password/password-input-result"; - -import { DefaultSetPasswordJitService } from "./default-set-password-jit.service"; -import { SetPasswordCredentials } from "./set-password-jit.service.abstraction"; - -describe("DefaultSetPasswordJitService", () => { - let sut: DefaultSetPasswordJitService; - - let masterPasswordApiService: MockProxy; - let keyService: MockProxy; - let encryptService: MockProxy; - let i18nService: MockProxy; - let kdfConfigService: MockProxy; - let masterPasswordService: MockProxy; - let organizationApiService: MockProxy; - let organizationUserApiService: MockProxy; - let userDecryptionOptionsService: MockProxy; - - beforeEach(() => { - masterPasswordApiService = mock(); - keyService = mock(); - encryptService = mock(); - i18nService = mock(); - kdfConfigService = mock(); - masterPasswordService = mock(); - organizationApiService = mock(); - organizationUserApiService = mock(); - userDecryptionOptionsService = mock(); - - sut = new DefaultSetPasswordJitService( - encryptService, - i18nService, - kdfConfigService, - keyService, - masterPasswordApiService, - masterPasswordService, - organizationApiService, - organizationUserApiService, - userDecryptionOptionsService, - ); - }); - - it("should instantiate the DefaultSetPasswordJitService", () => { - expect(sut).not.toBeFalsy(); - }); - - describe("setPassword", () => { - let masterKey: MasterKey; - let userKey: UserKey; - let userKeyEncString: EncString; - let protectedUserKey: [UserKey, EncString]; - let keyPair: [string, EncString]; - let keysRequest: KeysRequest; - let organizationKeys: OrganizationKeysResponse; - let orgPublicKey: Uint8Array; - - let orgSsoIdentifier: string; - let orgId: string; - let resetPasswordAutoEnroll: boolean; - let userId: UserId; - let passwordInputResult: PasswordInputResult; - let credentials: SetPasswordCredentials; - - let userDecryptionOptionsSubject: BehaviorSubject; - let setPasswordRequest: SetPasswordRequest; - - beforeEach(() => { - masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; - userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; - userKeyEncString = new EncString("userKeyEncrypted"); - protectedUserKey = [userKey, userKeyEncString]; - keyPair = ["publicKey", new EncString("privateKey")]; - keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString); - organizationKeys = { - privateKey: "orgPrivateKey", - publicKey: "orgPublicKey", - } as OrganizationKeysResponse; - orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey); - - orgSsoIdentifier = "orgSsoIdentifier"; - orgId = "orgId"; - resetPasswordAutoEnroll = false; - userId = "userId" as UserId; - - passwordInputResult = { - newMasterKey: masterKey, - newServerMasterKeyHash: "newServerMasterKeyHash", - newLocalMasterKeyHash: "newLocalMasterKeyHash", - newPasswordHint: "newPasswordHint", - kdfConfig: DEFAULT_KDF_CONFIG, - newPassword: "newPassword", - }; - - credentials = { - newMasterKey: passwordInputResult.newMasterKey, - newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash, - newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash, - newPasswordHint: passwordInputResult.newPasswordHint, - kdfConfig: passwordInputResult.kdfConfig, - orgSsoIdentifier, - orgId, - resetPasswordAutoEnroll, - userId, - }; - - userDecryptionOptionsSubject = new BehaviorSubject(null); - userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject; - - setPasswordRequest = new SetPasswordRequest( - passwordInputResult.newServerMasterKeyHash, - protectedUserKey[1].encryptedString, - passwordInputResult.newPasswordHint, - orgSsoIdentifier, - keysRequest, - passwordInputResult.kdfConfig.kdfType, - passwordInputResult.kdfConfig.iterations, - ); - }); - - function setupSetPasswordMocks(hasUserKey = true) { - if (!hasUserKey) { - keyService.userKey$.mockReturnValue(of(null)); - keyService.makeUserKey.mockResolvedValue(protectedUserKey); - } else { - keyService.userKey$.mockReturnValue(of(userKey)); - keyService.encryptUserKeyWithMasterKey.mockResolvedValue(protectedUserKey); - } - - keyService.makeKeyPair.mockResolvedValue(keyPair); - - masterPasswordApiService.setPassword.mockResolvedValue(undefined); - masterPasswordService.setForceSetPasswordReason.mockResolvedValue(undefined); - - userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); - userDecryptionOptionsService.setUserDecryptionOptions.mockResolvedValue(undefined); - kdfConfigService.setKdfConfig.mockResolvedValue(undefined); - keyService.setUserKey.mockResolvedValue(undefined); - - keyService.setPrivateKey.mockResolvedValue(undefined); - - masterPasswordService.setMasterKeyHash.mockResolvedValue(undefined); - } - - function setupResetPasswordAutoEnrollMocks(organizationKeysExist = true) { - if (organizationKeysExist) { - organizationApiService.getKeys.mockResolvedValue(organizationKeys); - } else { - organizationApiService.getKeys.mockResolvedValue(null); - return; - } - - keyService.userKey$.mockReturnValue(of(userKey)); - encryptService.encapsulateKeyUnsigned.mockResolvedValue(userKeyEncString); - - organizationUserApiService.putOrganizationUserResetPasswordEnrollment.mockResolvedValue( - undefined, - ); - } - - it("should set password successfully (given a user key)", async () => { - // Arrange - setupSetPasswordMocks(); - - // Act - await sut.setPassword(credentials); - - // Assert - expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); - }); - - it("should set password successfully (given no user key)", async () => { - // Arrange - setupSetPasswordMocks(false); - - // Act - await sut.setPassword(credentials); - - // Assert - expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); - }); - - it("should handle reset password auto enroll", async () => { - // Arrange - credentials.resetPasswordAutoEnroll = true; - - setupSetPasswordMocks(); - setupResetPasswordAutoEnrollMocks(); - - // Act - await sut.setPassword(credentials); - - // Assert - expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest); - expect(organizationApiService.getKeys).toHaveBeenCalledWith(orgId); - expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(userKey, orgPublicKey); - expect( - organizationUserApiService.putOrganizationUserResetPasswordEnrollment, - ).toHaveBeenCalled(); - }); - - it("when handling reset password auto enroll, it should throw an error if organization keys are not found", async () => { - // Arrange - credentials.resetPasswordAutoEnroll = true; - - setupSetPasswordMocks(); - setupResetPasswordAutoEnrollMocks(false); - - // Act and Assert - await expect(sut.setPassword(credentials)).rejects.toThrow(); - expect( - organizationUserApiService.putOrganizationUserResetPasswordEnrollment, - ).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.ts b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.ts deleted file mode 100644 index 5fc3272b650..00000000000 --- a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.ts +++ /dev/null @@ -1,176 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { firstValueFrom } from "rxjs"; - -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { - OrganizationUserApiService, - OrganizationUserResetPasswordEnrollmentRequest, -} from "@bitwarden/admin-console/common"; -import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { UserId } from "@bitwarden/common/types/guid"; -import { MasterKey, UserKey } from "@bitwarden/common/types/key"; -import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management"; - -import { - SetPasswordCredentials, - SetPasswordJitService, -} from "./set-password-jit.service.abstraction"; - -export class DefaultSetPasswordJitService implements SetPasswordJitService { - constructor( - protected encryptService: EncryptService, - protected i18nService: I18nService, - protected kdfConfigService: KdfConfigService, - protected keyService: KeyService, - protected masterPasswordApiService: MasterPasswordApiService, - protected masterPasswordService: InternalMasterPasswordServiceAbstraction, - protected organizationApiService: OrganizationApiServiceAbstraction, - protected organizationUserApiService: OrganizationUserApiService, - protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, - ) {} - - async setPassword(credentials: SetPasswordCredentials): Promise { - const { - newMasterKey, - newServerMasterKeyHash, - newLocalMasterKeyHash, - newPasswordHint, - kdfConfig, - orgSsoIdentifier, - orgId, - resetPasswordAutoEnroll, - userId, - } = credentials; - - for (const [key, value] of Object.entries(credentials)) { - if (value == null) { - throw new Error(`${key} not found. Could not set password.`); - } - } - - const protectedUserKey = await this.makeProtectedUserKey(newMasterKey, userId); - if (protectedUserKey == null) { - throw new Error("protectedUserKey not found. Could not set password."); - } - - // Since this is an existing JIT provisioned user in a MP encryption org setting first password, - // they will not already have a user asymmetric key pair so we must create it for them. - const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey); - - const request = new SetPasswordRequest( - newServerMasterKeyHash, - protectedUserKey[1].encryptedString, - newPasswordHint, - orgSsoIdentifier, - keysRequest, - kdfConfig.kdfType, - kdfConfig.iterations, - ); - - await this.masterPasswordApiService.setPassword(request); - - // Clear force set password reason to allow navigation back to vault. - await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); - - // User now has a password so update account decryption options in state - await this.updateAccountDecryptionProperties(newMasterKey, kdfConfig, protectedUserKey, userId); - - await this.keyService.setPrivateKey(keyPair[1].encryptedString, userId); - - await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId); - - if (resetPasswordAutoEnroll) { - await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId); - } - } - - private async makeProtectedUserKey( - masterKey: MasterKey, - userId: UserId, - ): Promise<[UserKey, EncString]> { - let protectedUserKey: [UserKey, EncString] = null; - - const userKey = await firstValueFrom(this.keyService.userKey$(userId)); - - if (userKey == null) { - protectedUserKey = await this.keyService.makeUserKey(masterKey); - } else { - protectedUserKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey); - } - - return protectedUserKey; - } - - private async makeKeyPairAndRequest( - protectedUserKey: [UserKey, EncString], - ): Promise<[[string, EncString], KeysRequest]> { - const keyPair = await this.keyService.makeKeyPair(protectedUserKey[0]); - if (keyPair == null) { - throw new Error("keyPair not found. Could not set password."); - } - const keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString); - - return [keyPair, keysRequest]; - } - - private async updateAccountDecryptionProperties( - masterKey: MasterKey, - kdfConfig: KdfConfig, - protectedUserKey: [UserKey, EncString], - userId: UserId, - ) { - const userDecryptionOpts = await firstValueFrom( - this.userDecryptionOptionsService.userDecryptionOptions$, - ); - userDecryptionOpts.hasMasterPassword = true; - await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts); - await this.kdfConfigService.setKdfConfig(userId, kdfConfig); - await this.masterPasswordService.setMasterKey(masterKey, userId); - await this.keyService.setUserKey(protectedUserKey[0], userId); - } - - private async handleResetPasswordAutoEnroll( - masterKeyHash: string, - orgId: string, - userId: UserId, - ) { - const organizationKeys = await this.organizationApiService.getKeys(orgId); - - if (organizationKeys == null) { - throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); - } - - const publicKey = Utils.fromB64ToArray(organizationKeys.publicKey); - - // RSA Encrypt user key with organization public key - const userKey = await firstValueFrom(this.keyService.userKey$(userId)); - - if (userKey == null) { - throw new Error("userKey not found. Could not handle reset password auto enroll."); - } - - const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(userKey, publicKey); - - const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest(); - resetRequest.masterPasswordHash = masterKeyHash; - resetRequest.resetPasswordKey = encryptedUserKey.encryptedString; - - await this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment( - orgId, - userId, - resetRequest, - ); - } -} diff --git a/libs/auth/src/angular/set-password-jit/set-password-jit.component.html b/libs/auth/src/angular/set-password-jit/set-password-jit.component.html deleted file mode 100644 index 797f18732cb..00000000000 --- a/libs/auth/src/angular/set-password-jit/set-password-jit.component.html +++ /dev/null @@ -1,24 +0,0 @@ - - - {{ "loading" | i18n }} - - - - - {{ "resetPasswordAutoEnrollInviteWarning" | i18n }} - - - - diff --git a/libs/auth/src/angular/set-password-jit/set-password-jit.component.ts b/libs/auth/src/angular/set-password-jit/set-password-jit.component.ts deleted file mode 100644 index 1a2674cd3d4..00000000000 --- a/libs/auth/src/angular/set-password-jit/set-password-jit.component.ts +++ /dev/null @@ -1,135 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; - -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { UserId } from "@bitwarden/common/types/guid"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; - -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { ToastService } from "../../../../components/src/toast"; -import { - InputPasswordComponent, - InputPasswordFlow, -} from "../input-password/input-password.component"; -import { PasswordInputResult } from "../input-password/password-input-result"; - -import { - SetPasswordCredentials, - SetPasswordJitService, -} from "./set-password-jit.service.abstraction"; - -@Component({ - selector: "auth-set-password-jit", - templateUrl: "set-password-jit.component.html", - imports: [CommonModule, InputPasswordComponent, JslibModule], -}) -export class SetPasswordJitComponent implements OnInit { - protected inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAuthedUser; - protected email: string; - protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions; - protected orgId: string; - protected orgSsoIdentifier: string; - protected resetPasswordAutoEnroll: boolean; - protected submitting = false; - protected syncLoading = true; - protected userId: UserId; - - constructor( - private accountService: AccountService, - private activatedRoute: ActivatedRoute, - private i18nService: I18nService, - private organizationApiService: OrganizationApiServiceAbstraction, - private policyApiService: PolicyApiServiceAbstraction, - private router: Router, - private setPasswordJitService: SetPasswordJitService, - private syncService: SyncService, - private toastService: ToastService, - private validationService: ValidationService, - ) {} - - async ngOnInit() { - const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - this.userId = activeAccount?.id; - this.email = activeAccount?.email; - - await this.syncService.fullSync(true); - this.syncLoading = false; - - await this.handleQueryParams(); - } - - private async handleQueryParams() { - const qParams = await firstValueFrom(this.activatedRoute.queryParams); - - if (qParams.identifier != null) { - try { - this.orgSsoIdentifier = qParams.identifier; - - const autoEnrollStatus = await this.organizationApiService.getAutoEnrollStatus( - this.orgSsoIdentifier, - ); - this.orgId = autoEnrollStatus.id; - this.resetPasswordAutoEnroll = autoEnrollStatus.resetPasswordEnabled; - this.masterPasswordPolicyOptions = - await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(autoEnrollStatus.id); - } catch { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("errorOccurred"), - }); - } - } - } - - protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { - this.submitting = true; - - const credentials: SetPasswordCredentials = { - newMasterKey: passwordInputResult.newMasterKey, - newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash, - newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash, - newPasswordHint: passwordInputResult.newPasswordHint, - kdfConfig: passwordInputResult.kdfConfig, - orgSsoIdentifier: this.orgSsoIdentifier, - orgId: this.orgId, - resetPasswordAutoEnroll: this.resetPasswordAutoEnroll, - userId: this.userId, - }; - - try { - await this.setPasswordJitService.setPassword(credentials); - } catch (e) { - this.validationService.showError(e); - this.submitting = false; - return; - } - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("accountSuccessfullyCreated"), - }); - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("inviteAccepted"), - }); - - this.submitting = false; - - await this.router.navigate(["vault"]); - } -} diff --git a/libs/auth/src/angular/set-password-jit/set-password-jit.service.abstraction.ts b/libs/auth/src/angular/set-password-jit/set-password-jit.service.abstraction.ts deleted file mode 100644 index 92db88868a2..00000000000 --- a/libs/auth/src/angular/set-password-jit/set-password-jit.service.abstraction.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { UserId } from "@bitwarden/common/types/guid"; -import { MasterKey } from "@bitwarden/common/types/key"; -import { KdfConfig } from "@bitwarden/key-management"; - -export interface SetPasswordCredentials { - newMasterKey: MasterKey; - newServerMasterKeyHash: string; - newLocalMasterKeyHash: string; - newPasswordHint: string; - kdfConfig: KdfConfig; - orgSsoIdentifier: string; - orgId: string; - resetPasswordAutoEnroll: boolean; - userId: UserId; -} - -/** - * This service handles setting a password for a "just-in-time" provisioned user. - * - * A "just-in-time" (JIT) provisioned user is a user who does not have a registered account at the - * time they first click "Login with SSO". Once they click "Login with SSO" we register the account on - * the fly ("just-in-time"). - */ -export abstract class SetPasswordJitService { - /** - * Sets the password for a JIT provisioned user. - * - * @param credentials An object of the credentials needed to set the password for a JIT provisioned user - * @throws If any property on the `credentials` object is null or undefined, or if a protectedUserKey - * or newKeyPair could not be created. - */ - abstract setPassword(credentials: SetPasswordCredentials): Promise; -} diff --git a/libs/auth/src/angular/sso/sso.component.ts b/libs/auth/src/angular/sso/sso.component.ts index 07b59ac661f..8acd6865b70 100644 --- a/libs/auth/src/angular/sso/sso.component.ts +++ b/libs/auth/src/angular/sso/sso.component.ts @@ -23,12 +23,10 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response"; import { ClientType, HttpStatusCode } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -118,7 +116,6 @@ export class SsoComponent implements OnInit { private toastService: ToastService, private ssoComponentService: SsoComponentService, private loginSuccessHandlerService: LoginSuccessHandlerService, - private configService: ConfigService, ) { environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => { this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html"; @@ -534,11 +531,7 @@ export class SsoComponent implements OnInit { } private async handleChangePasswordRequired(orgIdentifier: string) { - const isSetInitialPasswordRefactorFlagOn = await this.configService.getFeatureFlag( - FeatureFlag.PM16117_SetInitialPasswordRefactor, - ); - const route = isSetInitialPasswordRefactorFlagOn ? "set-initial-password" : "set-password-jit"; - + const route = "set-initial-password"; await this.router.navigate([route], { queryParams: { identifier: orgIdentifier, diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts index e7678102360..62271feee59 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts @@ -2,7 +2,7 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute, Router, convertToParamMap } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { @@ -24,8 +24,10 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication- import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; +import { + InternalMasterPasswordServiceAbstraction, + MasterPasswordServiceAbstraction, +} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -66,7 +68,7 @@ describe("TwoFactorAuthComponent", () => { let mockLoginEmailService: MockProxy; let mockUserDecryptionOptionsService: MockProxy; let mockSsoLoginService: MockProxy; - let mockMasterPasswordService: FakeMasterPasswordService; + let mockMasterPasswordService: MockProxy; let mockAccountService: FakeAccountService; let mockDialogService: MockProxy; let mockToastService: MockProxy; @@ -107,7 +109,7 @@ describe("TwoFactorAuthComponent", () => { mockUserDecryptionOptionsService = mock(); mockSsoLoginService = mock(); mockAccountService = mockAccountServiceWith(userId); - mockMasterPasswordService = new FakeMasterPasswordService(); + mockMasterPasswordService = mock(); mockDialogService = mock(); mockToastService = mock(); mockTwoFactorAuthCompService = mock(); @@ -212,6 +214,7 @@ describe("TwoFactorAuthComponent", () => { }, { provide: AuthService, useValue: mockAuthService }, { provide: ConfigService, useValue: mockConfigService }, + { provide: MasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, ], }); @@ -267,54 +270,16 @@ describe("TwoFactorAuthComponent", () => { selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword); }); - describe("Given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => { - it("navigates to the /set-initial-password route when user doesn't have a MP and key connector isn't enabled", async () => { - // Arrange - mockConfigService.getFeatureFlag.mockResolvedValue(true); - - // Act - await component.submit("testToken"); - - // Assert - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith(["set-initial-password"], { - queryParams: { - identifier: component.orgSsoIdentifier, - }, - }); - }); - }); - - describe("Given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => { - it("navigates to the /set-password route when user doesn't have a MP and key connector isn't enabled", async () => { - // Arrange - mockConfigService.getFeatureFlag.mockResolvedValue(false); - - // Act - await component.submit("testToken"); - - // Assert - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith(["set-password"], { - queryParams: { - identifier: component.orgSsoIdentifier, - }, - }); - }); - }); - }); - - describe("Given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => { - it("does not navigate to the /set-initial-password route when the user has key connector even if user has no master password", async () => { + it("navigates to the /set-initial-password route when user doesn't have a MP and key connector isn't enabled", async () => { + // Arrange mockConfigService.getFeatureFlag.mockResolvedValue(true); - selectedUserDecryptionOptions.next( - mockUserDecryptionOpts.noMasterPasswordWithKeyConnector, - ); + // Act + await component.submit("testToken"); - await component.submit(token, remember); - - expect(mockRouter.navigate).not.toHaveBeenCalledWith(["set-initial-password"], { + // Assert + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith(["set-initial-password"], { queryParams: { identifier: component.orgSsoIdentifier, }, @@ -322,21 +287,19 @@ describe("TwoFactorAuthComponent", () => { }); }); - describe("Given the PM16117_SetInitialPasswordRefactor feature flag is OFF", () => { - it("does not navigate to the /set-password route when the user has key connector even if user has no master password", async () => { - mockConfigService.getFeatureFlag.mockResolvedValue(false); + it("does not navigate to the /set-initial-password route when the user has key connector even if user has no master password", async () => { + mockConfigService.getFeatureFlag.mockResolvedValue(true); - selectedUserDecryptionOptions.next( - mockUserDecryptionOpts.noMasterPasswordWithKeyConnector, - ); + selectedUserDecryptionOptions.next( + mockUserDecryptionOpts.noMasterPasswordWithKeyConnector, + ); - await component.submit(token, remember); + await component.submit(token, remember); - expect(mockRouter.navigate).not.toHaveBeenCalledWith(["set-password"], { - queryParams: { - identifier: component.orgSsoIdentifier, - }, - }); + expect(mockRouter.navigate).not.toHaveBeenCalledWith(["set-initial-password"], { + queryParams: { + identifier: component.orgSsoIdentifier, + }, }); }); }); @@ -344,6 +307,9 @@ describe("TwoFactorAuthComponent", () => { it("navigates to the component's defined success route (vault is default) when the login is successful", async () => { mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); mockAuthService.activeAccountStatus$ = new BehaviorSubject(AuthenticationStatus.Unlocked); + mockMasterPasswordService.forceSetPasswordReason$.mockReturnValue( + of(ForceSetPasswordReason.None), + ); // Act await component.submit("testToken"); @@ -409,7 +375,7 @@ describe("TwoFactorAuthComponent", () => { await component.submit(token, remember); // Assert - expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( + expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, userId, ); diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts index 50cc2d88d6a..07746cf6479 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -17,7 +17,6 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, UserDecryptionOptionsServiceAbstraction, TrustedDeviceUserDecryptionOption, UserDecryptionOptions, @@ -32,9 +31,7 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -156,7 +153,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { private activatedRoute: ActivatedRoute, private logService: LogService, private twoFactorService: TwoFactorService, - private loginEmailService: LoginEmailServiceAbstraction, private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, private ssoLoginService: SsoLoginServiceAbstraction, private masterPasswordService: InternalMasterPasswordServiceAbstraction, @@ -171,7 +167,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { private loginSuccessHandlerService: LoginSuccessHandlerService, private twoFactorAuthComponentCacheService: TwoFactorAuthComponentCacheService, private authService: AuthService, - private configService: ConfigService, ) {} async ngOnInit() { @@ -507,19 +502,15 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { } // TODO: PM-22663 use the new service to handle routing. - if ( - await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor) - ) { - const forceSetPasswordReason = await firstValueFrom( - this.masterPasswordService.forceSetPasswordReason$(userId), - ); + const forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(userId), + ); - if ( - forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword || - forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset - ) { - return "change-password"; - } + if ( + forceSetPasswordReason === ForceSetPasswordReason.WeakMasterPassword || + forceSetPasswordReason === ForceSetPasswordReason.AdminForcePasswordReset + ) { + return "change-password"; } return "vault"; @@ -575,11 +566,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { } private async handleChangePasswordRequired(orgIdentifier: string | undefined) { - const isSetInitialPasswordRefactorFlagOn = await this.configService.getFeatureFlag( - FeatureFlag.PM16117_SetInitialPasswordRefactor, - ); - const route = isSetInitialPasswordRefactorFlagOn ? "set-initial-password" : "set-password"; - + const route = "set-initial-password"; await this.router.navigate([route], { queryParams: { identifier: orgIdentifier, diff --git a/libs/auth/src/common/abstractions/index.ts b/libs/auth/src/common/abstractions/index.ts index 937b79e5ba0..6961a029e7d 100644 --- a/libs/auth/src/common/abstractions/index.ts +++ b/libs/auth/src/common/abstractions/index.ts @@ -1,5 +1,4 @@ export * from "./auth-request-api.service"; -export * from "./pin.service.abstraction"; export * from "./login-email.service"; export * from "./login-strategy.service"; export * from "./user-decryption-options.service.abstraction"; diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 78561b443a3..1a6592887ba 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -327,6 +327,7 @@ describe("LoginStrategy", () => { const tokenResponse = identityTokenResponseFactory(); tokenResponse.privateKey = null; keyService.makeKeyPair.mockResolvedValue(["PUBLIC_KEY", new EncString("PRIVATE_KEY")]); + keyService.getUserKey.mockResolvedValue(userKey); apiService.postIdentityToken.mockResolvedValue(tokenResponse); masterPasswordService.masterKeySubject.next(masterKey); @@ -343,6 +344,15 @@ describe("LoginStrategy", () => { expect(apiService.postAccountKeys).toHaveBeenCalled(); }); + + it("throws if userKey is CoseEncrypt0 (V2 encryption) in createKeyPairForOldAccount", async () => { + keyService.getUserKey.mockResolvedValue({ + inner: () => ({ type: 7 }), + } as UserKey); + await expect(passwordLoginStrategy["createKeyPairForOldAccount"](userId)).resolves.toBe( + undefined, + ); + }); }); describe("Two-factor authentication", () => { diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 463ea676163..53e34147d9f 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -31,6 +31,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Account, AccountProfile } from "@bitwarden/common/platform/models/domain/account"; import { UserId } from "@bitwarden/common/types/guid"; import { @@ -325,7 +326,11 @@ export abstract class LoginStrategy { protected async createKeyPairForOldAccount(userId: UserId) { try { - const userKey = await this.keyService.getUserKeyWithLegacySupport(userId); + const userKey = await this.keyService.getUserKey(userId); + if (userKey.inner().type == EncryptionType.CoseEncrypt0) { + throw new Error("Cannot create key pair for account on V2 encryption"); + } + const [publicKey, privateKey] = await this.keyService.makeKeyPair(userKey); if (!privateKey.encryptedString) { throw new Error("Failed to create encrypted private key"); diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index 61a06f94b02..8e3867d1b36 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -12,7 +12,6 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; import { @@ -221,7 +220,10 @@ describe("PasswordLoginStrategy", () => { await passwordLoginStrategy.logIn(credentials); - expect(policyService.evaluateMasterPassword).not.toHaveBeenCalled(); + expect(masterPasswordService.mock.setForceSetPasswordReason).not.toHaveBeenCalledWith( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); }); it("does not force the user to update their master password when it meets requirements", async () => { @@ -230,7 +232,10 @@ describe("PasswordLoginStrategy", () => { await passwordLoginStrategy.logIn(credentials); - expect(policyService.evaluateMasterPassword).toHaveBeenCalled(); + expect(masterPasswordService.mock.setForceSetPasswordReason).not.toHaveBeenCalledWith( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); }); it("when given master password policies as part of the login credentials from an org invite, it combines them with the token response policies to evaluate the user's password as weak", async () => { @@ -242,12 +247,6 @@ describe("PasswordLoginStrategy", () => { policyService.evaluateMasterPassword.mockReturnValue(false); tokenService.decodeAccessToken.mockResolvedValue({ sub: userId }); - jest - .spyOn(configService, "getFeatureFlag") - .mockImplementation((flag: FeatureFlag) => - Promise.resolve(flag === FeatureFlag.PM16117_ChangeExistingPasswordRefactor), - ); - credentials.masterPasswordPoliciesFromOrgInvite = Object.assign( new MasterPasswordPolicyOptions(), { @@ -296,9 +295,16 @@ describe("PasswordLoginStrategy", () => { it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => { passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any); - policyService.evaluateMasterPassword.mockReturnValue(false); tokenService.decodeAccessToken.mockResolvedValue({ sub: userId }); + const combinedMasterPasswordPolicyOptions = Object.assign(new MasterPasswordPolicyOptions(), { + enforceOnLogin: true, + }); + policyService.combineMasterPasswordPolicyOptions.mockReturnValue( + combinedMasterPasswordPolicyOptions, + ); + policyService.evaluateMasterPassword.mockReturnValue(false); + await passwordLoginStrategy.logIn(credentials); expect(policyService.evaluateMasterPassword).toHaveBeenCalled(); @@ -330,9 +336,16 @@ describe("PasswordLoginStrategy", () => { it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => { passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any); - policyService.evaluateMasterPassword.mockReturnValue(false); tokenService.decodeAccessToken.mockResolvedValue({ sub: userId }); + const combinedMasterPasswordPolicyOptions = Object.assign(new MasterPasswordPolicyOptions(), { + enforceOnLogin: true, + }); + policyService.combineMasterPasswordPolicyOptions.mockReturnValue( + combinedMasterPasswordPolicyOptions, + ); + policyService.evaluateMasterPassword.mockReturnValue(false); + const token2FAResponse = new IdentityTwoFactorResponse({ TwoFactorProviders: ["0"], TwoFactorProviders2: { 0: null }, diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index cd3d5df1d5e..3482e73d5d7 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -12,7 +12,6 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { HashPurpose } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; @@ -171,35 +170,22 @@ export class PasswordLoginStrategy extends LoginStrategy { return; } - // The identity result can contain master password policies for the user's organizations - let masterPasswordPolicyOptions: MasterPasswordPolicyOptions | undefined; + // The identity result can contain master password policies for the user's organizations. + // Get the master password policy options from both the org invite and the identity response. + const masterPasswordPolicyOptions = this.policyService.combineMasterPasswordPolicyOptions( + credentials.masterPasswordPoliciesFromOrgInvite, + this.getMasterPasswordPolicyOptionsFromResponse(identityResponse), + ); + // We deliberately do not check enforceOnLogin as existing users who are logging + // in after getting an org invite should always be forced to set a password that + // meets the org's policy. Org Invite -> Registration also works this way for + // new BW users as well. if ( - await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor) + !credentials.masterPasswordPoliciesFromOrgInvite && + !masterPasswordPolicyOptions?.enforceOnLogin ) { - // Get the master password policy options from both the org invite and the identity response. - masterPasswordPolicyOptions = this.policyService.combineMasterPasswordPolicyOptions( - credentials.masterPasswordPoliciesFromOrgInvite, - this.getMasterPasswordPolicyOptionsFromResponse(identityResponse), - ); - - // We deliberately do not check enforceOnLogin as existing users who are logging - // in after getting an org invite should always be forced to set a password that - // meets the org's policy. Org Invite -> Registration also works this way for - // new BW users as well. - if ( - !credentials.masterPasswordPoliciesFromOrgInvite && - !masterPasswordPolicyOptions?.enforceOnLogin - ) { - return; - } - } else { - masterPasswordPolicyOptions = - this.getMasterPasswordPolicyOptionsFromResponse(identityResponse); - - if (!masterPasswordPolicyOptions?.enforceOnLogin) { - return; - } + return; } // If there is a policy active, evaluate the supplied password before its no longer in memory diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index 47a9d19f651..f057dc47c63 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -10,7 +10,6 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; @@ -83,6 +82,7 @@ describe("SsoLoginStrategy", () => { const ssoCodeVerifier = "SSO_CODE_VERIFIER"; const ssoRedirectUrl = "SSO_REDIRECT_URL"; const ssoOrgId = "SSO_ORG_ID"; + const privateKey = "userKeyEncryptedPrivateKey"; beforeEach(async () => { accountService = mockAccountServiceWith(userId); @@ -114,6 +114,9 @@ describe("SsoLoginStrategy", () => { tokenService.decodeAccessToken.mockResolvedValue({ sub: userId, }); + keyService.userEncryptedPrivateKey$ + .calledWith(userId) + .mockReturnValue(of(privateKey as EncryptedString)); const mockVaultTimeoutAction = VaultTimeoutAction.Lock; const mockVaultTimeoutActionBSub = new BehaviorSubject( @@ -163,6 +166,7 @@ describe("SsoLoginStrategy", () => { it("sends SSO information to server", async () => { apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); + keyService.hasUserKey.mockResolvedValue(true); await ssoLoginStrategy.logIn(credentials); @@ -185,6 +189,7 @@ describe("SsoLoginStrategy", () => { it("does not set keys for new SSO user flow", async () => { const tokenResponse = identityTokenResponseFactory(); tokenResponse.key = null; + tokenResponse.privateKey = null; apiService.postIdentityToken.mockResolvedValue(tokenResponse); await ssoLoginStrategy.logIn(credentials); @@ -210,42 +215,28 @@ describe("SsoLoginStrategy", () => { ); }); - describe("given the PM16117_SetInitialPasswordRefactor feature flag is ON", () => { - beforeEach(() => { - configService.getFeatureFlag.mockImplementation(async (flag) => { - if (flag === FeatureFlag.PM16117_SetInitialPasswordRefactor) { - return true; - } - return false; - }); - }); + describe("given the user does not have the `trustedDeviceOption`, does not have a master password, is not using key connector, does not have a user key, but they DO have a `userKeyEncryptedPrivateKey`", () => { + it("should set the forceSetPasswordReason to TdeOffboardingUntrustedDevice", async () => { + // Arrange + const mockUserDecryptionOptions: IUserDecryptionOptionsServerResponse = { + HasMasterPassword: false, + TrustedDeviceOption: null, + KeyConnectorOption: null, + }; + const tokenResponse = identityTokenResponseFactory(null, mockUserDecryptionOptions); + apiService.postIdentityToken.mockResolvedValue(tokenResponse); - describe("given the user does not have the `trustedDeviceOption`, does not have a master password, is not using key connector, does not have a user key, but they DO have a `userKeyEncryptedPrivateKey`", () => { - it("should set the forceSetPasswordReason to TdeOffboardingUntrustedDevice", async () => { - // Arrange - const mockUserDecryptionOptions: IUserDecryptionOptionsServerResponse = { - HasMasterPassword: false, - TrustedDeviceOption: null, - KeyConnectorOption: null, - }; - const tokenResponse = identityTokenResponseFactory(null, mockUserDecryptionOptions); - apiService.postIdentityToken.mockResolvedValue(tokenResponse); + keyService.hasUserKey.mockResolvedValue(false); - keyService.userEncryptedPrivateKey$.mockReturnValue( - of("userKeyEncryptedPrivateKey" as EncryptedString), - ); - keyService.hasUserKey.mockResolvedValue(false); + // Act + await ssoLoginStrategy.logIn(credentials); - // Act - await ssoLoginStrategy.logIn(credentials); - - // Assert - expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledTimes(1); - expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( - ForceSetPasswordReason.TdeOffboardingUntrustedDevice, - userId, - ); - }); + // Assert + expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledTimes(1); + expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.TdeOffboardingUntrustedDevice, + userId, + ); }); }); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 8ab84f0968a..6f1231b3559 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -9,7 +9,6 @@ import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity- import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { HttpStatusCode } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; @@ -344,38 +343,18 @@ export class SsoLoginStrategy extends LoginStrategy { tokenResponse: IdentityTokenResponse, userId: UserId, ): Promise { - const isSetInitialPasswordFlagOn = await this.configService.getFeatureFlag( - FeatureFlag.PM16117_SetInitialPasswordRefactor, - ); - - if (isSetInitialPasswordFlagOn) { - if (tokenResponse.hasMasterKeyEncryptedUserKey()) { - // User has masterKeyEncryptedUserKey, so set the userKeyEncryptedPrivateKey - // Note: new JIT provisioned SSO users will not yet have a user asymmetric key pair - // and so we don't want them falling into the createKeyPairForOldAccount flow - await this.keyService.setPrivateKey( - tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount(userId)), - userId, - ); - } else if (tokenResponse.privateKey) { - // User doesn't have masterKeyEncryptedUserKey but they do have a userKeyEncryptedPrivateKey - // This is just existing TDE users or a TDE offboarder on an untrusted device - await this.keyService.setPrivateKey(tokenResponse.privateKey, userId); - } - // else { - // User could be new JIT provisioned SSO user in either a MP encryption org OR a TDE org. - // In either case, the user doesn't yet have a user asymmetric key pair, a user key, or a master key + master key encrypted user key. - // } - } else { - // A user that does not yet have a masterKeyEncryptedUserKey set is a new SSO user - const newSsoUser = tokenResponse.key == null; - - if (!newSsoUser) { - await this.keyService.setPrivateKey( - tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount(userId)), - userId, - ); - } + if (tokenResponse.hasMasterKeyEncryptedUserKey()) { + // User has masterKeyEncryptedUserKey, so set the userKeyEncryptedPrivateKey + // Note: new JIT provisioned SSO users will not yet have a user asymmetric key pair + // and so we don't want them falling into the createKeyPairForOldAccount flow + await this.keyService.setPrivateKey( + tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount(userId)), + userId, + ); + } else if (tokenResponse.privateKey) { + // User doesn't have masterKeyEncryptedUserKey but they do have a userKeyEncryptedPrivateKey + // This is just existing TDE users or a TDE offboarder on an untrusted device + await this.keyService.setPrivateKey(tokenResponse.privateKey, userId); } } @@ -431,30 +410,25 @@ export class SsoLoginStrategy extends LoginStrategy { // - UserDecryptionOptions.UsesKeyConnector is undefined. -- they aren't using key connector // - UserKey is not set after successful login -- because automatic decryption is not available // - userKeyEncryptedPrivateKey is set after successful login -- this is the key differentiator between a TDE org user logging into an untrusted device and MP encryption JIT provisioned user logging in for the first time. - const isSetInitialPasswordFlagOn = await this.configService.getFeatureFlag( - FeatureFlag.PM16117_SetInitialPasswordRefactor, + // Why is that the case? Because we set the userKeyEncryptedPrivateKey when we create the userKey, and this is serving as a proxy to tell us that the userKey has been created already (when enrolling in TDE). + const hasUserKeyEncryptedPrivateKey = await firstValueFrom( + this.keyService.userEncryptedPrivateKey$(userId), ); + const hasUserKey = await this.keyService.hasUserKey(userId); - if (isSetInitialPasswordFlagOn) { - const hasUserKeyEncryptedPrivateKey = await firstValueFrom( - this.keyService.userEncryptedPrivateKey$(userId), + // TODO: PM-23491 we should explore consolidating this logic into a flag on the server. It could be set when an org is switched from TDE to MP encryption for each org user. + if ( + !userDecryptionOptions.trustedDeviceOption && + !userDecryptionOptions.hasMasterPassword && + !userDecryptionOptions.keyConnectorOption?.keyConnectorUrl && + hasUserKeyEncryptedPrivateKey && + !hasUserKey + ) { + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.TdeOffboardingUntrustedDevice, + userId, ); - const hasUserKey = await this.keyService.hasUserKey(userId); - - // TODO: PM-23491 we should explore consolidating this logic into a flag on the server. It could be set when an org is switched from TDE to MP encryption for each org user. - if ( - !userDecryptionOptions.trustedDeviceOption && - !userDecryptionOptions.hasMasterPassword && - !userDecryptionOptions.keyConnectorOption?.keyConnectorUrl && - hasUserKeyEncryptedPrivateKey && - !hasUserKey - ) { - await this.masterPasswordService.setForceSetPasswordReason( - ForceSetPasswordReason.TdeOffboardingUntrustedDevice, - userId, - ); - return true; - } + return true; } // Check if user has permission to set password but hasn't yet diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts index 3e1761290dd..69f38f40989 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts @@ -86,9 +86,6 @@ describe("AuthRequestService", () => { describe("approveOrDenyAuthRequest", () => { beforeEach(() => { - encryptService.rsaEncrypt.mockResolvedValue({ - encryptedString: "ENCRYPTED_STRING", - } as EncString); encryptService.encapsulateKeyUnsigned.mockResolvedValue({ encryptedString: "ENCRYPTED_STRING", } as EncString); diff --git a/libs/auth/src/common/services/index.ts b/libs/auth/src/common/services/index.ts index f66a827a2c2..4d9c4ff42f7 100644 --- a/libs/auth/src/common/services/index.ts +++ b/libs/auth/src/common/services/index.ts @@ -1,4 +1,3 @@ -export * from "./pin/pin.service.implementation"; export * from "./login-email/login-email.service"; export * from "./login-strategies/login-strategy.service"; export * from "./user-decryption-options/user-decryption-options.service"; diff --git a/libs/client-type/README.md b/libs/client-type/README.md new file mode 100644 index 00000000000..3a29bea584f --- /dev/null +++ b/libs/client-type/README.md @@ -0,0 +1,5 @@ +# client-type + +Owned by: platform + +Exports the ClientType enum diff --git a/libs/client-type/eslint.config.mjs b/libs/client-type/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/client-type/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/client-type/jest.config.js b/libs/client-type/jest.config.js new file mode 100644 index 00000000000..f54ab83aa31 --- /dev/null +++ b/libs/client-type/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "client-type", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/client-type", +}; diff --git a/libs/client-type/package.json b/libs/client-type/package.json new file mode 100644 index 00000000000..1db72603bf9 --- /dev/null +++ b/libs/client-type/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/client-type", + "version": "0.0.1", + "description": "Exports the ClientType enum", + "private": true, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0", + "author": "platform" +} diff --git a/libs/client-type/project.json b/libs/client-type/project.json new file mode 100644 index 00000000000..8231e6634e4 --- /dev/null +++ b/libs/client-type/project.json @@ -0,0 +1,33 @@ +{ + "name": "client-type", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/client-type/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/client-type", + "main": "libs/client-type/src/index.ts", + "tsConfig": "libs/client-type/tsconfig.lib.json", + "assets": ["libs/client-type/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/client-type/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/client-type/jest.config.js" + } + } + } +} diff --git a/libs/client-type/src/client-type.spec.ts b/libs/client-type/src/client-type.spec.ts new file mode 100644 index 00000000000..a178bba394b --- /dev/null +++ b/libs/client-type/src/client-type.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("client-type", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/client-type/src/index.ts b/libs/client-type/src/index.ts new file mode 100644 index 00000000000..25e9d6f3371 --- /dev/null +++ b/libs/client-type/src/index.ts @@ -0,0 +1,10 @@ +// FIXME: update to use a const object instead of a typescript enum +// eslint-disable-next-line @bitwarden/platform/no-enums +export enum ClientType { + Web = "web", + Browser = "browser", + Desktop = "desktop", + // Mobile = "mobile", + Cli = "cli", + // DirectoryConnector = "connector", +} diff --git a/libs/client-type/tsconfig.eslint.json b/libs/client-type/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/client-type/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/client-type/tsconfig.json b/libs/client-type/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/client-type/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/client-type/tsconfig.lib.json b/libs/client-type/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/client-type/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/client-type/tsconfig.spec.json b/libs/client-type/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/client-type/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/common/spec/fake-state-provider.ts b/libs/common/spec/fake-state-provider.ts index 9f72ccada55..73aadc7931a 100644 --- a/libs/common/spec/fake-state-provider.ts +++ b/libs/common/spec/fake-state-provider.ts @@ -1,309 +1,9 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { mock } from "jest-mock-extended"; -import { Observable, map, of, switchMap, take } from "rxjs"; - -import { - GlobalState, - GlobalStateProvider, - KeyDefinition, - ActiveUserState, - SingleUserState, - SingleUserStateProvider, - StateProvider, - ActiveUserStateProvider, - DerivedState, - DeriveDefinition, - DerivedStateProvider, - UserKeyDefinition, -} from "../src/platform/state"; -import { UserId } from "../src/types/guid"; -import { DerivedStateDependencies } from "../src/types/state"; - -import { FakeAccountService } from "./fake-account-service"; -import { - FakeActiveUserState, - FakeDerivedState, - FakeGlobalState, - FakeSingleUserState, -} from "./fake-state"; - -export class FakeGlobalStateProvider implements GlobalStateProvider { - mock = mock(); - establishedMocks: Map> = new Map(); - states: Map> = new Map(); - get(keyDefinition: KeyDefinition): GlobalState { - this.mock.get(keyDefinition); - const cacheKey = this.cacheKey(keyDefinition); - let result = this.states.get(cacheKey); - - if (result == null) { - let fake: FakeGlobalState; - // Look for established mock - if (this.establishedMocks.has(keyDefinition.key)) { - fake = this.establishedMocks.get(keyDefinition.key) as FakeGlobalState; - } else { - fake = new FakeGlobalState(); - } - fake.keyDefinition = keyDefinition; - result = fake; - this.states.set(cacheKey, result); - - result = new FakeGlobalState(); - this.states.set(cacheKey, result); - } - return result as GlobalState; - } - - private cacheKey(keyDefinition: KeyDefinition) { - return `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`; - } - - getFake(keyDefinition: KeyDefinition): FakeGlobalState { - return this.get(keyDefinition) as FakeGlobalState; - } - - mockFor(keyDefinition: KeyDefinition, initialValue?: T): FakeGlobalState { - const cacheKey = this.cacheKey(keyDefinition); - if (!this.states.has(cacheKey)) { - this.states.set(cacheKey, new FakeGlobalState(initialValue)); - } - return this.states.get(cacheKey) as FakeGlobalState; - } -} - -export class FakeSingleUserStateProvider implements SingleUserStateProvider { - mock = mock(); - states: Map> = new Map(); - - constructor( - readonly updateSyncCallback?: ( - key: UserKeyDefinition, - userId: UserId, - newValue: unknown, - ) => Promise, - ) {} - - get(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState { - this.mock.get(userId, userKeyDefinition); - const cacheKey = this.cacheKey(userId, userKeyDefinition); - let result = this.states.get(cacheKey); - - if (result == null) { - result = this.buildFakeState(userId, userKeyDefinition); - this.states.set(cacheKey, result); - } - return result as SingleUserState; - } - - getFake( - userId: UserId, - userKeyDefinition: UserKeyDefinition, - { allowInit }: { allowInit: boolean } = { allowInit: true }, - ): FakeSingleUserState { - if (!allowInit && this.states.get(this.cacheKey(userId, userKeyDefinition)) == null) { - return null; - } - - return this.get(userId, userKeyDefinition) as FakeSingleUserState; - } - - mockFor( - userId: UserId, - userKeyDefinition: UserKeyDefinition, - initialValue?: T, - ): FakeSingleUserState { - const cacheKey = this.cacheKey(userId, userKeyDefinition); - if (!this.states.has(cacheKey)) { - this.states.set(cacheKey, this.buildFakeState(userId, userKeyDefinition, initialValue)); - } - return this.states.get(cacheKey) as FakeSingleUserState; - } - - private buildFakeState( - userId: UserId, - userKeyDefinition: UserKeyDefinition, - initialValue?: T, - ) { - const state = new FakeSingleUserState(userId, initialValue, async (...args) => { - await this.updateSyncCallback?.(userKeyDefinition, ...args); - }); - state.keyDefinition = userKeyDefinition; - return state; - } - - private cacheKey(userId: UserId, userKeyDefinition: UserKeyDefinition) { - return `${userKeyDefinitionCacheKey(userKeyDefinition)}_${userId}`; - } -} - -export class FakeActiveUserStateProvider implements ActiveUserStateProvider { - activeUserId$: Observable; - states: Map> = new Map(); - - constructor( - public accountService: FakeAccountService, - readonly updateSyncCallback?: ( - key: UserKeyDefinition, - userId: UserId, - newValue: unknown, - ) => Promise, - ) { - this.activeUserId$ = accountService.activeAccountSubject.asObservable().pipe(map((a) => a?.id)); - } - - get(userKeyDefinition: UserKeyDefinition): ActiveUserState { - const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition); - let result = this.states.get(cacheKey); - - if (result == null) { - result = this.buildFakeState(userKeyDefinition); - this.states.set(cacheKey, result); - } - return result as ActiveUserState; - } - - getFake( - userKeyDefinition: UserKeyDefinition, - { allowInit }: { allowInit: boolean } = { allowInit: true }, - ): FakeActiveUserState { - if (!allowInit && this.states.get(userKeyDefinitionCacheKey(userKeyDefinition)) == null) { - return null; - } - return this.get(userKeyDefinition) as FakeActiveUserState; - } - - mockFor(userKeyDefinition: UserKeyDefinition, initialValue?: T): FakeActiveUserState { - const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition); - if (!this.states.has(cacheKey)) { - this.states.set(cacheKey, this.buildFakeState(userKeyDefinition, initialValue)); - } - return this.states.get(cacheKey) as FakeActiveUserState; - } - - private buildFakeState(userKeyDefinition: UserKeyDefinition, initialValue?: T) { - const state = new FakeActiveUserState(this.accountService, initialValue, async (...args) => { - await this.updateSyncCallback?.(userKeyDefinition, ...args); - }); - state.keyDefinition = userKeyDefinition; - return state; - } -} - -function userKeyDefinitionCacheKey(userKeyDefinition: UserKeyDefinition) { - return `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`; -} - -export class FakeStateProvider implements StateProvider { - mock = mock(); - getUserState$(userKeyDefinition: UserKeyDefinition, userId?: UserId): Observable { - this.mock.getUserState$(userKeyDefinition, userId); - if (userId) { - return this.getUser(userId, userKeyDefinition).state$; - } - - return this.getActive(userKeyDefinition).state$; - } - - getUserStateOrDefault$( - userKeyDefinition: UserKeyDefinition, - config: { userId: UserId | undefined; defaultValue?: T }, - ): Observable { - const { userId, defaultValue = null } = config; - this.mock.getUserStateOrDefault$(userKeyDefinition, config); - if (userId) { - return this.getUser(userId, userKeyDefinition).state$; - } - - return this.activeUserId$.pipe( - take(1), - switchMap((userId) => - userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue), - ), - ); - } - - async setUserState( - userKeyDefinition: UserKeyDefinition, - value: T | null, - userId?: UserId, - ): Promise<[UserId, T | null]> { - await this.mock.setUserState(userKeyDefinition, value, userId); - if (userId) { - return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)]; - } else { - return await this.getActive(userKeyDefinition).update(() => value); - } - } - - getActive(userKeyDefinition: UserKeyDefinition): ActiveUserState { - return this.activeUser.get(userKeyDefinition); - } - - getGlobal(keyDefinition: KeyDefinition): GlobalState { - return this.global.get(keyDefinition); - } - - getUser(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState { - return this.singleUser.get(userId, userKeyDefinition); - } - - getDerived( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState { - return this.derived.get(parentState$, deriveDefinition, dependencies); - } - - constructor(public accountService: FakeAccountService) {} - - private distributeSingleUserUpdate( - key: UserKeyDefinition, - userId: UserId, - newState: unknown, - ) { - if (this.activeUser.accountService.activeUserId === userId) { - const state = this.activeUser.getFake(key, { allowInit: false }); - state?.nextState(newState, { syncValue: false }); - } - } - - private distributeActiveUserUpdate( - key: UserKeyDefinition, - userId: UserId, - newState: unknown, - ) { - this.singleUser - .getFake(userId, key, { allowInit: false }) - ?.nextState(newState, { syncValue: false }); - } - - global: FakeGlobalStateProvider = new FakeGlobalStateProvider(); - singleUser: FakeSingleUserStateProvider = new FakeSingleUserStateProvider( - this.distributeSingleUserUpdate.bind(this), - ); - activeUser: FakeActiveUserStateProvider = new FakeActiveUserStateProvider( - this.accountService, - this.distributeActiveUserUpdate.bind(this), - ); - derived: FakeDerivedStateProvider = new FakeDerivedStateProvider(); - activeUserId$: Observable = this.activeUser.activeUserId$; -} - -export class FakeDerivedStateProvider implements DerivedStateProvider { - states: Map> = new Map(); - get( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState { - let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState; - - if (result == null) { - result = new FakeDerivedState(parentState$, deriveDefinition, dependencies); - this.states.set(deriveDefinition.buildCacheKey(), result); - } - return result; - } -} +export { + MinimalAccountService, + FakeActiveUserAccessor, + FakeGlobalStateProvider, + FakeSingleUserStateProvider, + FakeActiveUserStateProvider, + FakeStateProvider, + FakeDerivedStateProvider, +} from "@bitwarden/state-test-utils"; diff --git a/libs/common/spec/fake-state.ts b/libs/common/spec/fake-state.ts index d4fbd108475..14c1b117b5f 100644 --- a/libs/common/spec/fake-state.ts +++ b/libs/common/spec/fake-state.ts @@ -1,279 +1,6 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Observable, ReplaySubject, concatMap, filter, firstValueFrom, map, timeout } from "rxjs"; - -import { - DerivedState, - GlobalState, - SingleUserState, - ActiveUserState, - KeyDefinition, - DeriveDefinition, - UserKeyDefinition, -} from "../src/platform/state"; -// eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class -import { StateUpdateOptions } from "../src/platform/state/state-update-options"; -// eslint-disable-next-line import/no-restricted-paths -- using unexposed options for clean typing in test class -import { CombinedState, activeMarker } from "../src/platform/state/user-state"; -import { UserId } from "../src/types/guid"; -import { DerivedStateDependencies } from "../src/types/state"; - -import { FakeAccountService } from "./fake-account-service"; - -const DEFAULT_TEST_OPTIONS: StateUpdateOptions = { - shouldUpdate: () => true, - combineLatestWith: null, - msTimeout: 10, -}; - -function populateOptionsWithDefault( - options: StateUpdateOptions, -): StateUpdateOptions { - return { - ...DEFAULT_TEST_OPTIONS, - ...options, - }; -} - -export class FakeGlobalState implements GlobalState { - // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup - stateSubject = new ReplaySubject(1); - - constructor(initialValue?: T) { - this.stateSubject.next(initialValue ?? null); - } - - nextState(state: T) { - this.stateSubject.next(state); - } - - async update( - configureState: (state: T, dependency: TCombine) => T, - options?: StateUpdateOptions, - ): Promise { - options = populateOptionsWithDefault(options); - if (this.stateSubject["_buffer"].length == 0) { - // throw a more helpful not initialized error - throw new Error( - "You must initialize the state with a value before calling update. Try calling `stateSubject.next(initialState)` before calling update", - ); - } - const current = await firstValueFrom(this.state$.pipe(timeout(100))); - const combinedDependencies = - options.combineLatestWith != null - ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) - : null; - if (!options.shouldUpdate(current, combinedDependencies)) { - return current; - } - const newState = configureState(current, combinedDependencies); - this.stateSubject.next(newState); - this.nextMock(newState); - return newState; - } - - /** Tracks update values resolved by `FakeState.update` */ - nextMock = jest.fn(); - - get state$() { - return this.stateSubject.asObservable(); - } - - private _keyDefinition: KeyDefinition | null = null; - get keyDefinition() { - if (this._keyDefinition == null) { - throw new Error( - "Key definition not yet set, usually this means your sut has not asked for this state yet", - ); - } - return this._keyDefinition; - } - set keyDefinition(value: KeyDefinition) { - this._keyDefinition = value; - } -} - -export class FakeSingleUserState implements SingleUserState { - // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup - stateSubject = new ReplaySubject<{ - syncValue: boolean; - combinedState: CombinedState; - }>(1); - - state$: Observable; - combinedState$: Observable>; - - constructor( - readonly userId: UserId, - initialValue?: T, - updateSyncCallback?: (userId: UserId, newValue: T) => Promise, - ) { - // Inform the state provider of updates to keep active user states in sync - this.stateSubject - .pipe( - filter((next) => next.syncValue), - concatMap(async ({ combinedState }) => { - await updateSyncCallback?.(...combinedState); - }), - ) - .subscribe(); - this.nextState(initialValue ?? null, { syncValue: initialValue != null }); - - this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState)); - this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); - } - - nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) { - this.stateSubject.next({ - syncValue, - combinedState: [this.userId, state], - }); - } - - async update( - configureState: (state: T | null, dependency: TCombine) => T | null, - options?: StateUpdateOptions, - ): Promise { - options = populateOptionsWithDefault(options); - const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout))); - const combinedDependencies = - options.combineLatestWith != null - ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) - : null; - if (!options.shouldUpdate(current, combinedDependencies)) { - return current; - } - const newState = configureState(current, combinedDependencies); - this.nextState(newState); - this.nextMock(newState); - return newState; - } - - /** Tracks update values resolved by `FakeState.update` */ - nextMock = jest.fn(); - private _keyDefinition: UserKeyDefinition | null = null; - get keyDefinition() { - if (this._keyDefinition == null) { - throw new Error( - "Key definition not yet set, usually this means your sut has not asked for this state yet", - ); - } - return this._keyDefinition; - } - set keyDefinition(value: UserKeyDefinition) { - this._keyDefinition = value; - } -} -export class FakeActiveUserState implements ActiveUserState { - [activeMarker]: true; - - // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup - stateSubject = new ReplaySubject<{ - syncValue: boolean; - combinedState: CombinedState; - }>(1); - - state$: Observable; - combinedState$: Observable>; - - constructor( - private accountService: FakeAccountService, - initialValue?: T, - updateSyncCallback?: (userId: UserId, newValue: T) => Promise, - ) { - // Inform the state provider of updates to keep single user states in sync - this.stateSubject.pipe( - filter((next) => next.syncValue), - concatMap(async ({ combinedState }) => { - await updateSyncCallback?.(...combinedState); - }), - ); - this.nextState(initialValue ?? null, { syncValue: initialValue != null }); - - this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState)); - this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); - } - - get userId() { - return this.accountService.activeUserId; - } - - nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) { - this.stateSubject.next({ - syncValue, - combinedState: [this.userId, state], - }); - } - - async update( - configureState: (state: T | null, dependency: TCombine) => T | null, - options?: StateUpdateOptions, - ): Promise<[UserId, T | null]> { - options = populateOptionsWithDefault(options); - const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout))); - const combinedDependencies = - options.combineLatestWith != null - ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) - : null; - if (!options.shouldUpdate(current, combinedDependencies)) { - return [this.userId, current]; - } - const newState = configureState(current, combinedDependencies); - this.nextState(newState); - this.nextMock([this.userId, newState]); - return [this.userId, newState]; - } - - /** Tracks update values resolved by `FakeState.update` */ - nextMock = jest.fn(); - - private _keyDefinition: UserKeyDefinition | null = null; - get keyDefinition() { - if (this._keyDefinition == null) { - throw new Error( - "Key definition not yet set, usually this means your sut has not asked for this state yet", - ); - } - return this._keyDefinition; - } - set keyDefinition(value: UserKeyDefinition) { - this._keyDefinition = value; - } -} - -export class FakeDerivedState - implements DerivedState -{ - // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup - stateSubject = new ReplaySubject(1); - - constructor( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ) { - parentState$ - .pipe( - concatMap(async (v) => { - const newState = deriveDefinition.derive(v, dependencies); - if (newState instanceof Promise) { - return newState; - } - return Promise.resolve(newState); - }), - ) - .subscribe((newState) => { - this.stateSubject.next(newState); - }); - } - - forceValue(value: TTo): Promise { - this.stateSubject.next(value); - return Promise.resolve(value); - } - forceValueMock = this.forceValue as jest.MockedFunction; - - get state$() { - return this.stateSubject.asObservable(); - } -} +export { + FakeGlobalState, + FakeSingleUserState, + FakeActiveUserState, + FakeDerivedState, +} from "@bitwarden/state-test-utils"; diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index 65b709a201c..db9a7e0842c 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -1,7 +1,6 @@ // 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 { Observable } from "rxjs"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; @@ -78,57 +77,4 @@ export const mockFromSdk = (stub: any) => { return `${stub}_fromSdk`; }; -/** - * Tracks the emissions of the given observable. - * - * Call this function before you expect any emissions and then use code that will cause the observable to emit values, - * then assert after all expected emissions have occurred. - * @param observable - * @returns An array that will be populated with all emissions of the observable. - */ -export function trackEmissions(observable: Observable): T[] { - const emissions: T[] = []; - observable.subscribe((value) => { - switch (value) { - case undefined: - case null: - emissions.push(value); - return; - default: - // process by type - break; - } - - switch (typeof value) { - case "string": - case "number": - case "boolean": - emissions.push(value); - break; - case "symbol": - // Cheating types to make symbols work at all - emissions.push(value.toString() as T); - break; - default: { - emissions.push(clone(value)); - } - } - }); - return emissions; -} - -function clone(value: any): any { - if (global.structuredClone != undefined) { - return structuredClone(value); - } else { - return JSON.parse(JSON.stringify(value)); - } -} - -export async function awaitAsync(ms = 1) { - if (ms < 1) { - await Promise.resolve(); - } else { - await new Promise((resolve) => setTimeout(resolve, ms)); - } -} +export { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils"; diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.ts b/libs/common/src/admin-console/services/policy/default-policy.service.ts index 798adf520f2..2d9518ee508 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.ts @@ -89,8 +89,7 @@ export class DefaultPolicyService implements PolicyService { const policies$ = policies ? of(policies) : this.policies$(userId); return policies$.pipe( map((obsPolicies) => { - // TODO: replace with this.combinePoliciesIntoMasterPasswordPolicyOptions(obsPolicies)) once - // FeatureFlag.PM16117_ChangeExistingPasswordRefactor is removed. + // TODO ([PM-23777]): replace with this.combinePoliciesIntoMasterPasswordPolicyOptions(obsPolicies)) let enforcedOptions: MasterPasswordPolicyOptions | undefined = undefined; const filteredPolicies = obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? []; diff --git a/libs/common/src/auth/services/default-active-user.accessor.ts b/libs/common/src/auth/services/default-active-user.accessor.ts new file mode 100644 index 00000000000..05775df9457 --- /dev/null +++ b/libs/common/src/auth/services/default-active-user.accessor.ts @@ -0,0 +1,19 @@ +import { map, Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +import { ActiveUserAccessor } from "../../platform/state"; +import { AccountService } from "../abstractions/account.service"; + +/** + * Implementation for Platform so they can avoid a direct dependency on AccountService. Not for general consumption. + */ +export class DefaultActiveUserAccessor implements ActiveUserAccessor { + constructor(private readonly accountService: AccountService) { + this.activeUserId$ = this.accountService.activeAccount$.pipe( + map((a) => (a != null ? a.id : null)), + ); + } + + activeUserId$: Observable; +} diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts b/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts index 33f276a87f2..b8a9a85ed0a 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts @@ -4,8 +4,6 @@ import { of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { - PinLockType, - PinServiceAbstraction, UserDecryptionOptions, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; @@ -21,6 +19,8 @@ import { import { FakeAccountService, mockAccountServiceWith } from "../../../../spec"; import { InternalMasterPasswordServiceAbstraction } from "../../../key-management/master-password/abstractions/master-password.service.abstraction"; +import { PinServiceAbstraction } from "../../../key-management/pin/pin.service.abstraction"; +import { PinLockType } from "../../../key-management/pin/pin.service.implementation"; import { VaultTimeoutSettingsService } from "../../../key-management/vault-timeout"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { HashPurpose } from "../../../platform/enums"; diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.ts b/libs/common/src/auth/services/user-verification/user-verification.service.ts index 5837042b93f..2fed6713b40 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.ts @@ -14,10 +14,8 @@ import { KeyService, } from "@bitwarden/key-management"; -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { PinServiceAbstraction } from "../../../../../auth/src/common/abstractions/pin.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "../../../key-management/master-password/abstractions/master-password.service.abstraction"; +import { PinServiceAbstraction } from "../../../key-management/pin/pin.service.abstraction"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { HashPurpose } from "../../../platform/enums"; import { UserId } from "../../../types/guid"; diff --git a/libs/common/src/billing/abstractions/tax.service.abstraction.ts b/libs/common/src/billing/abstractions/tax.service.abstraction.ts index 73dc848c95f..c94fbcba652 100644 --- a/libs/common/src/billing/abstractions/tax.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/tax.service.abstraction.ts @@ -3,7 +3,6 @@ import { PreviewIndividualInvoiceRequest } from "../models/request/preview-indiv import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request"; import { PreviewTaxAmountForOrganizationTrialRequest } from "../models/request/tax"; import { PreviewInvoiceResponse } from "../models/response/preview-invoice.response"; -import { PreviewTaxAmountResponse } from "../models/response/tax"; export abstract class TaxServiceAbstraction { abstract getCountries(): CountryListItem[]; @@ -20,5 +19,5 @@ export abstract class TaxServiceAbstraction { abstract previewTaxAmountForOrganizationTrial: ( request: PreviewTaxAmountForOrganizationTrialRequest, - ) => Promise; + ) => Promise; } diff --git a/libs/common/src/billing/services/tax.service.ts b/libs/common/src/billing/services/tax.service.ts index 2632ca7083b..27966016913 100644 --- a/libs/common/src/billing/services/tax.service.ts +++ b/libs/common/src/billing/services/tax.service.ts @@ -1,5 +1,4 @@ import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax"; -import { PreviewTaxAmountResponse } from "@bitwarden/common/billing/models/response/tax"; import { ApiService } from "../../abstractions/api.service"; import { TaxServiceAbstraction } from "../abstractions/tax.service.abstraction"; @@ -306,13 +305,14 @@ export class TaxService implements TaxServiceAbstraction { async previewTaxAmountForOrganizationTrial( request: PreviewTaxAmountForOrganizationTrialRequest, - ): Promise { - return await this.apiService.send( + ): Promise { + const response = await this.apiService.send( "POST", "/tax/preview-amount/organization-trial", request, true, true, ); + return response as number; } } diff --git a/libs/common/src/enums/client-type.enum.ts b/libs/common/src/enums/client-type.enum.ts index 25e9d6f3371..466e67ecd52 100644 --- a/libs/common/src/enums/client-type.enum.ts +++ b/libs/common/src/enums/client-type.enum.ts @@ -1,10 +1 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum ClientType { - Web = "web", - Browser = "browser", - Desktop = "desktop", - // Mobile = "mobile", - Cli = "cli", - // DirectoryConnector = "connector", -} +export { ClientType } from "@bitwarden/client-type"; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 114ace8bd8e..e992ac64d46 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -14,8 +14,6 @@ export enum FeatureFlag { CreateDefaultLocation = "pm-19467-create-default-location", /* Auth */ - PM16117_SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor", - PM16117_ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor", PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals", /* Autofill */ @@ -34,17 +32,19 @@ export enum FeatureFlag { UseOrganizationWarningsService = "use-organization-warnings-service", AllowTrialLengthZero = "pm-20322-allow-trial-length-0", PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout", + PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", - PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service", - UseSDKForDecryption = "use-sdk-for-decryption", - PM17987_BlockType0 = "pm-17987-block-type-0", EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", + UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators", + + /* DIRT */ + EventBasedOrganizationIntegrations = "event-based-organization-integrations", /* Vault */ PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge", @@ -88,6 +88,10 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.DesktopSendUIRefresh]: FALSE, + [FeatureFlag.UseSdkPasswordGenerators]: FALSE, + + /* DIRT */ + [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, /* Vault */ [FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE, @@ -101,8 +105,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, /* Auth */ - [FeatureFlag.PM16117_SetInitialPasswordRefactor]: FALSE, - [FeatureFlag.PM16117_ChangeExistingPasswordRefactor]: FALSE, [FeatureFlag.PM14938_BrowserExtensionLoginApproval]: FALSE, /* Billing */ @@ -113,12 +115,10 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.UseOrganizationWarningsService]: FALSE, [FeatureFlag.AllowTrialLengthZero]: FALSE, [FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE, + [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, - [FeatureFlag.PM4154_BulkEncryptionService]: FALSE, - [FeatureFlag.UseSDKForDecryption]: FALSE, - [FeatureFlag.PM17987_BlockType0]: FALSE, [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, diff --git a/libs/common/src/key-management/crypto/abstractions/bulk-encrypt.service.ts b/libs/common/src/key-management/crypto/abstractions/bulk-encrypt.service.ts deleted file mode 100644 index 399ad75231e..00000000000 --- a/libs/common/src/key-management/crypto/abstractions/bulk-encrypt.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; -import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; - -export abstract class BulkEncryptService { - abstract decryptItems( - items: Decryptable[], - key: SymmetricCryptoKey, - ): Promise; - - abstract onServerConfigChange(newConfig: ServerConfig): void; -} diff --git a/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts b/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts index 5e4fa86a684..705a1c1a24e 100644 --- a/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/crypto-function.service.ts @@ -6,12 +6,20 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { CsprngArray } from "../../../types/csprng"; export abstract class CryptoFunctionService { + /** + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. + */ abstract pbkdf2( password: string | Uint8Array, salt: string | Uint8Array, algorithm: "sha256" | "sha512", iterations: number, ): Promise; + /** + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. + */ abstract hkdf( ikm: Uint8Array, salt: string | Uint8Array, @@ -19,51 +27,76 @@ export abstract class CryptoFunctionService { outputByteSize: number, algorithm: "sha256" | "sha512", ): Promise; + /** + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. + */ abstract hkdfExpand( prk: Uint8Array, info: string | Uint8Array, outputByteSize: number, algorithm: "sha256" | "sha512", ): Promise; + /** + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. + */ abstract hash( value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512" | "md5", ): Promise; - abstract hmac( - value: Uint8Array, - key: Uint8Array, - algorithm: "sha1" | "sha256" | "sha512", - ): Promise; - abstract compare(a: Uint8Array, b: Uint8Array): Promise; + /** + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. + */ abstract hmacFast( value: Uint8Array | string, key: Uint8Array | string, algorithm: "sha1" | "sha256" | "sha512", ): Promise; abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise; + /** + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. + */ abstract aesDecryptFastParameters( data: string, iv: string, mac: string, key: SymmetricCryptoKey, ): CbcDecryptParameters; + /** + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. + */ abstract aesDecryptFast({ mode, parameters, }: | { mode: "cbc"; parameters: CbcDecryptParameters } | { mode: "ecb"; parameters: EcbDecryptParameters }): Promise; + /** + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer. + */ abstract aesDecrypt( data: Uint8Array, iv: Uint8Array, key: Uint8Array, mode: "cbc" | "ecb", ): Promise; + /** + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. + */ abstract rsaEncrypt( data: Uint8Array, publicKey: Uint8Array, algorithm: "sha1" | "sha256", ): Promise; + /** + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations + * in the SDK instead. Further, you should probably never find yourself using this low-level crypto function. + */ abstract rsaDecrypt( data: Uint8Array, privateKey: Uint8Array, @@ -77,7 +110,6 @@ export abstract class CryptoFunctionService { abstract aesGenerateKey(bitLength: 128 | 192 | 256 | 512): Promise; /** * Generates a random array of bytes of the given length. Uses a cryptographically secure random number generator. - * * Do not use this for generating encryption keys. Use aesGenerateKey or rsaGenerateKeyPair instead. */ abstract randomBytes(length: number): Promise; diff --git a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts index 67db3591e74..ed43e25bd0a 100644 --- a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts @@ -1,51 +1,8 @@ -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; -import { Encrypted } from "../../../platform/interfaces/encrypted"; -import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { EncString } from "../models/enc-string"; export abstract class EncryptService { - /** - * @deprecated - * Decrypts an EncString to a string - * @param encString - The EncString to decrypt - * @param key - The key to decrypt the EncString with - * @param decryptTrace - A string to identify the context of the object being decrypted. This can include: field name, encryption type, cipher id, key type, but should not include - * sensitive information like encryption keys or data. This is used for logging when decryption errors occur in order to identify what failed to decrypt - * @returns The decrypted string - */ - abstract decryptToUtf8( - encString: EncString, - key: SymmetricCryptoKey, - decryptTrace?: string, - ): Promise; - /** - * @deprecated - * Decrypts an Encrypted object to a Uint8Array - * @param encThing - The Encrypted object to decrypt - * @param key - The key to decrypt the Encrypted object with - * @param decryptTrace - A string to identify the context of the object being decrypted. This can include: field name, encryption type, cipher id, key type, but should not include - * sensitive information like encryption keys or data. This is used for logging when decryption errors occur in order to identify what failed to decrypt - * @returns The decrypted Uint8Array - */ - abstract decryptToBytes( - encThing: Encrypted, - key: SymmetricCryptoKey, - decryptTrace?: string, - ): Promise; - - /** - * @deprecated Replaced by BulkEncryptService, remove once the feature is tested and the featureflag PM-4154-multi-worker-encryption-service is removed - * @param items The items to decrypt - * @param key The key to decrypt the items with - */ - abstract decryptItems( - items: Decryptable[], - key: SymmetricCryptoKey, - ): Promise; - /** * Encrypts a string to an EncString * @param plainValue - The value to encrypt @@ -188,12 +145,6 @@ export abstract class EncryptService { decapsulationKey: Uint8Array, ): Promise; - /** - * @deprecated Use @see {@link encapsulateKeyUnsigned} instead - * @param data - The data to encrypt - * @param publicKey - The public key to encrypt with - */ - abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise; /** * @deprecated Use @see {@link decapsulateKeyUnsigned} instead * @param data - The ciphertext to decrypt @@ -210,6 +161,4 @@ export abstract class EncryptService { value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512", ): Promise; - - abstract onServerConfigChange(newConfig: ServerConfig): void; } diff --git a/libs/common/src/key-management/crypto/models/enc-string.spec.ts b/libs/common/src/key-management/crypto/models/enc-string.spec.ts index ec1434c55d2..1be28d58963 100644 --- a/libs/common/src/key-management/crypto/models/enc-string.spec.ts +++ b/libs/common/src/key-management/crypto/models/enc-string.spec.ts @@ -4,7 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended"; // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; -import { makeEncString, makeStaticByteArray } from "../../../../spec"; +import { makeStaticByteArray } from "../../../../spec"; import { EncryptionType } from "../../../platform/enums"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../platform/services/container.service"; @@ -83,7 +83,7 @@ describe("EncString", () => { const keyService = mock(); keyService.hasUserKey.mockResolvedValue(true); - keyService.getUserKeyWithLegacySupport.mockResolvedValue( + keyService.getUserKey.mockResolvedValue( new SymmetricCryptoKey(makeStaticByteArray(32)) as UserKey, ); @@ -114,67 +114,6 @@ describe("EncString", () => { }); }); - describe("decryptWithKey", () => { - const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data"); - - const keyService = mock(); - const encryptService = mock(); - encryptService.decryptString - .calledWith(encString, expect.anything()) - .mockResolvedValue("decrypted"); - - function setupEncryption() { - encryptService.encryptString.mockImplementation(async (data, key) => { - return makeEncString(data); - }); - encryptService.decryptString.mockImplementation(async (encString, key) => { - return encString.data; - }); - } - - beforeEach(() => { - (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); - }); - - it("decrypts using the provided key and encryptService", async () => { - setupEncryption(); - - const key = new SymmetricCryptoKey(makeStaticByteArray(32)); - await encString.decryptWithKey(key, encryptService); - - expect(encryptService.decryptString).toHaveBeenCalledWith(encString, key); - }); - - it("fails to decrypt when key is null", async () => { - const decrypted = await encString.decryptWithKey(null, encryptService); - - expect(decrypted).toBe("[error: cannot decrypt]"); - expect(encString.decryptedValue).toBe("[error: cannot decrypt]"); - }); - - it("fails to decrypt when encryptService is null", async () => { - const decrypted = await encString.decryptWithKey( - new SymmetricCryptoKey(makeStaticByteArray(32)), - null, - ); - - expect(decrypted).toBe("[error: cannot decrypt]"); - expect(encString.decryptedValue).toBe("[error: cannot decrypt]"); - }); - - it("fails to decrypt when encryptService throws", async () => { - encryptService.decryptString.mockRejectedValue("error"); - - const decrypted = await encString.decryptWithKey( - new SymmetricCryptoKey(makeStaticByteArray(32)), - encryptService, - ); - - expect(decrypted).toBe("[error: cannot decrypt]"); - expect(encString.decryptedValue).toBe("[error: cannot decrypt]"); - }); - }); - describe("AesCbc256_B64", () => { it("constructor", () => { const encString = new EncString(EncryptionType.AesCbc256_B64, "data", "iv"); @@ -343,7 +282,7 @@ describe("EncString", () => { await encString.decrypt(null, key); - expect(keyService.getUserKeyWithLegacySupport).not.toHaveBeenCalled(); + expect(keyService.getUserKey).not.toHaveBeenCalled(); expect(encryptService.decryptString).toHaveBeenCalledWith(encString, key); }); @@ -361,11 +300,11 @@ describe("EncString", () => { it("gets the user's decryption key if required", async () => { const userKey = mock(); - keyService.getUserKeyWithLegacySupport.mockResolvedValue(userKey); + keyService.getUserKey.mockResolvedValue(userKey); await encString.decrypt(null, null); - expect(keyService.getUserKeyWithLegacySupport).toHaveBeenCalledWith(); + expect(keyService.getUserKey).toHaveBeenCalledWith(); expect(encryptService.decryptString).toHaveBeenCalledWith(encString, userKey); }); }); diff --git a/libs/common/src/key-management/crypto/models/enc-string.ts b/libs/common/src/key-management/crypto/models/enc-string.ts index 3478ced0cf3..47aa41275df 100644 --- a/libs/common/src/key-management/crypto/models/enc-string.ts +++ b/libs/common/src/key-management/crypto/models/enc-string.ts @@ -1,17 +1,18 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Jsonify, Opaque } from "type-fest"; +import { Jsonify } from "type-fest"; + +import { EncString as SdkEncString } from "@bitwarden/sdk-internal"; import { EncryptionType, EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE } from "../../../platform/enums"; import { Encrypted } from "../../../platform/interfaces/encrypted"; import { Utils } from "../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { EncryptService } from "../abstractions/encrypt.service"; export const DECRYPT_ERROR = "[error: cannot decrypt]"; export class EncString implements Encrypted { - encryptedString?: EncryptedString; + encryptedString?: SdkEncString; encryptionType?: EncryptionType; decryptedValue?: string; data?: string; @@ -43,7 +44,11 @@ export class EncString implements Encrypted { return this.data == null ? null : Utils.fromB64ToArray(this.data); } - toJSON() { + toSdk(): SdkEncString { + return this.encryptedString; + } + + toJSON(): string { return this.encryptedString as string; } @@ -57,14 +62,14 @@ export class EncString implements Encrypted { private initFromData(encType: EncryptionType, data: string, iv: string, mac: string) { if (iv != null) { - this.encryptedString = (encType + "." + iv + "|" + data) as EncryptedString; + this.encryptedString = (encType + "." + iv + "|" + data) as SdkEncString; } else { - this.encryptedString = (encType + "." + data) as EncryptedString; + this.encryptedString = (encType + "." + data) as SdkEncString; } // mac if (mac != null) { - this.encryptedString = (this.encryptedString + "|" + mac) as EncryptedString; + this.encryptedString = (this.encryptedString + "|" + mac) as SdkEncString; } this.encryptionType = encType; @@ -74,7 +79,7 @@ export class EncString implements Encrypted { } private initFromEncryptedString(encryptedString: string) { - this.encryptedString = encryptedString as EncryptedString; + this.encryptedString = encryptedString as SdkEncString; if (!this.encryptedString) { return; } @@ -184,31 +189,14 @@ export class EncString implements Encrypted { return this.decryptedValue; } - async decryptWithKey( - key: SymmetricCryptoKey, - encryptService: EncryptService, - decryptTrace: string = "domain-withkey", - ): Promise { - try { - if (key == null) { - throw new Error("No key to decrypt EncString"); - } - - this.decryptedValue = await encryptService.decryptString(this, key); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - this.decryptedValue = DECRYPT_ERROR; - } - - return this.decryptedValue; - } private async getKeyForDecryption(orgId: string) { const keyService = Utils.getContainerService().getKeyService(); - return orgId != null - ? await keyService.getOrgKey(orgId) - : await keyService.getUserKeyWithLegacySupport(); + return orgId != null ? await keyService.getOrgKey(orgId) : await keyService.getUserKey(); } } -export type EncryptedString = Opaque; +/** + * Temporary type mapping until consumers are moved over. + * @deprecated - Use SdkEncString directly + */ +export type EncryptedString = SdkEncString; diff --git a/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts deleted file mode 100644 index 1bc1827a07a..00000000000 --- a/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; -import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface"; -import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; - -import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum"; -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; - -/** - * @deprecated Will be deleted in an immediate subsequent PR - */ -export class BulkEncryptServiceImplementation implements BulkEncryptService { - protected useSDKForDecryption: boolean = DefaultFeatureFlagValue[FeatureFlag.UseSDKForDecryption]; - - constructor( - protected cryptoFunctionService: CryptoFunctionService, - protected logService: LogService, - ) {} - - /** - * Decrypts items using a web worker if the environment supports it. - * Will fall back to the main thread if the window object is not available. - */ - async decryptItems( - items: Decryptable[], - key: SymmetricCryptoKey, - ): Promise { - if (key == null) { - throw new Error("No encryption key provided."); - } - - if (items == null || items.length < 1) { - return []; - } - - const results = []; - for (let i = 0; i < items.length; i++) { - results.push(await items[i].decrypt(key)); - } - return results; - } - - onServerConfigChange(newConfig: ServerConfig): void {} -} diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index 3e36fd334ec..6daede6be67 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -5,15 +5,11 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; -import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface"; -import { Encrypted } from "@bitwarden/common/platform/interfaces/encrypted"; -import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PureCrypto } from "@bitwarden/sdk-internal"; -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; import { EncryptService } from "../abstractions/encrypt.service"; export class EncryptServiceImplementation implements EncryptService { @@ -23,7 +19,6 @@ export class EncryptServiceImplementation implements EncryptService { protected logMacFailures: boolean, ) {} - // Proxy functions; Their implementation are temporary before moving at this level to the SDK async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise { if (plainValue == null) { this.logService.warning( @@ -171,36 +166,6 @@ export class EncryptServiceImplementation implements EncryptService { return Utils.fromBufferToB64(hashArray); } - // Handle updating private properties to turn on/off feature flags. - onServerConfigChange(newConfig: ServerConfig): void {} - - async decryptToUtf8( - encString: EncString, - key: SymmetricCryptoKey, - _decryptContext: string = "no context", - ): Promise { - await SdkLoadService.Ready; - return PureCrypto.symmetric_decrypt(encString.encryptedString, key.toEncoded()); - } - - async decryptToBytes( - encThing: Encrypted, - key: SymmetricCryptoKey, - _decryptContext: string = "no context", - ): Promise { - if (encThing.encryptionType == null || encThing.ivBytes == null || encThing.dataBytes == null) { - throw new Error("Cannot decrypt, missing type, IV, or data bytes."); - } - const buffer = EncArrayBuffer.fromParts( - encThing.encryptionType, - encThing.ivBytes, - encThing.dataBytes, - encThing.macBytes, - ).buffer; - await SdkLoadService.Ready; - return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.toEncoded()); - } - async encapsulateKeyUnsigned( sharedKey: SymmetricCryptoKey, encapsulationKey: Uint8Array, @@ -228,45 +193,14 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("No decapsulationKey provided for decapsulation"); } + await SdkLoadService.Ready; const keyBytes = PureCrypto.decapsulate_key_unsigned( encryptedSharedKey.encryptedString, decapsulationKey, ); - await SdkLoadService.Ready; return new SymmetricCryptoKey(keyBytes); } - /** - * @deprecated Replaced by BulkEncryptService (PM-4154) - */ - async decryptItems( - items: Decryptable[], - key: SymmetricCryptoKey, - ): Promise { - if (items == null || items.length < 1) { - return []; - } - - // don't use promise.all because this task is not io bound - const results = []; - for (let i = 0; i < items.length; i++) { - results.push(await items[i].decrypt(key)); - } - return results; - } - - async rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise { - if (data == null) { - throw new Error("No data provided for encryption."); - } - - if (publicKey == null) { - throw new Error("No public key provided for encryption."); - } - const encrypted = await this.cryptoFunctionService.rsaEncrypt(data, publicKey, "sha1"); - return new EncString(EncryptionType.Rsa2048_OaepSha1_B64, Utils.fromBufferToB64(encrypted)); - } - async rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise { if (data == null) { throw new Error("[Encrypt service] rsaDecrypt: No data provided for decryption."); diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts index 4cc76d45f50..0cc7824a918 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts @@ -303,12 +303,6 @@ describe("EncryptService", () => { const actual = await encryptService.encapsulateKeyUnsigned(testKey, publicKey); expect(actual).toEqual(new EncString("encapsulated_key_unsigned")); }); - - it("throws if no data was provided", () => { - return expect(encryptService.rsaEncrypt(null, new Uint8Array(32))).rejects.toThrow( - "No data provided for encryption", - ); - }); }); describe("decapsulateKeyUnsigned", () => { @@ -338,23 +332,4 @@ describe("EncryptService", () => { expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test", "sha256"); }); }); - - describe("decryptItems", () => { - it("returns empty array if no items are provided", async () => { - const key = mock(); - const actual = await encryptService.decryptItems(null, key); - expect(actual).toEqual([]); - }); - - it("returns items decrypted with provided key", async () => { - const key = mock(); - const decryptable = { - decrypt: jest.fn().mockResolvedValue("decrypted"), - }; - const items = [decryptable]; - const actual = await encryptService.decryptItems(items as any, key); - expect(actual).toEqual(["decrypted"]); - expect(decryptable.decrypt).toHaveBeenCalledWith(key); - }); - }); }); diff --git a/libs/common/src/key-management/crypto/services/encrypt.worker.ts b/libs/common/src/key-management/crypto/services/encrypt.worker.ts deleted file mode 100644 index e5aeb06560b..00000000000 --- a/libs/common/src/key-management/crypto/services/encrypt.worker.ts +++ /dev/null @@ -1,81 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Jsonify } from "type-fest"; - -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { LogService } from "../../../platform/abstractions/log.service"; -import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { ConsoleLogService } from "../../../platform/services/console-log.service"; -import { ContainerService } from "../../../platform/services/container.service"; -import { getClassInitializer } from "../../../platform/services/cryptography/get-class-initializer"; -import { - DECRYPT_COMMAND, - SET_CONFIG_COMMAND, - ParsedDecryptCommandData, -} from "../types/worker-command.type"; - -import { EncryptServiceImplementation } from "./encrypt.service.implementation"; -import { WebCryptoFunctionService } from "./web-crypto-function.service"; - -const workerApi: Worker = self as any; - -let inited = false; -let encryptService: EncryptServiceImplementation; -let logService: LogService; - -/** - * Bootstrap the worker environment with services required for decryption - */ -export function init() { - const cryptoFunctionService = new WebCryptoFunctionService(self); - logService = new ConsoleLogService(false); - encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true); - - const bitwardenContainerService = new ContainerService(null, encryptService); - bitwardenContainerService.attachToGlobal(self); - - inited = true; -} - -/** - * Listen for messages and decrypt their contents - */ -workerApi.addEventListener("message", async (event: { data: string }) => { - if (!inited) { - init(); - } - - const request: { - command: string; - } = JSON.parse(event.data); - - switch (request.command) { - case DECRYPT_COMMAND: - return await handleDecrypt(request as unknown as ParsedDecryptCommandData); - case SET_CONFIG_COMMAND: { - const newConfig = (request as unknown as { newConfig: Jsonify }).newConfig; - return await handleSetConfig(newConfig); - } - default: - logService.error(`[EncryptWorker] unknown worker command`, request.command, request); - } -}); - -async function handleDecrypt(request: ParsedDecryptCommandData) { - const key = SymmetricCryptoKey.fromJSON(request.key); - const items = request.items.map((jsonItem) => { - const initializer = getClassInitializer>(jsonItem.initializerKey); - return initializer(jsonItem); - }); - const result = await encryptService.decryptItems(items, key); - - workerApi.postMessage({ - id: request.id, - items: JSON.stringify(result), - }); -} - -async function handleSetConfig(newConfig: Jsonify) { - encryptService.onServerConfigChange(ServerConfig.fromJSON(newConfig)); -} diff --git a/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts b/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts deleted file mode 100644 index 0a05bff4422..00000000000 --- a/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; -import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface"; -import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; - -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { EncryptService } from "../abstractions/encrypt.service"; - -/** - * @deprecated Will be deleted in an immediate subsequent PR - */ -export class FallbackBulkEncryptService implements BulkEncryptService { - private featureFlagEncryptService: BulkEncryptService; - private currentServerConfig: ServerConfig | undefined = undefined; - - constructor(protected encryptService: EncryptService) {} - - /** - * Decrypts items using a web worker if the environment supports it. - * Will fall back to the main thread if the window object is not available. - */ - async decryptItems( - items: Decryptable[], - key: SymmetricCryptoKey, - ): Promise { - return await this.encryptService.decryptItems(items, key); - } - - async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) {} - - onServerConfigChange(newConfig: ServerConfig): void {} -} diff --git a/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts deleted file mode 100644 index ab65074af3b..00000000000 --- a/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface"; -import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; - -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; - -import { EncryptServiceImplementation } from "./encrypt.service.implementation"; - -/** - * @deprecated Will be deleted in an immediate subsequent PR - */ -export class MultithreadEncryptServiceImplementation extends EncryptServiceImplementation { - protected useSDKForDecryption: boolean = true; - - /** - * Sends items to a web worker to decrypt them. - * This utilises multithreading to decrypt items faster without interrupting other operations (e.g. updating UI). - */ - async decryptItems( - items: Decryptable[], - key: SymmetricCryptoKey, - ): Promise { - return await super.decryptItems(items, key); - } - - override onServerConfigChange(newConfig: ServerConfig): void {} -} diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts index ae968fc6844..c0b5150a720 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.spec.ts @@ -154,46 +154,6 @@ describe("WebCrypto Function Service", () => { testHmac("sha512", Sha512Mac); }); - describe("compare", () => { - it("should successfully compare two of the same values", async () => { - const cryptoFunctionService = getWebCryptoFunctionService(); - const a = new Uint8Array(2); - a[0] = 1; - a[1] = 2; - const equal = await cryptoFunctionService.compare(a, a); - expect(equal).toBe(true); - }); - - it("should successfully compare two different values of the same length", async () => { - const cryptoFunctionService = getWebCryptoFunctionService(); - const a = new Uint8Array(2); - a[0] = 1; - a[1] = 2; - const b = new Uint8Array(2); - b[0] = 3; - b[1] = 4; - const equal = await cryptoFunctionService.compare(a, b); - expect(equal).toBe(false); - }); - - it("should successfully compare two different values of different lengths", async () => { - const cryptoFunctionService = getWebCryptoFunctionService(); - const a = new Uint8Array(2); - a[0] = 1; - a[1] = 2; - const b = new Uint8Array(2); - b[0] = 3; - const equal = await cryptoFunctionService.compare(a, b); - expect(equal).toBe(false); - }); - }); - - describe("hmacFast", () => { - testHmacFast("sha1", Sha1Mac); - testHmacFast("sha256", Sha256Mac); - testHmacFast("sha512", Sha512Mac); - }); - describe("compareFast", () => { it("should successfully compare two of the same values", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); @@ -523,20 +483,6 @@ function testHmac(algorithm: "sha1" | "sha256" | "sha512", mac: string) { }); } -function testHmacFast(algorithm: "sha1" | "sha256" | "sha512", mac: string) { - it("should create valid " + algorithm + " hmac", async () => { - const cryptoFunctionService = getWebCryptoFunctionService(); - const keyByteString = Utils.fromBufferToByteString(Utils.fromUtf8ToArray("secretkey")); - const dataByteString = Utils.fromBufferToByteString(Utils.fromUtf8ToArray("SignMe!!")); - const computedMac = await cryptoFunctionService.hmacFast( - dataByteString, - keyByteString, - algorithm, - ); - expect(Utils.fromBufferToHex(Utils.fromByteStringToArray(computedMac))).toBe(mac); - }); -} - function testRsaGenerateKeyPair(length: 1024 | 2048 | 4096) { it( "should successfully generate a " + length + " bit key pair", diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts index 80a2a31dea6..175da716803 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts @@ -146,34 +146,6 @@ export class WebCryptoFunctionService implements CryptoFunctionService { return new Uint8Array(buffer); } - // Safely compare two values in a way that protects against timing attacks (Double HMAC Verification). - // ref: https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/ - // ref: https://paragonie.com/blog/2015/11/preventing-timing-attacks-on-string-comparison-with-double-hmac-strategy - async compare(a: Uint8Array, b: Uint8Array): Promise { - const macKey = await this.randomBytes(32); - const signingAlgorithm = { - name: "HMAC", - hash: { name: "SHA-256" }, - }; - const impKey = await this.subtle.importKey("raw", macKey, signingAlgorithm, false, ["sign"]); - const mac1 = await this.subtle.sign(signingAlgorithm, impKey, a); - const mac2 = await this.subtle.sign(signingAlgorithm, impKey, b); - - if (mac1.byteLength !== mac2.byteLength) { - return false; - } - - const arr1 = new Uint8Array(mac1); - const arr2 = new Uint8Array(mac2); - for (let i = 0; i < arr2.length; i++) { - if (arr1[i] !== arr2[i]) { - return false; - } - } - - return true; - } - hmacFast(value: string, key: string, algorithm: "sha1" | "sha256" | "sha512"): Promise { const hmac = forge.hmac.create(); hmac.start(algorithm, key); @@ -182,6 +154,9 @@ export class WebCryptoFunctionService implements CryptoFunctionService { return Promise.resolve(bytes); } + // Safely compare two values in a way that protects against timing attacks (Double HMAC Verification). + // ref: https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/ + // ref: https://paragonie.com/blog/2015/11/preventing-timing-attacks-on-string-comparison-with-double-hmac-strategy async compareFast(a: string, b: string): Promise { const rand = await this.randomBytes(32); const bytes = new Uint32Array(rand); diff --git a/libs/common/src/key-management/crypto/types/worker-command.type.spec.ts b/libs/common/src/key-management/crypto/types/worker-command.type.spec.ts deleted file mode 100644 index e820543caa9..00000000000 --- a/libs/common/src/key-management/crypto/types/worker-command.type.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { mock } from "jest-mock-extended"; - -import { makeStaticByteArray } from "../../../../spec"; -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; - -import { - DECRYPT_COMMAND, - DecryptCommandData, - SET_CONFIG_COMMAND, - buildDecryptMessage, - buildSetConfigMessage, -} from "./worker-command.type"; - -describe("Worker command types", () => { - describe("buildDecryptMessage", () => { - it("builds a message with the correct command", () => { - const commandData = createDecryptCommandData(); - - const result = buildDecryptMessage(commandData); - - const parsedResult = JSON.parse(result); - expect(parsedResult.command).toBe(DECRYPT_COMMAND); - }); - - it("includes the provided data in the message", () => { - const mockItems = [{ encrypted: "test-encrypted" } as unknown as Decryptable]; - const commandData = createDecryptCommandData(mockItems); - - const result = buildDecryptMessage(commandData); - - const parsedResult = JSON.parse(result); - expect(parsedResult.command).toBe(DECRYPT_COMMAND); - expect(parsedResult.id).toBe("test-id"); - expect(parsedResult.items).toEqual(mockItems); - expect(SymmetricCryptoKey.fromJSON(parsedResult.key)).toEqual(commandData.key); - }); - }); - - describe("buildSetConfigMessage", () => { - it("builds a message with the correct command", () => { - const result = buildSetConfigMessage({ newConfig: mock() }); - - const parsedResult = JSON.parse(result); - expect(parsedResult.command).toBe(SET_CONFIG_COMMAND); - }); - - it("includes the provided data in the message", () => { - const serverConfig = { version: "test-version" } as unknown as ServerConfig; - - const result = buildSetConfigMessage({ newConfig: serverConfig }); - - const parsedResult = JSON.parse(result); - expect(parsedResult.command).toBe(SET_CONFIG_COMMAND); - expect(ServerConfig.fromJSON(parsedResult.newConfig).version).toEqual(serverConfig.version); - }); - }); -}); - -function createDecryptCommandData(items?: Decryptable[]): DecryptCommandData { - return { - id: "test-id", - items: items ?? [], - key: new SymmetricCryptoKey(makeStaticByteArray(64)), - }; -} diff --git a/libs/common/src/key-management/crypto/types/worker-command.type.ts b/libs/common/src/key-management/crypto/types/worker-command.type.ts deleted file mode 100644 index e058bf3eaac..00000000000 --- a/libs/common/src/key-management/crypto/types/worker-command.type.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Jsonify } from "type-fest"; - -import { ServerConfig } from "../../../platform/abstractions/config/server-config"; -import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; -import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; - -export const DECRYPT_COMMAND = "decrypt"; -export const SET_CONFIG_COMMAND = "updateConfig"; - -export type DecryptCommandData = { - id: string; - items: Decryptable[]; - key: SymmetricCryptoKey; -}; - -export type ParsedDecryptCommandData = { - id: string; - items: Jsonify>[]; - key: Jsonify; -}; - -type SetConfigCommandData = { newConfig: ServerConfig }; - -export function buildDecryptMessage(data: DecryptCommandData): string { - return JSON.stringify({ - command: DECRYPT_COMMAND, - ...data, - }); -} - -export function buildSetConfigMessage(data: SetConfigCommandData): string { - return JSON.stringify({ - command: SET_CONFIG_COMMAND, - ...data, - }); -} diff --git a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts index 641e969c5a6..e8346b67b5e 100644 --- a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts +++ b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts @@ -1,9 +1,17 @@ import { Observable } from "rxjs"; +// eslint-disable-next-line no-restricted-imports +import { KdfConfig } from "@bitwarden/key-management"; + import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; import { EncString } from "../../crypto/models/enc-string"; +import { + MasterPasswordAuthenticationData, + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "../types/master-password.types"; export abstract class MasterPasswordServiceAbstraction { /** @@ -12,14 +20,23 @@ export abstract class MasterPasswordServiceAbstraction { * @throws If the user ID is missing. */ abstract forceSetPasswordReason$: (userId: UserId) => Observable; + /** + * An observable that emits the master password salt for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + * @throws If the user ID is provided, but the user is not found. + */ + abstract saltForUser$: (userId: UserId) => Observable; /** * An observable that emits the master key for the user. + * @deprecated Interacting with the master-key directly is deprecated. Please use {@link makeMasterPasswordUnlockData}, {@link makeMasterPasswordAuthenticationData} or {@link unwrapUserKeyFromMasterPasswordUnlockData} instead. * @param userId The user ID. * @throws If the user ID is missing. */ abstract masterKey$: (userId: UserId) => Observable; /** * An observable that emits the master key hash for the user. + * @deprecated Interacting with the master-key directly is deprecated. Please use {@link makeMasterPasswordAuthenticationData}. * @param userId The user ID. * @throws If the user ID is missing. */ @@ -32,6 +49,7 @@ export abstract class MasterPasswordServiceAbstraction { abstract getMasterKeyEncryptedUserKey: (userId: UserId) => Promise; /** * Decrypts the user key with the provided master key + * @deprecated Interacting with the master-key directly is deprecated. Please use {@link unwrapUserKeyFromMasterPasswordUnlockData} instead. * @param masterKey The user's master key * * @param userId The desired user * @param userKey The user's encrypted symmetric key @@ -44,12 +62,52 @@ export abstract class MasterPasswordServiceAbstraction { userId: string, userKey?: EncString, ) => Promise; + + /** + * Makes the authentication hash for authenticating to the server with the master password. + * @param password The master password. + * @param kdf The KDF configuration. + * @param salt The master password salt to use. See {@link saltForUser$} for current salt. + * @throws If password, KDF or salt are null or undefined. + */ + abstract makeMasterPasswordAuthenticationData: ( + password: string, + kdf: KdfConfig, + salt: MasterPasswordSalt, + ) => Promise; + + /** + * Creates a MasterPasswordUnlockData bundle that encrypts the user-key with a key derived from the password. The + * bundle also contains the KDF settings and salt used to derive the key, which are required to decrypt the user-key later. + * @param password The master password. + * @param kdf The KDF configuration. + * @param salt The master password salt to use. See {@link saltForUser$} for current salt. + * @param userKey The user's userKey to encrypt. + * @throws If password, KDF, salt, or userKey are null or undefined. + */ + abstract makeMasterPasswordUnlockData: ( + password: string, + kdf: KdfConfig, + salt: MasterPasswordSalt, + userKey: UserKey, + ) => Promise; + + /** + * Unwraps a user-key that was wrapped with a password provided KDF settings. The same KDF settings and salt must be provided to unwrap the user-key, otherwise it will fail to decrypt. + * @throws If the encryption type is not supported. + * @throws If the password, KDF, or salt don't match the original wrapping parameters. + */ + abstract unwrapUserKeyFromMasterPasswordUnlockData: ( + password: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + ) => Promise; } export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction { /** * Set the master key for the user. * Note: Use {@link clearMasterKey} to clear the master key. + * @deprecated Interacting with the master-key directly is deprecated. * @param masterKey The master key. * @param userId The user ID. * @throws If the user ID or master key is missing. @@ -57,6 +115,7 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas abstract setMasterKey: (masterKey: MasterKey, userId: UserId) => Promise; /** * Clear the master key for the user. + * @deprecated Interacting with the master-key directly is deprecated. * @param userId The user ID. * @throws If the user ID is missing. */ @@ -64,6 +123,7 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas /** * Set the master key hash for the user. * Note: Use {@link clearMasterKeyHash} to clear the master key hash. + * @deprecated Interacting with the master-key directly is deprecated. * @param masterKeyHash The master key hash. * @param userId The user ID. * @throws If the user ID or master key hash is missing. @@ -71,6 +131,7 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas abstract setMasterKeyHash: (masterKeyHash: string, userId: UserId) => Promise; /** * Clear the master key hash for the user. + * @deprecated Interacting with the master-key directly is deprecated. * @param userId The user ID. * @throws If the user ID is missing. */ diff --git a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts index 8ae3a4265cd..465a0c0403f 100644 --- a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts @@ -3,11 +3,20 @@ import { mock } from "jest-mock-extended"; import { ReplaySubject, Observable } from "rxjs"; +// FIXME: Update this file to be type safe and remove this and next line +// eslint-disable-next-line no-restricted-imports +import { KdfConfig } from "@bitwarden/key-management"; + import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; import { EncString } from "../../crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; +import { + MasterPasswordAuthenticationData, + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "../types/master-password.types"; export class FakeMasterPasswordService implements InternalMasterPasswordServiceAbstraction { mock = mock(); @@ -24,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA this.masterKeyHashSubject.next(initialMasterKeyHash); } + saltForUser$(userId: UserId): Observable { + return this.mock.saltForUser$(userId); + } + masterKey$(userId: UserId): Observable { return this.masterKeySubject.asObservable(); } @@ -71,4 +84,28 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA ): Promise { return this.mock.decryptUserKeyWithMasterKey(masterKey, userId, userKey); } + + makeMasterPasswordAuthenticationData( + password: string, + kdf: KdfConfig, + salt: MasterPasswordSalt, + ): Promise { + return this.mock.makeMasterPasswordAuthenticationData(password, kdf, salt); + } + + makeMasterPasswordUnlockData( + password: string, + kdf: KdfConfig, + salt: MasterPasswordSalt, + userKey: UserKey, + ): Promise { + return this.mock.makeMasterPasswordUnlockData(password, kdf, salt, userKey); + } + + unwrapUserKeyFromMasterPasswordUnlockData( + password: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + ): Promise { + return this.mock.unwrapUserKeyFromMasterPasswordUnlockData(password, masterPasswordUnlockData); + } } diff --git a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts index 5486ed68e9f..a09de9008d1 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts @@ -1,8 +1,17 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import * as rxjs from "rxjs"; -import { makeSymmetricCryptoKey } from "../../../../spec"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +// eslint-disable-next-line no-restricted-imports +import { KdfConfig, PBKDF2KdfConfig } from "@bitwarden/key-management"; + +import { + FakeAccountService, + makeSymmetricCryptoKey, + mockAccountServiceWith, +} from "../../../../spec"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { LogService } from "../../../platform/abstractions/log.service"; @@ -10,9 +19,11 @@ import { StateService } from "../../../platform/abstractions/state.service"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; -import { MasterKey } from "../../../types/key"; +import { MasterKey, UserKey } from "../../../types/key"; +import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncString } from "../../crypto/models/enc-string"; +import { MasterPasswordSalt } from "../types/master-password.types"; import { MasterPasswordService } from "./master-password.service"; @@ -24,8 +35,10 @@ describe("MasterPasswordService", () => { let keyGenerationService: MockProxy; let encryptService: MockProxy; let logService: MockProxy; + let cryptoFunctionService: MockProxy; + let accountService: FakeAccountService; - const userId = "user-id" as UserId; + const userId = "00000000-0000-0000-0000-000000000000" as UserId; const mockUserState = { state$: of(null), update: jest.fn().mockResolvedValue(null), @@ -45,6 +58,8 @@ describe("MasterPasswordService", () => { keyGenerationService = mock(); encryptService = mock(); logService = mock(); + cryptoFunctionService = mock(); + accountService = mockAccountServiceWith(userId); stateProvider.getUser.mockReturnValue(mockUserState as any); @@ -56,10 +71,33 @@ describe("MasterPasswordService", () => { keyGenerationService, encryptService, logService, + cryptoFunctionService, + accountService, ); encryptService.unwrapSymmetricKey.mockResolvedValue(makeSymmetricCryptoKey(64, 1)); keyGenerationService.stretchKey.mockResolvedValue(makeSymmetricCryptoKey(64, 3)); + Object.defineProperty(SdkLoadService, "Ready", { + value: Promise.resolve(), + configurable: true, + }); + }); + + describe("saltForUser$", () => { + it("throws when userid not present", async () => { + expect(() => { + sut.saltForUser$(null as unknown as UserId); + }).toThrow("userId is null or undefined."); + }); + it("throws when userid present but not in account service", async () => { + await expect( + firstValueFrom(sut.saltForUser$("00000000-0000-0000-0000-000000000001" as UserId)), + ).rejects.toThrow("Cannot read properties of undefined (reading 'email')"); + }); + it("returns salt", async () => { + const salt = await firstValueFrom(sut.saltForUser$(userId)); + expect(salt).toBeDefined(); + }); }); describe("setForceSetPasswordReason", () => { @@ -190,4 +228,97 @@ describe("MasterPasswordService", () => { expect(updateFn(null)).toEqual(encryptedKey.toJSON()); }); }); + + describe("makeMasterPasswordAuthenticationData", () => { + const password = "test-password"; + const kdf: KdfConfig = new PBKDF2KdfConfig(600_000); + const salt = "test@bitwarden.com" as MasterPasswordSalt; + const masterKey = makeSymmetricCryptoKey(32, 2); + const masterKeyHash = makeSymmetricCryptoKey(32, 3).toEncoded(); + + beforeEach(() => { + keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey); + cryptoFunctionService.pbkdf2.mockResolvedValue(masterKeyHash); + }); + + it("derives master key and creates authentication hash", async () => { + const result = await sut.makeMasterPasswordAuthenticationData(password, kdf, salt); + + expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(password, salt, kdf); + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith( + masterKey.toEncoded(), + password, + "sha256", + 1, + ); + + expect(result).toEqual({ + kdf, + salt, + masterPasswordAuthenticationHash: Utils.fromBufferToB64(masterKeyHash), + }); + }); + + it("throws if password is null", async () => { + await expect( + sut.makeMasterPasswordAuthenticationData(null as unknown as string, kdf, salt), + ).rejects.toThrow(); + }); + it("throws if kdf is null", async () => { + await expect( + sut.makeMasterPasswordAuthenticationData(password, null as unknown as KdfConfig, salt), + ).rejects.toThrow(); + }); + it("throws if salt is null", async () => { + await expect( + sut.makeMasterPasswordAuthenticationData( + password, + kdf, + null as unknown as MasterPasswordSalt, + ), + ).rejects.toThrow(); + }); + }); + + describe("wrapUnwrapUserKeyWithPassword", () => { + const password = "test-password"; + const kdf: KdfConfig = new PBKDF2KdfConfig(600_000); + const salt = "test@bitwarden.com" as MasterPasswordSalt; + const userKey = makeSymmetricCryptoKey(64, 2) as UserKey; + + it("wraps and unwraps user key with password", async () => { + const unlockData = await sut.makeMasterPasswordUnlockData(password, kdf, salt, userKey); + const unwrappedUserkey = await sut.unwrapUserKeyFromMasterPasswordUnlockData( + password, + unlockData, + ); + expect(unwrappedUserkey).toEqual(userKey); + }); + + it("throws if password is null", async () => { + await expect( + sut.makeMasterPasswordUnlockData(null as unknown as string, kdf, salt, userKey), + ).rejects.toThrow(); + }); + it("throws if kdf is null", async () => { + await expect( + sut.makeMasterPasswordUnlockData(password, null as unknown as KdfConfig, salt, userKey), + ).rejects.toThrow(); + }); + it("throws if salt is null", async () => { + await expect( + sut.makeMasterPasswordUnlockData( + password, + kdf, + null as unknown as MasterPasswordSalt, + userKey, + ), + ).rejects.toThrow(); + }); + it("throws if userKey is null", async () => { + await expect( + sut.makeMasterPasswordUnlockData(password, kdf, salt, null as unknown as UserKey), + ).rejects.toThrow(); + }); + }); }); diff --git a/libs/common/src/key-management/master-password/services/master-password.service.ts b/libs/common/src/key-management/master-password/services/master-password.service.ts index bd8aa6eb229..75e5032e004 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.ts @@ -2,6 +2,14 @@ // @ts-strict-ignore import { firstValueFrom, map, Observable } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +// eslint-disable-next-line no-restricted-imports +import { KdfConfig } from "@bitwarden/key-management"; +import { PureCrypto } from "@bitwarden/sdk-internal"; + import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { LogService } from "../../../platform/abstractions/log.service"; @@ -16,9 +24,17 @@ import { } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; +import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncryptedString, EncString } from "../../crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; +import { + MasterKeyWrappedUserKey, + MasterPasswordAuthenticationData, + MasterPasswordAuthenticationHash, + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "../types/master-password.types"; /** Memory since master key shouldn't be available on lock */ const MASTER_KEY = new UserKeyDefinition(MASTER_PASSWORD_MEMORY, "masterKey", { @@ -59,8 +75,18 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr private keyGenerationService: KeyGenerationService, private encryptService: EncryptService, private logService: LogService, + private cryptoFunctionService: CryptoFunctionService, + private accountService: AccountService, ) {} + saltForUser$(userId: UserId): Observable { + assertNonNullish(userId, "userId"); + return this.accountService.accounts$.pipe( + map((accounts) => accounts[userId].email), + map((email) => this.emailToSalt(email)), + ); + } + masterKey$(userId: UserId): Observable { if (userId == null) { throw new Error("User ID is required."); @@ -95,6 +121,10 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr return EncString.fromJSON(key); } + private emailToSalt(email: string): MasterPasswordSalt { + return email.toLowerCase().trim() as MasterPasswordSalt; + } + async setMasterKey(masterKey: MasterKey, userId: UserId): Promise { if (masterKey == null) { throw new Error("Master key is required."); @@ -202,4 +232,89 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr return decUserKey as UserKey; } + + async makeMasterPasswordAuthenticationData( + password: string, + kdf: KdfConfig, + salt: MasterPasswordSalt, + ): Promise { + assertNonNullish(password, "password"); + assertNonNullish(kdf, "kdf"); + assertNonNullish(salt, "salt"); + + // We don't trust callers to use masterpasswordsalt correctly. They may type assert incorrectly. + salt = salt.toLowerCase().trim() as MasterPasswordSalt; + + const SERVER_AUTHENTICATION_HASH_ITERATIONS = 1; + + const masterKey = (await this.keyGenerationService.deriveKeyFromPassword( + password, + salt, + kdf, + )) as MasterKey; + + const masterPasswordAuthenticationHash = Utils.fromBufferToB64( + await this.cryptoFunctionService.pbkdf2( + masterKey.toEncoded(), + password, + "sha256", + SERVER_AUTHENTICATION_HASH_ITERATIONS, + ), + ) as MasterPasswordAuthenticationHash; + + return { + salt, + kdf, + masterPasswordAuthenticationHash, + } as MasterPasswordAuthenticationData; + } + + async makeMasterPasswordUnlockData( + password: string, + kdf: KdfConfig, + salt: MasterPasswordSalt, + userKey: UserKey, + ): Promise { + assertNonNullish(password, "password"); + assertNonNullish(kdf, "kdf"); + assertNonNullish(salt, "salt"); + assertNonNullish(userKey, "userKey"); + + // We don't trust callers to use masterpasswordsalt correctly. They may type assert incorrectly. + salt = salt.toLowerCase().trim() as MasterPasswordSalt; + + await SdkLoadService.Ready; + const masterKeyWrappedUserKey = new EncString( + PureCrypto.encrypt_user_key_with_master_password( + userKey.toEncoded(), + password, + salt, + kdf.toSdkConfig(), + ), + ) as MasterKeyWrappedUserKey; + return { + salt, + kdf, + masterKeyWrappedUserKey, + }; + } + + async unwrapUserKeyFromMasterPasswordUnlockData( + password: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + ): Promise { + assertNonNullish(password, "password"); + assertNonNullish(masterPasswordUnlockData, "masterPasswordUnlockData"); + + await SdkLoadService.Ready; + const userKey = new SymmetricCryptoKey( + PureCrypto.decrypt_user_key_with_master_password( + masterPasswordUnlockData.masterKeyWrappedUserKey.encryptedString, + password, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf.toSdkConfig(), + ), + ); + return userKey as UserKey; + } } diff --git a/libs/common/src/key-management/master-password/types/master-password.types.ts b/libs/common/src/key-management/master-password/types/master-password.types.ts new file mode 100644 index 00000000000..76451ed0870 --- /dev/null +++ b/libs/common/src/key-management/master-password/types/master-password.types.ts @@ -0,0 +1,34 @@ +import { Opaque } from "type-fest"; + +// eslint-disable-next-line no-restricted-imports +import { KdfConfig } from "@bitwarden/key-management"; + +import { EncString } from "../../crypto/models/enc-string"; + +/** + * The Base64-encoded master password authentication hash, that is sent to the server for authentication. + */ +export type MasterPasswordAuthenticationHash = Opaque; +/** + * You MUST obtain this through the emailToSalt function in MasterPasswordService + */ +export type MasterPasswordSalt = Opaque; +export type MasterKeyWrappedUserKey = Opaque; + +/** + * The data required to unlock with the master password. + */ +export type MasterPasswordUnlockData = { + salt: MasterPasswordSalt; + kdf: KdfConfig; + masterKeyWrappedUserKey: MasterKeyWrappedUserKey; +}; + +/** + * The data required to authenticate with the master password. + */ +export type MasterPasswordAuthenticationData = { + salt: MasterPasswordSalt; + kdf: KdfConfig; + masterPasswordAuthenticationHash: MasterPasswordAuthenticationHash; +}; diff --git a/libs/auth/src/common/abstractions/pin.service.abstraction.ts b/libs/common/src/key-management/pin/pin.service.abstraction.ts similarity index 94% rename from libs/auth/src/common/abstractions/pin.service.abstraction.ts rename to libs/common/src/key-management/pin/pin.service.abstraction.ts index cc679951313..33f369cc50f 100644 --- a/libs/auth/src/common/abstractions/pin.service.abstraction.ts +++ b/libs/common/src/key-management/pin/pin.service.abstraction.ts @@ -1,9 +1,11 @@ -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { UserId } from "@bitwarden/common/types/guid"; -import { PinKey, UserKey } from "@bitwarden/common/types/key"; +// eslint-disable-next-line no-restricted-imports import { KdfConfig } from "@bitwarden/key-management"; -import { PinLockType } from "../services"; +import { EncString } from "../../key-management/crypto/models/enc-string"; +import { UserId } from "../../types/guid"; +import { PinKey, UserKey } from "../../types/key"; + +import { PinLockType } from "./pin.service.implementation"; /** * The PinService is used for PIN-based unlocks. Below is a very basic overview of the PIN flow: diff --git a/libs/auth/src/common/services/pin/pin.service.implementation.ts b/libs/common/src/key-management/pin/pin.service.implementation.ts similarity index 93% rename from libs/auth/src/common/services/pin/pin.service.implementation.ts rename to libs/common/src/key-management/pin/pin.service.implementation.ts index 8417daa24e0..f926f4a4af2 100644 --- a/libs/auth/src/common/services/pin/pin.service.implementation.ts +++ b/libs/common/src/key-management/pin/pin.service.implementation.ts @@ -2,26 +2,20 @@ // @ts-strict-ignore import { firstValueFrom, map } from "rxjs"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { - EncString, - EncryptedString, -} from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { - PIN_DISK, - PIN_MEMORY, - StateProvider, - UserKeyDefinition, -} from "@bitwarden/common/platform/state"; -import { UserId } from "@bitwarden/common/types/guid"; -import { PinKey, UserKey } from "@bitwarden/common/types/key"; +// eslint-disable-next-line no-restricted-imports import { KdfConfig, KdfConfigService } from "@bitwarden/key-management"; -import { PinServiceAbstraction } from "../../abstractions/pin.service.abstraction"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service"; +import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; +import { EncString, EncryptedString } from "../../key-management/crypto/models/enc-string"; +import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { PIN_DISK, PIN_MEMORY, StateProvider, UserKeyDefinition } from "../../platform/state"; +import { UserId } from "../../types/guid"; +import { PinKey, UserKey } from "../../types/key"; + +import { PinServiceAbstraction } from "./pin.service.abstraction"; /** * - DISABLED : No PIN set. diff --git a/libs/auth/src/common/services/pin/pin.service.spec.ts b/libs/common/src/key-management/pin/pin.service.spec.ts similarity index 95% rename from libs/auth/src/common/services/pin/pin.service.spec.ts rename to libs/common/src/key-management/pin/pin.service.spec.ts index 1f0f2f5996c..3d7dbaa4718 100644 --- a/libs/auth/src/common/services/pin/pin.service.spec.ts +++ b/libs/common/src/key-management/pin/pin.service.spec.ts @@ -1,21 +1,19 @@ import { mock } from "jest-mock-extended"; -import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { - FakeAccountService, - FakeStateProvider, - mockAccountServiceWith, -} from "@bitwarden/common/spec"; -import { UserId } from "@bitwarden/common/types/guid"; -import { PinKey, UserKey } from "@bitwarden/common/types/key"; +// eslint-disable-next-line no-restricted-imports import { DEFAULT_KDF_CONFIG, KdfConfigService } from "@bitwarden/key-management"; +import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec"; +import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { Utils } from "../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { UserId } from "../../types/guid"; +import { PinKey, UserKey } from "../../types/key"; +import { CryptoFunctionService } from "../crypto/abstractions/crypto-function.service"; +import { EncryptService } from "../crypto/abstractions/encrypt.service"; +import { EncString } from "../crypto/models/enc-string"; + import { PinService, PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, diff --git a/libs/common/src/key-management/services/default-process-reload.service.ts b/libs/common/src/key-management/services/default-process-reload.service.ts index e43fa5f0977..bc5739167ce 100644 --- a/libs/common/src/key-management/services/default-process-reload.service.ts +++ b/libs/common/src/key-management/services/default-process-reload.service.ts @@ -2,9 +2,6 @@ // @ts-strict-ignore import { firstValueFrom, map, timeout } from "rxjs"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { PinServiceAbstraction } from "@bitwarden/auth/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { BiometricStateService } from "@bitwarden/key-management"; @@ -20,6 +17,7 @@ import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { UserId } from "../../types/guid"; import { ProcessReloadServiceAbstraction } from "../abstractions/process-reload.service"; +import { PinServiceAbstraction } from "../pin/pin.service.abstraction"; export class DefaultProcessReloadService implements ProcessReloadServiceAbstraction { private reloadInterval: any = null; diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts index 349aa474872..93067111bed 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts @@ -6,7 +6,6 @@ import { BehaviorSubject, firstValueFrom, map, of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { - PinServiceAbstraction, FakeUserDecryptionOptions as UserDecryptionOptions, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; @@ -21,6 +20,7 @@ import { TokenService } from "../../../auth/services/token.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; import { UserId } from "../../../types/guid"; +import { PinServiceAbstraction } from "../../pin/pin.service.abstraction"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service"; import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type"; diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index b3ed2165ed9..7e43ee394f6 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -16,10 +16,7 @@ import { // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { - PinServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { BiometricStateService, KeyService } from "@bitwarden/key-management"; @@ -33,6 +30,7 @@ import { TokenService } from "../../../auth/abstractions/token.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { PinServiceAbstraction } from "../../pin/pin.service.abstraction"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service"; import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type"; diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts index bf79c8be395..8d319ccebb6 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts @@ -143,10 +143,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { ), ); - if (userId == null || userId === currentUserId) { - await this.collectionService.clearActiveUserCache(); - } - await this.searchService.clearIndex(lockingUserId); await this.folderService.clearDecryptedFolderState(lockingUserId); diff --git a/libs/common/src/platform/misc/rxjs-operators.ts b/libs/common/src/platform/misc/rxjs-operators.ts index 689b928cd29..423bcbb790f 100644 --- a/libs/common/src/platform/misc/rxjs-operators.ts +++ b/libs/common/src/platform/misc/rxjs-operators.ts @@ -13,9 +13,9 @@ export const getById = (id: TId) => * @param id The IDs of the objects to return. * @returns An array containing objects with matching IDs, or an empty array if there are no matching objects. */ -export const getByIds = (ids: TId[]) => { - const idSet = new Set(ids); +export const getByIds = (ids: TId[]) => { + const idSet = new Set(ids.filter((id) => id != null)); return map((objects) => { - return objects.filter((o) => idSet.has(o.id)); + return objects.filter((o) => o.id && idSet.has(o.id)); }); }; diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index b3c1db91806..c103e346a85 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -252,6 +252,7 @@ export class Utils { } // ref: http://stackoverflow.com/a/2117523/1090359 + /** @deprecated Use newGuid from @bitwarden/guid instead */ static newGuid(): string { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; @@ -260,8 +261,10 @@ export class Utils { }); } + /** @deprecated Use guidRegex from @bitwarden/guid instead */ static guidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/; + /** @deprecated Use isGuid from @bitwarden/guid instead */ static isGuid(id: string) { return RegExp(Utils.guidRegex, "i").test(id); } diff --git a/libs/common/src/platform/models/domain/domain-base.spec.ts b/libs/common/src/platform/models/domain/domain-base.spec.ts deleted file mode 100644 index 9233795eb95..00000000000 --- a/libs/common/src/platform/models/domain/domain-base.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { mock, MockProxy } from "jest-mock-extended"; - -import { makeEncString, makeSymmetricCryptoKey } from "../../../../spec"; -import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "../../../key-management/crypto/models/enc-string"; - -import Domain from "./domain-base"; - -class TestDomain extends Domain { - plainText: string; - encToString: EncString; - encString2: EncString; -} - -describe("DomainBase", () => { - let encryptService: MockProxy; - const key = makeSymmetricCryptoKey(64); - - beforeEach(() => { - encryptService = mock(); - }); - - function setUpCryptography() { - encryptService.encryptString.mockImplementation((value) => - Promise.resolve(makeEncString(value)), - ); - - encryptService.decryptString.mockImplementation((value) => { - return Promise.resolve(value.data); - }); - } - - describe("decryptWithKey", () => { - it("domain property types are decryptable", async () => { - const domain = new TestDomain(); - - await domain["decryptObjWithKey"]( - // @ts-expect-error -- clear is not of type EncString - ["plainText"], - makeSymmetricCryptoKey(64), - mock(), - ); - - await domain["decryptObjWithKey"]( - // @ts-expect-error -- Clear is not of type EncString - ["encToString", "encString2", "plainText"], - makeSymmetricCryptoKey(64), - mock(), - ); - - const decrypted = await domain["decryptObjWithKey"]( - ["encToString"], - makeSymmetricCryptoKey(64), - mock(), - ); - - // @ts-expect-error -- encString2 was not decrypted - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - decrypted as { encToString: string; encString2: string; plainText: string }; - - // encString2 was not decrypted, so it's still an EncString - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - decrypted as { encToString: string; encString2: EncString; plainText: string }; - }); - - it("decrypts the encrypted properties", async () => { - setUpCryptography(); - - const domain = new TestDomain(); - - domain.encToString = await encryptService.encryptString("string", key); - - const decrypted = await domain["decryptObjWithKey"](["encToString"], key, encryptService); - - expect(decrypted).toEqual({ - encToString: "string", - }); - }); - - it("decrypts multiple encrypted properties", async () => { - setUpCryptography(); - - const domain = new TestDomain(); - - domain.encToString = await encryptService.encryptString("string", key); - domain.encString2 = await encryptService.encryptString("string2", key); - - const decrypted = await domain["decryptObjWithKey"]( - ["encToString", "encString2"], - key, - encryptService, - ); - - expect(decrypted).toEqual({ - encToString: "string", - encString2: "string2", - }); - }); - - it("does not decrypt properties that are not encrypted", async () => { - const domain = new TestDomain(); - domain.plainText = "clear"; - - const decrypted = await domain["decryptObjWithKey"]([], key, encryptService); - - expect(decrypted).toEqual({ - plainText: "clear", - }); - }); - - it("does not decrypt properties that were not requested to be decrypted", async () => { - setUpCryptography(); - - const domain = new TestDomain(); - - domain.plainText = "clear"; - domain.encToString = makeEncString("string"); - domain.encString2 = makeEncString("string2"); - - const decrypted = await domain["decryptObjWithKey"]([], key, encryptService); - - expect(decrypted).toEqual({ - plainText: "clear", - encToString: makeEncString("string"), - encString2: makeEncString("string2"), - }); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/domain-base.ts b/libs/common/src/platform/models/domain/domain-base.ts index 08f5aca3a3c..b999a5e5d15 100644 --- a/libs/common/src/platform/models/domain/domain-base.ts +++ b/libs/common/src/platform/models/domain/domain-base.ts @@ -1,6 +1,5 @@ -import { ConditionalExcept, ConditionalKeys, Constructor } from "type-fest"; +import { ConditionalExcept, ConditionalKeys } from "type-fest"; -import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import { View } from "../../../models/view/view"; @@ -14,7 +13,7 @@ export type DecryptedObject< > = Record & Omit; // extracts shared keys from the domain and view types -type EncryptableKeys = (keyof D & +export type EncryptableKeys = (keyof D & ConditionalKeys) & (keyof V & ConditionalKeys); @@ -89,66 +88,4 @@ export default class Domain { return viewModel as V; } - - /** - * Decrypts the requested properties of the domain object with the provided key and encrypt service. - * - * If a property is null, the result will be null. - * @see {@link EncString.decryptWithKey} for more details on decryption behavior. - * - * @param encryptedProperties The properties to decrypt. Type restricted to EncString properties of the domain object. - * @param key The key to use for decryption. - * @param encryptService The encryption service to use for decryption. - * @param _ The constructor of the domain object. Used for type inference if the domain object is not automatically inferred. - * @returns An object with the requested properties decrypted and the rest of the domain object untouched. - */ - protected async decryptObjWithKey< - TThis extends Domain, - const TEncryptedKeys extends EncStringKeys, - >( - this: TThis, - encryptedProperties: TEncryptedKeys[], - key: SymmetricCryptoKey, - encryptService: EncryptService, - _: Constructor = this.constructor as Constructor, - objectContext: string = "No Domain Context", - ): Promise> { - const decryptedObjects = []; - - for (const prop of encryptedProperties) { - const value = this[prop] as EncString; - const decrypted = await this.decryptProperty( - prop, - value, - key, - encryptService, - `Property: ${prop.toString()}; ObjectContext: ${objectContext}`, - ); - decryptedObjects.push(decrypted); - } - - const decryptedObject = decryptedObjects.reduce( - (acc, obj) => { - return { ...acc, ...obj }; - }, - { ...this }, - ); - return decryptedObject as DecryptedObject; - } - - private async decryptProperty>( - propertyKey: TEncryptedKeys, - value: EncString, - key: SymmetricCryptoKey, - encryptService: EncryptService, - decryptTrace: string, - ) { - let decrypted: string | null = null; - if (value) { - decrypted = await value.decryptWithKey(key, encryptService, decryptTrace); - } - return { - [propertyKey]: decrypted, - }; - } } diff --git a/libs/common/src/platform/services/migration-builder.service.spec.ts b/libs/common/src/platform/services/migration-builder.service.spec.ts index ee9508e8b15..1ed7cb9b71a 100644 --- a/libs/common/src/platform/services/migration-builder.service.spec.ts +++ b/libs/common/src/platform/services/migration-builder.service.spec.ts @@ -1,8 +1,9 @@ import { mock } from "jest-mock-extended"; +import { MigrationHelper } from "@bitwarden/state"; + import { FakeStorageService } from "../../../spec/fake-storage.service"; import { ClientType } from "../../enums"; -import { MigrationHelper } from "../../state-migrations/migration-helper"; import { MigrationBuilderService } from "./migration-builder.service"; diff --git a/libs/common/src/platform/services/migration-runner.ts b/libs/common/src/platform/services/migration-runner.ts index 9e3a6118af8..9e066069e32 100644 --- a/libs/common/src/platform/services/migration-runner.ts +++ b/libs/common/src/platform/services/migration-runner.ts @@ -1,7 +1,7 @@ +import { CURRENT_VERSION, currentVersion, MigrationHelper } from "@bitwarden/state"; + import { ClientType } from "../../enums"; import { waitForMigrations } from "../../state-migrations"; -import { CURRENT_VERSION, currentVersion } from "../../state-migrations/migrate"; -import { MigrationHelper } from "../../state-migrations/migration-helper"; import { LogService } from "../abstractions/log.service"; import { AbstractStorageService } from "../abstractions/storage.service"; diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index c12fbe2dbb2..d8780b0f1f4 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -22,6 +22,7 @@ import { ClientSettings, DeviceType as SdkDeviceType, TokenProvider, + UnsignedSharedKey, } from "@bitwarden/sdk-internal"; import { EncryptedOrganizationKeyData } from "../../../admin-console/models/data/encrypted-organization-key.data"; @@ -237,7 +238,7 @@ export class DefaultSdkService implements SdkService { organizationKeys: new Map( Object.entries(orgKeys ?? {}) .filter(([_, v]) => v.type === "organization") - .map(([k, v]) => [k, v.key]), + .map(([k, v]) => [k, v.key as UnsignedSharedKey]), ), }); } diff --git a/libs/common/src/platform/state/active-user.accessor.ts b/libs/common/src/platform/state/active-user.accessor.ts new file mode 100644 index 00000000000..8ee2d53a93f --- /dev/null +++ b/libs/common/src/platform/state/active-user.accessor.ts @@ -0,0 +1,11 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +export abstract class ActiveUserAccessor { + /** + * Returns a stream of the current active user for the application. The stream either emits the user id for that account + * or returns null if there is no current active user. + */ + abstract activeUserId$: Observable; +} diff --git a/libs/common/src/platform/state/derive-definition.ts b/libs/common/src/platform/state/derive-definition.ts index 663b55465b8..3882e89fd68 100644 --- a/libs/common/src/platform/state/derive-definition.ts +++ b/libs/common/src/platform/state/derive-definition.ts @@ -1,196 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Jsonify } from "type-fest"; - -import { UserId } from "../../types/guid"; -import { DerivedStateDependencies, StorageKey } from "../../types/state"; - -import { KeyDefinition } from "./key-definition"; -import { StateDefinition } from "./state-definition"; -import { UserKeyDefinition } from "./user-key-definition"; - -declare const depShapeMarker: unique symbol; -/** - * A set of options for customizing the behavior of a {@link DeriveDefinition} - */ -type DeriveDefinitionOptions = { - /** - * A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable - * and the resulting value will be emitted from the derived state observable. - * - * @param from Populated with the latest emission from the parent state observable. - * @param deps Populated with the dependencies passed into the constructor of the derived state. - * These are constant for the lifetime of the derived state. - * @returns The derived state value or a Promise that resolves to the derived state value. - */ - derive: (from: TFrom, deps: TDeps) => TTo | Promise; - /** - * A function to use to safely convert your type from json to your expected type. - * - * **Important:** Your data may be serialized/deserialized at any time and this - * callback needs to be able to faithfully re-initialize from the JSON object representation of your type. - * - * @param jsonValue The JSON object representation of your state. - * @returns The fully typed version of your state. - */ - deserializer: (serialized: Jsonify) => TTo; - /** - * An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies - * and the values are the types of the dependencies. - * - * for example: - * ``` - * { - * myService: MyService, - * myOtherService: MyOtherService, - * } - * ``` - */ - [depShapeMarker]?: TDeps; - /** - * The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. - * Defaults to 1000ms. - */ - cleanupDelayMs?: number; - /** - * Whether or not to clear the derived state when cleanup occurs. Defaults to true. - */ - clearOnCleanup?: boolean; -}; - -/** - * DeriveDefinitions describe state derived from another observable, the value type of which is given by `TFrom`. - * - * The StateDefinition is used to describe the domain of the state, and the DeriveDefinition - * sub-divides that domain into specific keys. These keys are used to cache data in memory and enables derived state to - * be calculated once regardless of multiple execution contexts. - */ - -export class DeriveDefinition { - /** - * Creates a new instance of a DeriveDefinition. Derived state is always stored in memory, so the storage location - * defined in @link{StateDefinition} is ignored. - * - * @param stateDefinition The state definition for which this key belongs to. - * @param uniqueDerivationName The name of the key, this should be unique per domain. - * @param options A set of options to customize the behavior of {@link DeriveDefinition}. - * @param options.derive A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable - * and the resulting value will be emitted from the derived state observable. - * @param options.cleanupDelayMs The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. - * Defaults to 1000ms. - * @param options.dependencyShape An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies - * and the values are the types of the dependencies. - * for example: - * ``` - * { - * myService: MyService, - * myOtherService: MyOtherService, - * } - * ``` - * - * @param options.deserializer A function to use to safely convert your type from json to your expected type. - * Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize - * from the JSON object representation of your type. - */ - constructor( - readonly stateDefinition: StateDefinition, - readonly uniqueDerivationName: string, - readonly options: DeriveDefinitionOptions, - ) {} - - /** - * Factory that produces a {@link DeriveDefinition} from a {@link KeyDefinition} or {@link DeriveDefinition} and new name. - * - * If a `KeyDefinition` is passed in, the returned definition will have the same key as the given key definition, but - * will not collide with it in storage, even if they both reside in memory. - * - * If a `DeriveDefinition` is passed in, the returned definition will instead use the name given in the second position - * of the tuple. It is up to you to ensure this is unique within the domain of derived state. - * - * @param options A set of options to customize the behavior of {@link DeriveDefinition}. - * @param options.derive A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable - * and the resulting value will be emitted from the derived state observable. - * @param options.cleanupDelayMs The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. - * Defaults to 1000ms. - * @param options.dependencyShape An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies - * and the values are the types of the dependencies. - * for example: - * ``` - * { - * myService: MyService, - * myOtherService: MyOtherService, - * } - * ``` - * - * @param options.deserializer A function to use to safely convert your type from json to your expected type. - * Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize - * from the JSON object representation of your type. - * @param definition - * @param options - * @returns - */ - static from( - definition: - | KeyDefinition - | UserKeyDefinition - | [DeriveDefinition, string], - options: DeriveDefinitionOptions, - ) { - if (isFromDeriveDefinition(definition)) { - return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); - } else { - return new DeriveDefinition(definition.stateDefinition, definition.key, options); - } - } - - static fromWithUserId( - definition: - | KeyDefinition - | UserKeyDefinition - | [DeriveDefinition, string], - options: DeriveDefinitionOptions<[UserId, TKeyDef], TTo, TDeps>, - ) { - if (isFromDeriveDefinition(definition)) { - return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); - } else { - return new DeriveDefinition(definition.stateDefinition, definition.key, options); - } - } - - get derive() { - return this.options.derive; - } - - deserialize(serialized: Jsonify): TTo { - return this.options.deserializer(serialized); - } - - get cleanupDelayMs() { - return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000); - } - - get clearOnCleanup() { - return this.options.clearOnCleanup ?? true; - } - - buildCacheKey(): string { - return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}`; - } - - /** - * Creates a {@link StorageKey} that points to the data for the given derived definition. - * @returns A key that is ready to be used in a storage service to get data. - */ - get storageKey(): StorageKey { - return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}` as StorageKey; - } -} - -function isFromDeriveDefinition( - definition: - | KeyDefinition - | UserKeyDefinition - | [DeriveDefinition, string], -): definition is [DeriveDefinition, string] { - return Array.isArray(definition); -} +export { DeriveDefinition } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/derived-state.provider.ts b/libs/common/src/platform/state/derived-state.provider.ts index 21860482479..3118780a0cf 100644 --- a/libs/common/src/platform/state/derived-state.provider.ts +++ b/libs/common/src/platform/state/derived-state.provider.ts @@ -1,25 +1 @@ -import { Observable } from "rxjs"; - -import { DerivedStateDependencies } from "../../types/state"; - -import { DeriveDefinition } from "./derive-definition"; -import { DerivedState } from "./derived-state"; - -/** - * State derived from an observable and a derive function - */ -export abstract class DerivedStateProvider { - /** - * Creates a derived state observable from a parent state observable, a deriveDefinition, and the dependencies - * required by the deriveDefinition - * @param parentState$ The parent state observable - * @param deriveDefinition The deriveDefinition that defines conversion from the parent state to the derived state as - * well as some memory persistent information. - * @param dependencies The dependencies of the derive function - */ - abstract get( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState; -} +export { DerivedStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/derived-state.ts b/libs/common/src/platform/state/derived-state.ts index b466c3024f8..06dd28bf4f0 100644 --- a/libs/common/src/platform/state/derived-state.ts +++ b/libs/common/src/platform/state/derived-state.ts @@ -1,23 +1 @@ -import { Observable } from "rxjs"; - -export type StateConverter, TTo> = (...args: TFrom) => TTo; - -/** - * State derived from an observable and a converter function - * - * Derived state is cached and persisted to memory for sychronization across execution contexts. - * For clients with multiple execution contexts, the derived state will be executed only once in the background process. - */ -export interface DerivedState { - /** - * The derived state observable - */ - state$: Observable; - /** - * Forces the derived state to a given value. - * - * Useful for setting an in-memory value as a side effect of some event, such as emptying state as a result of a lock. - * @param value The value to force the derived state to - */ - forceValue(value: T): Promise; -} +export { DerivedState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/global-state.provider.ts b/libs/common/src/platform/state/global-state.provider.ts index a7179ba0f1d..a92e6374c49 100644 --- a/libs/common/src/platform/state/global-state.provider.ts +++ b/libs/common/src/platform/state/global-state.provider.ts @@ -1,13 +1 @@ -import { GlobalState } from "./global-state"; -import { KeyDefinition } from "./key-definition"; - -/** - * A provider for getting an implementation of global state scoped to the given key. - */ -export abstract class GlobalStateProvider { - /** - * Gets a {@link GlobalState} scoped to the given {@link KeyDefinition} - * @param keyDefinition - The {@link KeyDefinition} for which you want the state for. - */ - abstract get(keyDefinition: KeyDefinition): GlobalState; -} +export { GlobalStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/global-state.ts b/libs/common/src/platform/state/global-state.ts index b2ac634df24..d65866c9305 100644 --- a/libs/common/src/platform/state/global-state.ts +++ b/libs/common/src/platform/state/global-state.ts @@ -1,30 +1 @@ -import { Observable } from "rxjs"; - -import { StateUpdateOptions } from "./state-update-options"; - -/** - * A helper object for interacting with state that is scoped to a specific domain - * but is not scoped to a user. This is application wide storage. - */ -export interface GlobalState { - /** - * Method for allowing you to manipulate state in an additive way. - * @param configureState callback for how you want to manipulate this section of state - * @param options Defaults given by @see {module:state-update-options#DEFAULT_OPTIONS} - * @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true - * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null - * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. - * @returns A promise that must be awaited before your next action to ensure the update has been written to state. - * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. - */ - update: ( - configureState: (state: T | null, dependency: TCombine) => T | null, - options?: StateUpdateOptions, - ) => Promise; - - /** - * An observable stream of this state, the first emission of this will be the current state on disk - * and subsequent updates will be from an update to that state. - */ - state$: Observable; -} +export { GlobalState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.provider.spec.ts b/libs/common/src/platform/state/implementations/default-active-user-state.provider.spec.ts deleted file mode 100644 index 681963f8233..00000000000 --- a/libs/common/src/platform/state/implementations/default-active-user-state.provider.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { mock } from "jest-mock-extended"; - -import { mockAccountServiceWith, trackEmissions } from "../../../../spec"; -import { UserId } from "../../../types/guid"; -import { SingleUserStateProvider } from "../user-state.provider"; - -import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider"; - -describe("DefaultActiveUserStateProvider", () => { - const singleUserStateProvider = mock(); - const userId = "userId" as UserId; - const accountInfo = { - id: userId, - name: "name", - email: "email", - emailVerified: false, - }; - const accountService = mockAccountServiceWith(userId, accountInfo); - let sut: DefaultActiveUserStateProvider; - - beforeEach(() => { - sut = new DefaultActiveUserStateProvider(accountService, singleUserStateProvider); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it("should track the active User id from account service", () => { - const emissions = trackEmissions(sut.activeUserId$); - - accountService.activeAccountSubject.next(undefined); - accountService.activeAccountSubject.next(accountInfo); - - expect(emissions).toEqual([userId, undefined, userId]); - }); -}); diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts b/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts index 91e6f37c418..d24d2f8df72 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.provider.ts @@ -1,9 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Observable, distinctUntilChanged, map } from "rxjs"; +import { Observable, distinctUntilChanged } from "rxjs"; -import { AccountService } from "../../../auth/abstractions/account.service"; import { UserId } from "../../../types/guid"; +import { ActiveUserAccessor } from "../active-user.accessor"; import { UserKeyDefinition } from "../user-key-definition"; import { ActiveUserState } from "../user-state"; import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider"; @@ -14,11 +14,10 @@ export class DefaultActiveUserStateProvider implements ActiveUserStateProvider { activeUserId$: Observable; constructor( - private readonly accountService: AccountService, + private readonly activeAccountAccessor: ActiveUserAccessor, private readonly singleUserStateProvider: SingleUserStateProvider, ) { - this.activeUserId$ = this.accountService.activeAccount$.pipe( - map((account) => account?.id), + this.activeUserId$ = this.activeAccountAccessor.activeUserId$.pipe( // To avoid going to storage when we don't need to, only get updates when there is a true change. distinctUntilChanged((a, b) => (a == null || b == null ? a == b : a === b)), // Treat null and undefined as equal ); diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.ts b/libs/common/src/platform/state/implementations/default-active-user-state.ts index 964b74f5378..eb8165f8534 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.ts @@ -1,64 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Observable, map, switchMap, firstValueFrom, timeout, throwError, NEVER } from "rxjs"; - -import { UserId } from "../../../types/guid"; -import { StateUpdateOptions } from "../state-update-options"; -import { UserKeyDefinition } from "../user-key-definition"; -import { ActiveUserState, CombinedState, activeMarker } from "../user-state"; -import { SingleUserStateProvider } from "../user-state.provider"; - -export class DefaultActiveUserState implements ActiveUserState { - [activeMarker]: true; - combinedState$: Observable>; - state$: Observable; - - constructor( - protected keyDefinition: UserKeyDefinition, - private activeUserId$: Observable, - private singleUserStateProvider: SingleUserStateProvider, - ) { - this.combinedState$ = this.activeUserId$.pipe( - switchMap((userId) => - userId != null - ? this.singleUserStateProvider.get(userId, this.keyDefinition).combinedState$ - : NEVER, - ), - ); - - // State should just be combined state without the user id - this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); - } - - async update( - configureState: (state: T, dependency: TCombine) => T, - options: StateUpdateOptions = {}, - ): Promise<[UserId, T]> { - const userId = await firstValueFrom( - this.activeUserId$.pipe( - timeout({ - first: 1000, - with: () => - throwError( - () => - new Error( - `Timeout while retrieving active user for key ${this.keyDefinition.fullName}.`, - ), - ), - }), - ), - ); - if (userId == null) { - throw new Error( - `Error storing ${this.keyDefinition.fullName} for the active user: No active user at this time.`, - ); - } - - return [ - userId, - await this.singleUserStateProvider - .get(userId, this.keyDefinition) - .update(configureState, options), - ]; - } -} +export { DefaultActiveUserState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-derived-state.provider.ts b/libs/common/src/platform/state/implementations/default-derived-state.provider.ts index 61f36fa0b75..06e5e30b5a4 100644 --- a/libs/common/src/platform/state/implementations/default-derived-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-derived-state.provider.ts @@ -1,53 +1 @@ -import { Observable } from "rxjs"; - -import { DerivedStateDependencies } from "../../../types/state"; -import { DeriveDefinition } from "../derive-definition"; -import { DerivedState } from "../derived-state"; -import { DerivedStateProvider } from "../derived-state.provider"; - -import { DefaultDerivedState } from "./default-derived-state"; - -export class DefaultDerivedStateProvider implements DerivedStateProvider { - /** - * The cache uses a WeakMap to maintain separate derived states per user. - * Each user's state Observable acts as a unique key, without needing to - * pass around `userId`. Also, when a user's state Observable is cleaned up - * (like during an account swap) their cache is automatically garbage - * collected. - */ - private cache = new WeakMap, Record>>(); - - constructor() {} - - get( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState { - let stateCache = this.cache.get(parentState$); - if (!stateCache) { - stateCache = {}; - this.cache.set(parentState$, stateCache); - } - - const cacheKey = deriveDefinition.buildCacheKey(); - const existingDerivedState = stateCache[cacheKey]; - if (existingDerivedState != null) { - // I have to cast out of the unknown generic but this should be safe if rules - // around domain token are made - return existingDerivedState as DefaultDerivedState; - } - - const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies); - stateCache[cacheKey] = newDerivedState; - return newDerivedState; - } - - protected buildDerivedState( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState { - return new DefaultDerivedState(parentState$, deriveDefinition, dependencies); - } -} +export { DefaultDerivedStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-derived-state.ts b/libs/common/src/platform/state/implementations/default-derived-state.ts index 9abb2998099..e66bc754c42 100644 --- a/libs/common/src/platform/state/implementations/default-derived-state.ts +++ b/libs/common/src/platform/state/implementations/default-derived-state.ts @@ -1,50 +1 @@ -import { Observable, ReplaySubject, Subject, concatMap, merge, share, timer } from "rxjs"; - -import { DerivedStateDependencies } from "../../../types/state"; -import { DeriveDefinition } from "../derive-definition"; -import { DerivedState } from "../derived-state"; - -/** - * Default derived state - */ -export class DefaultDerivedState - implements DerivedState -{ - private readonly storageKey: string; - private forcedValueSubject = new Subject(); - - state$: Observable; - - constructor( - private parentState$: Observable, - protected deriveDefinition: DeriveDefinition, - private dependencies: TDeps, - ) { - this.storageKey = deriveDefinition.storageKey; - - const derivedState$ = this.parentState$.pipe( - concatMap(async (state) => { - let derivedStateOrPromise = this.deriveDefinition.derive(state, this.dependencies); - if (derivedStateOrPromise instanceof Promise) { - derivedStateOrPromise = await derivedStateOrPromise; - } - const derivedState = derivedStateOrPromise; - return derivedState; - }), - ); - - this.state$ = merge(this.forcedValueSubject, derivedState$).pipe( - share({ - connector: () => { - return new ReplaySubject(1); - }, - resetOnRefCountZero: () => timer(this.deriveDefinition.cleanupDelayMs), - }), - ); - } - - async forceValue(value: TTo) { - this.forcedValueSubject.next(value); - return value; - } -} +export { DefaultDerivedState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-global-state.provider.ts b/libs/common/src/platform/state/implementations/default-global-state.provider.ts index bd0cfc1dc9a..667dcd60faf 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.provider.ts @@ -1,46 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { StorageServiceProvider } from "@bitwarden/storage-core"; - -import { LogService } from "../../abstractions/log.service"; -import { GlobalState } from "../global-state"; -import { GlobalStateProvider } from "../global-state.provider"; -import { KeyDefinition } from "../key-definition"; - -import { DefaultGlobalState } from "./default-global-state"; - -export class DefaultGlobalStateProvider implements GlobalStateProvider { - private globalStateCache: Record> = {}; - - constructor( - private storageServiceProvider: StorageServiceProvider, - private readonly logService: LogService, - ) {} - - get(keyDefinition: KeyDefinition): GlobalState { - const [location, storageService] = this.storageServiceProvider.get( - keyDefinition.stateDefinition.defaultStorageLocation, - keyDefinition.stateDefinition.storageLocationOverrides, - ); - const cacheKey = this.buildCacheKey(location, keyDefinition); - const existingGlobalState = this.globalStateCache[cacheKey]; - if (existingGlobalState != null) { - // The cast into the actual generic is safe because of rules around key definitions - // being unique. - return existingGlobalState as DefaultGlobalState; - } - - const newGlobalState = new DefaultGlobalState( - keyDefinition, - storageService, - this.logService, - ); - - this.globalStateCache[cacheKey] = newGlobalState; - return newGlobalState; - } - - private buildCacheKey(location: string, keyDefinition: KeyDefinition) { - return `${location}_${keyDefinition.fullName}`; - } -} +export { DefaultGlobalStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-global-state.ts b/libs/common/src/platform/state/implementations/default-global-state.ts index a06eb23e010..6306721cd6b 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.ts +++ b/libs/common/src/platform/state/implementations/default-global-state.ts @@ -1,20 +1 @@ -import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; - -import { LogService } from "../../abstractions/log.service"; -import { GlobalState } from "../global-state"; -import { KeyDefinition, globalKeyBuilder } from "../key-definition"; - -import { StateBase } from "./state-base"; - -export class DefaultGlobalState - extends StateBase> - implements GlobalState -{ - constructor( - keyDefinition: KeyDefinition, - chosenLocation: AbstractStorageService & ObservableStorageService, - logService: LogService, - ) { - super(globalKeyBuilder(keyDefinition), chosenLocation, keyDefinition, logService); - } -} +export { DefaultGlobalState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts b/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts index bef56ad2309..b822c917a7f 100644 --- a/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-single-user-state.provider.ts @@ -1,54 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { StorageServiceProvider } from "@bitwarden/storage-core"; - -import { UserId } from "../../../types/guid"; -import { LogService } from "../../abstractions/log.service"; -import { StateEventRegistrarService } from "../state-event-registrar.service"; -import { UserKeyDefinition } from "../user-key-definition"; -import { SingleUserState } from "../user-state"; -import { SingleUserStateProvider } from "../user-state.provider"; - -import { DefaultSingleUserState } from "./default-single-user-state"; - -export class DefaultSingleUserStateProvider implements SingleUserStateProvider { - private cache: Record> = {}; - - constructor( - private readonly storageServiceProvider: StorageServiceProvider, - private readonly stateEventRegistrarService: StateEventRegistrarService, - private readonly logService: LogService, - ) {} - - get(userId: UserId, keyDefinition: UserKeyDefinition): SingleUserState { - const [location, storageService] = this.storageServiceProvider.get( - keyDefinition.stateDefinition.defaultStorageLocation, - keyDefinition.stateDefinition.storageLocationOverrides, - ); - const cacheKey = this.buildCacheKey(location, userId, keyDefinition); - const existingUserState = this.cache[cacheKey]; - if (existingUserState != null) { - // I have to cast out of the unknown generic but this should be safe if rules - // around domain token are made - return existingUserState as SingleUserState; - } - - const newUserState = new DefaultSingleUserState( - userId, - keyDefinition, - storageService, - this.stateEventRegistrarService, - this.logService, - ); - this.cache[cacheKey] = newUserState; - return newUserState; - } - - private buildCacheKey( - location: string, - userId: UserId, - keyDefinition: UserKeyDefinition, - ) { - return `${location}_${keyDefinition.fullName}_${userId}`; - } -} +export { DefaultSingleUserStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.ts b/libs/common/src/platform/state/implementations/default-single-user-state.ts index db776c3d11d..aec186a2756 100644 --- a/libs/common/src/platform/state/implementations/default-single-user-state.ts +++ b/libs/common/src/platform/state/implementations/default-single-user-state.ts @@ -1,36 +1 @@ -import { Observable, combineLatest, of } from "rxjs"; - -import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; - -import { UserId } from "../../../types/guid"; -import { LogService } from "../../abstractions/log.service"; -import { StateEventRegistrarService } from "../state-event-registrar.service"; -import { UserKeyDefinition } from "../user-key-definition"; -import { CombinedState, SingleUserState } from "../user-state"; - -import { StateBase } from "./state-base"; - -export class DefaultSingleUserState - extends StateBase> - implements SingleUserState -{ - readonly combinedState$: Observable>; - - constructor( - readonly userId: UserId, - keyDefinition: UserKeyDefinition, - chosenLocation: AbstractStorageService & ObservableStorageService, - private stateEventRegistrarService: StateEventRegistrarService, - logService: LogService, - ) { - super(keyDefinition.buildKey(userId), chosenLocation, keyDefinition, logService); - this.combinedState$ = combineLatest([of(userId), this.state$]); - } - - protected override async doStorageSave(newState: T, oldState: T): Promise { - await super.doStorageSave(newState, oldState); - if (newState != null && oldState == null) { - await this.stateEventRegistrarService.registerEvents(this.keyDefinition); - } - } -} +export { DefaultSingleUserState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/default-state.provider.ts b/libs/common/src/platform/state/implementations/default-state.provider.ts index 31795767979..e79cf5b59b2 100644 --- a/libs/common/src/platform/state/implementations/default-state.provider.ts +++ b/libs/common/src/platform/state/implementations/default-state.provider.ts @@ -1,79 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Observable, filter, of, switchMap, take } from "rxjs"; - -import { UserId } from "../../../types/guid"; -import { DerivedStateDependencies } from "../../../types/state"; -import { DeriveDefinition } from "../derive-definition"; -import { DerivedState } from "../derived-state"; -import { DerivedStateProvider } from "../derived-state.provider"; -import { GlobalStateProvider } from "../global-state.provider"; -import { StateProvider } from "../state.provider"; -import { UserKeyDefinition } from "../user-key-definition"; -import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider"; - -export class DefaultStateProvider implements StateProvider { - activeUserId$: Observable; - constructor( - private readonly activeUserStateProvider: ActiveUserStateProvider, - private readonly singleUserStateProvider: SingleUserStateProvider, - private readonly globalStateProvider: GlobalStateProvider, - private readonly derivedStateProvider: DerivedStateProvider, - ) { - this.activeUserId$ = this.activeUserStateProvider.activeUserId$; - } - - getUserState$(userKeyDefinition: UserKeyDefinition, userId?: UserId): Observable { - if (userId) { - return this.getUser(userId, userKeyDefinition).state$; - } else { - return this.activeUserId$.pipe( - filter((userId) => userId != null), // Filter out null-ish user ids since we can't get state for a null user id - take(1), - switchMap((userId) => this.getUser(userId, userKeyDefinition).state$), - ); - } - } - - getUserStateOrDefault$( - userKeyDefinition: UserKeyDefinition, - config: { userId: UserId | undefined; defaultValue?: T }, - ): Observable { - const { userId, defaultValue = null } = config; - if (userId) { - return this.getUser(userId, userKeyDefinition).state$; - } else { - return this.activeUserId$.pipe( - take(1), - switchMap((userId) => - userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue), - ), - ); - } - } - - async setUserState( - userKeyDefinition: UserKeyDefinition, - value: T | null, - userId?: UserId, - ): Promise<[UserId, T | null]> { - if (userId) { - return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)]; - } else { - return await this.getActive(userKeyDefinition).update(() => value); - } - } - - getActive: InstanceType["get"] = - this.activeUserStateProvider.get.bind(this.activeUserStateProvider); - getUser: InstanceType["get"] = - this.singleUserStateProvider.get.bind(this.singleUserStateProvider); - getGlobal: InstanceType["get"] = this.globalStateProvider.get.bind( - this.globalStateProvider, - ); - getDerived: ( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ) => DerivedState = this.derivedStateProvider.get.bind(this.derivedStateProvider); -} +export { DefaultStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/inline-derived-state.ts b/libs/common/src/platform/state/implementations/inline-derived-state.ts index 79b2c921007..aa19d8d7f16 100644 --- a/libs/common/src/platform/state/implementations/inline-derived-state.ts +++ b/libs/common/src/platform/state/implementations/inline-derived-state.ts @@ -1,37 +1 @@ -import { Observable, concatMap } from "rxjs"; - -import { DerivedStateDependencies } from "../../../types/state"; -import { DeriveDefinition } from "../derive-definition"; -import { DerivedState } from "../derived-state"; -import { DerivedStateProvider } from "../derived-state.provider"; - -export class InlineDerivedStateProvider implements DerivedStateProvider { - get( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState { - return new InlineDerivedState(parentState$, deriveDefinition, dependencies); - } -} - -export class InlineDerivedState - implements DerivedState -{ - constructor( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ) { - this.state$ = parentState$.pipe( - concatMap(async (value) => await deriveDefinition.derive(value, dependencies)), - ); - } - - state$: Observable; - - forceValue(value: TTo): Promise { - // No need to force anything, we don't keep a cache - return Promise.resolve(value); - } -} +export { InlineDerivedState, InlineDerivedStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/implementations/state-base.ts b/libs/common/src/platform/state/implementations/state-base.ts index 03140e1fe1f..88a2c8cfacf 100644 --- a/libs/common/src/platform/state/implementations/state-base.ts +++ b/libs/common/src/platform/state/implementations/state-base.ts @@ -1,137 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { - defer, - filter, - firstValueFrom, - merge, - Observable, - ReplaySubject, - share, - switchMap, - tap, - timeout, - timer, -} from "rxjs"; -import { Jsonify } from "type-fest"; - -import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; - -import { StorageKey } from "../../../types/state"; -import { LogService } from "../../abstractions/log.service"; -import { DebugOptions } from "../key-definition"; -import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options"; - -import { getStoredValue } from "./util"; - -// The parts of a KeyDefinition this class cares about to make it work -type KeyDefinitionRequirements = { - deserializer: (jsonState: Jsonify) => T | null; - cleanupDelayMs: number; - debug: Required; -}; - -export abstract class StateBase> { - private updatePromise: Promise; - - readonly state$: Observable; - - constructor( - protected readonly key: StorageKey, - protected readonly storageService: AbstractStorageService & ObservableStorageService, - protected readonly keyDefinition: KeyDef, - protected readonly logService: LogService, - ) { - const storageUpdate$ = storageService.updates$.pipe( - filter((storageUpdate) => storageUpdate.key === key), - switchMap(async (storageUpdate) => { - if (storageUpdate.updateType === "remove") { - return null; - } - - return await getStoredValue(key, storageService, keyDefinition.deserializer); - }), - ); - - let state$ = merge( - defer(() => getStoredValue(key, storageService, keyDefinition.deserializer)), - storageUpdate$, - ); - - if (keyDefinition.debug.enableRetrievalLogging) { - state$ = state$.pipe( - tap({ - next: (v) => { - this.logService.info( - `Retrieving '${key}' from storage, value is ${v == null ? "null" : "non-null"}`, - ); - }, - }), - ); - } - - // If 0 cleanup is chosen, treat this as absolutely no cache - if (keyDefinition.cleanupDelayMs !== 0) { - state$ = state$.pipe( - share({ - connector: () => new ReplaySubject(1), - resetOnRefCountZero: () => timer(keyDefinition.cleanupDelayMs), - }), - ); - } - - this.state$ = state$; - } - - async update( - configureState: (state: T | null, dependency: TCombine) => T | null, - options: StateUpdateOptions = {}, - ): Promise { - options = populateOptionsWithDefault(options); - if (this.updatePromise != null) { - await this.updatePromise; - } - - try { - this.updatePromise = this.internalUpdate(configureState, options); - return await this.updatePromise; - } finally { - this.updatePromise = null; - } - } - - private async internalUpdate( - configureState: (state: T | null, dependency: TCombine) => T | null, - options: StateUpdateOptions, - ): Promise { - const currentState = await this.getStateForUpdate(); - const combinedDependencies = - options.combineLatestWith != null - ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) - : null; - - if (!options.shouldUpdate(currentState, combinedDependencies)) { - return currentState; - } - - const newState = configureState(currentState, combinedDependencies); - await this.doStorageSave(newState, currentState); - return newState; - } - - protected async doStorageSave(newState: T | null, oldState: T) { - if (this.keyDefinition.debug.enableUpdateLogging) { - this.logService.info( - `Updating '${this.key}' from ${oldState == null ? "null" : "non-null"} to ${newState == null ? "null" : "non-null"}`, - ); - } - await this.storageService.save(this.key, newState); - } - - /** For use in update methods, does not wait for update to complete before yielding state. - * The expectation is that that await is already done - */ - private async getStateForUpdate() { - return await getStoredValue(this.key, this.storageService, this.keyDefinition.deserializer); - } -} +export { StateBase } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/index.ts b/libs/common/src/platform/state/index.ts index 367beefb495..8a9175b171c 100644 --- a/libs/common/src/platform/state/index.ts +++ b/libs/common/src/platform/state/index.ts @@ -1,14 +1 @@ -export { DeriveDefinition } from "./derive-definition"; -export { DerivedStateProvider } from "./derived-state.provider"; -export { DerivedState } from "./derived-state"; -export { GlobalState } from "./global-state"; -export { StateProvider } from "./state.provider"; -export { GlobalStateProvider } from "./global-state.provider"; -export { ActiveUserState, SingleUserState, CombinedState } from "./user-state"; -export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider"; -export { KeyDefinition, KeyDefinitionOptions } from "./key-definition"; -export { StateUpdateOptions } from "./state-update-options"; -export { UserKeyDefinitionOptions, UserKeyDefinition } from "./user-key-definition"; -export { StateEventRunnerService } from "./state-event-runner.service"; - -export * from "./state-definitions"; +export * from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/key-definition.ts b/libs/common/src/platform/state/key-definition.ts index 519e98ef52d..bc5b02ad5d6 100644 --- a/libs/common/src/platform/state/key-definition.ts +++ b/libs/common/src/platform/state/key-definition.ts @@ -1,182 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Jsonify } from "type-fest"; - -import { StorageKey } from "../../types/state"; - -import { array, record } from "./deserialization-helpers"; -import { StateDefinition } from "./state-definition"; - -export type DebugOptions = { - /** - * When true, logs will be written that look like the following: - * - * ``` - * "Updating 'global_myState_myKey' from null to non-null" - * "Updating 'user_32265eda-62ff-4797-9ead-22214772f888_myState_myKey' from non-null to null." - * ``` - * - * It does not include the value of the data, only whether it is null or non-null. - */ - enableUpdateLogging?: boolean; - - /** - * When true, logs will be written that look like the following everytime a value is retrieved from storage. - * - * "Retrieving 'global_myState_myKey' from storage, value is null." - * "Retrieving 'user_32265eda-62ff-4797-9ead-22214772f888_myState_myKey' from storage, value is non-null." - */ - enableRetrievalLogging?: boolean; -}; - -/** - * A set of options for customizing the behavior of a {@link KeyDefinition} - */ -export type KeyDefinitionOptions = { - /** - * A function to use to safely convert your type from json to your expected type. - * - * **Important:** Your data may be serialized/deserialized at any time and this - * callback needs to be able to faithfully re-initialize from the JSON object representation of your type. - * - * @param jsonValue The JSON object representation of your state. - * @returns The fully typed version of your state. - */ - readonly deserializer: (jsonValue: Jsonify) => T | null; - /** - * The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. - * Defaults to 1000ms. - */ - readonly cleanupDelayMs?: number; - - /** - * Options for configuring the debugging behavior, see individual options for more info. - */ - readonly debug?: DebugOptions; -}; - -/** - * KeyDefinitions describe the precise location to store data for a given piece of state. - * The StateDefinition is used to describe the domain of the state, and the KeyDefinition - * sub-divides that domain into specific keys. - */ -export class KeyDefinition { - readonly debug: Required; - - /** - * Creates a new instance of a KeyDefinition - * @param stateDefinition The state definition for which this key belongs to. - * @param key The name of the key, this should be unique per domain. - * @param options A set of options to customize the behavior of {@link KeyDefinition}. All options are required. - * @param options.deserializer A function to use to safely convert your type from json to your expected type. - * Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize - * from the JSON object representation of your type. - */ - constructor( - readonly stateDefinition: StateDefinition, - readonly key: string, - private readonly options: KeyDefinitionOptions, - ) { - if (options.deserializer == null) { - throw new Error(`'deserializer' is a required property on key ${this.errorKeyName}`); - } - - if (options.cleanupDelayMs < 0) { - throw new Error( - `'cleanupDelayMs' must be greater than or equal to 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `, - ); - } - - // Normalize optional debug options - const { enableUpdateLogging = false, enableRetrievalLogging = false } = options.debug ?? {}; - this.debug = { - enableUpdateLogging, - enableRetrievalLogging, - }; - } - - /** - * Gets the deserializer configured for this {@link KeyDefinition} - */ - get deserializer() { - return this.options.deserializer; - } - - /** - * Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. - */ - get cleanupDelayMs() { - return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000); - } - - /** - * Creates a {@link KeyDefinition} for state that is an array. - * @param stateDefinition The state definition to be added to the KeyDefinition - * @param key The key to be added to the KeyDefinition - * @param options The options to customize the final {@link KeyDefinition}. - * @returns A {@link KeyDefinition} initialized for arrays, the options run - * the deserializer on the provided options for each element of an array. - * - * @example - * ```typescript - * const MY_KEY = KeyDefinition.array(MY_STATE, "key", { - * deserializer: (myJsonElement) => convertToElement(myJsonElement), - * }); - * ``` - */ - static array( - stateDefinition: StateDefinition, - key: string, - // We have them provide options for the element of the array, depending on future options we add, this could get a little weird. - options: KeyDefinitionOptions, // The array helper forces an initialValue of an empty array - ) { - return new KeyDefinition(stateDefinition, key, { - ...options, - deserializer: array((e) => options.deserializer(e)), - }); - } - - /** - * Creates a {@link KeyDefinition} for state that is a record. - * @param stateDefinition The state definition to be added to the KeyDefinition - * @param key The key to be added to the KeyDefinition - * @param options The options to customize the final {@link KeyDefinition}. - * @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each - * value in a record and returns every key as a string. - * - * @example - * ```typescript - * const MY_KEY = KeyDefinition.record(MY_STATE, "key", { - * deserializer: (myJsonValue) => convertToValue(myJsonValue), - * }); - * ``` - */ - static record( - stateDefinition: StateDefinition, - key: string, - // We have them provide options for the value of the record, depending on future options we add, this could get a little weird. - options: KeyDefinitionOptions, // The array helper forces an initialValue of an empty record - ) { - return new KeyDefinition>(stateDefinition, key, { - ...options, - deserializer: record((v) => options.deserializer(v)), - }); - } - - get fullName() { - return `${this.stateDefinition.name}_${this.key}`; - } - - protected get errorKeyName() { - return `${this.stateDefinition.name} > ${this.key}`; - } -} - -/** - * Creates a {@link StorageKey} - * @param keyDefinition The key definition of which data the key should point to. - * @returns A key that is ready to be used in a storage service to get data. - */ -export function globalKeyBuilder(keyDefinition: KeyDefinition): StorageKey { - return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey; -} +export { KeyDefinition, KeyDefinitionOptions } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/state-definition.ts b/libs/common/src/platform/state/state-definition.ts index 5e24146fbdd..f2a40429d1c 100644 --- a/libs/common/src/platform/state/state-definition.ts +++ b/libs/common/src/platform/state/state-definition.ts @@ -1,24 +1,4 @@ -import { StorageLocation, ClientLocations } from "@bitwarden/storage-core"; +export { StateDefinition } from "@bitwarden/state"; // To be removed once references are updated to point to @bitwarden/storage-core -export { StorageLocation, ClientLocations }; - -/** - * Defines the base location and instruction of where this state is expected to be located. - */ -export class StateDefinition { - readonly storageLocationOverrides: Partial; - - /** - * Creates a new instance of {@link StateDefinition}, the creation of which is owned by the platform team. - * @param name The name of the state, this needs to be unique from all other {@link StateDefinition}'s. - * @param defaultStorageLocation The location of where this state should be stored. - */ - constructor( - readonly name: string, - readonly defaultStorageLocation: StorageLocation, - storageLocationOverrides?: Partial, - ) { - this.storageLocationOverrides = storageLocationOverrides ?? {}; - } -} +export { StorageLocation, ClientLocations } from "@bitwarden/storage-core"; diff --git a/libs/common/src/platform/state/state-event-registrar.service.ts b/libs/common/src/platform/state/state-event-registrar.service.ts index e74d46d3b7b..1186221c626 100644 --- a/libs/common/src/platform/state/state-event-registrar.service.ts +++ b/libs/common/src/platform/state/state-event-registrar.service.ts @@ -1,76 +1,6 @@ -import { PossibleLocation, StorageServiceProvider } from "../services/storage-service.provider"; - -import { GlobalState } from "./global-state"; -import { GlobalStateProvider } from "./global-state.provider"; -import { KeyDefinition } from "./key-definition"; -import { CLEAR_EVENT_DISK } from "./state-definitions"; -import { ClearEvent, UserKeyDefinition } from "./user-key-definition"; - -export type StateEventInfo = { - state: string; - key: string; - location: PossibleLocation; -}; - -export const STATE_LOCK_EVENT = KeyDefinition.array(CLEAR_EVENT_DISK, "lock", { - deserializer: (e) => e, -}); - -export const STATE_LOGOUT_EVENT = KeyDefinition.array(CLEAR_EVENT_DISK, "logout", { - deserializer: (e) => e, -}); - -export class StateEventRegistrarService { - private readonly stateEventStateMap: { [Prop in ClearEvent]: GlobalState }; - - constructor( - globalStateProvider: GlobalStateProvider, - private storageServiceProvider: StorageServiceProvider, - ) { - this.stateEventStateMap = { - lock: globalStateProvider.get(STATE_LOCK_EVENT), - logout: globalStateProvider.get(STATE_LOGOUT_EVENT), - }; - } - - async registerEvents(keyDefinition: UserKeyDefinition) { - for (const clearEvent of keyDefinition.clearOn) { - const eventState = this.stateEventStateMap[clearEvent]; - // Determine the storage location for this - const [storageLocation] = this.storageServiceProvider.get( - keyDefinition.stateDefinition.defaultStorageLocation, - keyDefinition.stateDefinition.storageLocationOverrides, - ); - - const newEvent: StateEventInfo = { - state: keyDefinition.stateDefinition.name, - key: keyDefinition.key, - location: storageLocation, - }; - - // Only update the event state if the existing list doesn't have a matching entry - await eventState.update( - (existingTickets) => { - existingTickets ??= []; - existingTickets.push(newEvent); - return existingTickets; - }, - { - shouldUpdate: (currentTickets) => { - return ( - // If the current tickets are null, then it will for sure be added - currentTickets == null || - // If an existing match couldn't be found, we also need to add one - currentTickets.findIndex( - (e) => - e.state === newEvent.state && - e.key === newEvent.key && - e.location === newEvent.location, - ) === -1 - ); - }, - }, - ); - } - } -} +export { + StateEventRegistrarService, + StateEventInfo, + STATE_LOCK_EVENT, + STATE_LOGOUT_EVENT, +} from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/state-event-runner.service.ts b/libs/common/src/platform/state/state-event-runner.service.ts index 9e6f8f214e1..60fb11a8f5e 100644 --- a/libs/common/src/platform/state/state-event-runner.service.ts +++ b/libs/common/src/platform/state/state-event-runner.service.ts @@ -1,83 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { firstValueFrom } from "rxjs"; - -import { StorageServiceProvider } from "@bitwarden/storage-core"; - -import { UserId } from "../../types/guid"; - -import { GlobalState } from "./global-state"; -import { GlobalStateProvider } from "./global-state.provider"; -import { StateDefinition, StorageLocation } from "./state-definition"; -import { - STATE_LOCK_EVENT, - STATE_LOGOUT_EVENT, - StateEventInfo, -} from "./state-event-registrar.service"; -import { ClearEvent, UserKeyDefinition } from "./user-key-definition"; - -export class StateEventRunnerService { - private readonly stateEventMap: { [Prop in ClearEvent]: GlobalState }; - - constructor( - globalStateProvider: GlobalStateProvider, - private storageServiceProvider: StorageServiceProvider, - ) { - this.stateEventMap = { - lock: globalStateProvider.get(STATE_LOCK_EVENT), - logout: globalStateProvider.get(STATE_LOGOUT_EVENT), - }; - } - - async handleEvent(event: ClearEvent, userId: UserId) { - let tickets = await firstValueFrom(this.stateEventMap[event].state$); - tickets ??= []; - - const failures: string[] = []; - - for (const ticket of tickets) { - try { - const [, service] = this.storageServiceProvider.get( - ticket.location, - {}, // The storage location is already the computed storage location for this client - ); - - const ticketStorageKey = this.storageKeyFor(userId, ticket); - - // Evaluate current value so we can avoid writing to state if we don't need to - const currentValue = await service.get(ticketStorageKey); - if (currentValue != null) { - await service.remove(ticketStorageKey); - } - } catch (err: unknown) { - let errorMessage = "Unknown Error"; - if (typeof err === "object" && "message" in err && typeof err.message === "string") { - errorMessage = err.message; - } - - failures.push( - `${errorMessage} in ${ticket.state} > ${ticket.key} located ${ticket.location}`, - ); - } - } - - if (failures.length > 0) { - // Throw aggregated error - throw new Error( - `One or more errors occurred while handling event '${event}' for user ${userId}.\n${failures.join("\n")}`, - ); - } - } - - private storageKeyFor(userId: UserId, ticket: StateEventInfo) { - const userKey = new UserKeyDefinition( - new StateDefinition(ticket.state, ticket.location as unknown as StorageLocation), - ticket.key, - { - deserializer: (v) => v, - clearOn: [], - }, - ); - return userKey.buildKey(userId); - } -} +export { StateEventRunnerService } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/state.provider.ts b/libs/common/src/platform/state/state.provider.ts index dc8cb3e9359..4c36bed9593 100644 --- a/libs/common/src/platform/state/state.provider.ts +++ b/libs/common/src/platform/state/state.provider.ts @@ -1,80 +1 @@ -import { Observable } from "rxjs"; - -import { UserId } from "../../types/guid"; -import { DerivedStateDependencies } from "../../types/state"; - -import { DeriveDefinition } from "./derive-definition"; -import { DerivedState } from "./derived-state"; -import { GlobalState } from "./global-state"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs -import { GlobalStateProvider } from "./global-state.provider"; -import { KeyDefinition } from "./key-definition"; -import { UserKeyDefinition } from "./user-key-definition"; -import { ActiveUserState, SingleUserState } from "./user-state"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs -import { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider"; - -/** Convenience wrapper class for {@link ActiveUserStateProvider}, {@link SingleUserStateProvider}, - * and {@link GlobalStateProvider}. - */ -export abstract class StateProvider { - /** @see{@link ActiveUserStateProvider.activeUserId$} */ - abstract activeUserId$: Observable; - - /** - * Gets a state observable for a given key and userId. - * - * @remarks If userId is falsy the observable returned will attempt to point to the currently active user _and not update if the active user changes_. - * This is different to how `getActive` works and more similar to `getUser` for whatever user happens to be active at the time of the call. - * If no user happens to be active at the time this method is called with a falsy userId then this observable will not emit a value until - * a user becomes active. If you are not confident a user is active at the time this method is called, you may want to pipe a call to `timeout` - * or instead call {@link getUserStateOrDefault$} and supply a value you would rather have given in the case of no passed in userId and no active user. - * - * @param keyDefinition - The key definition for the state you want to get. - * @param userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned. - */ - abstract getUserState$(keyDefinition: UserKeyDefinition, userId?: UserId): Observable; - - /** - * Gets a state observable for a given key and userId - * - * @remarks If userId is falsy the observable return will first attempt to point to the currently active user but will not follow subsequent active user changes, - * if there is no immediately available active user, then it will fallback to returning a default value in an observable that immediately completes. - * - * @param keyDefinition - The key definition for the state you want to get. - * @param config.userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned. - * @param config.defaultValue - The default value that should be wrapped in an observable if no active user is immediately available and no truthy userId is passed in. - */ - abstract getUserStateOrDefault$( - keyDefinition: UserKeyDefinition, - config: { userId: UserId | undefined; defaultValue?: T }, - ): Observable; - - /** - * Sets the state for a given key and userId. - * - * @overload - * @param keyDefinition - The key definition for the state you want to set. - * @param value - The value to set the state to. - * @param userId - The userId for which you want to set the state for. If not provided, the state for the currently active user will be set. - */ - abstract setUserState( - keyDefinition: UserKeyDefinition, - value: T | null, - userId?: UserId, - ): Promise<[UserId, T | null]>; - - /** @see{@link ActiveUserStateProvider.get} */ - abstract getActive(userKeyDefinition: UserKeyDefinition): ActiveUserState; - - /** @see{@link SingleUserStateProvider.get} */ - abstract getUser(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState; - - /** @see{@link GlobalStateProvider.get} */ - abstract getGlobal(keyDefinition: KeyDefinition): GlobalState; - abstract getDerived( - parentState$: Observable, - deriveDefinition: DeriveDefinition, - dependencies: TDeps, - ): DerivedState; -} +export { StateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/user-key-definition.ts b/libs/common/src/platform/state/user-key-definition.ts index 090a8aad31d..fd3bdea32d2 100644 --- a/libs/common/src/platform/state/user-key-definition.ts +++ b/libs/common/src/platform/state/user-key-definition.ts @@ -1,142 +1 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { UserId } from "../../types/guid"; -import { StorageKey } from "../../types/state"; -import { Utils } from "../misc/utils"; - -import { array, record } from "./deserialization-helpers"; -import { DebugOptions, KeyDefinitionOptions } from "./key-definition"; -import { StateDefinition } from "./state-definition"; - -export type ClearEvent = "lock" | "logout"; - -export type UserKeyDefinitionOptions = KeyDefinitionOptions & { - clearOn: ClearEvent[]; -}; - -const USER_KEY_DEFINITION_MARKER: unique symbol = Symbol("UserKeyDefinition"); - -export class UserKeyDefinition { - readonly [USER_KEY_DEFINITION_MARKER] = true; - /** - * A unique array of events that the state stored at this key should be cleared on. - */ - readonly clearOn: ClearEvent[]; - - /** - * Normalized options used for debugging purposes. - */ - readonly debug: Required; - - constructor( - readonly stateDefinition: StateDefinition, - readonly key: string, - private readonly options: UserKeyDefinitionOptions, - ) { - if (options.deserializer == null) { - throw new Error(`'deserializer' is a required property on key ${this.errorKeyName}`); - } - - if (options.cleanupDelayMs < 0) { - throw new Error( - `'cleanupDelayMs' must be greater than or equal to 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `, - ); - } - - // Filter out repeat values - this.clearOn = Array.from(new Set(options.clearOn)); - - // Normalize optional debug options - const { enableUpdateLogging = false, enableRetrievalLogging = false } = options.debug ?? {}; - this.debug = { - enableUpdateLogging, - enableRetrievalLogging, - }; - } - - /** - * Gets the deserializer configured for this {@link KeyDefinition} - */ - get deserializer() { - return this.options.deserializer; - } - - /** - * Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. - */ - get cleanupDelayMs() { - return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000); - } - - /** - * Creates a {@link UserKeyDefinition} for state that is an array. - * @param stateDefinition The state definition to be added to the UserKeyDefinition - * @param key The key to be added to the KeyDefinition - * @param options The options to customize the final {@link UserKeyDefinition}. - * @returns A {@link UserKeyDefinition} initialized for arrays, the options run - * the deserializer on the provided options for each element of an array - * **unless that array is null, in which case it will return an empty list.** - * - * @example - * ```typescript - * const MY_KEY = UserKeyDefinition.array(MY_STATE, "key", { - * deserializer: (myJsonElement) => convertToElement(myJsonElement), - * }); - * ``` - */ - static array( - stateDefinition: StateDefinition, - key: string, - // We have them provide options for the element of the array, depending on future options we add, this could get a little weird. - options: UserKeyDefinitionOptions, - ) { - return new UserKeyDefinition(stateDefinition, key, { - ...options, - deserializer: array((e) => options.deserializer(e)), - }); - } - - /** - * Creates a {@link UserKeyDefinition} for state that is a record. - * @param stateDefinition The state definition to be added to the UserKeyDefinition - * @param key The key to be added to the KeyDefinition - * @param options The options to customize the final {@link UserKeyDefinition}. - * @returns A {@link UserKeyDefinition} that contains a serializer that will run the provided deserializer for each - * value in a record and returns every key as a string **unless that record is null, in which case it will return an record.** - * - * @example - * ```typescript - * const MY_KEY = UserKeyDefinition.record(MY_STATE, "key", { - * deserializer: (myJsonValue) => convertToValue(myJsonValue), - * }); - * ``` - */ - static record( - stateDefinition: StateDefinition, - key: string, - // We have them provide options for the value of the record, depending on future options we add, this could get a little weird. - options: UserKeyDefinitionOptions, // The array helper forces an initialValue of an empty record - ) { - return new UserKeyDefinition>(stateDefinition, key, { - ...options, - deserializer: record((v) => options.deserializer(v)), - }); - } - - get fullName() { - return `${this.stateDefinition.name}_${this.key}`; - } - - buildKey(userId: UserId) { - if (!Utils.isGuid(userId)) { - throw new Error( - `You cannot build a user key without a valid UserId, building for key ${this.fullName}`, - ); - } - return `user_${userId}_${this.stateDefinition.name}_${this.key}` as StorageKey; - } - - private get errorKeyName() { - return `${this.stateDefinition.name} > ${this.key}`; - } -} +export { UserKeyDefinition, UserKeyDefinitionOptions } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/user-state.provider.ts b/libs/common/src/platform/state/user-state.provider.ts index 677f8b472dc..eff529d79b7 100644 --- a/libs/common/src/platform/state/user-state.provider.ts +++ b/libs/common/src/platform/state/user-state.provider.ts @@ -1,35 +1 @@ -import { Observable } from "rxjs"; - -import { UserId } from "../../types/guid"; - -import { UserKeyDefinition } from "./user-key-definition"; -import { ActiveUserState, SingleUserState } from "./user-state"; - -/** A provider for getting an implementation of state scoped to a given key and userId */ -export abstract class SingleUserStateProvider { - /** - * Gets a {@link SingleUserState} scoped to the given {@link UserKeyDefinition} and {@link UserId} - * - * @param userId - The {@link UserId} for which you want the user state for. - * @param userKeyDefinition - The {@link UserKeyDefinition} for which you want the user state for. - */ - abstract get(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState; -} - -/** A provider for getting an implementation of state scoped to a given key, but always pointing - * to the currently active user - */ -export abstract class ActiveUserStateProvider { - /** - * Convenience re-emission of active user ID from {@link AccountService.activeAccount$} - */ - abstract activeUserId$: Observable; - - /** - * Gets a {@link ActiveUserState} scoped to the given {@link KeyDefinition}, but updates when active user changes such - * that the emitted values always represents the state for the currently active user. - * - * @param keyDefinition - The {@link UserKeyDefinition} for which you want the user state for. - */ - abstract get(userKeyDefinition: UserKeyDefinition): ActiveUserState; -} +export { ActiveUserStateProvider, SingleUserStateProvider } from "@bitwarden/state"; diff --git a/libs/common/src/platform/state/user-state.ts b/libs/common/src/platform/state/user-state.ts index 26fa6f83fa3..2fbfd4ce418 100644 --- a/libs/common/src/platform/state/user-state.ts +++ b/libs/common/src/platform/state/user-state.ts @@ -1,64 +1 @@ -import { Observable } from "rxjs"; - -import { UserId } from "../../types/guid"; - -import { StateUpdateOptions } from "./state-update-options"; - -export type CombinedState = readonly [userId: UserId, state: T]; - -/** A helper object for interacting with state that is scoped to a specific user. */ -export interface UserState { - /** Emits a stream of data. Emits null if the user does not have specified state. */ - readonly state$: Observable; - - /** Emits a stream of tuples, with the first element being a user id and the second element being the data for that user. */ - readonly combinedState$: Observable>; -} - -export const activeMarker: unique symbol = Symbol("active"); - -export interface ActiveUserState extends UserState { - readonly [activeMarker]: true; - - /** - * Emits a stream of data. Emits null if the user does not have specified state. - * Note: Will not emit if there is no active user. - */ - readonly state$: Observable; - - /** - * Updates backing stores for the active user. - * @param configureState function that takes the current state and returns the new state - * @param options Defaults to @see {module:state-update-options#DEFAULT_OPTIONS} - * @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true - * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null - * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. - * - * @returns A promise that must be awaited before your next action to ensure the update has been written to state. - * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. - */ - readonly update: ( - configureState: (state: T | null, dependencies: TCombine) => T | null, - options?: StateUpdateOptions, - ) => Promise<[UserId, T | null]>; -} - -export interface SingleUserState extends UserState { - readonly userId: UserId; - - /** - * Updates backing stores for the active user. - * @param configureState function that takes the current state and returns the new state - * @param options Defaults to @see {module:state-update-options#DEFAULT_OPTIONS} - * @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true - * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null - * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. - * - * @returns A promise that must be awaited before your next action to ensure the update has been written to state. - * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. - */ - readonly update: ( - configureState: (state: T | null, dependencies: TCombine) => T | null, - options?: StateUpdateOptions, - ) => Promise; -} +export { ActiveUserState, SingleUserState, CombinedState } from "@bitwarden/state"; diff --git a/libs/common/src/platform/sync/core-sync.service.ts b/libs/common/src/platform/sync/core-sync.service.ts index 63f9ab17fb3..40419a343da 100644 --- a/libs/common/src/platform/sync/core-sync.service.ts +++ b/libs/common/src/platform/sync/core-sync.service.ts @@ -172,7 +172,11 @@ export abstract class CoreSyncService implements SyncService { notification.collectionIds != null && notification.collectionIds.length > 0 ) { - const collections = await this.collectionService.getAll(); + const collections = await firstValueFrom( + this.collectionService + .encryptedCollections$(userId) + .pipe(map((collections) => collections ?? [])), + ); if (collections != null) { for (let i = 0; i < collections.length; i++) { if (notification.collectionIds.indexOf(collections[i].id) > -1) { diff --git a/libs/common/src/state-migrations/index.ts b/libs/common/src/state-migrations/index.ts index e51a4e8e937..69f56fe06aa 100644 --- a/libs/common/src/state-migrations/index.ts +++ b/libs/common/src/state-migrations/index.ts @@ -1 +1,2 @@ -export { createMigrationBuilder, waitForMigrations, CURRENT_VERSION } from "./migrate"; +// Compatibility re-export for @bitwarden/common/state-migrations +export * from "@bitwarden/state"; diff --git a/libs/common/src/state-migrations/migration-builder.ts b/libs/common/src/state-migrations/migration-builder.ts index b9a1c67cd6d..9fdadefa1ad 100644 --- a/libs/common/src/state-migrations/migration-builder.ts +++ b/libs/common/src/state-migrations/migration-builder.ts @@ -1,106 +1 @@ -import { MigrationHelper } from "./migration-helper"; -import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator"; - -export class MigrationBuilder { - /** Create a new MigrationBuilder with an empty buffer of migrations to perform. - * - * Add migrations to the buffer with {@link with} and {@link rollback}. - * @returns A new MigrationBuilder. - */ - static create(): MigrationBuilder<0> { - return new MigrationBuilder([]); - } - - private constructor( - private migrations: readonly { migrator: Migrator; direction: Direction }[], - ) {} - - /** Add a migrator to the MigrationBuilder. Types are updated such that the chained MigrationBuilder must currently be - * at state version equal to the from version of the migrator. Return as MigrationBuilder where TTo is the to - * version of the migrator, so that the next migrator can be chained. - * - * @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is - * required to instantiate version numbers unless a default constructor is defined. - * @returns A new MigrationBuilder with the to version of the migrator as the current version. - */ - with< - TMigrator extends Migrator, - TFrom extends VersionFrom & TCurrent, - TTo extends VersionTo, - >( - ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo] - ): MigrationBuilder { - return this.addMigrator(migrate, "up"); - } - - /** Add a migrator to rollback on the MigrationBuilder's list of migrations. As with {@link with}, types of - * MigrationBuilder and Migrator must align. However, this time the migration is reversed so TCurrent of the - * MigrationBuilder must be equal to the to version of the migrator. Return as MigrationBuilder where TFrom - * is the from version of the migrator, so that the next migrator can be chained. - * - * @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is - * required to instantiate version numbers unless a default constructor is defined. - * @returns A new MigrationBuilder with the from version of the migrator as the current version. - */ - rollback< - TMigrator extends Migrator, - TFrom extends VersionFrom, - TTo extends VersionTo & TCurrent, - >( - ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom] - ): MigrationBuilder { - if (migrate.length === 3) { - migrate = [migrate[0], migrate[2], migrate[1]]; - } - return this.addMigrator(migrate, "down"); - } - - /** Execute the migrations as defined in the MigrationBuilder's migrator buffer */ - migrate(helper: MigrationHelper): Promise { - return this.migrations.reduce( - (promise, migrator) => - promise.then(async () => { - await this.runMigrator(migrator.migrator, helper, migrator.direction); - }), - Promise.resolve(), - ); - } - - private addMigrator< - TMigrator extends Migrator, - TFrom extends VersionFrom & TCurrent, - TTo extends VersionTo, - >( - migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo], - direction: Direction = "up", - ) { - const newMigration = - migrate.length === 1 - ? { migrator: new migrate[0](), direction } - : { migrator: new migrate[0](migrate[1], migrate[2]), direction }; - - return new MigrationBuilder([...this.migrations, newMigration]); - } - - private async runMigrator( - migrator: Migrator, - helper: MigrationHelper, - direction: Direction, - ): Promise { - const shouldMigrate = await migrator.shouldMigrate(helper, direction); - helper.info( - `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) should migrate: ${shouldMigrate} - ${direction}`, - ); - if (shouldMigrate) { - const method = direction === "up" ? migrator.migrate : migrator.rollback; - await method.bind(migrator)(helper); - helper.info( - `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}`, - ); - await migrator.updateVersion(helper, direction); - helper.info( - `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) updated version - ${direction}`, - ); - } - } -} +export { MigrationBuilder } from "@bitwarden/state"; diff --git a/libs/common/src/state-migrations/migration-helper.ts b/libs/common/src/state-migrations/migration-helper.ts index b377df8ef9d..167a9a53399 100644 --- a/libs/common/src/state-migrations/migration-helper.ts +++ b/libs/common/src/state-migrations/migration-helper.ts @@ -1,261 +1 @@ -// eslint-disable-next-line import/no-restricted-paths -- Needed to provide client type to migrations -import { ClientType } from "../enums"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages -import { LogService } from "../platform/abstractions/log.service"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations -import { AbstractStorageService } from "../platform/abstractions/storage.service"; - -export type StateDefinitionLike = { name: string }; -export type KeyDefinitionLike = { - stateDefinition: StateDefinitionLike; - key: string; -}; - -export type MigrationHelperType = "general" | "web-disk-local"; - -export class MigrationHelper { - constructor( - public currentVersion: number, - private storageService: AbstractStorageService, - public logService: LogService, - type: MigrationHelperType, - public clientType: ClientType, - ) { - this.type = type; - } - - /** - * On some clients, migrations are ran multiple times without direct action from the migration writer. - * - * All clients will run through migrations at least once, this run is referred to as `"general"`. If a migration is - * ran more than that single time, they will get a unique name if that the write can make conditional logic based on which - * migration run this is. - * - * @remarks The preferrable way of writing migrations is ALWAYS to be defensive and reflect on the data you are given back. This - * should really only be used when reflecting on the data given isn't enough. - */ - type: MigrationHelperType; - - /** - * Gets a value from the storage service at the given key. - * - * This is a brute force method to just get a value from the storage service. If you can use {@link getFromGlobal} or {@link getFromUser}, you should. - * @param key location - * @returns the value at the location - */ - get(key: string): Promise { - return this.storageService.get(key); - } - - /** - * Sets a value in the storage service at the given key. - * - * This is a brute force method to just set a value in the storage service. If you can use {@link setToGlobal} or {@link setToUser}, you should. - * @param key location - * @param value the value to set - * @returns - */ - set(key: string, value: T): Promise { - this.logService.info(`Setting ${key}`); - return this.storageService.save(key, value); - } - - /** - * Remove a value in the storage service at the given key. - * - * This is a brute force method to just remove a value in the storage service. If you can use {@link removeFromGlobal} or {@link removeFromUser}, you should. - * @param key location - * @returns void - */ - remove(key: string): Promise { - this.logService.info(`Removing ${key}`); - return this.storageService.remove(key); - } - - /** - * Gets a globally scoped value from a location derived through the key definition - * - * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, - * use {@link get} for those. - * @param keyDefinition unique key definition - * @returns value from store - */ - getFromGlobal(keyDefinition: KeyDefinitionLike): Promise { - return this.get(this.getGlobalKey(keyDefinition)); - } - - /** - * Sets a globally scoped value to a location derived through the key definition - * - * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, - * use {@link set} for those. - * @param keyDefinition unique key definition - * @param value value to store - * @returns void - */ - setToGlobal(keyDefinition: KeyDefinitionLike, value: T): Promise { - return this.set(this.getGlobalKey(keyDefinition), value); - } - - /** - * Remove a globally scoped location derived through the key definition - * - * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, - * use {@link remove} for those. - * @param keyDefinition unique key definition - * @returns void - */ - removeFromGlobal(keyDefinition: KeyDefinitionLike): Promise { - return this.remove(this.getGlobalKey(keyDefinition)); - } - - /** - * Gets a user scoped value from a location derived through the user id and key definition - * - * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, - * use {@link get} for those. - * @param userId userId to use in the key - * @param keyDefinition unique key definition - * @returns value from store - */ - getFromUser(userId: string, keyDefinition: KeyDefinitionLike): Promise { - return this.get(this.getUserKey(userId, keyDefinition)); - } - - /** - * Sets a user scoped value to a location derived through the user id and key definition - * - * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, - * use {@link set} for those. - * @param userId userId to use in the key - * @param keyDefinition unique key definition - * @param value value to store - * @returns void - */ - setToUser(userId: string, keyDefinition: KeyDefinitionLike, value: T): Promise { - return this.set(this.getUserKey(userId, keyDefinition), value); - } - - /** - * Remove a user scoped location derived through the key definition - * - * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, - * use {@link remove} for those. - * @param keyDefinition unique key definition - * @returns void - */ - removeFromUser(userId: string, keyDefinition: KeyDefinitionLike): Promise { - return this.remove(this.getUserKey(userId, keyDefinition)); - } - - info(message: string): void { - this.logService.info(message); - } - - /** - * Helper method to read all Account objects stored by the State Service. - * - * This is useful from creating migrations off of this paradigm, but should not be used once a value is migrated to a state provider. - * - * @returns a list of all accounts that have been authenticated with state service, cast the expected type. - */ - async getAccounts(): Promise< - { userId: string; account: ExpectedAccountType }[] - > { - const userIds = await this.getKnownUserIds(); - return Promise.all( - userIds.map(async (userId) => ({ - userId, - account: await this.get(userId), - })), - ); - } - - /** - * Helper method to read known users ids. - */ - async getKnownUserIds(): Promise { - if (this.currentVersion < 60) { - return knownAccountUserIdsBuilderPre60(this.storageService); - } else { - return knownAccountUserIdsBuilder(this.storageService); - } - } - - /** - * Builds a user storage key appropriate for the current version. - * - * @param userId userId to use in the key - * @param keyDefinition state and key to use in the key - * @returns - */ - private getUserKey(userId: string, keyDefinition: KeyDefinitionLike): string { - if (this.currentVersion < 9) { - return userKeyBuilderPre9(); - } else { - return userKeyBuilder(userId, keyDefinition); - } - } - - /** - * Builds a global storage key appropriate for the current version. - * - * @param keyDefinition state and key to use in the key - * @returns - */ - private getGlobalKey(keyDefinition: KeyDefinitionLike): string { - if (this.currentVersion < 9) { - return globalKeyBuilderPre9(); - } else { - return globalKeyBuilder(keyDefinition); - } - } -} - -/** - * When this is updated, rename this function to `userKeyBuilderXToY` where `X` is the version number it - * became relevant, and `Y` prior to the version it was updated. - * - * Be sure to update the map in `MigrationHelper` to point to the appropriate function for the current version. - * @param userId The userId of the user you want the key to be for. - * @param keyDefinition the key definition of which data the key should point to. - * @returns - */ -function userKeyBuilder(userId: string, keyDefinition: KeyDefinitionLike): string { - return `user_${userId}_${keyDefinition.stateDefinition.name}_${keyDefinition.key}`; -} - -function userKeyBuilderPre9(): string { - throw Error("No key builder should be used for versions prior to 9."); -} - -/** - * When this is updated, rename this function to `globalKeyBuilderXToY` where `X` is the version number - * it became relevant, and `Y` prior to the version it was updated. - * - * Be sure to update the map in `MigrationHelper` to point to the appropriate function for the current version. - * @param keyDefinition the key definition of which data the key should point to. - * @returns - */ -function globalKeyBuilder(keyDefinition: KeyDefinitionLike): string { - return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}`; -} - -function globalKeyBuilderPre9(): string { - throw Error("No key builder should be used for versions prior to 9."); -} - -async function knownAccountUserIdsBuilderPre60( - storageService: AbstractStorageService, -): Promise { - return (await storageService.get("authenticatedAccounts")) ?? []; -} - -async function knownAccountUserIdsBuilder( - storageService: AbstractStorageService, -): Promise { - const accounts = await storageService.get>( - globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }), - ); - return Object.keys(accounts ?? {}); -} +export { MigrationHelper } from "@bitwarden/state"; diff --git a/libs/common/src/tools/providers.ts b/libs/common/src/tools/providers.ts index a22a22addc5..181df94be83 100644 --- a/libs/common/src/tools/providers.ts +++ b/libs/common/src/tools/providers.ts @@ -1,4 +1,7 @@ +import { BitwardenClient } from "@bitwarden/sdk-internal"; + import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction"; +import { ConfigService } from "../platform/abstractions/config/config.service"; import { ExtensionService } from "./extension/extension.service"; import { LogProvider } from "./log"; @@ -13,4 +16,10 @@ export type SystemServiceProvider = { /** Event monitoring and diagnostic interfaces */ readonly log: LogProvider; + + /** Config Service to determine flag features */ + readonly configService: ConfigService; + + /** SDK Service */ + readonly sdk: BitwardenClient; }; diff --git a/libs/common/src/types/guid.ts b/libs/common/src/types/guid.ts index 5edd34e4fc5..bd0980cd36c 100644 --- a/libs/common/src/types/guid.ts +++ b/libs/common/src/types/guid.ts @@ -15,3 +15,8 @@ export type IndexedEntityId = Opaque; export type SecurityTaskId = Opaque; export type NotificationId = Opaque; export type EmergencyAccessId = Opaque; +export type OrganizationIntegrationId = Opaque; +export type OrganizationIntegrationConfigurationId = Opaque< + string, + "OrganizationIntegrationConfigurationId" +>; diff --git a/libs/common/src/types/key.ts b/libs/common/src/types/key.ts index c9fd6975960..8984452e701 100644 --- a/libs/common/src/types/key.ts +++ b/libs/common/src/types/key.ts @@ -6,6 +6,7 @@ import { SymmetricCryptoKey } from "../platform/models/domain/symmetric-crypto-k export type DeviceKey = Opaque; export type PrfKey = Opaque; export type UserKey = Opaque; +/** @deprecated Interacting with the master key directly is prohibited. Use a high level function from MasterPasswordService instead. */ export type MasterKey = Opaque; export type PinKey = Opaque; export type OrgKey = Opaque; diff --git a/libs/common/src/types/state.ts b/libs/common/src/types/state.ts index b98e3a4e791..f5a8a11bd7c 100644 --- a/libs/common/src/types/state.ts +++ b/libs/common/src/types/state.ts @@ -1,5 +1,2 @@ -import { Opaque } from "type-fest"; - -export type StorageKey = Opaque; - -export type DerivedStateDependencies = Record; +// Compatibility re-export for @bitwarden/common/types/state +export { StorageKey, DerivedStateDependencies } from "@bitwarden/state"; diff --git a/libs/common/src/vault/abstractions/cipher-encryption.service.ts b/libs/common/src/vault/abstractions/cipher-encryption.service.ts index 067c63b2110..35becd4b0e7 100644 --- a/libs/common/src/vault/abstractions/cipher-encryption.service.ts +++ b/libs/common/src/vault/abstractions/cipher-encryption.service.ts @@ -1,3 +1,4 @@ +import { UserKey } from "@bitwarden/common/types/key"; import { EncryptionContext } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherListView } from "@bitwarden/sdk-internal"; @@ -32,6 +33,18 @@ export abstract class CipherEncryptionService { userId: UserId, ): Promise; + /** + * Encrypts a cipher for a given userId with a new key for key rotation. + * @param model The cipher view to encrypt + * @param userId The user ID to initialize the SDK client with + * @param newKey The new key to use for re-encryption + */ + abstract encryptCipherForRotation( + model: CipherView, + userId: UserId, + newKey: UserKey, + ): Promise; + /** * Decrypts a cipher using the SDK for the given userId. * diff --git a/libs/common/src/vault/models/domain/attachment.spec.ts b/libs/common/src/vault/models/domain/attachment.spec.ts index 2ea2c3d9a1d..93f693f14c0 100644 --- a/libs/common/src/vault/models/domain/attachment.spec.ts +++ b/libs/common/src/vault/models/domain/attachment.spec.ts @@ -110,7 +110,7 @@ describe("Attachment", () => { await attachment.decrypt(null, "", providedKey); - expect(keyService.getUserKeyWithLegacySupport).not.toHaveBeenCalled(); + expect(keyService.getUserKey).not.toHaveBeenCalled(); expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(attachment.key, providedKey); }); @@ -126,11 +126,11 @@ describe("Attachment", () => { it("gets the user's decryption key if required", async () => { const userKey = mock(); - keyService.getUserKeyWithLegacySupport.mockResolvedValue(userKey); + keyService.getUserKey.mockResolvedValue(userKey); await attachment.decrypt(null, "", null); - expect(keyService.getUserKeyWithLegacySupport).toHaveBeenCalled(); + expect(keyService.getUserKey).toHaveBeenCalled(); expect(encryptService.unwrapSymmetricKey).toHaveBeenCalledWith(attachment.key, userKey); }); }); diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index 638f354c4b8..5fff6b32aac 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -80,9 +80,7 @@ export class Attachment extends Domain { private async getKeyForDecryption(orgId: string) { const keyService = Utils.getContainerService().getKeyService(); - return orgId != null - ? await keyService.getOrgKey(orgId) - : await keyService.getUserKeyWithLegacySupport(); + return orgId != null ? await keyService.getOrgKey(orgId) : await keyService.getUserKey(); } toAttachmentData(): AttachmentData { @@ -128,8 +126,8 @@ export class Attachment extends Domain { url: this.url, size: this.size, sizeName: this.sizeName, - fileName: this.fileName?.toJSON(), - key: this.key?.toJSON(), + fileName: this.fileName?.toSdk(), + key: this.key?.toSdk(), }; } diff --git a/libs/common/src/vault/models/domain/card.ts b/libs/common/src/vault/models/domain/card.ts index 688053ae93c..89cc361b454 100644 --- a/libs/common/src/vault/models/domain/card.ts +++ b/libs/common/src/vault/models/domain/card.ts @@ -95,12 +95,12 @@ export class Card extends Domain { */ toSdkCard(): SdkCard { return { - cardholderName: this.cardholderName?.toJSON(), - brand: this.brand?.toJSON(), - number: this.number?.toJSON(), - expMonth: this.expMonth?.toJSON(), - expYear: this.expYear?.toJSON(), - code: this.code?.toJSON(), + cardholderName: this.cardholderName?.toSdk(), + brand: this.brand?.toSdk(), + number: this.number?.toSdk(), + expMonth: this.expMonth?.toSdk(), + expYear: this.expYear?.toSdk(), + code: this.code?.toSdk(), }; } diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index 60fff8b510e..008324f9aec 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -11,6 +11,7 @@ import { CipherRepromptType as SdkCipherRepromptType, LoginLinkedIdType, Cipher as SdkCipher, + EncString as SdkEncString, } from "@bitwarden/sdk-internal"; import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils"; @@ -1010,22 +1011,22 @@ describe("Cipher DTO", () => { organizationId: "orgId", folderId: "folderId", collectionIds: [], - key: "EncryptedString", - name: "EncryptedString", - notes: "EncryptedString", + key: "EncryptedString" as SdkEncString, + name: "EncryptedString" as SdkEncString, + notes: "EncryptedString" as SdkEncString, type: SdkCipherType.Login, login: { - username: "EncryptedString", - password: "EncryptedString", + username: "EncryptedString" as SdkEncString, + password: "EncryptedString" as SdkEncString, passwordRevisionDate: "2022-01-31T12:00:00.000Z", uris: [ { - uri: "EncryptedString", - uriChecksum: "EncryptedString", + uri: "EncryptedString" as SdkEncString, + uriChecksum: "EncryptedString" as SdkEncString, match: UriMatchType.Domain, }, ], - totp: "EncryptedString", + totp: "EncryptedString" as SdkEncString, autofillOnPageLoad: false, fido2Credentials: undefined, }, @@ -1049,35 +1050,35 @@ describe("Cipher DTO", () => { url: "url", size: "1100", sizeName: "1.1 KB", - fileName: "file", - key: "EncKey", + fileName: "file" as SdkEncString, + key: "EncKey" as SdkEncString, }, { id: "a2", url: "url", size: "1100", sizeName: "1.1 KB", - fileName: "file", - key: "EncKey", + fileName: "file" as SdkEncString, + key: "EncKey" as SdkEncString, }, ], fields: [ { - name: "EncryptedString", - value: "EncryptedString", + name: "EncryptedString" as SdkEncString, + value: "EncryptedString" as SdkEncString, type: FieldType.Linked, linkedId: LoginLinkedIdType.Username, }, { - name: "EncryptedString", - value: "EncryptedString", + name: "EncryptedString" as SdkEncString, + value: "EncryptedString" as SdkEncString, type: FieldType.Linked, linkedId: LoginLinkedIdType.Password, }, ], passwordHistory: [ { - password: "EncryptedString", + password: "EncryptedString" as SdkEncString, lastUsedDate: "2022-01-31T12:00:00.000Z", }, ], diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 2a13cb06d71..f884dc32cce 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -348,9 +348,9 @@ export class Cipher extends Domain implements Decryptable { organizationId: this.organizationId ?? undefined, folderId: this.folderId ?? undefined, collectionIds: this.collectionIds ?? [], - key: this.key?.toJSON(), - name: this.name.toJSON(), - notes: this.notes?.toJSON(), + key: this.key?.toSdk(), + name: this.name.toSdk(), + notes: this.notes?.toSdk(), type: this.type, favorite: this.favorite ?? false, organizationUseTotp: this.organizationUseTotp ?? false, diff --git a/libs/common/src/vault/models/domain/fido2-credential.ts b/libs/common/src/vault/models/domain/fido2-credential.ts index 5dbf55b44fc..a74afc2336d 100644 --- a/libs/common/src/vault/models/domain/fido2-credential.ts +++ b/libs/common/src/vault/models/domain/fido2-credential.ts @@ -158,18 +158,18 @@ export class Fido2Credential extends Domain { */ toSdkFido2Credential(): SdkFido2Credential { return { - credentialId: this.credentialId?.toJSON(), - keyType: this.keyType.toJSON(), - keyAlgorithm: this.keyAlgorithm.toJSON(), - keyCurve: this.keyCurve.toJSON(), - keyValue: this.keyValue.toJSON(), - rpId: this.rpId.toJSON(), - userHandle: this.userHandle?.toJSON(), - userName: this.userName?.toJSON(), - counter: this.counter.toJSON(), - rpName: this.rpName?.toJSON(), - userDisplayName: this.userDisplayName?.toJSON(), - discoverable: this.discoverable?.toJSON(), + credentialId: this.credentialId?.toSdk(), + keyType: this.keyType.toSdk(), + keyAlgorithm: this.keyAlgorithm.toSdk(), + keyCurve: this.keyCurve.toSdk(), + keyValue: this.keyValue.toSdk(), + rpId: this.rpId.toSdk(), + userHandle: this.userHandle?.toSdk(), + userName: this.userName?.toSdk(), + counter: this.counter.toSdk(), + rpName: this.rpName?.toSdk(), + userDisplayName: this.userDisplayName?.toSdk(), + discoverable: this.discoverable?.toSdk(), creationDate: this.creationDate.toISOString(), }; } diff --git a/libs/common/src/vault/models/domain/field.ts b/libs/common/src/vault/models/domain/field.ts index 53756e21046..f652a2820d4 100644 --- a/libs/common/src/vault/models/domain/field.ts +++ b/libs/common/src/vault/models/domain/field.ts @@ -83,8 +83,8 @@ export class Field extends Domain { */ toSdkField(): SdkField { return { - name: this.name?.toJSON(), - value: this.value?.toJSON(), + name: this.name?.toSdk(), + value: this.value?.toSdk(), type: this.type, // Safe type cast: client and SDK LinkedIdType enums have identical values linkedId: this.linkedId as unknown as SdkLinkedIdType, diff --git a/libs/common/src/vault/models/domain/folder.spec.ts b/libs/common/src/vault/models/domain/folder.spec.ts index 82a3aba22a2..a837fbb2726 100644 --- a/libs/common/src/vault/models/domain/folder.spec.ts +++ b/libs/common/src/vault/models/domain/folder.spec.ts @@ -1,7 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; - import { makeEncString, makeSymmetricCryptoKey, mockEnc, mockFromJson } from "../../../../spec"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; @@ -73,13 +71,8 @@ describe("Folder", () => { beforeEach(() => { encryptService = mock(); // Platform code is not migrated yet - encryptService.decryptToUtf8.mockImplementation( - (value: EncString, key: SymmetricCryptoKey, decryptTrace: string) => { - return Promise.resolve(value.data); - }, - ); - encryptService.decryptString.mockImplementation((value) => { - return Promise.resolve(value.data); + encryptService.decryptString.mockImplementation((_value, _key) => { + return Promise.resolve("encName"); }); }); diff --git a/libs/common/src/vault/models/domain/folder.ts b/libs/common/src/vault/models/domain/folder.ts index acefb6ce1c5..01f5b9599bd 100644 --- a/libs/common/src/vault/models/domain/folder.ts +++ b/libs/common/src/vault/models/domain/folder.ts @@ -47,11 +47,11 @@ export class Folder extends Domain { key: SymmetricCryptoKey, encryptService: EncryptService, ): Promise { - const decrypted = await this.decryptObjWithKey(["name"], key, encryptService, Folder); - - const view = new FolderView(decrypted); - view.name = decrypted.name; - return view; + const folderView = new FolderView(); + folderView.id = this.id; + folderView.revisionDate = this.revisionDate; + folderView.name = await encryptService.decryptString(this.name, key); + return folderView; } static fromJSON(obj: Jsonify) { diff --git a/libs/common/src/vault/models/domain/identity.ts b/libs/common/src/vault/models/domain/identity.ts index 16e68c72551..f0d5b3123ab 100644 --- a/libs/common/src/vault/models/domain/identity.ts +++ b/libs/common/src/vault/models/domain/identity.ts @@ -175,24 +175,24 @@ export class Identity extends Domain { */ toSdkIdentity(): SdkIdentity { return { - title: this.title?.toJSON(), - firstName: this.firstName?.toJSON(), - middleName: this.middleName?.toJSON(), - lastName: this.lastName?.toJSON(), - address1: this.address1?.toJSON(), - address2: this.address2?.toJSON(), - address3: this.address3?.toJSON(), - city: this.city?.toJSON(), - state: this.state?.toJSON(), - postalCode: this.postalCode?.toJSON(), - country: this.country?.toJSON(), - company: this.company?.toJSON(), - email: this.email?.toJSON(), - phone: this.phone?.toJSON(), - ssn: this.ssn?.toJSON(), - username: this.username?.toJSON(), - passportNumber: this.passportNumber?.toJSON(), - licenseNumber: this.licenseNumber?.toJSON(), + title: this.title?.toSdk(), + firstName: this.firstName?.toSdk(), + middleName: this.middleName?.toSdk(), + lastName: this.lastName?.toSdk(), + address1: this.address1?.toSdk(), + address2: this.address2?.toSdk(), + address3: this.address3?.toSdk(), + city: this.city?.toSdk(), + state: this.state?.toSdk(), + postalCode: this.postalCode?.toSdk(), + country: this.country?.toSdk(), + company: this.company?.toSdk(), + email: this.email?.toSdk(), + phone: this.phone?.toSdk(), + ssn: this.ssn?.toSdk(), + username: this.username?.toSdk(), + passportNumber: this.passportNumber?.toSdk(), + licenseNumber: this.licenseNumber?.toSdk(), }; } diff --git a/libs/common/src/vault/models/domain/login-uri.ts b/libs/common/src/vault/models/domain/login-uri.ts index 9cfa4951dd8..973e25c8ff1 100644 --- a/libs/common/src/vault/models/domain/login-uri.ts +++ b/libs/common/src/vault/models/domain/login-uri.ts @@ -97,8 +97,8 @@ export class LoginUri extends Domain { */ toSdkLoginUri(): SdkLoginUri { return { - uri: this.uri?.toJSON(), - uriChecksum: this.uriChecksum?.toJSON(), + uri: this.uri?.toSdk(), + uriChecksum: this.uriChecksum?.toSdk(), match: this.match, }; } diff --git a/libs/common/src/vault/models/domain/login.ts b/libs/common/src/vault/models/domain/login.ts index 93af2269185..723478b10a8 100644 --- a/libs/common/src/vault/models/domain/login.ts +++ b/libs/common/src/vault/models/domain/login.ts @@ -155,10 +155,10 @@ export class Login extends Domain { toSdkLogin(): SdkLogin { return { uris: this.uris?.map((u) => u.toSdkLoginUri()), - username: this.username?.toJSON(), - password: this.password?.toJSON(), + username: this.username?.toSdk(), + password: this.password?.toSdk(), passwordRevisionDate: this.passwordRevisionDate?.toISOString(), - totp: this.totp?.toJSON(), + totp: this.totp?.toSdk(), autofillOnPageLoad: this.autofillOnPageLoad ?? undefined, fido2Credentials: this.fido2Credentials?.map((f) => f.toSdkFido2Credential()), }; diff --git a/libs/common/src/vault/models/domain/password.ts b/libs/common/src/vault/models/domain/password.ts index b8a30099454..ca594075e0b 100644 --- a/libs/common/src/vault/models/domain/password.ts +++ b/libs/common/src/vault/models/domain/password.ts @@ -67,7 +67,7 @@ export class Password extends Domain { */ toSdkPasswordHistory(): PasswordHistory { return { - password: this.password.toJSON(), + password: this.password.toSdk(), lastUsedDate: this.lastUsedDate.toISOString(), }; } diff --git a/libs/common/src/vault/models/domain/ssh-key.ts b/libs/common/src/vault/models/domain/ssh-key.ts index 0c8abf76e44..ab1685955a3 100644 --- a/libs/common/src/vault/models/domain/ssh-key.ts +++ b/libs/common/src/vault/models/domain/ssh-key.ts @@ -80,9 +80,9 @@ export class SshKey extends Domain { */ toSdkSshKey(): SdkSshKey { return { - privateKey: this.privateKey.toJSON(), - publicKey: this.publicKey.toJSON(), - fingerprint: this.keyFingerprint.toJSON(), + privateKey: this.privateKey.toSdk(), + publicKey: this.publicKey.toSdk(), + fingerprint: this.keyFingerprint.toSdk(), }; } diff --git a/libs/common/src/vault/models/view/attachment.view.ts b/libs/common/src/vault/models/view/attachment.view.ts index 4c1033efd08..1c796c8f275 100644 --- a/libs/common/src/vault/models/view/attachment.view.ts +++ b/libs/common/src/vault/models/view/attachment.view.ts @@ -69,7 +69,7 @@ export class AttachmentView implements View { size: this.size, sizeName: this.sizeName, fileName: this.fileName, - key: this.encryptedKey?.toJSON(), + key: this.encryptedKey?.toSdk(), // TODO: PM-23005 - Temporary field, should be removed when encrypted migration is complete decryptedKey: this.key ? this.key.toBase64() : null, }; diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 0c41e49c3ab..c91d6e21ca2 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -331,7 +331,7 @@ export class CipherView implements View, InitializerMetadata { creationDate: (this.creationDate ?? new Date()).toISOString(), deletedDate: this.deletedDate?.toISOString(), reprompt: this.reprompt ?? CipherRepromptType.None, - key: this.key?.toJSON(), + key: this.key?.toSdk(), // Cipher type specific properties are set in the switch statement below // CipherView initializes each with default constructors (undefined values) // The SDK does not expect those undefined values and will throw exceptions diff --git a/libs/common/src/vault/services/cipher-authorization.service.spec.ts b/libs/common/src/vault/services/cipher-authorization.service.spec.ts index 43e68bfc71f..78fe6f18913 100644 --- a/libs/common/src/vault/services/cipher-authorization.service.spec.ts +++ b/libs/common/src/vault/services/cipher-authorization.service.spec.ts @@ -119,7 +119,7 @@ describe("CipherAuthorizationService", () => { cipherAuthorizationService.canRestoreCipher$(cipher, false).subscribe((result) => { expect(result).toBe(false); - expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled(); + expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled(); done(); }); }); @@ -133,7 +133,7 @@ describe("CipherAuthorizationService", () => { cipherAuthorizationService.canRestoreCipher$(cipher, false).subscribe((result) => { expect(result).toBe(true); - expect(mockCollectionService.decryptedCollectionViews$).not.toHaveBeenCalled(); + expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled(); done(); }); }); @@ -198,6 +198,7 @@ describe("CipherAuthorizationService", () => { cipherAuthorizationService.canDeleteCipher$(cipher, false).subscribe((result) => { expect(result).toBe(false); + expect(mockCollectionService.decryptedCollections$).not.toHaveBeenCalled(); done(); }); }); @@ -251,7 +252,7 @@ describe("CipherAuthorizationService", () => { createMockCollection("col1", true), createMockCollection("col2", false), ]; - mockCollectionService.decryptedCollectionViews$.mockReturnValue( + mockCollectionService.decryptedCollections$.mockReturnValue( of(allCollections as CollectionView[]), ); @@ -270,7 +271,7 @@ describe("CipherAuthorizationService", () => { createMockCollection("col1", false), createMockCollection("col2", false), ]; - mockCollectionService.decryptedCollectionViews$.mockReturnValue( + mockCollectionService.decryptedCollections$.mockReturnValue( of(allCollections as CollectionView[]), ); diff --git a/libs/common/src/vault/services/cipher-authorization.service.ts b/libs/common/src/vault/services/cipher-authorization.service.ts index 2933e94c302..06177629de5 100644 --- a/libs/common/src/vault/services/cipher-authorization.service.ts +++ b/libs/common/src/vault/services/cipher-authorization.service.ts @@ -1,11 +1,11 @@ -import { map, Observable, of, shareReplay, switchMap } from "rxjs"; +import { combineLatest, map, Observable, of, shareReplay, switchMap } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { CollectionId } from "@bitwarden/common/types/guid"; +import { getByIds } from "@bitwarden/common/platform/misc"; import { getUserId } from "../../auth/services/account.service"; import { CipherLike } from "../types/cipher-like"; @@ -125,8 +125,11 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer return of(true); } - return this.organization$(cipher).pipe( - switchMap((organization) => { + return combineLatest([ + this.organization$(cipher), + this.accountService.activeAccount$.pipe(getUserId), + ]).pipe( + switchMap(([organization, userId]) => { // Admins and custom users can always clone when in the Admin Console if ( isAdminConsoleAction && @@ -136,9 +139,10 @@ export class DefaultCipherAuthorizationService implements CipherAuthorizationSer return of(true); } - return this.collectionService - .decryptedCollectionViews$(cipher.collectionIds as CollectionId[]) - .pipe(map((allCollections) => allCollections.some((collection) => collection.manage))); + return this.collectionService.decryptedCollections$(userId).pipe( + getByIds(cipher.collectionIds), + map((allCollections) => allCollections.some((collection) => collection.manage)), + ); }), shareReplay({ bufferSize: 1, refCount: false }), ); diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index f027122993d..996de182f6e 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -14,7 +14,6 @@ import { makeStaticByteArray, makeSymmetricCryptoKey } from "../../../spec/utils import { ApiService } from "../../abstractions/api.service"; import { AutofillSettingsService } from "../../autofill/services/autofill-settings.service"; import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; -import { BulkEncryptService } from "../../key-management/crypto/abstractions/bulk-encrypt.service"; import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../key-management/crypto/models/enc-string"; import { UriMatchStrategy } from "../../models/domain/domain-service"; @@ -102,7 +101,6 @@ describe("Cipher Service", () => { const i18nService = mock(); const searchService = mock(); const encryptService = mock(); - const bulkEncryptService = mock(); const configService = mock(); accountService = mockAccountServiceWith(mockUserId); const logService = mock(); @@ -130,7 +128,6 @@ describe("Cipher Service", () => { stateService, autofillSettingsService, encryptService, - bulkEncryptService, cipherFileUploadService, configService, stateProvider, @@ -397,7 +394,7 @@ describe("Cipher Service", () => { }); }); - describe("encryptWithCipherKey", () => { + describe("encryptCipherForRotation", () => { beforeEach(() => { jest.spyOn(cipherService, "encryptCipherWithCipherKey"); keyService.getOrgKey.mockReturnValue( @@ -534,6 +531,26 @@ describe("Cipher Service", () => { cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId), ).rejects.toThrow("Cannot rotate ciphers when decryption failures are present"); }); + + it("uses the sdk to re-encrypt ciphers when feature flag is enabled", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM22136_SdkCipherEncryption) + .mockResolvedValue(true); + + cipherEncryptionService.encryptCipherForRotation.mockResolvedValue({ + cipher: encryptionContext.cipher, + encryptedFor: mockUserId, + }); + + const result = await cipherService.getRotatedData(originalUserKey, newUserKey, mockUserId); + + expect(result).toHaveLength(2); + expect(cipherEncryptionService.encryptCipherForRotation).toHaveBeenCalledWith( + expect.any(CipherView), + mockUserId, + newUserKey, + ); + }); }); describe("decrypt", () => { @@ -558,7 +575,6 @@ describe("Cipher Service", () => { .calledWith(FeatureFlag.PM19941MigrateCipherDomainToSdk) .mockResolvedValue(false); cipherService.getKeyForCipherKeyDecryption = jest.fn().mockResolvedValue(mockUserKey); - encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); jest .spyOn(encryptionContext.cipher, "decrypt") .mockResolvedValue(new CipherView(encryptionContext.cipher)); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 1524e4e1b29..596483c19ec 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -13,7 +13,6 @@ import { AccountService } from "../../auth/abstractions/account.service"; import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service"; import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; import { FeatureFlag } from "../../enums/feature-flag.enum"; -import { BulkEncryptService } from "../../key-management/crypto/abstractions/bulk-encrypt.service"; import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../key-management/crypto/models/enc-string"; import { UriMatchStrategySetting } from "../../models/domain/domain-service"; @@ -104,7 +103,6 @@ export class CipherService implements CipherServiceAbstraction { private stateService: StateService, private autofillSettingsService: AutofillSettingsServiceAbstraction, private encryptService: EncryptService, - private bulkEncryptService: BulkEncryptService, private cipherFileUploadService: CipherFileUploadService, private configService: ConfigService, private stateProvider: StateProvider, @@ -172,7 +170,7 @@ export class CipherService implements CipherServiceAbstraction { return combineLatest([ this.encryptedCiphersState(userId).state$, this.localData$(userId), - this.keyService.cipherDecryptionKeys$(userId, true), + this.keyService.cipherDecryptionKeys$(userId), ]).pipe( filter(([ciphers, _, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet switchMap(() => this.getAllDecrypted(userId)), @@ -488,7 +486,7 @@ export class CipherService implements CipherServiceAbstraction { return [decrypted, []]; } - const keys = await firstValueFrom(this.keyService.cipherDecryptionKeys$(userId, true)); + const keys = await firstValueFrom(this.keyService.cipherDecryptionKeys$(userId)); if (keys == null || (keys.userKey == null && Object.keys(keys.orgKeys).length === 0)) { // return early if there are no keys to decrypt with return null; @@ -506,17 +504,12 @@ export class CipherService implements CipherServiceAbstraction { const allCipherViews = ( await Promise.all( Object.entries(grouped).map(async ([orgId, groupedCiphers]) => { - if (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) { - return await this.bulkEncryptService.decryptItems( - groupedCiphers, - keys.orgKeys?.[orgId as OrganizationId] ?? keys.userKey, - ); - } else { - return await this.encryptService.decryptItems( - groupedCiphers, - keys.orgKeys?.[orgId as OrganizationId] ?? keys.userKey, - ); - } + const key = keys.orgKeys[orgId as OrganizationId] ?? keys.userKey; + return await Promise.all( + groupedCiphers.map(async (cipher) => { + return await cipher.decrypt(key); + }), + ); }), ) ) @@ -684,12 +677,11 @@ export class CipherService implements CipherServiceAbstraction { const ciphers = response.data.map((cr) => new Cipher(new CipherData(cr))); const key = await this.keyService.getOrgKey(organizationId); - let decCiphers: CipherView[] = []; - if (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) { - decCiphers = await this.bulkEncryptService.decryptItems(ciphers, key); - } else { - decCiphers = await this.encryptService.decryptItems(ciphers, key); - } + const decCiphers: CipherView[] = await Promise.all( + ciphers.map(async (cipher) => { + return await cipher.decrypt(key); + }), + ); decCiphers.sort(this.getLocaleSortingFunction()); return decCiphers; @@ -1474,7 +1466,7 @@ export class CipherService implements CipherServiceAbstraction { async getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise { return ( (await this.keyService.getOrgKey(cipher.organizationId)) || - ((await this.keyService.getUserKeyWithLegacySupport(userId)) as UserKey) + ((await this.keyService.getUserKey(userId)) as UserKey) ); } @@ -1512,9 +1504,16 @@ export class CipherService implements CipherServiceAbstraction { if (userCiphers.length === 0) { return encryptedCiphers; } + + const useSdkEncryption = await this.configService.getFeatureFlag( + FeatureFlag.PM22136_SdkCipherEncryption, + ); + encryptedCiphers = await Promise.all( userCiphers.map(async (cipher) => { - const encryptedCipher = await this.encrypt(cipher, userId, newUserKey, originalUserKey); + const encryptedCipher = useSdkEncryption + ? await this.cipherEncryptionService.encryptCipherForRotation(cipher, userId, newUserKey) + : await this.encrypt(cipher, userId, newUserKey, originalUserKey); return new CipherWithIdRequest(encryptedCipher); }), ); @@ -1599,7 +1598,7 @@ export class CipherService implements CipherServiceAbstraction { // In the case of a cipher that is being shared with an organization, we want to decrypt the // cipher key with the user's key and then re-encrypt it with the organization's key. private async encryptSharedCipher(model: CipherView, userId: UserId): Promise { - const keyForCipherKeyDecryption = await this.keyService.getUserKeyWithLegacySupport(userId); + const keyForCipherKeyDecryption = await this.keyService.getUserKey(userId); return await this.encrypt(model, userId, null, keyForCipherKeyDecryption); } @@ -1674,12 +1673,12 @@ export class CipherService implements CipherServiceAbstraction { const encBuf = await EncArrayBuffer.fromResponse(attachmentResponse); const activeUserId = await firstValueFrom(this.accountService.activeAccount$); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId.id); + const userKey = await this.keyService.getUserKey(activeUserId.id); const decBuf = await this.encryptService.decryptFileData(encBuf, userKey); let encKey: UserKey | OrgKey; encKey = await this.keyService.getOrgKey(organizationId); - encKey ||= (await this.keyService.getUserKeyWithLegacySupport()) as UserKey; + encKey ||= (await this.keyService.getUserKey()) as UserKey; const dataEncKey = await this.keyService.makeDataEncKey(encKey); diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts index 9e0cf62ed08..16e39421490 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -1,6 +1,9 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserKey } from "@bitwarden/common/types/key"; import { Fido2Credential } from "@bitwarden/common/vault/models/domain/fido2-credential"; import { Fido2Credential as SdkFido2Credential, @@ -91,6 +94,7 @@ describe("DefaultCipherEncryptionService", () => { vault: jest.fn().mockReturnValue({ ciphers: jest.fn().mockReturnValue({ encrypt: jest.fn(), + encrypt_cipher_for_rotation: jest.fn(), set_fido2_credentials: jest.fn(), decrypt: jest.fn(), decrypt_list: jest.fn(), @@ -247,6 +251,31 @@ describe("DefaultCipherEncryptionService", () => { }); }); + describe("encryptCipherForRotation", () => { + it("should call the sdk method to encrypt the cipher with a new key for rotation", async () => { + mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + + const newUserKey: UserKey = new SymmetricCryptoKey( + Utils.fromUtf8ToArray("00000000000000000000000000000000"), + ) as UserKey; + + const result = await cipherEncryptionService.encryptCipherForRotation( + cipherViewObj, + userId, + newUserKey, + ); + + expect(result).toBeDefined(); + expect(mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation).toHaveBeenCalledWith( + expect.objectContaining({ id: cipherId }), + newUserKey.toBase64(), + ); + }); + }); + describe("moveToOrganization", () => { it("should call the sdk method to move a cipher to an organization", async () => { const expectedCipher: Cipher = { diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts index 3547bafb4c9..2cef4ca1ca1 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -1,5 +1,6 @@ import { EMPTY, catchError, firstValueFrom, map } from "rxjs"; +import { UserKey } from "@bitwarden/common/types/key"; import { EncryptionContext } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherListView, @@ -84,6 +85,39 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { ); } + async encryptCipherForRotation( + model: CipherView, + userId: UserId, + newKey: UserKey, + ): Promise { + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + const sdkCipherView = this.toSdkCipherView(model, ref.value); + + const encryptionContext = ref.value + .vault() + .ciphers() + .encrypt_cipher_for_rotation(sdkCipherView, newKey.toBase64()); + + return { + cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!, + encryptedFor: asUuid(encryptionContext.encryptedFor), + }; + }), + catchError((error: unknown) => { + this.logService.error(`Failed to rotate cipher data: ${error}`); + return EMPTY; + }), + ), + ); + } + async decrypt(cipher: Cipher, userId: UserId): Promise { return firstValueFrom( this.sdkService.userClient$(userId).pipe( diff --git a/libs/common/src/vault/services/folder/folder.service.spec.ts b/libs/common/src/vault/services/folder/folder.service.spec.ts index e87f974d517..a520fd4852d 100644 --- a/libs/common/src/vault/services/folder/folder.service.spec.ts +++ b/libs/common/src/vault/services/folder/folder.service.spec.ts @@ -49,7 +49,6 @@ describe("Folder Service", () => { keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any)); encryptService.decryptString.mockResolvedValue("DEC"); - encryptService.decryptToUtf8.mockResolvedValue("DEC"); folderService = new FolderService( keyService, diff --git a/libs/components/src/a11y/a11y-cell.directive.ts b/libs/components/src/a11y/a11y-cell.directive.ts deleted file mode 100644 index 3a2d5c4f6b2..00000000000 --- a/libs/components/src/a11y/a11y-cell.directive.ts +++ /dev/null @@ -1,34 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { ContentChild, Directive, ElementRef, HostBinding } from "@angular/core"; - -import { FocusableElement } from "../shared/focusable-element"; - -@Directive({ - selector: "bitA11yCell", - providers: [{ provide: FocusableElement, useExisting: A11yCellDirective }], -}) -export class A11yCellDirective implements FocusableElement { - @HostBinding("attr.role") - role: "gridcell" | null; - - @ContentChild(FocusableElement) - private focusableChild: FocusableElement; - - getFocusTarget() { - let focusTarget: HTMLElement; - if (this.focusableChild) { - focusTarget = this.focusableChild.getFocusTarget(); - } else { - focusTarget = this.elementRef.nativeElement.querySelector("button, a"); - } - - if (!focusTarget) { - return this.elementRef.nativeElement; - } - - return focusTarget; - } - - constructor(private elementRef: ElementRef) {} -} diff --git a/libs/components/src/a11y/a11y-grid.directive.ts b/libs/components/src/a11y/a11y-grid.directive.ts deleted file mode 100644 index c061464239e..00000000000 --- a/libs/components/src/a11y/a11y-grid.directive.ts +++ /dev/null @@ -1,148 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { - AfterViewInit, - ContentChildren, - Directive, - HostBinding, - HostListener, - Input, - QueryList, -} from "@angular/core"; - -import type { A11yCellDirective } from "./a11y-cell.directive"; -import { A11yRowDirective } from "./a11y-row.directive"; - -@Directive({ - selector: "bitA11yGrid", -}) -export class A11yGridDirective implements AfterViewInit { - @HostBinding("attr.role") - role = "grid"; - - @ContentChildren(A11yRowDirective) - rows: QueryList; - - /** The number of pages to navigate on `PageUp` and `PageDown` */ - @Input() pageSize = 5; - - private grid: A11yCellDirective[][]; - - /** The row that currently has focus */ - private activeRow = 0; - - /** The cell that currently has focus */ - private activeCol = 0; - - @HostListener("keydown", ["$event"]) - onKeyDown(event: KeyboardEvent) { - switch (event.code) { - case "ArrowUp": - this.updateCellFocusByDelta(-1, 0); - break; - case "ArrowRight": - this.updateCellFocusByDelta(0, 1); - break; - case "ArrowDown": - this.updateCellFocusByDelta(1, 0); - break; - case "ArrowLeft": - this.updateCellFocusByDelta(0, -1); - break; - case "Home": - this.updateCellFocusByDelta(-this.activeRow, -this.activeCol); - break; - case "End": - this.updateCellFocusByDelta(this.grid.length, this.grid[this.grid.length - 1].length); - break; - case "PageUp": - this.updateCellFocusByDelta(-this.pageSize, 0); - break; - case "PageDown": - this.updateCellFocusByDelta(this.pageSize, 0); - break; - default: - return; - } - - /** Prevent default scrolling behavior */ - event.preventDefault(); - } - - ngAfterViewInit(): void { - this.initializeGrid(); - } - - private initializeGrid(): void { - try { - this.grid = this.rows.map((listItem) => { - listItem.role = "row"; - return [...listItem.cells]; - }); - this.grid.flat().forEach((cell) => { - cell.role = "gridcell"; - cell.getFocusTarget().tabIndex = -1; - }); - - this.getActiveCellContent().tabIndex = 0; - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (error) { - // eslint-disable-next-line no-console - console.error("Unable to initialize grid"); - } - } - - /** Get the focusable content of the active cell */ - private getActiveCellContent(): HTMLElement { - return this.grid[this.activeRow][this.activeCol].getFocusTarget(); - } - - /** Move focus via a delta against the currently active gridcell */ - private updateCellFocusByDelta(rowDelta: number, colDelta: number) { - const prevActive = this.getActiveCellContent(); - - this.activeCol += colDelta; - this.activeRow += rowDelta; - - // Row upper bound - if (this.activeRow >= this.grid.length) { - this.activeRow = this.grid.length - 1; - } - - // Row lower bound - if (this.activeRow < 0) { - this.activeRow = 0; - } - - // Column upper bound - if (this.activeCol >= this.grid[this.activeRow].length) { - if (this.activeRow < this.grid.length - 1) { - // Wrap to next row on right arrow - this.activeCol = 0; - this.activeRow += 1; - } else { - this.activeCol = this.grid[this.activeRow].length - 1; - } - } - - // Column lower bound - if (this.activeCol < 0) { - if (this.activeRow > 0) { - // Wrap to prev row on left arrow - this.activeRow -= 1; - this.activeCol = this.grid[this.activeRow].length - 1; - } else { - this.activeCol = 0; - } - } - - const nextActive = this.getActiveCellContent(); - nextActive.tabIndex = 0; - nextActive.focus(); - - if (nextActive !== prevActive) { - prevActive.tabIndex = -1; - } - } -} diff --git a/libs/components/src/a11y/a11y-row.directive.ts b/libs/components/src/a11y/a11y-row.directive.ts deleted file mode 100644 index f7588dc0053..00000000000 --- a/libs/components/src/a11y/a11y-row.directive.ts +++ /dev/null @@ -1,32 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { - AfterViewInit, - ContentChildren, - Directive, - HostBinding, - QueryList, - ViewChildren, -} from "@angular/core"; - -import { A11yCellDirective } from "./a11y-cell.directive"; - -@Directive({ - selector: "bitA11yRow", -}) -export class A11yRowDirective implements AfterViewInit { - @HostBinding("attr.role") - role: "row" | null; - - cells: A11yCellDirective[]; - - @ViewChildren(A11yCellDirective) - private viewCells: QueryList; - - @ContentChildren(A11yCellDirective) - private contentCells: QueryList; - - ngAfterViewInit(): void { - this.cells = [...this.viewCells, ...this.contentCells]; - } -} diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index a55e66845f6..34fdc5b60fc 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; +import { ChangeDetectorRef, Component, OnInit, inject, DestroyRef } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; -import { Subject, filter, switchMap, takeUntil, tap } from "rxjs"; +import { filter, switchMap, tap } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -51,9 +52,7 @@ export interface AnonLayoutWrapperData { templateUrl: "anon-layout-wrapper.component.html", imports: [AnonLayoutComponent, RouterModule], }) -export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - +export class AnonLayoutWrapperComponent implements OnInit { protected pageTitle: string; protected pageSubtitle: string; protected pageIcon: Icon; @@ -70,6 +69,8 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { private changeDetectorRef: ChangeDetectorRef, ) {} + private readonly destroyRef = inject(DestroyRef); + ngOnInit(): void { // Set the initial page data on load this.setAnonLayoutWrapperDataFromRouteData(this.route.snapshot.firstChild?.data); @@ -85,7 +86,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { // reset page data on page changes tap(() => this.resetPageData()), switchMap(() => this.route.firstChild?.data || null), - takeUntil(this.destroy$), + takeUntilDestroyed(this.destroyRef), ) .subscribe((firstChildRouteData: Data | null) => { this.setAnonLayoutWrapperDataFromRouteData(firstChildRouteData); @@ -121,7 +122,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { private listenForServiceDataChanges() { this.anonLayoutWrapperDataService .anonLayoutWrapperData$() - .pipe(takeUntil(this.destroy$)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((data: AnonLayoutWrapperData) => { this.setAnonLayoutWrapperData(data); }); @@ -180,9 +181,4 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { this.hideCardWrapper = null; this.hideIcon = null; } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/libs/components/src/async-actions/bit-action.directive.ts b/libs/components/src/async-actions/bit-action.directive.ts index 2d00dba1a1e..2de8a16dd31 100644 --- a/libs/components/src/async-actions/bit-action.directive.ts +++ b/libs/components/src/async-actions/bit-action.directive.ts @@ -1,7 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Directive, HostListener, model, OnDestroy, Optional } from "@angular/core"; -import { BehaviorSubject, finalize, Subject, takeUntil, tap } from "rxjs"; +import { Directive, HostListener, model, Optional, inject, DestroyRef } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { BehaviorSubject, finalize, tap } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -16,8 +17,7 @@ import { FunctionReturningAwaitable, functionToObservable } from "../utils/funct @Directive({ selector: "[bitAction]", }) -export class BitActionDirective implements OnDestroy { - private destroy$ = new Subject(); +export class BitActionDirective { private _loading$ = new BehaviorSubject(false); /** @@ -40,6 +40,8 @@ export class BitActionDirective implements OnDestroy { readonly handler = model(undefined, { alias: "bitAction" }); + private readonly destroyRef = inject(DestroyRef); + constructor( private buttonComponent: ButtonLikeAbstraction, @Optional() private validationService?: ValidationService, @@ -62,13 +64,8 @@ export class BitActionDirective implements OnDestroy { }, }), finalize(() => (this.loading = false)), - takeUntil(this.destroy$), + takeUntilDestroyed(this.destroyRef), ) .subscribe(); } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/libs/components/src/async-actions/bit-submit.directive.ts b/libs/components/src/async-actions/bit-submit.directive.ts index cafc8d634b9..e7911196fc3 100644 --- a/libs/components/src/async-actions/bit-submit.directive.ts +++ b/libs/components/src/async-actions/bit-submit.directive.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Directive, OnDestroy, OnInit, Optional, input } from "@angular/core"; +import { Directive, OnInit, Optional, input, inject, DestroyRef } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormGroupDirective } from "@angular/forms"; -import { BehaviorSubject, catchError, filter, of, Subject, switchMap, takeUntil } from "rxjs"; +import { BehaviorSubject, catchError, filter, of, switchMap } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -15,8 +16,9 @@ import { FunctionReturningAwaitable, functionToObservable } from "../utils/funct @Directive({ selector: "[formGroup][bitSubmit]", }) -export class BitSubmitDirective implements OnInit, OnDestroy { - private destroy$ = new Subject(); +export class BitSubmitDirective implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private _loading$ = new BehaviorSubject(false); private _disabled$ = new BehaviorSubject(false); @@ -51,7 +53,7 @@ export class BitSubmitDirective implements OnInit, OnDestroy { }), ); }), - takeUntil(this.destroy$), + takeUntilDestroyed(), ) .subscribe({ next: () => (this.loading = false), @@ -60,13 +62,15 @@ export class BitSubmitDirective implements OnInit, OnDestroy { } ngOnInit(): void { - this.formGroupDirective.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => { - if (this.allowDisabledFormSubmit()) { - this._disabled$.next(false); - } else { - this._disabled$.next(c === "DISABLED"); - } - }); + this.formGroupDirective.statusChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((c) => { + if (this.allowDisabledFormSubmit()) { + this._disabled$.next(false); + } else { + this._disabled$.next(c === "DISABLED"); + } + }); } get disabled() { @@ -85,9 +89,4 @@ export class BitSubmitDirective implements OnInit, OnDestroy { this.disabled = value; this._loading$.next(value); } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/libs/components/src/async-actions/form-button.directive.ts b/libs/components/src/async-actions/form-button.directive.ts index 2bbd8fa87b6..dc8c095fd18 100644 --- a/libs/components/src/async-actions/form-button.directive.ts +++ b/libs/components/src/async-actions/form-button.directive.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Directive, OnDestroy, Optional, input } from "@angular/core"; -import { Subject, takeUntil } from "rxjs"; +import { Directive, Optional, input } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; @@ -26,9 +26,7 @@ import { BitSubmitDirective } from "./bit-submit.directive"; @Directive({ selector: "button[bitFormButton]", }) -export class BitFormButtonDirective implements OnDestroy { - private destroy$ = new Subject(); - +export class BitFormButtonDirective { readonly type = input(); readonly disabled = input(); @@ -38,7 +36,7 @@ export class BitFormButtonDirective implements OnDestroy { @Optional() actionDirective?: BitActionDirective, ) { if (submitDirective && buttonComponent) { - submitDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => { + submitDirective.loading$.pipe(takeUntilDestroyed()).subscribe((loading) => { if (this.type() === "submit") { buttonComponent.loading.set(loading); } else { @@ -46,7 +44,7 @@ export class BitFormButtonDirective implements OnDestroy { } }); - submitDirective.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { + submitDirective.disabled$.pipe(takeUntilDestroyed()).subscribe((disabled) => { const disabledValue = this.disabled(); if (disabledValue !== false) { buttonComponent.disabled.set(disabledValue || disabled); @@ -55,18 +53,13 @@ export class BitFormButtonDirective implements OnDestroy { } if (submitDirective && actionDirective) { - actionDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { + actionDirective.loading$.pipe(takeUntilDestroyed()).subscribe((disabled) => { submitDirective.disabled = disabled; }); - submitDirective.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { + submitDirective.disabled$.pipe(takeUntilDestroyed()).subscribe((disabled) => { actionDirective.disabled = disabled; }); } } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/libs/components/src/card/card.stories.ts b/libs/components/src/card/card.stories.ts index 3482eedfd54..411cc8e83cc 100644 --- a/libs/components/src/card/card.stories.ts +++ b/libs/components/src/card/card.stories.ts @@ -91,7 +91,7 @@ export const WithoutBorderRadius: Story = { template: /*html*/ ` -

Cards used in bit-layout will not have a border radius

+

Cards used in bit-layout will not have a border radius

`, diff --git a/libs/components/src/checkbox/checkbox.stories.ts b/libs/components/src/checkbox/checkbox.stories.ts index 2796f6fde51..ee3e4ab402d 100644 --- a/libs/components/src/checkbox/checkbox.stories.ts +++ b/libs/components/src/checkbox/checkbox.stories.ts @@ -218,7 +218,10 @@ export const Indeterminate: Story = { render: (args) => ({ props: args, template: /*html*/ ` - + `, }), }; @@ -256,6 +259,9 @@ export const InTableRow: Story = { bitCheckbox id="checkOne" /> + Lorem Ipsum diff --git a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts index f849fe81df6..c1044ba81e2 100644 --- a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts +++ b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts @@ -68,7 +68,9 @@ export class SimpleConfigurableDialogComponent { await this.simpleDialogOpts.acceptAction(); } - this.dialogRef.close(true); + if (!this.simpleDialogOpts.disableClose) { + this.dialogRef.close(true); + } }; private localizeText() { diff --git a/libs/components/src/form-field/form-field.stories.ts b/libs/components/src/form-field/form-field.stories.ts index d5dc6614ff8..5b92ab5dbd3 100644 --- a/libs/components/src/form-field/form-field.stories.ts +++ b/libs/components/src/form-field/form-field.stories.ts @@ -172,7 +172,7 @@ export const LabelWithIcon: Story = { Label -
+ @@ -203,7 +203,7 @@ export const LongLabel: Story = { Hello I am a very long label with lots of very cool helpful information - + diff --git a/libs/components/src/icon/icon.mdx b/libs/components/src/icon/icon.mdx index 01f03d1861b..e4186b5e4a9 100644 --- a/libs/components/src/icon/icon.mdx +++ b/libs/components/src/icon/icon.mdx @@ -19,7 +19,6 @@ import { IconModule } from "@bitwarden/components"; ## Developer Instructions 1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice. - - The SVG should be formatted using either a built-in formatter or an external tool like [SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying classes easier. @@ -35,7 +34,6 @@ import { IconModule } from "@bitwarden/components"; ``` 5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class. - - **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when styling the inside of an SVG path. @@ -52,7 +50,6 @@ import { IconModule } from "@bitwarden/components"; - If the hex that you have on an SVG path is not listed above, there are a few ways to figure out the appropriate Tailwind class: - - **Option 1: Figma** - Open the SVG in Figma. - Click on an individual path on the SVG until you see the path's properties in the @@ -76,14 +73,11 @@ import { IconModule } from "@bitwarden/components"; 6. **Remove any hardcoded width or height attributes** if your SVG has a configured [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order to allow the SVG to scale to fit its container. - - **Note:** Scaling is required for any SVG used as an [AnonLayout](?path=/docs/auth-anon-layout--docs) `pageIcon`. 7. **Import your SVG const** anywhere you want to use the SVG. - - **Angular Component Example:** - - **TypeScript:** ```typescript diff --git a/libs/components/src/item/item-action.component.ts b/libs/components/src/item/item-action.component.ts index d169ee7c00b..c47ee8eea69 100644 --- a/libs/components/src/item/item-action.component.ts +++ b/libs/components/src/item/item-action.component.ts @@ -1,12 +1,9 @@ import { Component } from "@angular/core"; -import { A11yCellDirective } from "../a11y/a11y-cell.directive"; - @Component({ selector: "bit-item-action", imports: [], template: ``, - providers: [{ provide: A11yCellDirective, useExisting: ItemActionComponent }], host: { class: /** @@ -16,4 +13,4 @@ import { A11yCellDirective } from "../a11y/a11y-cell.directive"; "[&>button]:tw-relative [&>button:not([bit-item-content])]:after:tw-content-[''] [&>button]:after:tw-absolute [&>button]:after:tw-block bit-compact:[&>button]:after:tw-top-[-0.7rem] bit-compact:[&>button]:after:tw-bottom-[-0.7rem] [&>button]:after:tw-top-[-0.8rem] [&>button]:after:tw-bottom-[-0.80rem] [&>button]:after:tw-right-[-0.25rem] [&>button]:after:tw-left-[-0.25rem]", }, }) -export class ItemActionComponent extends A11yCellDirective {} +export class ItemActionComponent {} diff --git a/libs/components/src/item/item-content.component.ts b/libs/components/src/item/item-content.component.ts index f75713b139e..281f741e5d2 100644 --- a/libs/components/src/item/item-content.component.ts +++ b/libs/components/src/item/item-content.component.ts @@ -24,7 +24,7 @@ import { TypographyModule } from "../typography"; * y-axis padding should be kept in sync with `item-action.component.ts`'s `top` and `bottom` units. * we want this to be the same height as the `item-action`'s `:after` element */ - "tw-outline-none tw-text-main hover:tw-text-main tw-no-underline hover:tw-no-underline tw-text-base tw-py-2 tw-px-4 bit-compact:tw-py-1.5 bit-compact:tw-px-2 tw-bg-transparent tw-w-full tw-border-none tw-flex tw-gap-4 tw-items-center tw-justify-between", + "tw-outline-none tw-text-main hover:tw-text-main tw-no-underline hover:tw-no-underline tw-text-base tw-py-2 tw-px-4 bit-compact:tw-py-1.5 bit-compact:tw-px-2 tw-bg-transparent tw-w-full tw-border-none tw-flex tw-gap-4 tw-items-center tw-justify-between disabled:tw-cursor-not-allowed [&[disabled]_[bittypography]]:!tw-text-secondary-300 [&[disabled]_i]:!tw-text-secondary-300", "data-fvw-target": "", }, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/libs/components/src/item/item.component.ts b/libs/components/src/item/item.component.ts index e1dfd599aac..f6f2c3d7e35 100644 --- a/libs/components/src/item/item.component.ts +++ b/libs/components/src/item/item.component.ts @@ -6,8 +6,6 @@ import { signal, } from "@angular/core"; -import { A11yRowDirective } from "../a11y/a11y-row.directive"; - import { ItemActionComponent } from "./item-action.component"; @Component({ @@ -15,13 +13,12 @@ import { ItemActionComponent } from "./item-action.component"; imports: [ItemActionComponent], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "item.component.html", - providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }], host: { class: - "tw-block tw-box-border tw-overflow-hidden tw-flex tw-bg-background [&:has([data-item-main-content]_button:hover,[data-item-main-content]_a:hover)]:tw-cursor-pointer [&:has([data-item-main-content]_button:hover,[data-item-main-content]_a:hover)]:tw-bg-hover-default tw-text-main tw-border-solid tw-border-b tw-border-0 [&:not(bit-layout_*)]:tw-rounded-lg bit-compact:[&:not(bit-layout_*)]:tw-rounded-none bit-compact:[&:not(bit-layout_*)]:last-of-type:tw-rounded-b-lg bit-compact:[&:not(bit-layout_*)]:first-of-type:tw-rounded-t-lg tw-min-h-9 tw-mb-1.5 bit-compact:tw-mb-0", + "tw-block tw-box-border tw-overflow-hidden tw-flex tw-bg-background [&:has([data-item-main-content]_button:hover,[data-item-main-content]_a:hover)]:tw-cursor-pointer [&:has([data-item-main-content]_button:enabled:hover,[data-item-main-content]_a:hover)]:tw-bg-hover-default tw-text-main tw-border-solid tw-border-b tw-border-0 [&:not(bit-layout_*)]:tw-rounded-lg bit-compact:[&:not(bit-layout_*)]:tw-rounded-none bit-compact:[&:not(bit-layout_*)]:last-of-type:tw-rounded-b-lg bit-compact:[&:not(bit-layout_*)]:first-of-type:tw-rounded-t-lg tw-min-h-9 tw-mb-1.5 bit-compact:tw-mb-0", }, }) -export class ItemComponent extends A11yRowDirective { +export class ItemComponent { /** * We have `:focus-within` and `:focus-visible` but no `:focus-visible-within` */ diff --git a/libs/components/src/item/item.stories.ts b/libs/components/src/item/item.stories.ts index fd2d59c7ac2..d23caa63370 100644 --- a/libs/components/src/item/item.stories.ts +++ b/libs/components/src/item/item.stories.ts @@ -5,7 +5,6 @@ import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@stor import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { A11yGridDirective } from "../a11y/a11y-grid.directive"; import { AvatarModule } from "../avatar"; import { BadgeModule } from "../badge"; import { IconButtonModule } from "../icon-button"; @@ -32,7 +31,6 @@ export default { TypographyModule, ItemActionComponent, ItemContentComponent, - A11yGridDirective, ScrollingModule, LayoutComponent, RouterTestingModule, @@ -126,6 +124,11 @@ export const ContentTypes: Story = { And I am a button. + + + I'm just static :( diff --git a/libs/components/src/popover/popover.stories.ts b/libs/components/src/popover/popover.stories.ts index ee387d69e56..100990decca 100644 --- a/libs/components/src/popover/popover.stories.ts +++ b/libs/components/src/popover/popover.stories.ts @@ -4,6 +4,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { ButtonModule } from "../button"; import { IconButtonModule } from "../icon-button"; +import { LinkModule } from "../link"; import { SharedModule } from "../shared/shared.module"; import { I18nMockService } from "../utils/i18n-mock.service"; @@ -14,7 +15,7 @@ export default { title: "Component Library/Popover", decorators: [ moduleMetadata({ - imports: [PopoverModule, ButtonModule, IconButtonModule, SharedModule], + imports: [PopoverModule, ButtonModule, IconButtonModule, SharedModule, LinkModule], providers: [ { provide: I18nService, @@ -59,13 +60,13 @@ export default { type Story = StoryObj; -const popoverContent = ` +const popoverContent = /*html*/ ` -
Lorem ipsum dolor adipisicing elit.
+
Lorem ipsum dolor adipisicing elit.
  • Dolor sit amet consectetur
  • Esse labore veniam tempora
  • -
  • Adipisicing elit ipsum iustolaborum
  • +
  • Adipisicing elit ipsum iustolaborum
@@ -74,13 +75,15 @@ const popoverContent = ` export const Default: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -93,13 +96,13 @@ export const Default: Story = { export const Open: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` -
Lorem ipsum dolor adipisicing elit.
+
Lorem ipsum dolor adipisicing elit.
  • Dolor sit amet consectetur
  • Esse labore veniam tempora
  • -
  • Adipisicing elit ipsum iustolaborum
  • +
  • Adipisicing elit ipsum iustolaborum
@@ -115,13 +118,13 @@ export const Open: Story = { export const OpenLongTitle: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` -
Lorem ipsum dolor adipisicing elit.
+
Lorem ipsum dolor adipisicing elit.
  • Dolor sit amet consectetur
  • Esse labore veniam tempora
  • -
  • Adipisicing elit ipsum iustolaborum
  • +
  • Adipisicing elit ipsum iustolaborum
@@ -137,7 +140,7 @@ export const OpenLongTitle: Story = { export const InitiallyOpen: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -163,7 +168,7 @@ export const RightStart: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -186,7 +193,7 @@ export const RightCenter: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -209,7 +218,7 @@ export const RightEnd: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -232,7 +243,7 @@ export const LeftStart: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -255,7 +268,7 @@ export const LeftCenter: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -277,7 +292,7 @@ export const LeftEnd: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -300,7 +317,7 @@ export const BelowStart: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -323,7 +342,7 @@ export const BelowCenter: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -346,7 +367,7 @@ export const BelowEnd: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -369,7 +392,7 @@ export const AboveStart: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -392,7 +417,7 @@ export const AboveCenter: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -415,7 +442,7 @@ export const AboveEnd: Story = { }, render: (args) => ({ props: args, - template: ` + template: /*html*/ `
diff --git a/libs/components/src/shared/focusable-element.ts b/libs/components/src/shared/focusable-element.ts index 99340d5a7bf..b67dd099dd4 100644 --- a/libs/components/src/shared/focusable-element.ts +++ b/libs/components/src/shared/focusable-element.ts @@ -3,7 +3,7 @@ /** * Interface for implementing focusable components. * - * Used by the `AutofocusDirective` and `A11yGridDirective`. + * Used by the `AutofocusDirective`. */ export abstract class FocusableElement { getFocusTarget: () => HTMLElement | undefined; diff --git a/libs/components/src/stories/icons/icons.stories.ts b/libs/components/src/stories/icons/icons.stories.ts index 2d2656c9dcf..e3296730394 100644 --- a/libs/components/src/stories/icons/icons.stories.ts +++ b/libs/components/src/stories/icons/icons.stories.ts @@ -37,7 +37,7 @@ export const StatusIcons = { @for (row of rows$ | async; track row.id) { - {{row.id}} + {{row.id}} {{row.usage}} } @@ -152,7 +152,7 @@ export const SizeVariants = { @for (row of rows$ | async; track row.size) { - {{row.size}} + {{row.size}} {{row.usage}} } @@ -201,7 +201,7 @@ export const RotationVariants = { @for (row of rows$ | async; track row.class) { - {{row.class}} + {{row.class}} {{row.usage}} } diff --git a/libs/components/src/stories/introduction.mdx b/libs/components/src/stories/introduction.mdx index eb7328cd76d..7580262a6ef 100644 --- a/libs/components/src/stories/introduction.mdx +++ b/libs/components/src/stories/introduction.mdx @@ -153,7 +153,6 @@ what would be helpful to you if you were consuming this component for the first 2. (For team-owned components) Check if your file path is already included in the `.storybook/main.ts` config -- if not, add it 3. Write the docs `*.mdx` page - - What is the component intended to be used for? - How to import and use it? What inputs and slots are available? - Are there other usage guidelines, such as pointing out similar components and when to use each? diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts index 767659de3cb..f8b5421f370 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-main.component.ts @@ -105,7 +105,21 @@ class KitchenSinkDialog {

Bitwarden Kitchen Sink

- Learn more + This is a link +

+  and this is a link button popover trigger:  +

+
@@ -138,6 +152,7 @@ class KitchenSinkDialog { +

Tab Number 2

This tab is empty @@ -149,6 +164,14 @@ class KitchenSinkDialog {
+ + +
You can learn more things at:
+
    +
  • Help center
  • +
  • Support
  • +
+
`, }) export class KitchenSinkMainComponent { diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts index 9e7e6f5d3ba..cb8a72e1b3f 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -6,7 +6,6 @@ import { userEvent, getAllByRole, getByRole, - getByLabelText, fireEvent, getByText, getAllByLabelText, @@ -155,11 +154,11 @@ export const PopoverOpen: Story = { render: Default.render, play: async (context) => { const canvas = context.canvasElement; - const passwordLabelIcon = getByLabelText(canvas, "A random password (required)", { - selector: "button", + const popoverLink = getByRole(canvas, "button", { + name: "Popover trigger link", }); - await userEvent.click(passwordLabelIcon); + await userEvent.click(popoverLink); }, }; diff --git a/libs/components/src/tabs/tab-group/tab-group.component.ts b/libs/components/src/tabs/tab-group/tab-group.component.ts index 2bd81375d39..8efb91ba0c5 100644 --- a/libs/components/src/tabs/tab-group/tab-group.component.ts +++ b/libs/components/src/tabs/tab-group/tab-group.component.ts @@ -11,13 +11,14 @@ import { ContentChildren, EventEmitter, Input, - OnDestroy, Output, QueryList, ViewChildren, input, + inject, + DestroyRef, } from "@angular/core"; -import { Subject, takeUntil } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { TabHeaderComponent } from "../shared/tab-header.component"; import { TabListContainerDirective } from "../shared/tab-list-container.directive"; @@ -40,11 +41,10 @@ let nextId = 0; TabBodyComponent, ], }) -export class TabGroupComponent - implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy -{ +export class TabGroupComponent implements AfterContentChecked, AfterContentInit, AfterViewInit { + private readonly destroyRef = inject(DestroyRef); + private readonly _groupId: number; - private readonly destroy$ = new Subject(); private _indexToSelect: number | null = 0; /** @@ -150,7 +150,7 @@ export class TabGroupComponent ngAfterContentInit() { // Subscribe to any changes in the number of tabs, in order to be able // to re-render content when new tabs are added or removed. - this.tabs.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.tabs.changes.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { const indexToSelect = this._clampTabIndex(this._indexToSelect); // If the selected tab didn't explicitly change, keep the previously @@ -183,11 +183,6 @@ export class TabGroupComponent }); } - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - private _clampTabIndex(index: number): number { return Math.min(this.tabs.length - 1, Math.max(index || 0, 0)); } diff --git a/libs/components/src/tabs/tab-nav-bar/tab-link.component.ts b/libs/components/src/tabs/tab-nav-bar/tab-link.component.ts index f1b279c4371..301f9c4b191 100644 --- a/libs/components/src/tabs/tab-nav-bar/tab-link.component.ts +++ b/libs/components/src/tabs/tab-nav-bar/tab-link.component.ts @@ -6,12 +6,13 @@ import { Component, HostListener, Input, - OnDestroy, ViewChild, input, + inject, + DestroyRef, } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { IsActiveMatchOptions, RouterLinkActive, RouterModule } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; import { TabListItemDirective } from "../shared/tab-list-item.directive"; @@ -22,9 +23,8 @@ import { TabNavBarComponent } from "./tab-nav-bar.component"; templateUrl: "tab-link.component.html", imports: [TabListItemDirective, RouterModule], }) -export class TabLinkComponent implements FocusableOption, AfterViewInit, OnDestroy { - private destroy$ = new Subject(); - +export class TabLinkComponent implements FocusableOption, AfterViewInit { + private readonly destroyRef = inject(DestroyRef); @ViewChild(TabListItemDirective) tabItem: TabListItemDirective; @ViewChild("rla") routerLinkActive: RouterLinkActive; @@ -61,12 +61,7 @@ export class TabLinkComponent implements FocusableOption, AfterViewInit, OnDestr // The active state of tab links are tracked via the routerLinkActive directive // We need to watch for changes to tell the parent nav group when the tab is active this.routerLinkActive.isActiveChange - .pipe(takeUntil(this.destroy$)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((_) => this._tabNavBar.updateActiveLink()); } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/libs/components/src/toggle-group/toggle-group.stories.ts b/libs/components/src/toggle-group/toggle-group.stories.ts index 4860636c159..378e9eba2cd 100644 --- a/libs/components/src/toggle-group/toggle-group.stories.ts +++ b/libs/components/src/toggle-group/toggle-group.stories.ts @@ -75,7 +75,7 @@ export const LabelWrap: Story = { render: (args) => ({ props: args, template: /* HTML */ ` - fullWidth=false + fullWidth=false Deactivatedinvitationswraplabel
- fullWidth=true + fullWidth=true /tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/core-test-utils", +}; diff --git a/libs/core-test-utils/package.json b/libs/core-test-utils/package.json new file mode 100644 index 00000000000..acb2edc8eb5 --- /dev/null +++ b/libs/core-test-utils/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/core-test-utils", + "version": "0.0.1", + "description": "Async test tools for state and clients", + "private": true, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0", + "author": "platform" +} diff --git a/libs/core-test-utils/project.json b/libs/core-test-utils/project.json new file mode 100644 index 00000000000..a526209edc4 --- /dev/null +++ b/libs/core-test-utils/project.json @@ -0,0 +1,33 @@ +{ + "name": "core-test-utils", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/core-test-utils/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/core-test-utils", + "main": "libs/core-test-utils/src/index.ts", + "tsConfig": "libs/core-test-utils/tsconfig.lib.json", + "assets": ["libs/core-test-utils/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/core-test-utils/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/core-test-utils/jest.config.js" + } + } + } +} diff --git a/libs/core-test-utils/src/core-test-utils.spec.ts b/libs/core-test-utils/src/core-test-utils.spec.ts new file mode 100644 index 00000000000..fc878e2b691 --- /dev/null +++ b/libs/core-test-utils/src/core-test-utils.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("core-test-utils", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/core-test-utils/src/index.ts b/libs/core-test-utils/src/index.ts new file mode 100644 index 00000000000..abb60213c55 --- /dev/null +++ b/libs/core-test-utils/src/index.ts @@ -0,0 +1,60 @@ +import { Observable } from "rxjs"; + +/** + * Tracks all emissions of a given observable and returns them as an array. + * + * Typically used for testing: Call before actions that trigger observable emissions, + * then assert that expected values have been emitted. + * @param observable The observable to track. + * @returns An array of all emitted values. + */ +export function trackEmissions(observable: Observable): T[] { + const emissions: T[] = []; + observable.subscribe((value) => { + switch (value) { + case undefined: + case null: + emissions.push(value); + return; + default: + break; + } + switch (typeof value) { + case "string": + case "number": + case "boolean": + emissions.push(value); + break; + case "symbol": + // Symbols are converted to strings for storage + emissions.push(value.toString() as T); + break; + default: + emissions.push(clone(value)); + } + }); + return emissions; +} + +function clone(value: any): any { + if (global.structuredClone !== undefined) { + return structuredClone(value); + } else { + return JSON.parse(JSON.stringify(value)); + } +} + +/** + * Waits asynchronously for a given number of milliseconds. + * + * If ms < 1, yields to the event loop immediately. + * Useful in tests to await the next tick or introduce artificial delays. + * @param ms Milliseconds to wait (default: 1) + */ +export async function awaitAsync(ms = 1) { + if (ms < 1) { + await Promise.resolve(); + } else { + await new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/libs/core-test-utils/tsconfig.eslint.json b/libs/core-test-utils/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/core-test-utils/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/core-test-utils/tsconfig.json b/libs/core-test-utils/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/core-test-utils/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/core-test-utils/tsconfig.lib.json b/libs/core-test-utils/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/core-test-utils/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/core-test-utils/tsconfig.spec.json b/libs/core-test-utils/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/core-test-utils/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/guid/README.md b/libs/guid/README.md new file mode 100644 index 00000000000..506ef221a94 --- /dev/null +++ b/libs/guid/README.md @@ -0,0 +1,5 @@ +# guid + +Owned by: platform + +Guid utilities extracted from common diff --git a/libs/guid/eslint.config.mjs b/libs/guid/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/guid/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/guid/jest.config.js b/libs/guid/jest.config.js new file mode 100644 index 00000000000..d1c4dc01098 --- /dev/null +++ b/libs/guid/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "guid", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/guid", +}; diff --git a/libs/guid/package.json b/libs/guid/package.json new file mode 100644 index 00000000000..9f7af0667a3 --- /dev/null +++ b/libs/guid/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/guid", + "version": "0.0.1", + "description": "Guid utilities extracted from common", + "private": true, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0", + "author": "platform" +} diff --git a/libs/guid/project.json b/libs/guid/project.json new file mode 100644 index 00000000000..e5d510b2e2a --- /dev/null +++ b/libs/guid/project.json @@ -0,0 +1,33 @@ +{ + "name": "guid", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/guid/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/guid", + "main": "libs/guid/src/index.ts", + "tsConfig": "libs/guid/tsconfig.lib.json", + "assets": ["libs/guid/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/guid/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/guid/jest.config.js" + } + } + } +} diff --git a/libs/guid/src/guid.spec.ts b/libs/guid/src/guid.spec.ts new file mode 100644 index 00000000000..026f751a481 --- /dev/null +++ b/libs/guid/src/guid.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("guid", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/guid/src/index.ts b/libs/guid/src/index.ts new file mode 100644 index 00000000000..3ef62725ebf --- /dev/null +++ b/libs/guid/src/index.ts @@ -0,0 +1,13 @@ +export const guidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i; + +export function newGuid(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export function isGuid(id: string): boolean { + return guidRegex.test(id); +} diff --git a/libs/guid/tsconfig.eslint.json b/libs/guid/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/guid/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/guid/tsconfig.json b/libs/guid/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/guid/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/guid/tsconfig.lib.json b/libs/guid/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/guid/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/guid/tsconfig.spec.json b/libs/guid/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/guid/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 7bac6b0e0a5..985398599b2 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -23,7 +23,6 @@ import { combineLatestWith, filter, map, switchMap, takeUntil } from "rxjs/opera import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { getOrganizationById, @@ -36,6 +35,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ClientType } from "@bitwarden/common/enums"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; 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"; @@ -300,7 +300,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { // Retrieve all organizations a user is a member of and has collections they can manage const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.organizations$ = this.organizationService.memberOrganizations$(userId).pipe( - combineLatestWith(this.collectionService.decryptedCollections$), + combineLatestWith(this.collectionService.decryptedCollections$(userId)), map(([organizations, collections]) => organizations .filter((org) => collections.some((c) => c.organizationId === org.id && c.manage)) @@ -318,15 +318,15 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { } if (value) { - this.collections$ = Utils.asyncToObservable(() => - this.collectionService - .getAllDecrypted() - .then((decryptedCollections) => + this.collections$ = this.collectionService + .decryptedCollections$(userId) + .pipe( + map((decryptedCollections) => decryptedCollections .filter((c2) => c2.organizationId === value && c2.manage) .sort(Utils.getSortFunction(this.i18nService, "name")), ), - ); + ); } }); this.formGroup.controls.vaultSelector.setValue("myVault"); diff --git a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts index c835a2b73df..1636d867193 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts @@ -5,7 +5,6 @@ import { firstValueFrom, map } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionView } from "@bitwarden/admin-console/common"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; @@ -40,7 +39,6 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { protected encryptService: EncryptService, protected i18nService: I18nService, protected cipherService: CipherService, - protected pinService: PinServiceAbstraction, protected accountService: AccountService, ) { super(); diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts index 8d0f5dfcc1c..fc81adebcdc 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts @@ -1,8 +1,8 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts index 671fadbb0bc..02dedd98c75 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts @@ -1,9 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -29,11 +29,11 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im encryptService: EncryptService, i18nService: I18nService, cipherService: CipherService, - pinService: PinServiceAbstraction, + private pinService: PinServiceAbstraction, accountService: AccountService, private promptForPassword_callback: () => Promise, ) { - super(keyService, encryptService, i18nService, cipherService, pinService, accountService); + super(keyService, encryptService, i18nService, cipherService, accountService); } async parse(data: string): Promise { diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index a27b74c7ad5..4245b770ce4 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -3,9 +3,9 @@ import { mock, MockProxy } from "jest-mock-extended"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { MockSdkService } from "@bitwarden/common/platform/spec/mock-sdk.service"; diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index c6bff607633..9acd4514b31 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -9,9 +9,9 @@ import { CollectionWithIdRequest, CollectionView, } from "@bitwarden/admin-console/common"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { ImportCiphersRequest } from "@bitwarden/common/models/request/import-ciphers.request"; import { ImportOrganizationCiphersRequest } from "@bitwarden/common/models/request/import-organization-ciphers.request"; import { KvpRequest } from "@bitwarden/common/models/request/kvp.request"; @@ -406,7 +406,7 @@ export class ImportService implements ImportServiceAbstraction { if (importResult.collections != null) { for (let i = 0; i < importResult.collections.length; i++) { importResult.collections[i].organizationId = organizationId; - const c = await this.collectionService.encrypt(importResult.collections[i]); + const c = await this.collectionService.encrypt(importResult.collections[i], activeUserId); request.collections.push(new CollectionWithIdRequest(c)); } } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts new file mode 100644 index 00000000000..8c8429d3788 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -0,0 +1,678 @@ +import { DebugElement } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { firstValueFrom, interval, map, of, takeWhile, timeout } from "rxjs"; +import { ZXCVBNResult } from "zxcvbn"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { LogoutService } from "@bitwarden/auth/common"; +import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; +import { + MasterPasswordVerification, + MasterPasswordVerificationResponse, +} from "@bitwarden/common/auth/types/verification"; +import { ClientType, DeviceType } from "@bitwarden/common/enums"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; +import { MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { + AnonLayoutWrapperDataService, + AsyncActionsModule, + ButtonModule, + DialogService, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { + BiometricsService, + BiometricsStatus, + BiometricStateService, + KeyService, + PBKDF2KdfConfig, + UserAsymmetricKeysRegenerationService, +} from "@bitwarden/key-management"; + +import { + LockComponentService, + UnlockOption, + UnlockOptions, +} from "../services/lock-component.service"; + +import { LockComponent } from "./lock.component"; + +describe("LockComponent", () => { + let component: LockComponent; + let fixture: ComponentFixture; + + const userId = "test-user-id" as UserId; + + // Mock services + const mockAccountService = mockAccountServiceWith(userId); + const mockPinService = mock(); + const mockUserVerificationService = mock(); + const mockKeyService = mock(); + const mockPlatformUtilsService = mock(); + const mockRouter = mock(); + const mockDialogService = mock(); + const mockMessagingService = mock(); + const mockBiometricStateService = mock(); + const mockI18nService = mock(); + const mockMasterPasswordService = mock(); + const mockLogService = mock(); + const mockDeviceTrustService = mock(); + const mockSyncService = mock(); + const mockPolicyService = mock(); + const mockPasswordStrengthService = mock(); + const mockToastService = mock(); + const mockUserAsymmetricKeysRegenerationService = mock(); + const mockBiometricService = mock(); + const mockLogoutService = mock(); + const mockLockComponentService = mock(); + const mockAnonLayoutWrapperDataService = mock(); + const mockBroadcasterService = mock(); + + beforeEach(async () => { + jest.clearAllMocks(); + + // Setup default mock returns + mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); + mockKeyService.hasUserKey.mockResolvedValue(false); + mockI18nService.t.mockImplementation((key: string) => key); + + // Mock observables that cause timeouts + mockBiometricStateService.promptAutomatically$ = of(false); + mockBiometricStateService.promptCancelled$ = of(false); + mockBiometricStateService.resetUserPromptCancelled.mockResolvedValue(); + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(null)); + mockSyncService.fullSync.mockResolvedValue(true); + mockDeviceTrustService.trustDeviceIfRequired.mockResolvedValue(); + mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded.mockResolvedValue(); + mockAnonLayoutWrapperDataService.setAnonLayoutWrapperData.mockImplementation(() => {}); + + await TestBed.configureTestingModule({ + imports: [ + LockComponent, + ReactiveFormsModule, + JslibModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], + providers: [ + FormBuilder, + { provide: AccountService, useValue: mockAccountService }, + { provide: PinServiceAbstraction, useValue: mockPinService }, + { provide: UserVerificationService, useValue: mockUserVerificationService }, + { provide: KeyService, useValue: mockKeyService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: Router, useValue: mockRouter }, + { provide: DialogService, useValue: mockDialogService }, + { provide: MessagingService, useValue: mockMessagingService }, + { provide: BiometricStateService, useValue: mockBiometricStateService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, + { provide: LogService, useValue: mockLogService }, + { provide: DeviceTrustServiceAbstraction, useValue: mockDeviceTrustService }, + { provide: SyncService, useValue: mockSyncService }, + { provide: InternalPolicyService, useValue: mockPolicyService }, + { provide: PasswordStrengthServiceAbstraction, useValue: mockPasswordStrengthService }, + { provide: ToastService, useValue: mockToastService }, + { + provide: UserAsymmetricKeysRegenerationService, + useValue: mockUserAsymmetricKeysRegenerationService, + }, + { provide: BiometricsService, useValue: mockBiometricService }, + { provide: LogoutService, useValue: mockLogoutService }, + { provide: LockComponentService, useValue: mockLockComponentService }, + { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, + { provide: BroadcasterService, useValue: mockBroadcasterService }, + ], + }) + .overrideProvider(DialogService, { useValue: mockDialogService }) + .compileComponents(); + + fixture = TestBed.createComponent(LockComponent); + component = fixture.componentInstance; + }); + + describe("when master password unlock is active", () => { + let form: DebugElement; + + beforeEach(async () => { + const unlockOptions: UnlockOptions = { + masterPassword: { enabled: true }, + pin: { enabled: false }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.NotEnabledLocally, + }, + }; + + component.activeUnlockOption = UnlockOption.MasterPassword; + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(unlockOptions)); + await mockAccountService.switchAccount(userId); + mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); + + mockI18nService.t.mockImplementation((key: string) => { + switch (key) { + case "unlock": + return "Unlock"; + case "logOut": + return "Log Out"; + case "logOutConfirmation": + return "Confirm Log Out"; + case "masterPass": + return "Master Password"; + } + return ""; + }); + + // Trigger ngOnInit + fixture.detectChanges(); + + // Wait for html loading to complete + await firstValueFrom( + interval(10).pipe( + map(() => component["loading"]), + takeWhile((loading) => loading, true), + timeout(5000), + ), + ); + + // Wait for html to render + fixture.detectChanges(); + + form = fixture.debugElement.query(By.css("form")); + }); + + describe("form rendering", () => { + it("should render form with label", () => { + expect(form).toBeTruthy(); + expect(form.nativeElement).toBeInstanceOf(HTMLFormElement); + + const bitLabel = form.query(By.css("bit-label")); + expect(bitLabel).toBeTruthy(); + expect(bitLabel.nativeElement).toBeInstanceOf(HTMLElement); + expect((bitLabel.nativeElement as HTMLElement).textContent?.trim()).toBe("Master Password"); + }); + + it("should render master password input field", () => { + const input = form.query(By.css('input[formControlName="masterPassword"]')); + + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + const inputElement = input.nativeElement as HTMLInputElement; + expect(inputElement.type).toEqual("password"); + expect(inputElement.name).toEqual("masterPassword"); + expect(inputElement.required).toEqual(true); + expect(inputElement.attributes).toHaveProperty("bitInput"); + }); + + it("should render password toggle button", () => { + const toggleButton = form.query(By.css("button[bitPasswordInputToggle]")); + + expect(toggleButton).toBeTruthy(); + expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement; + expect(toggleButtonElement.type).toEqual("button"); + expect(toggleButtonElement.attributes).toHaveProperty("bitIconButton"); + }); + + it("should render unlock submit button", () => { + const submitButton = form.query(By.css('button[type="submit"]')); + + expect(submitButton).toBeTruthy(); + expect(submitButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const submitButtonElement = submitButton.nativeElement as HTMLButtonElement; + expect(submitButtonElement.type).toEqual("submit"); + expect(submitButtonElement.attributes).toHaveProperty("bitButton"); + expect(submitButtonElement.attributes).toHaveProperty("bitFormButton"); + expect(submitButtonElement.textContent?.trim()).toEqual("Unlock"); + }); + + it("should render logout button", () => { + const logoutButton = form.query( + By.css('button[type="button"]:not([bitPasswordInputToggle])'), + ); + + expect(logoutButton).toBeTruthy(); + expect(logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const logoutButtonElement = logoutButton.nativeElement as HTMLButtonElement; + expect(logoutButtonElement.type).toEqual("button"); + expect(logoutButtonElement.textContent?.trim()).toEqual("Log Out"); + }); + }); + + describe("unlock", () => { + it("should unlock with master password when unlock button is clicked", async () => { + const unlockViaMasterPasswordFunction = jest + .spyOn(component, "unlockViaMasterPassword") + .mockImplementation(); + const submitButton = form.query(By.css('button[type="submit"]')); + expect(submitButton).toBeTruthy(); + expect(submitButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const submitButtonElement = submitButton.nativeElement as HTMLButtonElement; + submitButtonElement.click(); + + expect(unlockViaMasterPasswordFunction).toHaveBeenCalled(); + }); + }); + + describe("logout", () => { + it("should logout when logout button is clicked", async () => { + const logOut = jest.spyOn(component, "logOut").mockImplementation(); + const logoutButton = form.query( + By.css('button[type="button"]:not([bitPasswordInputToggle])'), + ); + + expect(logoutButton).toBeTruthy(); + expect(logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const logoutButtonElement = logoutButton.nativeElement as HTMLButtonElement; + + logoutButtonElement.click(); + + expect(logOut).toHaveBeenCalled(); + }); + }); + + describe("password input", () => { + it("should bind form input to masterPassword form control", async () => { + const input = form.query(By.css('input[formControlName="masterPassword"]')); + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + expect(component.formGroup).toBeTruthy(); + const masterPasswordControl = component.formGroup!.get("masterPassword"); + expect(masterPasswordControl).toBeTruthy(); + + masterPasswordControl!.setValue("test-password"); + fixture.detectChanges(); + + const inputElement = input.nativeElement as HTMLInputElement; + expect(inputElement.value).toEqual("test-password"); + }); + + it("should validate required master password field", async () => { + const formGroup = component.formGroup; + + // Initially form should be invalid (empty required field) + expect(formGroup?.invalid).toEqual(true); + expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true); + + // Set a value + formGroup?.get("masterPassword")?.setValue("test-password"); + + expect(formGroup?.invalid).toEqual(false); + expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false); + }); + + it("should toggle password visibility when toggle button is clicked", async () => { + const toggleButton = form.query(By.css("button[bitPasswordInputToggle]")); + expect(toggleButton).toBeTruthy(); + expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement; + const input = form.query(By.css('input[formControlName="masterPassword"]')); + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + const inputElement = input.nativeElement as HTMLInputElement; + + // Initially password should be hidden + expect(component.showPassword).toEqual(false); + expect(inputElement.type).toEqual("password"); + + // Click toggle button + toggleButtonElement.click(); + fixture.detectChanges(); + + expect(component.showPassword).toEqual(true); + expect(inputElement.type).toEqual("text"); + + // Click toggle button again + toggleButtonElement.click(); + fixture.detectChanges(); + + expect(component.showPassword).toEqual(false); + expect(inputElement.type).toEqual("password"); + }); + }); + }); + + describe("unlockViaMasterPassword", () => { + const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey; + const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = { + masterKey: mockMasterKey, + kdfConfig: new PBKDF2KdfConfig(600_001), + email: "test-email@example.com", + policyOptions: null, + }; + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const masterPassword = "test-password"; + + beforeEach(async () => { + mockI18nService.t.mockImplementation((key: string) => { + switch (key) { + case "errorOccurred": + return "Error Occurred"; + case "masterPasswordRequired": + return "Master Password is required"; + case "invalidMasterPassword": + return "Invalid Master Password"; + } + return ""; + }); + + component.buildMasterPasswordForm(); + component.formGroup!.controls.masterPassword.setValue(masterPassword); + component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$); + mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue( + masterPasswordVerificationResponse, + ); + mockMasterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); + }); + + it("should not unlock and show password invalid toast when master password is empty", async () => { + component.formGroup!.controls.masterPassword.setValue(""); + + await component.unlockViaMasterPassword(); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "Error Occurred", + message: "Master Password is required", + }); + expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); + }); + + it("should not unlock when no active account", async () => { + component.activeAccount = null; + + await component.unlockViaMasterPassword(); + + expect(mockToastService.showToast).not.toHaveBeenCalled(); + expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); + }); + + it("should not unlock when no form group", async () => { + component.formGroup = null; + + await component.unlockViaMasterPassword(); + + expect(mockToastService.showToast).not.toHaveBeenCalled(); + expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); + }); + + it("should not unlock when input password verification failed due to invalid password", async () => { + mockUserVerificationService.verifyUserByMasterPassword.mockRejectedValueOnce( + new Error("invalid password"), + ); + + await component.unlockViaMasterPassword(); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "Error Occurred", + message: "Invalid Master Password", + }); + expect(mockUserVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( + { + type: VerificationType.MasterPassword, + secret: masterPassword, + } as MasterPasswordVerification, + userId, + component.activeAccount!.email, + ); + expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); + }); + + it("should not unlock when valid password but user have no user key", async () => { + mockMasterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null); + + await component.unlockViaMasterPassword(); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "Error Occurred", + message: "Invalid Master Password", + }); + expect(mockMasterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + userId, + ); + expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); + }); + + it("should unlock and set user key and sync when valid password", async () => { + await component.unlockViaMasterPassword(); + + assertUnlocked(); + expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); + }); + + it.each([ + [false, undefined, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true], + [true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true], + ])( + "should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy set during user verification by master password", + async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { + mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({ + ...masterPasswordVerificationResponse, + policyOptions: + masterPasswordPolicyOptions != null + ? new MasterPasswordPolicyResponse({ + EnforceOnLogin: masterPasswordPolicyOptions.enforceOnLogin, + }) + : null, + } as MasterPasswordVerificationResponse); + const passwordStrengthResult = { score: 1 } as ZXCVBNResult; + mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult); + mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword); + + await component.unlockViaMasterPassword(); + + assertUnlocked(); + if (masterPasswordPolicyOptions?.enforceOnLogin) { + expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( + masterPassword, + component.activeAccount!.email, + ); + expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith( + passwordStrengthResult.score, + masterPassword, + masterPasswordPolicyOptions, + ); + } + if (forceSetPassword) { + expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); + } else { + expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + [false, undefined, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true], + [true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true], + ])( + "should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service", + async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { + mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue( + of(masterPasswordPolicyOptions), + ); + const passwordStrengthResult = { score: 1 } as ZXCVBNResult; + mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult); + mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword); + + await component.unlockViaMasterPassword(); + + assertUnlocked(); + expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId); + if (masterPasswordPolicyOptions?.enforceOnLogin) { + expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( + masterPassword, + component.activeAccount!.email, + ); + expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith( + passwordStrengthResult.score, + masterPassword, + masterPasswordPolicyOptions, + ); + } + if (forceSetPassword) { + expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); + } else { + expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + [true, ClientType.Browser], + [false, ClientType.Cli], + [false, ClientType.Desktop], + [false, ClientType.Web], + ])( + "should unlock and navigate by url to previous url = %o when client type = %o and previous url was set", + async (shouldNavigate, clientType) => { + const previousUrl = "/test-url"; + component.clientType = clientType; + mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl); + + await component.unlockViaMasterPassword(); + + assertUnlocked(); + if (shouldNavigate) { + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl); + } else { + expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + ["/tabs/current", ClientType.Browser], + [undefined, ClientType.Cli], + ["vault", ClientType.Desktop], + ["vault", ClientType.Web], + ])( + "should unlock and navigate to success url = %o when client type = %o", + async (navigateUrl, clientType) => { + component.clientType = clientType; + mockLockComponentService.getPreviousUrl.mockReturnValue(null); + + await component.unlockViaMasterPassword(); + + assertUnlocked(); + expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]); + }, + ); + + it("should unlock and close browser extension popout on firefox extension", async () => { + component.shouldClosePopout = true; + mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); + + await component.unlockViaMasterPassword(); + + assertUnlocked(); + expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled(); + }); + + function assertUnlocked() { + expect(mockToastService.showToast).not.toHaveBeenCalled(); + expect(mockMasterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockMasterKey, + userId, + ); + expect(mockKeyService.setUserKey).toHaveBeenCalledWith(mockUserKey, userId); + expect(mockDeviceTrustService.trustDeviceIfRequired).toHaveBeenCalledWith(userId); + expect(mockBiometricStateService.resetUserPromptCancelled).toHaveBeenCalled(); + expect(mockMessagingService.send).toHaveBeenCalledWith("unlocked"); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(false); + expect(mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith( + userId, + ); + } + }); + + describe("logOut", () => { + it("should log out user and redirect to login page when dialog confirmed", async () => { + mockDialogService.openSimpleDialog.mockResolvedValue(true); + component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$); + + await component.logOut(); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "logOut" }, + content: { key: "logOutConfirmation" }, + acceptButtonText: { key: "logOut" }, + type: "warning", + }); + expect(mockLogoutService.logout).toHaveBeenCalledWith(userId); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]); + }); + + it("should not log out user when dialog cancelled", async () => { + mockDialogService.openSimpleDialog.mockResolvedValue(false); + component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$); + + await component.logOut(); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "logOut" }, + content: { key: "logOutConfirmation" }, + acceptButtonText: { key: "logOut" }, + type: "warning", + }); + expect(mockLogoutService.logout).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it("should not log out user when user already logged out", async () => { + mockDialogService.openSimpleDialog.mockResolvedValue(true); + component.activeAccount = null; + + await component.logOut(); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "logOut" }, + content: { key: "logOutConfirmation" }, + acceptButtonText: { key: "logOut" }, + type: "warning", + }); + expect(mockLogoutService.logout).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 9f370c88fa9..92c8004e4c9 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -16,7 +16,7 @@ import { } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { LogoutService, PinServiceAbstraction } from "@bitwarden/auth/common"; +import { LogoutService } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -31,6 +31,7 @@ import { import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -74,6 +75,7 @@ const clientTypeToSuccessRouteRecord: Partial> = { /// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible /// Fixes safari autoprompt behavior const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; + @Component({ selector: "bit-lock", templateUrl: "lock.component.html", @@ -123,7 +125,7 @@ export class LockComponent implements OnInit, OnDestroy { formGroup: FormGroup | null = null; // Browser extension properties: - private shouldClosePopout = false; + shouldClosePopout = false; // Desktop properties: private deferFocus: boolean | null = null; @@ -154,13 +156,10 @@ export class LockComponent implements OnInit, OnDestroy { private formBuilder: FormBuilder, private toastService: ToastService, private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService, - private biometricService: BiometricsService, private logoutService: LogoutService, - private lockComponentService: LockComponentService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, - // desktop deps private broadcasterService: BroadcasterService, ) {} @@ -211,7 +210,7 @@ export class LockComponent implements OnInit, OnDestroy { }); } - private buildMasterPasswordForm() { + buildMasterPasswordForm() { this.formGroup = this.formBuilder.group( { masterPassword: ["", [Validators.required]], @@ -512,7 +511,7 @@ export class LockComponent implements OnInit, OnDestroy { return true; } - private async unlockViaMasterPassword() { + async unlockViaMasterPassword() { if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) { return; } diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 3c0d6c8a138..12d0998a862 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -150,11 +150,18 @@ export abstract class KeyService { /** * Generates a new user key + * @deprecated Interacting with the master key directly is prohibited. Use {@link makeUserKeyV1} instead. * @throws Error when master key is null and there is no active user * @param masterKey The user's master key. When null, grabs master key from active user. * @returns A new user key and the master key protected version of it */ abstract makeUserKey(masterKey: MasterKey | null): Promise<[UserKey, EncString]>; + /** + * Generates a new user key for a V1 user + * Note: This will be replaced by a higher level function to initialize a whole users cryptographic state in the near future. + * @returns A new user key + */ + abstract makeUserKeyV1(): Promise; /** * Clears the user's stored version of the user key * @param keySuffix The desired version of the key to clear @@ -163,22 +170,28 @@ export abstract class KeyService { */ abstract clearStoredUserKey(keySuffix: KeySuffixOptions, userId: string): Promise; /** - * @throws Error when userId is null and no active user + * Retrieves the user's master key if it is in state, or derives it from the provided password * @param password The user's master password that will be used to derive a master key if one isn't found * @param userId The desired user + * @deprecated Interacting with the master key directly is prohibited. Use a high level function from MasterPasswordService instead. + * @throws Error when userId is null/undefined. + * @throws Error when email or Kdf configuration cannot be found for the user. + * @returns The user's master key if it exists, or a newly derived master key. */ - abstract getOrDeriveMasterKey(password: string, userId?: string): Promise; + abstract getOrDeriveMasterKey(password: string, userId: UserId): Promise; /** * Generates a master key from the provided password + * @deprecated Interacting with the master key directly is prohibited. * @param password The user's master password * @param email The user's email * @param KdfConfig The user's key derivation function configuration * @returns A master key derived from the provided password */ - abstract makeMasterKey(password: string, email: string, KdfConfig: KdfConfig): Promise; + abstract makeMasterKey(password: string, email: string, kdfConfig: KdfConfig): Promise; /** * Encrypts the existing (or provided) user key with the * provided master key + * @deprecated Interacting with the master key directly is prohibited. Use a high level function from MasterPasswordService instead. * @param masterKey The user's master key * @param userKey The user key * @returns The user key and the master key protected version of it @@ -191,24 +204,27 @@ export abstract class KeyService { * Creates a master password hash from the user's master password. Can * be used for local authentication or for server authentication depending * on the hashPurpose provided. - * @throws Error when password is null or key is null and no active user or active user have no master key + * @deprecated Interacting with the master key directly is prohibited. Use a high level function from MasterPasswordService instead. * @param password The user's master password * @param key The user's master key or active's user master key. - * @param hashPurpose The iterations to use for the hash + * @param hashPurpose The iterations to use for the hash. Defaults to {@link HashPurpose.ServerAuthorization}. + * @throws Error when password is null/undefined or key is null/undefined. * @returns The user's master password hash */ abstract hashMasterKey( password: string, - key: MasterKey | null, + key: MasterKey, hashPurpose?: HashPurpose, ): Promise; /** * Compares the provided master password to the stored password hash. + * @deprecated Interacting with the master key directly is prohibited. Use a high level function from MasterPasswordService instead. * @param masterPassword The user's master password - * @param key The user's master key + * @param masterKey The user's master key * @param userId The id of the user to do the operation for. - * @returns True if the provided master password matches either the stored - * key hash or the server key hash + * @throws Error when master key is null/undefined. + * @returns True if the derived master password hash matches the stored + * key hash, false otherwise. */ abstract compareKeyHash( masterPassword: string, diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 7a033792c79..1a76803a085 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -1,9 +1,6 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -12,13 +9,14 @@ import { EncryptedString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; +import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums"; import { Encrypted } from "@bitwarden/common/platform/interfaces/encrypted"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; @@ -47,6 +45,7 @@ import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { KdfConfigService } from "./abstractions/kdf-config.service"; import { UserPrivateKeyDecryptionFailedError } from "./abstractions/key.service"; import { DefaultKeyService } from "./key.service"; +import { KdfConfig } from "./models/kdf-config"; describe("keyService", () => { let keyService: DefaultKeyService; @@ -696,7 +695,6 @@ describe("keyService", () => { }); it("returns decryption keys when some of the org keys are providers", async () => { - encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(64)); const org2Id = "org2Id" as OrganizationId; updateKeys({ userKey: makeSymmetricCryptoKey(64), @@ -817,55 +815,160 @@ describe("keyService", () => { }); describe("getOrDeriveMasterKey", () => { + beforeEach(() => { + masterPasswordService.masterKeySubject.next(null); + }); + + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided userId is %s", + async (userId) => { + await expect(keyService.getOrDeriveMasterKey("password", userId)).rejects.toThrow( + "User ID is required.", + ); + }, + ); + it("returns the master key if it is already available", async () => { - const getMasterKey = jest - .spyOn(masterPasswordService, "masterKey$") - .mockReturnValue(of("masterKey" as any)); + const masterKey = makeSymmetricCryptoKey(32) as MasterKey; + masterPasswordService.masterKeySubject.next(masterKey); const result = await keyService.getOrDeriveMasterKey("password", mockUserId); - expect(getMasterKey).toHaveBeenCalledWith(mockUserId); - expect(result).toEqual("masterKey"); + expect(kdfConfigService.getKdfConfig$).not.toHaveBeenCalledWith(mockUserId); + expect(result).toEqual(masterKey); }); - it("derives the master key if it is not available", async () => { - const getMasterKey = jest - .spyOn(masterPasswordService, "masterKey$") - .mockReturnValue(of(null as any)); + it("throws an error if user's email is not available", async () => { + accountService.accounts$ = of({}); - const deriveKeyFromPassword = jest - .spyOn(keyGenerationService, "deriveKeyFromPassword") - .mockResolvedValue("mockMasterKey" as any); - - kdfConfigService.getKdfConfig$.mockReturnValue(of("mockKdfConfig" as any)); - - const result = await keyService.getOrDeriveMasterKey("password", mockUserId); - - expect(getMasterKey).toHaveBeenCalledWith(mockUserId); - expect(deriveKeyFromPassword).toHaveBeenCalledWith("password", "email", "mockKdfConfig"); - expect(result).toEqual("mockMasterKey"); - }); - - it("throws an error if no user is found", async () => { - accountService.activeAccountSubject.next(null); - - await expect(keyService.getOrDeriveMasterKey("password")).rejects.toThrow("No user found"); + await expect(keyService.getOrDeriveMasterKey("password", mockUserId)).rejects.toThrow( + "No email found for user " + mockUserId, + ); + expect(kdfConfigService.getKdfConfig$).not.toHaveBeenCalled(); }); it("throws an error if no kdf config is found", async () => { - jest.spyOn(masterPasswordService, "masterKey$").mockReturnValue(of(null as any)); kdfConfigService.getKdfConfig$.mockReturnValue(of(null)); await expect(keyService.getOrDeriveMasterKey("password", mockUserId)).rejects.toThrow( "No kdf found for user", ); }); + + it("derives the master key if it is not available", async () => { + keyGenerationService.deriveKeyFromPassword.mockReturnValue("mockMasterKey" as any); + kdfConfigService.getKdfConfig$.mockReturnValue(of("mockKdfConfig" as any)); + + const result = await keyService.getOrDeriveMasterKey("password", mockUserId); + + expect(kdfConfigService.getKdfConfig$).toHaveBeenCalledWith(mockUserId); + expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith( + "password", + "email", + "mockKdfConfig", + ); + expect(result).toEqual("mockMasterKey"); + }); + }); + + describe("makeMasterKey", () => { + const password = "testPassword"; + let email = "test@example.com"; + const masterKey = makeSymmetricCryptoKey(32) as MasterKey; + const kdfConfig = mock(); + + it("derives a master key from password and email", async () => { + keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey); + + const result = await keyService.makeMasterKey(password, email, kdfConfig); + + expect(result).toEqual(masterKey); + }); + + it("trims and lowercases the email for key generation call", async () => { + keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey); + email = "TEST@EXAMPLE.COM"; + + await keyService.makeMasterKey(password, email, kdfConfig); + + expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith( + password, + email.trim().toLowerCase(), + kdfConfig, + ); + }); + + it("should log the time taken to derive the master key", async () => { + keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey); + jest.spyOn(Date.prototype, "getTime").mockReturnValueOnce(1000).mockReturnValueOnce(1500); + + await keyService.makeMasterKey(password, email, kdfConfig); + + expect(logService.info).toHaveBeenCalledWith("[KeyService] Deriving master key took 500ms"); + }); + }); + + describe("hashMasterKey", () => { + const password = "testPassword"; + const masterKey = makeSymmetricCryptoKey(32) as MasterKey; + + test.each([null as unknown as string, undefined as unknown as string])( + "throws when the provided password is %s", + async (password) => { + await expect(keyService.hashMasterKey(password, masterKey)).rejects.toThrow( + "password is required.", + ); + }, + ); + + test.each([null as unknown as MasterKey, undefined as unknown as MasterKey])( + "throws when the provided key is %s", + async (key) => { + await expect(keyService.hashMasterKey("password", key)).rejects.toThrow("key is required."); + }, + ); + + it("hashes master key with default iterations when no hashPurpose is provided", async () => { + const mockReturnedHashB64 = "bXlfaGFzaA=="; + cryptoFunctionService.pbkdf2.mockResolvedValue(Utils.fromB64ToArray(mockReturnedHashB64)); + + const result = await keyService.hashMasterKey(password, masterKey); + + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith( + masterKey.inner().encryptionKey, + password, + "sha256", + 1, + ); + expect(result).toBe(mockReturnedHashB64); + }); + + test.each([ + [2, HashPurpose.LocalAuthorization], + [1, HashPurpose.ServerAuthorization], + ])( + "hashes master key with %s iterations when hashPurpose is %s", + async (expectedIterations, hashPurpose) => { + const mockReturnedHashB64 = "bXlfaGFzaA=="; + cryptoFunctionService.pbkdf2.mockResolvedValue(Utils.fromB64ToArray(mockReturnedHashB64)); + + const result = await keyService.hashMasterKey(password, masterKey, hashPurpose); + + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith( + masterKey.inner().encryptionKey, + password, + "sha256", + expectedIterations, + ); + expect(result).toBe(mockReturnedHashB64); + }, + ); }); describe("compareKeyHash", () => { type TestCase = { masterKey: MasterKey; - masterPassword: string | null; + masterPassword: string; storedMasterKeyHash: string | null; mockReturnedHash: string; expectedToMatch: boolean; @@ -873,26 +976,33 @@ describe("keyService", () => { const data: TestCase[] = [ { - masterKey: makeSymmetricCryptoKey(64), + masterKey: makeSymmetricCryptoKey(32), masterPassword: "my_master_password", storedMasterKeyHash: "bXlfaGFzaA==", mockReturnedHash: "bXlfaGFzaA==", expectedToMatch: true, }, { - masterKey: makeSymmetricCryptoKey(64), - masterPassword: null, + masterKey: makeSymmetricCryptoKey(32), + masterPassword: null as unknown as string, storedMasterKeyHash: "bXlfaGFzaA==", mockReturnedHash: "bXlfaGFzaA==", expectedToMatch: false, }, { - masterKey: makeSymmetricCryptoKey(64), - masterPassword: null, + masterKey: makeSymmetricCryptoKey(32), + masterPassword: null as unknown as string, storedMasterKeyHash: null, mockReturnedHash: "bXlfaGFzaA==", expectedToMatch: false, }, + { + masterKey: makeSymmetricCryptoKey(32), + masterPassword: "my_master_password", + storedMasterKeyHash: "bXlfaGFzaA==", + mockReturnedHash: "zxccbXlfaGFzaA==", + expectedToMatch: false, + }, ]; it.each(data)( @@ -907,7 +1017,7 @@ describe("keyService", () => { masterPasswordService.masterKeyHashSubject.next(storedMasterKeyHash); cryptoFunctionService.pbkdf2 - .calledWith(masterKey.inner().encryptionKey, masterPassword as string, "sha256", 2) + .calledWith(masterKey.inner().encryptionKey, masterPassword, "sha256", 2) .mockResolvedValue(Utils.fromB64ToArray(mockReturnedHash)); const actualDidMatch = await keyService.compareKeyHash( @@ -919,6 +1029,38 @@ describe("keyService", () => { expect(actualDidMatch).toBe(expectedToMatch); }, ); + + test.each([null as unknown as MasterKey, undefined as unknown as MasterKey])( + "throws an error if masterKey is %s", + async (masterKey) => { + await expect( + keyService.compareKeyHash("my_master_password", masterKey, mockUserId), + ).rejects.toThrow("'masterKey' is required to be non-null."); + }, + ); + + test.each([null as unknown as string, undefined as unknown as string])( + "returns false when masterPassword is %s", + async (masterPassword) => { + const result = await keyService.compareKeyHash( + masterPassword, + makeSymmetricCryptoKey(32), + mockUserId, + ); + expect(result).toBe(false); + }, + ); + + it("returns false when storedMasterKeyHash is null", async () => { + masterPasswordService.masterKeyHashSubject.next(null); + + const result = await keyService.compareKeyHash( + "my_master_password", + makeSymmetricCryptoKey(32), + mockUserId, + ); + expect(result).toBe(false); + }); }); describe("userPrivateKey$", () => { diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 0f4b101d9b2..27bc0515a8d 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -13,9 +13,6 @@ import { switchMap, } from "rxjs"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; import { BaseEncryptedOrganizationKey } from "@bitwarden/common/admin-console/models/domain/encrypted-organization-key"; import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response"; @@ -29,6 +26,7 @@ import { EncryptedString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -232,6 +230,11 @@ export class DefaultKeyService implements KeyServiceAbstraction { return this.buildProtectedSymmetricKey(masterKey, newUserKey); } + async makeUserKeyV1(): Promise { + const newUserKey = await this.keyGenerationService.createKey(512); + return newUserKey as UserKey; + } + /** * Clears the user key. Clears all stored versions of the user keys as well, such as the biometrics key * @param userId The desired user @@ -259,44 +262,49 @@ export class DefaultKeyService implements KeyServiceAbstraction { } } - // TODO: Move to MasterPasswordService - async getOrDeriveMasterKey(password: string, userId?: UserId) { - const [resolvedUserId, email] = await firstValueFrom( - combineLatest([this.accountService.activeAccount$, this.accountService.accounts$]).pipe( - map(([activeAccount, accounts]) => { - userId ??= activeAccount?.id; - if (userId == null || accounts[userId] == null) { - throw new Error("No user found"); - } - return [userId, accounts[userId].email]; - }), - ), - ); - const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(resolvedUserId)); + /** + * @deprecated Please use `makeMasterPasswordAuthenticationData`, `unwrapUserKeyFromMasterPasswordUnlockData` or `makeMasterPasswordUnlockData` in @link MasterPasswordService instead. + */ + async getOrDeriveMasterKey(password: string, userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required."); + } + + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey != null) { return masterKey; } - const kdf = await firstValueFrom(this.kdfConfigService.getKdfConfig$(resolvedUserId)); - if (kdf == null) { - throw new Error("No kdf found for user"); + const email = await firstValueFrom( + this.accountService.accounts$.pipe(map((accounts) => accounts[userId]?.email)), + ); + if (email == null) { + throw new Error("No email found for user " + userId); } + + const kdf = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId)); + if (kdf == null) { + throw new Error("No kdf found for user " + userId); + } + return await this.makeMasterKey(password, email, kdf); } /** * Derive a master key from a password and email. * + * @deprecated Please use `makeMasterPasswordAuthenticationData`, `makeMasterPasswordAuthenticationData`, `unwrapUserKeyFromMasterPasswordUnlockData` in @link MasterPasswordService instead. + * * @remarks * Does not validate the kdf config to ensure it satisfies the minimum requirements for the given kdf type. - * TODO: Move to MasterPasswordService */ - async makeMasterKey(password: string, email: string, KdfConfig: KdfConfig): Promise { + async makeMasterKey(password: string, email: string, kdfConfig: KdfConfig): Promise { const start = new Date().getTime(); + email = email.trim().toLowerCase(); const masterKey = (await this.keyGenerationService.deriveKeyFromPassword( password, email, - KdfConfig, + kdfConfig, )) as MasterKey; const end = new Date().getTime(); this.logService.info(`[KeyService] Deriving master key took ${end - start}ms`); @@ -304,6 +312,9 @@ export class DefaultKeyService implements KeyServiceAbstraction { return masterKey; } + /** + * @deprecated Please use `makeMasterPasswordUnlockData` in {@link MasterPasswordService} instead. + */ async encryptUserKeyWithMasterKey( masterKey: MasterKey, userKey?: UserKey, @@ -312,23 +323,19 @@ export class DefaultKeyService implements KeyServiceAbstraction { return await this.buildProtectedSymmetricKey(masterKey, userKey); } - // TODO: move to MasterPasswordService + /** + * @deprecated Please use `makeMasterPasswordAuthenticationData` in {@link MasterPasswordService} instead. + */ async hashMasterKey( password: string, - key: MasterKey | null, + key: MasterKey, hashPurpose?: HashPurpose, ): Promise { - if (key == null) { - const userId = await firstValueFrom(this.stateProvider.activeUserId$); - if (userId == null) { - throw new Error("No active user found."); - } - - key = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + if (password == null) { + throw new Error("password is required."); } - - if (password == null || key == null) { - throw new Error("Invalid parameters."); + if (key == null) { + throw new Error("key is required."); } const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1; @@ -341,9 +348,8 @@ export class DefaultKeyService implements KeyServiceAbstraction { return Utils.fromBufferToB64(hash); } - // TODO: move to MasterPasswordService async compareKeyHash( - masterPassword: string | null, + masterPassword: string, masterKey: MasterKey, userId: UserId, ): Promise { diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts index abccfee4c59..e57ab74de6b 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts @@ -15,7 +15,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; -import { VerifyAsymmetricKeysResponse } from "@bitwarden/sdk-internal"; +import { VerifyAsymmetricKeysResponse, EncString as SdkEncString } from "@bitwarden/sdk-internal"; import { KeyService } from "../../abstractions/key.service"; import { UserAsymmetricKeysRegenerationApiService } from "../abstractions/user-asymmetric-key-regeneration-api.service"; @@ -28,7 +28,7 @@ function setupVerificationResponse( ) { const mockKeyPairResponse = { userPublicKey: "userPublicKey", - userKeyEncryptedPrivateKey: "userKeyEncryptedPrivateKey", + userKeyEncryptedPrivateKey: "userKeyEncryptedPrivateKey" as SdkEncString, }; sdkService.client.crypto @@ -54,7 +54,6 @@ function setupUserKeyValidation( encryptService.unwrapSymmetricKey.mockResolvedValue( new SymmetricCryptoKey(makeStaticByteArray(64)), ); - encryptService.decryptToBytes.mockResolvedValue(makeStaticByteArray(64)); encryptService.decryptString.mockResolvedValue("mockDecryptedString"); (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); } @@ -276,7 +275,6 @@ describe("regenerateIfNeeded", () => { }; setupVerificationResponse(mockVerificationResponse, sdkService); setupUserKeyValidation(cipherService, keyService, encryptService); - encryptService.decryptToBytes.mockRejectedValue(new Error("error")); encryptService.decryptString.mockRejectedValue(new Error("error")); encryptService.unwrapSymmetricKey.mockRejectedValue(new Error("error")); @@ -327,7 +325,6 @@ describe("regenerateIfNeeded", () => { }; setupVerificationResponse(mockVerificationResponse, sdkService); setupUserKeyValidation(cipherService, keyService, encryptService); - encryptService.decryptToBytes.mockRejectedValue(new Error("error")); encryptService.decryptString.mockRejectedValue(new Error("error")); encryptService.unwrapSymmetricKey.mockRejectedValue(new Error("error")); @@ -354,4 +351,22 @@ describe("regenerateIfNeeded", () => { ).not.toHaveBeenCalled(); expect(keyService.setPrivateKey).not.toHaveBeenCalled(); }); + + it("should not regenerate when userKey type is CoseEncrypt0 (V2 encryption)", async () => { + const mockUserKey = { + keyB64: "mockKeyB64", + inner: () => ({ type: 7 }), + } as unknown as UserKey; + keyService.userKey$.mockReturnValue(of(mockUserKey)); + + await sut.regenerateIfNeeded(userId); + + expect( + userAsymmetricKeysRegenerationApiService.regenerateUserAsymmetricKeys, + ).not.toHaveBeenCalled(); + expect(keyService.setPrivateKey).not.toHaveBeenCalled(); + expect(logService.error).toHaveBeenCalledWith( + "[UserAsymmetricKeyRegeneration] Cannot regenerate asymmetric keys for accounts on V2 encryption.", + ); + }); }); diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts index 3e837237895..335f45b0ce2 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts @@ -6,6 +6,7 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -60,6 +61,13 @@ export class DefaultUserAsymmetricKeysRegenerationService return false; } + if (userKey.inner().type === EncryptionType.CoseEncrypt0) { + this.logService.error( + "[UserAsymmetricKeyRegeneration] Cannot regenerate asymmetric keys for accounts on V2 encryption.", + ); + return false; + } + const [userKeyEncryptedPrivateKey, publicKeyResponse] = await firstValueFrom( combineLatest([ this.keyService.userEncryptedPrivateKey$(userId), diff --git a/libs/serialization/README.md b/libs/serialization/README.md new file mode 100644 index 00000000000..9ae7f47724c --- /dev/null +++ b/libs/serialization/README.md @@ -0,0 +1,5 @@ +# serialization + +Owned by: platform + +Core serialization utilities diff --git a/libs/serialization/eslint.config.mjs b/libs/serialization/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/serialization/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/serialization/jest.config.js b/libs/serialization/jest.config.js new file mode 100644 index 00000000000..1137064a7d8 --- /dev/null +++ b/libs/serialization/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "serialization", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/serialization", +}; diff --git a/libs/serialization/package.json b/libs/serialization/package.json new file mode 100644 index 00000000000..d582d28ac23 --- /dev/null +++ b/libs/serialization/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/serialization", + "version": "0.0.1", + "description": "Core serialization utilities", + "private": true, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0", + "author": "platform" +} diff --git a/libs/serialization/project.json b/libs/serialization/project.json new file mode 100644 index 00000000000..3fe8968ea4b --- /dev/null +++ b/libs/serialization/project.json @@ -0,0 +1,33 @@ +{ + "name": "serialization", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/serialization/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/serialization", + "main": "libs/serialization/src/index.ts", + "tsConfig": "libs/serialization/tsconfig.lib.json", + "assets": ["libs/serialization/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/serialization/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/serialization/jest.config.js" + } + } + } +} diff --git a/libs/common/src/platform/state/deserialization-helpers.spec.ts b/libs/serialization/src/deserialization-helpers.spec.ts similarity index 89% rename from libs/common/src/platform/state/deserialization-helpers.spec.ts rename to libs/serialization/src/deserialization-helpers.spec.ts index b1ae447997f..1918673c8d2 100644 --- a/libs/common/src/platform/state/deserialization-helpers.spec.ts +++ b/libs/serialization/src/deserialization-helpers.spec.ts @@ -1,4 +1,4 @@ -import { record } from "./deserialization-helpers"; +import { record } from "@bitwarden/serialization/deserialization-helpers"; describe("deserialization helpers", () => { describe("record", () => { diff --git a/libs/common/src/platform/state/deserialization-helpers.ts b/libs/serialization/src/deserialization-helpers.ts similarity index 100% rename from libs/common/src/platform/state/deserialization-helpers.ts rename to libs/serialization/src/deserialization-helpers.ts diff --git a/libs/serialization/src/index.ts b/libs/serialization/src/index.ts new file mode 100644 index 00000000000..b6e874748f7 --- /dev/null +++ b/libs/serialization/src/index.ts @@ -0,0 +1 @@ +export * from "./deserialization-helpers"; diff --git a/libs/serialization/src/serialization.spec.ts b/libs/serialization/src/serialization.spec.ts new file mode 100644 index 00000000000..4e10000eab3 --- /dev/null +++ b/libs/serialization/src/serialization.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("serialization", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/serialization/tsconfig.eslint.json b/libs/serialization/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/serialization/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/serialization/tsconfig.json b/libs/serialization/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/serialization/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/serialization/tsconfig.lib.json b/libs/serialization/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/serialization/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/serialization/tsconfig.spec.json b/libs/serialization/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/serialization/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/state-test-utils/README.md b/libs/state-test-utils/README.md new file mode 100644 index 00000000000..69a6355f8e7 --- /dev/null +++ b/libs/state-test-utils/README.md @@ -0,0 +1,5 @@ +# state-test-utils + +Owned by: platform + +Test utilities and fakes for state management diff --git a/libs/state-test-utils/eslint.config.mjs b/libs/state-test-utils/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/state-test-utils/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/state-test-utils/jest.config.js b/libs/state-test-utils/jest.config.js new file mode 100644 index 00000000000..76c531ad78a --- /dev/null +++ b/libs/state-test-utils/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "state-test-utils", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/state-test-utils", +}; diff --git a/libs/state-test-utils/package.json b/libs/state-test-utils/package.json new file mode 100644 index 00000000000..9fd9aa64e5f --- /dev/null +++ b/libs/state-test-utils/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/state-test-utils", + "version": "0.0.1", + "description": "Test utilities and fakes for state management", + "private": true, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0", + "author": "platform" +} diff --git a/libs/state-test-utils/project.json b/libs/state-test-utils/project.json new file mode 100644 index 00000000000..ef524e5347b --- /dev/null +++ b/libs/state-test-utils/project.json @@ -0,0 +1,33 @@ +{ + "name": "state-test-utils", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/state-test-utils/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/state-test-utils", + "main": "libs/state-test-utils/src/index.ts", + "tsConfig": "libs/state-test-utils/tsconfig.lib.json", + "assets": ["libs/state-test-utils/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/state-test-utils/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/state-test-utils/jest.config.js" + } + } + } +} diff --git a/libs/state-test-utils/src/fake-state-provider.ts b/libs/state-test-utils/src/fake-state-provider.ts new file mode 100644 index 00000000000..47b1ee3dd0f --- /dev/null +++ b/libs/state-test-utils/src/fake-state-provider.ts @@ -0,0 +1,341 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, map, Observable, of, switchMap, take } from "rxjs"; + +import { + DerivedStateDependencies, + GlobalState, + GlobalStateProvider, + KeyDefinition, + ActiveUserState, + SingleUserState, + SingleUserStateProvider, + StateProvider, + ActiveUserStateProvider, + DerivedState, + DeriveDefinition, + DerivedStateProvider, + UserKeyDefinition, + ActiveUserAccessor, +} from "@bitwarden/state"; +import { + FakeActiveUserState, + FakeDerivedState, + FakeGlobalState, + FakeSingleUserState, +} from "@bitwarden/state-test-utils"; +import { UserId } from "@bitwarden/user-core"; + +export interface MinimalAccountService { + activeUserId: UserId | null; + activeAccount$: Observable<{ id: UserId } | null>; +} + +export class FakeActiveUserAccessor implements MinimalAccountService, ActiveUserAccessor { + private _subject: BehaviorSubject; + + constructor(startingUser: UserId | null) { + this._subject = new BehaviorSubject(startingUser); + this.activeAccount$ = this._subject + .asObservable() + .pipe(map((id) => (id != null ? { id } : null))); + this.activeUserId$ = this._subject.asObservable(); + } + + get activeUserId(): UserId { + return this._subject.value; + } + + activeUserId$: Observable; + + activeAccount$: Observable<{ id: UserId }>; + + switch(user: UserId | null) { + this._subject.next(user); + } +} + +export class FakeGlobalStateProvider implements GlobalStateProvider { + mock = mock(); + establishedMocks: Map> = new Map(); + states: Map> = new Map(); + get(keyDefinition: KeyDefinition): GlobalState { + this.mock.get(keyDefinition); + const cacheKey = this.cacheKey(keyDefinition); + let result = this.states.get(cacheKey); + + if (result == null) { + let fake: FakeGlobalState; + // Look for established mock + if (this.establishedMocks.has(keyDefinition.key)) { + fake = this.establishedMocks.get(keyDefinition.key) as FakeGlobalState; + } else { + fake = new FakeGlobalState(); + } + fake.keyDefinition = keyDefinition; + result = fake; + this.states.set(cacheKey, result); + + result = new FakeGlobalState(); + this.states.set(cacheKey, result); + } + return result as GlobalState; + } + + private cacheKey(keyDefinition: KeyDefinition) { + return `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`; + } + + getFake(keyDefinition: KeyDefinition): FakeGlobalState { + return this.get(keyDefinition) as FakeGlobalState; + } + + mockFor(keyDefinition: KeyDefinition, initialValue?: T): FakeGlobalState { + const cacheKey = this.cacheKey(keyDefinition); + if (!this.states.has(cacheKey)) { + this.states.set(cacheKey, new FakeGlobalState(initialValue)); + } + return this.states.get(cacheKey) as FakeGlobalState; + } +} + +export class FakeSingleUserStateProvider implements SingleUserStateProvider { + mock = mock(); + states: Map> = new Map(); + + constructor( + readonly updateSyncCallback?: ( + key: UserKeyDefinition, + userId: UserId, + newValue: unknown, + ) => Promise, + ) {} + + get(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState { + this.mock.get(userId, userKeyDefinition); + const cacheKey = this.cacheKey(userId, userKeyDefinition); + let result = this.states.get(cacheKey); + + if (result == null) { + result = this.buildFakeState(userId, userKeyDefinition); + this.states.set(cacheKey, result); + } + return result as SingleUserState; + } + + getFake( + userId: UserId, + userKeyDefinition: UserKeyDefinition, + { allowInit }: { allowInit: boolean } = { allowInit: true }, + ): FakeSingleUserState { + if (!allowInit && this.states.get(this.cacheKey(userId, userKeyDefinition)) == null) { + return null; + } + + return this.get(userId, userKeyDefinition) as FakeSingleUserState; + } + + mockFor( + userId: UserId, + userKeyDefinition: UserKeyDefinition, + initialValue?: T, + ): FakeSingleUserState { + const cacheKey = this.cacheKey(userId, userKeyDefinition); + if (!this.states.has(cacheKey)) { + this.states.set(cacheKey, this.buildFakeState(userId, userKeyDefinition, initialValue)); + } + return this.states.get(cacheKey) as FakeSingleUserState; + } + + private buildFakeState( + userId: UserId, + userKeyDefinition: UserKeyDefinition, + initialValue?: T, + ) { + const state = new FakeSingleUserState(userId, initialValue, async (...args) => { + await this.updateSyncCallback?.(userKeyDefinition, ...args); + }); + state.keyDefinition = userKeyDefinition; + return state; + } + + private cacheKey(userId: UserId, userKeyDefinition: UserKeyDefinition) { + return `${userKeyDefinitionCacheKey(userKeyDefinition)}_${userId}`; + } +} + +export class FakeActiveUserStateProvider implements ActiveUserStateProvider { + activeUserId$: Observable; + states: Map> = new Map(); + + constructor( + public accountServiceAccessor: MinimalAccountService, + readonly updateSyncCallback?: ( + key: UserKeyDefinition, + userId: UserId, + newValue: unknown, + ) => Promise, + ) { + this.activeUserId$ = accountServiceAccessor.activeAccount$.pipe(map((a) => a?.id)); + } + + get(userKeyDefinition: UserKeyDefinition): ActiveUserState { + const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition); + let result = this.states.get(cacheKey); + + if (result == null) { + result = this.buildFakeState(userKeyDefinition); + this.states.set(cacheKey, result); + } + return result as ActiveUserState; + } + + getFake( + userKeyDefinition: UserKeyDefinition, + { allowInit }: { allowInit: boolean } = { allowInit: true }, + ): FakeActiveUserState { + if (!allowInit && this.states.get(userKeyDefinitionCacheKey(userKeyDefinition)) == null) { + return null; + } + return this.get(userKeyDefinition) as FakeActiveUserState; + } + + mockFor(userKeyDefinition: UserKeyDefinition, initialValue?: T): FakeActiveUserState { + const cacheKey = userKeyDefinitionCacheKey(userKeyDefinition); + if (!this.states.has(cacheKey)) { + this.states.set(cacheKey, this.buildFakeState(userKeyDefinition, initialValue)); + } + return this.states.get(cacheKey) as FakeActiveUserState; + } + + private buildFakeState(userKeyDefinition: UserKeyDefinition, initialValue?: T) { + const state = new FakeActiveUserState( + this.accountServiceAccessor, + initialValue, + async (...args) => { + await this.updateSyncCallback?.(userKeyDefinition, ...args); + }, + ); + state.keyDefinition = userKeyDefinition; + return state; + } +} + +function userKeyDefinitionCacheKey(userKeyDefinition: UserKeyDefinition) { + return `${userKeyDefinition.fullName}_${userKeyDefinition.stateDefinition.defaultStorageLocation}`; +} + +export class FakeStateProvider implements StateProvider { + mock = mock(); + getUserState$(userKeyDefinition: UserKeyDefinition, userId?: UserId): Observable { + this.mock.getUserState$(userKeyDefinition, userId); + if (userId) { + return this.getUser(userId, userKeyDefinition).state$; + } + + return this.getActive(userKeyDefinition).state$; + } + + getUserStateOrDefault$( + userKeyDefinition: UserKeyDefinition, + config: { userId: UserId | undefined; defaultValue?: T }, + ): Observable { + const { userId, defaultValue = null } = config; + this.mock.getUserStateOrDefault$(userKeyDefinition, config); + if (userId) { + return this.getUser(userId, userKeyDefinition).state$; + } + + return this.activeUserId$.pipe( + take(1), + switchMap((userId) => + userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue), + ), + ); + } + + async setUserState( + userKeyDefinition: UserKeyDefinition, + value: T | null, + userId?: UserId, + ): Promise<[UserId, T | null]> { + await this.mock.setUserState(userKeyDefinition, value, userId); + if (userId) { + return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)]; + } else { + return await this.getActive(userKeyDefinition).update(() => value); + } + } + + getActive(userKeyDefinition: UserKeyDefinition): ActiveUserState { + return this.activeUser.get(userKeyDefinition); + } + + getGlobal(keyDefinition: KeyDefinition): GlobalState { + return this.global.get(keyDefinition); + } + + getUser(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState { + return this.singleUser.get(userId, userKeyDefinition); + } + + getDerived( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + return this.derived.get(parentState$, deriveDefinition, dependencies); + } + + constructor(private activeAccountAccessor: MinimalAccountService) {} + + private distributeSingleUserUpdate( + key: UserKeyDefinition, + userId: UserId, + newState: unknown, + ) { + if (this.activeUser.accountServiceAccessor.activeUserId === userId) { + const state = this.activeUser.getFake(key, { allowInit: false }); + state?.nextState(newState, { syncValue: false }); + } + } + + private distributeActiveUserUpdate( + key: UserKeyDefinition, + userId: UserId, + newState: unknown, + ) { + this.singleUser + .getFake(userId, key, { allowInit: false }) + ?.nextState(newState, { syncValue: false }); + } + + global: FakeGlobalStateProvider = new FakeGlobalStateProvider(); + singleUser: FakeSingleUserStateProvider = new FakeSingleUserStateProvider( + this.distributeSingleUserUpdate.bind(this), + ); + activeUser: FakeActiveUserStateProvider = new FakeActiveUserStateProvider( + this.activeAccountAccessor, + this.distributeActiveUserUpdate.bind(this), + ); + derived: FakeDerivedStateProvider = new FakeDerivedStateProvider(); + activeUserId$: Observable = this.activeUser.activeUserId$; +} + +export class FakeDerivedStateProvider implements DerivedStateProvider { + states: Map> = new Map(); + get( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + let result = this.states.get(deriveDefinition.buildCacheKey()) as DerivedState; + + if (result == null) { + result = new FakeDerivedState(parentState$, deriveDefinition, dependencies); + this.states.set(deriveDefinition.buildCacheKey(), result); + } + return result; + } +} diff --git a/libs/state-test-utils/src/fake-state.ts b/libs/state-test-utils/src/fake-state.ts new file mode 100644 index 00000000000..25aabcd9933 --- /dev/null +++ b/libs/state-test-utils/src/fake-state.ts @@ -0,0 +1,274 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Observable, ReplaySubject, concatMap, filter, firstValueFrom, map, timeout } from "rxjs"; + +import { + ActiveUserState, + CombinedState, + DeriveDefinition, + DerivedStateDependencies, + DerivedState, + GlobalState, + KeyDefinition, + SingleUserState, + StateUpdateOptions, + UserKeyDefinition, + activeMarker, +} from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; + +import { MinimalAccountService } from "./fake-state-provider"; + +const DEFAULT_TEST_OPTIONS: StateUpdateOptions = { + shouldUpdate: () => true, + combineLatestWith: null, + msTimeout: 10, +}; + +function populateOptionsWithDefault( + options: StateUpdateOptions, +): StateUpdateOptions { + return { + ...DEFAULT_TEST_OPTIONS, + ...options, + }; +} + +export class FakeGlobalState implements GlobalState { + // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup + stateSubject = new ReplaySubject(1); + + constructor(initialValue?: T) { + this.stateSubject.next(initialValue ?? null); + } + + nextState(state: T) { + this.stateSubject.next(state); + } + + async update( + configureState: (state: T, dependency: TCombine) => T, + options?: StateUpdateOptions, + ): Promise { + options = populateOptionsWithDefault(options); + if (this.stateSubject["_buffer"].length == 0) { + // throw a more helpful not initialized error + throw new Error( + "You must initialize the state with a value before calling update. Try calling `stateSubject.next(initialState)` before calling update", + ); + } + const current = await firstValueFrom(this.state$.pipe(timeout(100))); + const combinedDependencies = + options.combineLatestWith != null + ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) + : null; + if (!options.shouldUpdate(current, combinedDependencies)) { + return current; + } + const newState = configureState(current, combinedDependencies); + this.stateSubject.next(newState); + this.nextMock(newState); + return newState; + } + + /** Tracks update values resolved by `FakeState.update` */ + nextMock = jest.fn(); + + get state$() { + return this.stateSubject.asObservable(); + } + + private _keyDefinition: KeyDefinition | null = null; + get keyDefinition() { + if (this._keyDefinition == null) { + throw new Error( + "Key definition not yet set, usually this means your sut has not asked for this state yet", + ); + } + return this._keyDefinition; + } + set keyDefinition(value: KeyDefinition) { + this._keyDefinition = value; + } +} + +export class FakeSingleUserState implements SingleUserState { + // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup + stateSubject = new ReplaySubject<{ + syncValue: boolean; + combinedState: CombinedState; + }>(1); + + state$: Observable; + combinedState$: Observable>; + + constructor( + readonly userId: UserId, + initialValue?: T, + updateSyncCallback?: (userId: UserId, newValue: T) => Promise, + ) { + // Inform the state provider of updates to keep active user states in sync + this.stateSubject + .pipe( + filter((next) => next.syncValue), + concatMap(async ({ combinedState }) => { + await updateSyncCallback?.(...combinedState); + }), + ) + .subscribe(); + this.nextState(initialValue ?? null, { syncValue: initialValue != null }); + + this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState)); + this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); + } + + nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) { + this.stateSubject.next({ + syncValue, + combinedState: [this.userId, state], + }); + } + + async update( + configureState: (state: T | null, dependency: TCombine) => T | null, + options?: StateUpdateOptions, + ): Promise { + options = populateOptionsWithDefault(options); + const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout))); + const combinedDependencies = + options.combineLatestWith != null + ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) + : null; + if (!options.shouldUpdate(current, combinedDependencies)) { + return current; + } + const newState = configureState(current, combinedDependencies); + this.nextState(newState); + this.nextMock(newState); + return newState; + } + + /** Tracks update values resolved by `FakeState.update` */ + nextMock = jest.fn(); + private _keyDefinition: UserKeyDefinition | null = null; + get keyDefinition() { + if (this._keyDefinition == null) { + throw new Error( + "Key definition not yet set, usually this means your sut has not asked for this state yet", + ); + } + return this._keyDefinition; + } + set keyDefinition(value: UserKeyDefinition) { + this._keyDefinition = value; + } +} +export class FakeActiveUserState implements ActiveUserState { + [activeMarker]: true; + + // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup + stateSubject = new ReplaySubject<{ + syncValue: boolean; + combinedState: CombinedState; + }>(1); + + state$: Observable; + combinedState$: Observable>; + + constructor( + private activeAccountAccessor: MinimalAccountService, + initialValue?: T, + updateSyncCallback?: (userId: UserId, newValue: T) => Promise, + ) { + // Inform the state provider of updates to keep single user states in sync + this.stateSubject.pipe( + filter((next) => next.syncValue), + concatMap(async ({ combinedState }) => { + await updateSyncCallback?.(...combinedState); + }), + ); + this.nextState(initialValue ?? null, { syncValue: initialValue != null }); + + this.combinedState$ = this.stateSubject.pipe(map((v) => v.combinedState)); + this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); + } + + nextState(state: T | null, { syncValue }: { syncValue: boolean } = { syncValue: true }) { + this.stateSubject.next({ + syncValue, + combinedState: [this.activeAccountAccessor.activeUserId, state], + }); + } + + async update( + configureState: (state: T | null, dependency: TCombine) => T | null, + options?: StateUpdateOptions, + ): Promise<[UserId, T | null]> { + options = populateOptionsWithDefault(options); + const current = await firstValueFrom(this.state$.pipe(timeout(options.msTimeout))); + const combinedDependencies = + options.combineLatestWith != null + ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) + : null; + if (!options.shouldUpdate(current, combinedDependencies)) { + return [this.activeAccountAccessor.activeUserId, current]; + } + const newState = configureState(current, combinedDependencies); + this.nextState(newState); + this.nextMock([this.activeAccountAccessor.activeUserId, newState]); + return [this.activeAccountAccessor.activeUserId, newState]; + } + + /** Tracks update values resolved by `FakeState.update` */ + nextMock = jest.fn(); + + private _keyDefinition: UserKeyDefinition | null = null; + get keyDefinition() { + if (this._keyDefinition == null) { + throw new Error( + "Key definition not yet set, usually this means your sut has not asked for this state yet", + ); + } + return this._keyDefinition; + } + set keyDefinition(value: UserKeyDefinition) { + this._keyDefinition = value; + } +} + +export class FakeDerivedState + implements DerivedState +{ + // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup + stateSubject = new ReplaySubject(1); + + constructor( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ) { + parentState$ + .pipe( + concatMap(async (v) => { + const newState = deriveDefinition.derive(v, dependencies); + if (newState instanceof Promise) { + return newState; + } + return Promise.resolve(newState); + }), + ) + .subscribe((newState) => { + this.stateSubject.next(newState); + }); + } + + forceValue(value: TTo): Promise { + this.stateSubject.next(value); + return Promise.resolve(value); + } + forceValueMock = this.forceValue as jest.MockedFunction; + + get state$() { + return this.stateSubject.asObservable(); + } +} diff --git a/libs/state-test-utils/src/index.ts b/libs/state-test-utils/src/index.ts new file mode 100644 index 00000000000..27c5478870a --- /dev/null +++ b/libs/state-test-utils/src/index.ts @@ -0,0 +1,2 @@ +export * from "./fake-state"; +export * from "./fake-state-provider"; diff --git a/libs/state-test-utils/src/state-test-utils.spec.ts b/libs/state-test-utils/src/state-test-utils.spec.ts new file mode 100644 index 00000000000..9b35ba0e8eb --- /dev/null +++ b/libs/state-test-utils/src/state-test-utils.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("state-test-utils", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/state-test-utils/tsconfig.eslint.json b/libs/state-test-utils/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/state-test-utils/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/state-test-utils/tsconfig.json b/libs/state-test-utils/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/state-test-utils/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/state-test-utils/tsconfig.lib.json b/libs/state-test-utils/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/state-test-utils/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/state-test-utils/tsconfig.spec.json b/libs/state-test-utils/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/state-test-utils/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/state/README.md b/libs/state/README.md new file mode 100644 index 00000000000..8b9d0786129 --- /dev/null +++ b/libs/state/README.md @@ -0,0 +1,679 @@ +# `@bitwarden/state` + +# State Provider Framework + +The state provider framework was designed for the purpose of allowing state to be owned by domains +but also to enforce good practices, reduce boilerplate around account switching, and provide a +trustworthy observable stream of that state. + +## APIs + +- [Storage definitions](#storage-definitions) + - [`StateDefinition`](#statedefinition) + - [`KeyDefinition` & `UserKeyDefinition`](#keydefinition-and-userkeydefinition) +- [`StateProvider`](#stateprovider) +- [`Update`](#updating-state-with-update) +- [`GlobalState`](#globalstatet) +- [`SingleUserState`](#singleuserstatet) +- [`ActiveUserState`](#activeuserstatet) + +### Storage definitions + +In order to store and retrieve data, we need to have constant keys to reference storage locations. +This includes a storage medium (disk or memory) and a unique key. `StateDefinition` and +`KeyDefinition` classes allow for reasonable reuse of partial namespaces while also enabling +expansion to precise keys. They exist to help minimize the potential of overlaps in a distributed +storage framework. + +> [!WARNING] +> Once you have created the definitions you need to take extreme caution when changing any part of the +> namespace. If you change the name of a `StateDefinition` pointing at `"disk"` without also migrating +> data from the old name to the new name you will lose data. Data pointing at `"memory"` can have its +> name changed. + +#### `StateDefinition` + +> [!NOTE] +> Secure storage is not currently supported as a storage location in the State Provider Framework. For +> now, don't migrate data that is stored in secure storage but please contact the Platform team when +> you have data you wanted to migrate so we can prioritize a long-term solution. If you need new data +> in secure storage, use `StateService` for now. + +`StateDefinition` is a simple API but a very core part of making the State Provider Framework work +smoothly. It defines a storage location and top-level namespace for storage. Teams will interact +with it only in a single `state-definitions.ts` file in the +[`clients`](https://github.com/bitwarden/clients) repository. This file is located under Platform +team code ownership but teams are expected to create edits to it. A team will edit this file to +include a line such as: + +```typescript +export const MY_DOMAIN_DISK = new StateDefinition("myDomain", "disk"); +``` + +The first argument to the `StateDefinition` constructor is expected to be a human readable, +camelCase-formatted name for your domain or state area. The second argument will either be the +string literal `"disk"` or `"memory"` dictating where all the state using this `StateDefinition` +should be stored. + +The Platform team is responsible for reviewing all new and updated entries in this file and makes +sure that there are no duplicate entries containing the same state name and state location. Teams +are able to have the same state name used for both `"disk"` and `"memory"` locations. Tests are +included to ensure this uniqueness and core naming guidelines so teams can ensure a review for a new +`StateDefinition` entry is done promptly and with very few surprises. + +##### Client-specific storage locations + +An optional third parameter to the `StateDefinition` constructor is provided if you need to specify +client-specific storage location for your state. + +This will most commonly be used to handle the distinction between session and local storage on the +web client. The default `"disk"` storage for the web client is session storage, and local storage +can be specified by defining your state as: + +```typescript +export const MY_DOMAIN_DISK = new StateDefinition("myDomain", "disk", { web: "disk-local" }); +``` + +#### `KeyDefinition` and `UserKeyDefinition` + +`KeyDefinition` and `UserKeyDefinition` build on the [`StateDefinition`](#statedefinition), +specifying a single element of state data within the `StateDefinition`. + +The framework provides both `KeyDefinition` and `UserKeyDefinition` for teams to use. Use +`UserKeyDefinition` for state scoped to a user and `KeyDefinition` for user-independent state. These +will be consumed via the [`SingleUserState`](#singleuserstatet) or +[`ActiveUserState`](#activeuserstatet) within your consuming services and components. The +`UserKeyDefinition` extends the `KeyDefinition` and provides a way to specify how the state will be +cleaned up on specific user account actions. + +`KeyDefinition`s and `UserKeyDefinition`s can also be instantiated in your own team's code. This +might mean creating it in the same file as the service you plan to consume it or you may want to +have a single `key-definitions.ts` file that contains all the entries for your team. Some example +instantiations are: + +```typescript +const MY_DOMAIN_DATA = new UserKeyDefinition(MY_DOMAIN_DISK, "data", { + // convert to your data from serialized representation `{ foo: string }` to fully-typed `MyState` + deserializer: (jsonData) => MyState.fromJSON(jsonData), + clearOn: ["logout"], // can be lock, logout, both, or an empty array +}); + +// Or if your state is an array, use the built-in helper +const MY_DOMAIN_DATA: UserKeyDefinition = UserKeyDefinition.array( + MY_DOMAIN_DISK, + "data", + { + deserializer: (jsonDataElement) => MyState.fromJSON(jsonDataElement), // provide a deserializer just for the element of the array + }, + { + clearOn: ["logout"], + }, +); + +// record +const MY_DOMAIN_DATA: UserKeyDefinition> = + KeyDefinition.record(MY_DOMAIN_DISK, "data", { + deserializer: (jsonDataValue) => MyState.fromJSON(jsonDataValue), // provide a deserializer just for the value in each key-value pair + clearOn: ["logout"], + }); +``` + +The arguments for defining a `KeyDefinition` or `UserKeyDefinition` are: + +| Argument | Usage | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `stateDefinition` | The `StateDefinition` to which that this key belongs | +| `key` | A human readable, camelCase-formatted name for the key definition. This name should be unique amongst all other `KeyDefinition`s or `UserKeyDefinition`s that consume the same `StateDefinition`. | +| `options` | An object of type [`KeyDefinitionOptions`](#key-definition-options) or [`UserKeyDefinitionOptions`](#key-definition-options), which defines the behavior of the key. | + +> [!WARNING] +> It is the responsibility of the team to ensure the uniqueness of the `key` within a +> `StateDefinition`. As such, you should never consume the `StateDefinition` of another team in your +> own key definition. + +##### Key Definition Options + +| Option | Required? | Usage | +| ---------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deserializer` | Yes | Takes a method that gives you your state in it's JSON format and makes you responsible for converting that into JSON back into a full JavaScript object, if you choose to use a class to represent your state that means having its prototype and any method you declare on it. If your state is a simple value like `string`, `boolean`, `number`, or arrays of those values, your deserializer can be as simple as `data => data`. But, if your data has something like `Date`, which gets serialized as a string you will need to convert that back into a `Date` like: `data => new Date(data)`. | +| `cleanupDelayMs` | No | Takes a number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. Defaults to 1000ms. When this is set to 0, no `share()` is used on the underlying observable stream. | +| `clearOn` | Yes, for `UserKeyDefinition` | An additional parameter provided for `UserKeyDefinition` **only**, which allows specification of the user account `ClearEvent`s that will remove the piece of state from persistence. The available values for `ClearEvent` are `logout`, `lock`, or both. An empty array should be used if the state should not ever be removed (e.g. for settings). | + +### `StateProvider` + +`StateProvider` is an injectable service that includes four methods for getting state, expressed in +the type definition below: + +```typescript +interface StateProvider { + getGlobal(keyDefinition: KeyDefinition): GlobalState; + getUser(userId: UserId, keyDefinition: KeyDefinition): SingleUserState; + getDerived( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependenciess: TDeps, + ); + // Deprecated, do not use. + getActive(keyDefinition: KeyDefinition): ActiveUserState; +} +``` + +These methods are helpers for invoking their more modular siblings `SingleUserStateProvider.get`, +`GlobalStateProvider.get`, `DerivedStateProvider.get`, and `ActiveUserStateProvider.get`. These siblings +can all be injected into your service as well. If you prefer thin dependencies over the slightly +larger changeset required, you can absolutely make use of the more targeted providers. + +> [!WARNING] > `ActiveUserState` is deprecated +> +> The `ActiveUserStateProvider.get` and its helper `getActive` are deprecated. See +> [here](#should-i-use-activeuserstate) for details. + +You will most likely use `StateProvider` in a domain service that is responsible for managing the +state, with the state values being scoped to a single user. The `StateProvider` should be injected +as a `private` member into the class, with the `getUser()` helper method to retrieve the current +state value for the provided `userId`. See a simple example below: + +```typescript +import { DOMAIN_USER_STATE } from "../key-definitions"; + +class DomainService { + constructor(private stateProvider: StateProvider) {} + + private getStateValue(userId: UserId): SingleUserState { + return this.stateProvider.getUser(userId, DOMAIN_USER_STATE); + } + + async clearStateValue(userId: UserId): Promise { + await this.stateProvider.getUser(userId, DOMAIN_USER_STATE).update((state) => null); + } +} +``` + +Each of the methods on the `StateProvider` will return an object typed based on the state requested: + +#### `GlobalState` + +`GlobalState` is an object to help you maintain and view the state of global-scoped storage. You +can see the type definition of the API on `GlobalState` below: + +```typescript +interface GlobalState { + state$: Observable; +} +``` + +The `state$` property provides you with an `Observable` that can be subscribed to. +`GlobalState.state$` will emit when the chosen storage location emits an update to the state +defined by the corresponding `KeyDefinition`. + +#### `SingleUserState` + +`SingleUserState` behaves very similarly to `GlobalState`, but for state that is defined as +user-scoped with a `UserKeyDefinition`. The `UserId` for the state's user exposed as a `readonly` +member. + +The `state$` property provides you with an `Observable` that can be subscribed to. +`SingleUserState.state$` will emit when the chosen storage location emits an update to the state +defined by the corresponding `UserKeyDefinition` for the requested `userId`. + +> [!NOTE] +> Updates to `SingleUserState` or `ActiveUserState` handling the same `KeyDefinition` will cause each +> other to emit on their `state$` observables if the `userId` handled by the `SingleUserState` happens +> to be active at the time of the update. + +### `DerivedState` + +For details on how to use derived state, see [Derived State](#derived-state). + +### `ActiveUserState` + +> [!WARNING] > `ActiveUserState` has race condition problems. Do not add usages and consider transitioning your +> code to SingleUserState instead. [Read more.](#should-i-use-activeuserstate) + +`ActiveUserState` is an object to help you maintain and view the state of the currently active +user. If the currently-active user changes, like through account switching, the data this object +represents will change along with it. + +### Updating state with `update` + +The update method has options defined as follows: + +```typescript +{ActiveUser|SingleUser|Global}State { + // ... rest of type left out for brevity + update(updateState: (state: T, dependency: TCombine) => T, options?: StateUpdateOptions); +} + +type StateUpdateOptions = { + shouldUpdate?: (state: T, dependency: TCombine) => boolean; + combineLatestWith?: Observable; + msTimeout?: number +} +``` + +> [!WARNING] > `firstValueFrom()` and state updates +> +> A usage pattern of updating state and then immediately requesting a value through `firstValueFrom()` > **will not always result in the updated value being returned**. This is because we cannot guarantee +> that the update has taken place before the `firstValueFrom()` executes, in which case the previous +> (cached) value of the observable will be returned. +> +> Use of `firstValueFrom()` should be avoided. If you find yourself trying to use `firstValueFrom()`, +> consider propagating the underlying observable instead of leaving reactivity. +> +> If you do need to obtain the result of an update in a non-reactive way, you should use the result +> returned from the `update()` method. The `update()` will return the value that will be persisted +> to +> state, after any `shouldUpdate()` filters are applied. + +#### Using `shouldUpdate` to filter unnecessary updates + +We recommend using `shouldUpdate` when possible. This will avoid unnecessary I/O for redundant +updates and avoid an unnecessary emission of `state$`. The `shouldUpdate` method gives you in its +first parameter the value of state before any change has been made, and the dependency you have, +optionally, provided through `combineLatestWith`. + +If your state is a simple JavaScript primitive type, this can be done with the strict equality +operator (`===`): + +```typescript +const USES_KEYCONNECTOR: UserKeyDefinition = ...; + +async setUsesKeyConnector(value: boolean, userId: UserId) { + // Only do the update if the current value saved in state + // differs in equality of the incoming value. + await this.stateProvider.getUser(userId, USES_KEYCONNECTOR).update( + currentValue => currentValue !== value + ); +} +``` + +For more complex state, implementing a custom equality operator is recommended. It's important that +if you implement an equality function that you then negate the output of that function for use in +`shouldUpdate()` since you will want to go through the update when they are NOT the same value. + +```typescript +type Cipher = { id: string, username: string, password: string, revisionDate: Date }; +const LAST_USED_CIPHER: UserKeyDefinition = ...; + +async setLastUsedCipher(lastUsedCipher: Cipher | null, userId: UserId) { + await this.stateProvider.getUser(userId, LAST_USED_CIPHER).update( + currentValue => !this.areEqual(currentValue, lastUsedCipher) + ); +} + +areEqual(a: Cipher | null, b: Cipher | null) { + if (a == null) { + return b == null; + } + + if (b == null) { + return false; + } + + // Option one - Full equality, comparing every property for value equality + return a.id === b.id && + a.username === b.username && + a.password === b.password && + a.revisionDate === b.revisionDate; + + // Option two - Partial equality based on requirement that any update would + // bump the revision date. + return a.id === b.id && a.revisionDate === b.revisionDate; +} +``` + +#### Using `combineLatestWith` option to control updates + +The `combineLatestWith` option can be useful when updates to your state depend on the data from +another stream of data. + +For example, if we were asked to set a `userId` to the active account only if that `userId` exists +in our known accounts list, an initial approach could do the check as follows: + +```typescript +const accounts = await firstValueFrom(this.accounts$); +if (accounts?.[userId] == null) { + throw new Error(); +} +await this.activeAccountIdState.update(() => userId); +``` + +However, this implementation has a few subtle issues that the `combineLatestWith` option addresses: + +- The use of `firstValueFrom` with no `timeout`. Behind the scenes we enforce that the observable + given to `combineLatestWith` will emit a value in a timely manner, in this case a `1000ms` + timeout, but that number is configurable through the `msTimeout` option. +- We don't guarantee that your `updateState` callback is called the instant that the `update` method + is called. We do, however, promise that it will be called before the returned promise resolves or + rejects. This may be because we have a lock on the current storage key. No such locking mechanism + exists today but it may be implemented in the future. As such, it is safer to use + `combineLatestWith` because the data is more likely to retrieved closer to when it needs to be + evaluated. + +We recommend instead using the `combineLatestWith` option within the `update()` method to address +these issues: + +```typescript +await this.activeAccountIdState.update( + (_, accounts) => { + if (userId == null) { + // indicates no account is active + return null; + } + if (accounts?.[userId] == null) { + throw new Error("Account does not exist"); + } + return userId; + }, + { + combineLatestWith: this.accounts$, + shouldUpdate: (id) => { + // update only if userId changes + return id !== userId; + }, + }, +); +``` + +`combineLatestWith` can also be used to handle updates where either the new value depends on `async` +code or you prefer to handle generation of a new value in an observable transform flow: + +```typescript +const state = this.stateProvider.get(userId, SavedFiltersStateDefinition); + +const transform: OperatorFunction = pipe( + // perform some transforms + map((value) => value), +); + +async function transformAsync(value: T) { + return Promise.resolve(value); +} + +await state.update((_, newState) => newState, { + // Actual processing to generate the new state is done here + combineLatestWith: state.state$.pipe( + mergeMap(async (old) => { + return await transformAsync(old); + }), + transform, + ), + shouldUpdate: (oldState, newState) => !areEqual(oldState, newState), +}); +``` + +#### Conditions under which emission not guaranteed after `update()` + +The `state$` property is **not guaranteed** to emit a value after an update where the value would +conventionally be considered equal. It _is_ emitted in many cases but not guaranteed. The reason for +this is because we leverage on platform APIs to initiate state emission. In particular, we use the +`chrome.storage.{area}.onChanged` event to facilitate the `state$` observable in the extension +client, and Chrome won’t emit a change if the value is the same. You can easily see this with the +below instructions: + +``` +chrome.storage.local.onChanged.addListener(console.log); +chrome.storage.local.set({ key: true }); +chrome.storage.local.set({ key: true }); +``` + +The second instance of calling `set` will not log a changed event. As a result, the `state$` relying +on this value will not emit. Due to nuances like this, using a `StateProvider` as an event stream is +discouraged, and we recommend using [`MessageSender`](https://github.com/bitwarden/clients/blob/main/libs/messaging/src/message.sender.ts) for events that you always want sent to +subscribers. + +## Testing + +Testing business logic with data and observables can sometimes be cumbersome. To help make that a +little easier there are a suite of helpful "fakes" that can be used instead of traditional "mocks". +Now instead of calling `mock()` into your service you can instead use +`new FakeStateProvider()`. + +`FakeStateProvider` exposes the specific provider's fakes as properties on itself. Each of those +specific providers gives a method `getFake` that allows you to get the fake version of state that +you can control and `expect`. + +## Migrating + +Migrating data to state providers is incredibly similar to migrating data in general. You create +your own class that extends `Migrator`. That will require you to implement your own +`migrate(migrationHelper: MigrationHelper)` method. `MigrationHelper` already includes methods like +`get` and `set` for getting and settings value to storage by their string key. There are also +methods for getting and setting using your `KeyDefinition` or `KeyDefinitionLike` object to and from +user and global state. + +For examples of migrations, you can reference the +[existing](https://github.com/bitwarden/clients/tree/main/libs/common/src/state-migrations/migrations) +migrations list. + +## FAQ + +### Do I need to have my own in-memory cache? + +If you previously had a memory cache that exactly represented the data you stored on disk (not +decrypted for example), then you likely don't need that anymore. All the `*State` classes maintain +an in memory cache of the last known value in state for as long as someone is subscribed to the +data. The cache is cleared after 1000ms of no one subscribing to the state though. If you know you +have sporadic subscribers and a high cost of going to disk you may increase that time using the +`cleanupDelayMs` on `KeyDefinitionOptions`. + +### I store my data as a Record / Map but expose it as an array -- what should I do? + +Give `KeyDefinition` generic the record shape you want, or even use the static `record` helper +method. Then to convert that to an array that you expose just do a simple +`.pipe(map(data => this.transform(data)))` to convert that to the array you want to expose. + +### Why `KeyDefinitionLike`? + +`KeyDefinitionLike` exists to help you create a frozen-in-time version of your `KeyDefinition`. This +is helpful in state migrations so that you don't have to import something from the greater +application which is something that should rarely happen. + +### When does my deserializer run? + +The `deserialier` that you provide in the `KeyDefinitionOptions` is used whenever your state is +retrieved from a storage service that stores its data as JSON. All disk storage services serialize +data into JSON but memory storage differs in this area across platforms. That's why it's imperative +to include a high quality JSON deserializer even if you think your object will only be stored in +memory. This can mean you might be able to drop the `*Data` class pattern for your code. Since the +`*Data` class generally represented the JSON safe version of your state which we now do +automatically through the `Jsonify` given to your in your `deserializer` method. + +### Should I use `ActiveUserState`? + +Probably not, `ActiveUserState` is either currently in the process of or already completed the +removal of its `update` method. This will effectively make it readonly, but you should consider +maybe not even using it for reading either. `update` is actively bad, while reading is just not as +dynamic of a API design. + +Take the following example: + +```typescript +private folderState: ActiveUserState> + +renameFolder(folderId: string, newName: string) { + // Get state + const folders = await firstValueFrom(this.folderState.state$); + // Mutate state + folders[folderId].name = await encryptString(newName); + // Save state + await this.folderState.update(() => folders); +} +``` + +You can imagine a scenario where the active user changes between the read and the write. This would +be a big problem because now user A's folders was stored in state for user B. By taking a user id +and utilizing `SingleUserState` instead you can avoid this problem by passing ensuring both +operation happen for the same user. This is obviously an extreme example where the point between the +read and write is pretty minimal but there are places in our application where the time between is +much larger. Maybe information is read out and placed into a form for editing and then the form can +be submitted to be saved. + +The first reason for why you maybe shouldn't use `ActiveUserState` for reading is for API +flexibility. Even though you may not need an API to return the data of a non-active user right now, +you or someone else may want to. If you have a method that takes the `UserId` then it can be +consumed by someone passing in the active user or by passing a non-active user. You can now have a +single API that is useful in multiple scenarios. + +The other reason is so that you can more cleanly switch users to new data when multiple streams are +in play. Consider the following example: + +```typescript +const view$ = combineLatest([ + this.folderService.activeUserFolders$, + this.cipherService.activeUserCiphers$, +]).pipe(map(([folders, ciphers]) => buildView(folders, ciphers))); +``` + +Since both are tied to the active user, you will get one emission when first subscribed to and +during an account switch, you will likely get TWO other emissions. One for each, inner observable +reacting to the new user. This could mean you try to combine the folders and ciphers of two +accounts. This is ideally not a huge issue because the last emission will have the same users data +but it's not ideal, and easily avoidable. Instead you can write it like this: + +```typescript +const view$ = this.accountService.activeAccount$.pipe( + switchMap((account) => { + if (account == null) { + throw new Error("This view should only be viewable while there is an active user."); + } + + return combineLatest([ + this.folderService.userFolders$(account.id), + this.cipherService.userCiphers$(account.id), + ]); + }), + map(([folders, ciphers]) => buildView(folders, ciphers)), +); +``` + +You have to write a little more code but you do a few things that might force you to think about the +UX and rules around when this information should be viewed. With `ActiveUserState` it will simply +not emit while there is no active user. But with this, you can choose what to do when there isn't an +active user and you could simple add a `first()` to the `activeAccount$` pipe if you do NOT want to +support account switching. An account switch will also emit the `combineLatest` information a single +time and the info will be always for the same account. + +## Structure + +![State Diagram](state_diagram.svg) + +## Derived State + +It is common to need to cache the result of expensive work that does not represent true alterations +in application state. Derived state exists to store this kind of data in memory and keep it up to +date when the underlying observable state changes. + +## `DeriveDefinition` + +Derived state has all of the same issues with storage and retrieval that normal state does. Similar +to `KeyDefinition`, derived state depends on `DeriveDefinition`s to define magic string keys to +store and retrieve data from a cache. Unlike normal state, derived state is always stored in memory. +It still takes a `StateDefinition`, but this is used only to define a namespace for the derived +state, the storage location is ignored. _This can lead to collisions if you use the same key for two +different derived state definitions in the same namespace._ + +Derive definitions can be created in two ways: + + + +```typescript +new DeriveDefinition(STATE_DEFINITION, "uniqueKey", _DeriveOptions_); + +// or + +const keyDefinition: KeyDefinition; +DeriveDefinition.from(keyDefinition, _DeriveOptions_); +``` + +The first allows building from basic building blocks, the second recognizes that derived state is +often built from existing state and allows you to create a definition from an existing +`KeyDefinition`. The resulting `DeriveDefinition` will have the same state namespace, key, and +`TFrom` type as the `KeyDefinition` it was built from. + +### Type Parameters + +`DeriveDefinition`s have three type parameters: + +- `TFrom`: The type of the state that the derived state is built from. +- `TTo`: The type of the derived state. +- `TDeps`: defines the dependencies required to derive the state. This is further discussed in + [Derive Definition Options](#derivedefinitionoptions). + +### `DeriveDefinitionOptions` + +[The `DeriveDefinition` section](#deriveDefinitionFactories) specifies a third parameter as +`_DeriveOptions_`, which is used to fully specify the way to transform `TFrom` to `TTo`. + +- `deserializer` - For the same reasons as [Key Definition Options](#keydefinitionoptions), + `DeriveDefinition`s require have a `deserializer` function that is used to convert the stored data + back into the `TTo` type. +- `derive` - A function that takes the current state and returns the derived state. This function + takes two parameters: + - `from` - The latest value of the parent state. + - `deps` - dependencies used to instantiate the derived state. These are provided when the + `DerivedState` class is instantiated. This object should contain all of the application runtime + dependencies for transform the from parent state to the derived state. +- `cleanupDelayMs` (optional) - Takes the number of milliseconds to wait before cleaning up the + state after the last subscriber unsubscribes. Defaults to 1000ms. If you have a particularly + expensive operation, such as decryption of a vault, it may be worth increasing this value to avoid + unnecessary recomputation. + +Specifying dependencies required for your `derive` function is done through the type parameters on +`DerivedState`. + +```typescript +new DerivedState(); +``` + +would require a `deps` object with an `example` property of type `Dependency` to be passed to any +`DerivedState` configured to use the `DerivedDefinition`. + +> [!WARNING] +> Both `derive` and `deserializer` functions should take null inputs into consideration. Both parent +> state and stored data for deserialization can be `null` or `undefined`. + +## `DerivedStateProvider` + +The `DerivedState` class has a purpose-built provider which instantiates the +correct `DerivedState` implementation for a given application context. These derived states are +cached within a context, so that multiple instances of the same derived state will share the same +underlying cache, based on the `DeriveDefinition` used to create them. + +Instantiating a `DerivedState` instance requires an observable parent state, the derive definition, +and an object containing the dependencies defined in the `DeriveDefinition` type parameters. + +```typescript +interface DerivedStateProvider { + get: ( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ) => DerivedState; +} +``` + +> [!TIP] +> Any observable can be used as the parent state. If you need to perform some kind of work on data +> stored to disk prior to sending to your `derive` functions, that is supported. + +## `DerivedState` + +`DerivedState` is intended to be built with a provider rather than directly instantiated. The +interface consists of two items: + +```typescript +interface DerivedState { + state$: Observable; + forceValue(value: T): Promise; +} +``` + +- `state$` - An observable that emits the current value of the derived state and emits new values + whenever the parent state changes. +- `forceValue` - A function that takes a value and immediately sets `state$` to that value. This is + useful for clearing derived state from memory without impacting the parent state, such as during + logout. + +> [!NOTE] > `forceValue` forces `state$` _once_. It does not prevent the derived state from being recomputed +> when the parent state changes. diff --git a/libs/state/eslint.config.mjs b/libs/state/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/state/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/state/jest.config.js b/libs/state/jest.config.js new file mode 100644 index 00000000000..1ff9b60098c --- /dev/null +++ b/libs/state/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + displayName: "state", + preset: "../../jest.preset.js", + testEnvironment: "node", + transform: { + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }], + }, + moduleFileExtensions: ["ts", "js", "html"], + coverageDirectory: "../../coverage/libs/state", +}; diff --git a/libs/state/package.json b/libs/state/package.json new file mode 100644 index 00000000000..2c25647e4e3 --- /dev/null +++ b/libs/state/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/state", + "version": "0.0.1", + "description": "Centralized application state management", + "private": true, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0", + "author": "platform" +} diff --git a/libs/state/project.json b/libs/state/project.json new file mode 100644 index 00000000000..85313ddf14d --- /dev/null +++ b/libs/state/project.json @@ -0,0 +1,33 @@ +{ + "name": "state", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/state/src", + "projectType": "library", + "tags": ["scope:state", "type:lib", "!dependsOn:common"], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/state", + "main": "libs/state/src/index.ts", + "tsConfig": "libs/state/tsconfig.lib.json", + "assets": ["libs/state/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/state/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/state/jest.config.js" + } + } + } +} diff --git a/libs/state/src/core/active-user.accessor.ts b/libs/state/src/core/active-user.accessor.ts new file mode 100644 index 00000000000..8ee2d53a93f --- /dev/null +++ b/libs/state/src/core/active-user.accessor.ts @@ -0,0 +1,11 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +export abstract class ActiveUserAccessor { + /** + * Returns a stream of the current active user for the application. The stream either emits the user id for that account + * or returns null if there is no current active user. + */ + abstract activeUserId$: Observable; +} diff --git a/libs/common/src/platform/state/derive-definition.spec.ts b/libs/state/src/core/derive-definition.spec.ts similarity index 100% rename from libs/common/src/platform/state/derive-definition.spec.ts rename to libs/state/src/core/derive-definition.spec.ts diff --git a/libs/state/src/core/derive-definition.ts b/libs/state/src/core/derive-definition.ts new file mode 100644 index 00000000000..947a25e2679 --- /dev/null +++ b/libs/state/src/core/derive-definition.ts @@ -0,0 +1,197 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Jsonify } from "type-fest"; + +import { UserId } from "@bitwarden/user-core"; + +import { DerivedStateDependencies, StorageKey } from "../types/state"; + +import { KeyDefinition } from "./key-definition"; +import { StateDefinition } from "./state-definition"; +import { UserKeyDefinition } from "./user-key-definition"; + +declare const depShapeMarker: unique symbol; +/** + * A set of options for customizing the behavior of a {@link DeriveDefinition} + */ +type DeriveDefinitionOptions = { + /** + * A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable + * and the resulting value will be emitted from the derived state observable. + * + * @param from Populated with the latest emission from the parent state observable. + * @param deps Populated with the dependencies passed into the constructor of the derived state. + * These are constant for the lifetime of the derived state. + * @returns The derived state value or a Promise that resolves to the derived state value. + */ + derive: (from: TFrom, deps: TDeps) => TTo | Promise; + /** + * A function to use to safely convert your type from json to your expected type. + * + * **Important:** Your data may be serialized/deserialized at any time and this + * callback needs to be able to faithfully re-initialize from the JSON object representation of your type. + * + * @param jsonValue The JSON object representation of your state. + * @returns The fully typed version of your state. + */ + deserializer: (serialized: Jsonify) => TTo; + /** + * An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies + * and the values are the types of the dependencies. + * + * for example: + * ``` + * { + * myService: MyService, + * myOtherService: MyOtherService, + * } + * ``` + */ + [depShapeMarker]?: TDeps; + /** + * The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. + * Defaults to 1000ms. + */ + cleanupDelayMs?: number; + /** + * Whether or not to clear the derived state when cleanup occurs. Defaults to true. + */ + clearOnCleanup?: boolean; +}; + +/** + * DeriveDefinitions describe state derived from another observable, the value type of which is given by `TFrom`. + * + * The StateDefinition is used to describe the domain of the state, and the DeriveDefinition + * sub-divides that domain into specific keys. These keys are used to cache data in memory and enables derived state to + * be calculated once regardless of multiple execution contexts. + */ + +export class DeriveDefinition { + /** + * Creates a new instance of a DeriveDefinition. Derived state is always stored in memory, so the storage location + * defined in @link{StateDefinition} is ignored. + * + * @param stateDefinition The state definition for which this key belongs to. + * @param uniqueDerivationName The name of the key, this should be unique per domain. + * @param options A set of options to customize the behavior of {@link DeriveDefinition}. + * @param options.derive A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable + * and the resulting value will be emitted from the derived state observable. + * @param options.cleanupDelayMs The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. + * Defaults to 1000ms. + * @param options.dependencyShape An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies + * and the values are the types of the dependencies. + * for example: + * ``` + * { + * myService: MyService, + * myOtherService: MyOtherService, + * } + * ``` + * + * @param options.deserializer A function to use to safely convert your type from json to your expected type. + * Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize + * from the JSON object representation of your type. + */ + constructor( + readonly stateDefinition: StateDefinition, + readonly uniqueDerivationName: string, + readonly options: DeriveDefinitionOptions, + ) {} + + /** + * Factory that produces a {@link DeriveDefinition} from a {@link KeyDefinition} or {@link DeriveDefinition} and new name. + * + * If a `KeyDefinition` is passed in, the returned definition will have the same key as the given key definition, but + * will not collide with it in storage, even if they both reside in memory. + * + * If a `DeriveDefinition` is passed in, the returned definition will instead use the name given in the second position + * of the tuple. It is up to you to ensure this is unique within the domain of derived state. + * + * @param options A set of options to customize the behavior of {@link DeriveDefinition}. + * @param options.derive A function to use to convert values from TFrom to TTo. This is called on each emit of the parent state observable + * and the resulting value will be emitted from the derived state observable. + * @param options.cleanupDelayMs The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. + * Defaults to 1000ms. + * @param options.dependencyShape An object defining the dependencies of the derive function. The keys of the object are the names of the dependencies + * and the values are the types of the dependencies. + * for example: + * ``` + * { + * myService: MyService, + * myOtherService: MyOtherService, + * } + * ``` + * + * @param options.deserializer A function to use to safely convert your type from json to your expected type. + * Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize + * from the JSON object representation of your type. + * @param definition + * @param options + * @returns + */ + static from( + definition: + | KeyDefinition + | UserKeyDefinition + | [DeriveDefinition, string], + options: DeriveDefinitionOptions, + ) { + if (isFromDeriveDefinition(definition)) { + return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); + } else { + return new DeriveDefinition(definition.stateDefinition, definition.key, options); + } + } + + static fromWithUserId( + definition: + | KeyDefinition + | UserKeyDefinition + | [DeriveDefinition, string], + options: DeriveDefinitionOptions<[UserId, TKeyDef], TTo, TDeps>, + ) { + if (isFromDeriveDefinition(definition)) { + return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); + } else { + return new DeriveDefinition(definition.stateDefinition, definition.key, options); + } + } + + get derive() { + return this.options.derive; + } + + deserialize(serialized: Jsonify): TTo { + return this.options.deserializer(serialized); + } + + get cleanupDelayMs() { + return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000); + } + + get clearOnCleanup() { + return this.options.clearOnCleanup ?? true; + } + + buildCacheKey(): string { + return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}`; + } + + /** + * Creates a {@link StorageKey} that points to the data for the given derived definition. + * @returns A key that is ready to be used in a storage service to get data. + */ + get storageKey(): StorageKey { + return `derived_${this.stateDefinition.name}_${this.uniqueDerivationName}` as StorageKey; + } +} + +function isFromDeriveDefinition( + definition: + | KeyDefinition + | UserKeyDefinition + | [DeriveDefinition, string], +): definition is [DeriveDefinition, string] { + return Array.isArray(definition); +} diff --git a/libs/state/src/core/derived-state.provider.ts b/libs/state/src/core/derived-state.provider.ts new file mode 100644 index 00000000000..cf21d98f690 --- /dev/null +++ b/libs/state/src/core/derived-state.provider.ts @@ -0,0 +1,25 @@ +import { Observable } from "rxjs"; + +import { DerivedStateDependencies } from "../types/state"; + +import { DeriveDefinition } from "./derive-definition"; +import { DerivedState } from "./derived-state"; + +/** + * State derived from an observable and a derive function + */ +export abstract class DerivedStateProvider { + /** + * Creates a derived state observable from a parent state observable, a deriveDefinition, and the dependencies + * required by the deriveDefinition + * @param parentState$ The parent state observable + * @param deriveDefinition The deriveDefinition that defines conversion from the parent state to the derived state as + * well as some memory persistent information. + * @param dependencies The dependencies of the derive function + */ + abstract get( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState; +} diff --git a/libs/state/src/core/derived-state.ts b/libs/state/src/core/derived-state.ts new file mode 100644 index 00000000000..b466c3024f8 --- /dev/null +++ b/libs/state/src/core/derived-state.ts @@ -0,0 +1,23 @@ +import { Observable } from "rxjs"; + +export type StateConverter, TTo> = (...args: TFrom) => TTo; + +/** + * State derived from an observable and a converter function + * + * Derived state is cached and persisted to memory for sychronization across execution contexts. + * For clients with multiple execution contexts, the derived state will be executed only once in the background process. + */ +export interface DerivedState { + /** + * The derived state observable + */ + state$: Observable; + /** + * Forces the derived state to a given value. + * + * Useful for setting an in-memory value as a side effect of some event, such as emptying state as a result of a lock. + * @param value The value to force the derived state to + */ + forceValue(value: T): Promise; +} diff --git a/libs/state/src/core/global-state.provider.ts b/libs/state/src/core/global-state.provider.ts new file mode 100644 index 00000000000..a7179ba0f1d --- /dev/null +++ b/libs/state/src/core/global-state.provider.ts @@ -0,0 +1,13 @@ +import { GlobalState } from "./global-state"; +import { KeyDefinition } from "./key-definition"; + +/** + * A provider for getting an implementation of global state scoped to the given key. + */ +export abstract class GlobalStateProvider { + /** + * Gets a {@link GlobalState} scoped to the given {@link KeyDefinition} + * @param keyDefinition - The {@link KeyDefinition} for which you want the state for. + */ + abstract get(keyDefinition: KeyDefinition): GlobalState; +} diff --git a/libs/state/src/core/global-state.ts b/libs/state/src/core/global-state.ts new file mode 100644 index 00000000000..b2ac634df24 --- /dev/null +++ b/libs/state/src/core/global-state.ts @@ -0,0 +1,30 @@ +import { Observable } from "rxjs"; + +import { StateUpdateOptions } from "./state-update-options"; + +/** + * A helper object for interacting with state that is scoped to a specific domain + * but is not scoped to a user. This is application wide storage. + */ +export interface GlobalState { + /** + * Method for allowing you to manipulate state in an additive way. + * @param configureState callback for how you want to manipulate this section of state + * @param options Defaults given by @see {module:state-update-options#DEFAULT_OPTIONS} + * @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true + * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null + * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. + * @returns A promise that must be awaited before your next action to ensure the update has been written to state. + * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. + */ + update: ( + configureState: (state: T | null, dependency: TCombine) => T | null, + options?: StateUpdateOptions, + ) => Promise; + + /** + * An observable stream of this state, the first emission of this will be the current state on disk + * and subsequent updates will be from an update to that state. + */ + state$: Observable; +} diff --git a/libs/state/src/core/implementations/default-active-user-state.provider.spec.ts b/libs/state/src/core/implementations/default-active-user-state.provider.spec.ts new file mode 100644 index 00000000000..419daeb1ecc --- /dev/null +++ b/libs/state/src/core/implementations/default-active-user-state.provider.spec.ts @@ -0,0 +1,253 @@ +/** + * need to update test environment so structuredClone works appropriately + * @jest-environment ../shared/test.environment.ts + */ +import { Observable, of } from "rxjs"; + +import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils"; +import { + FakeActiveUserAccessor, + FakeActiveUserStateProvider, + FakeDerivedStateProvider, + FakeGlobalStateProvider, + FakeSingleUserStateProvider, +} from "@bitwarden/state-test-utils"; +import { UserId } from "@bitwarden/user-core"; + +import { DeriveDefinition } from "../derive-definition"; +import { KeyDefinition } from "../key-definition"; +import { StateDefinition } from "../state-definition"; +import { UserKeyDefinition } from "../user-key-definition"; + +import { DefaultStateProvider } from "./default-state.provider"; + +describe("DefaultStateProvider", () => { + let sut: DefaultStateProvider; + let activeUserStateProvider: FakeActiveUserStateProvider; + let singleUserStateProvider: FakeSingleUserStateProvider; + let globalStateProvider: FakeGlobalStateProvider; + let derivedStateProvider: FakeDerivedStateProvider; + let activeAccountAccessor: FakeActiveUserAccessor; + const userId = "fakeUserId" as UserId; + + beforeEach(() => { + activeAccountAccessor = new FakeActiveUserAccessor(userId); + activeUserStateProvider = new FakeActiveUserStateProvider(activeAccountAccessor); + singleUserStateProvider = new FakeSingleUserStateProvider(); + globalStateProvider = new FakeGlobalStateProvider(); + derivedStateProvider = new FakeDerivedStateProvider(); + sut = new DefaultStateProvider( + activeUserStateProvider, + singleUserStateProvider, + globalStateProvider, + derivedStateProvider, + ); + }); + + describe("activeUserId$", () => { + it("should track the active User id from active user state provider", () => { + expect(sut.activeUserId$).toBe(activeUserStateProvider.activeUserId$); + }); + }); + + describe.each([ + [ + "getUserState$", + (keyDefinition: UserKeyDefinition, userId?: UserId) => + sut.getUserState$(keyDefinition, userId), + ], + [ + "getUserStateOrDefault$", + (keyDefinition: UserKeyDefinition, userId?: UserId) => + sut.getUserStateOrDefault$(keyDefinition, { userId: userId }), + ], + ])( + "Shared behavior for %s", + ( + _testName: string, + methodUnderTest: ( + keyDefinition: UserKeyDefinition, + userId?: UserId, + ) => Observable, + ) => { + const keyDefinition = new UserKeyDefinition( + new StateDefinition("test", "disk"), + "test", + { + deserializer: (s) => s, + clearOn: [], + }, + ); + + it("should follow the specified user if userId is provided", async () => { + const state = singleUserStateProvider.getFake(userId, keyDefinition); + state.nextState("value"); + const emissions = trackEmissions(methodUnderTest(keyDefinition, userId)); + + state.nextState("value2"); + state.nextState("value3"); + + expect(emissions).toEqual(["value", "value2", "value3"]); + }); + + it("should follow the current active user if no userId is provided", async () => { + activeAccountAccessor.switch(userId); + const state = singleUserStateProvider.getFake(userId, keyDefinition); + state.nextState("value"); + const emissions = trackEmissions(methodUnderTest(keyDefinition)); + + state.nextState("value2"); + state.nextState("value3"); + + expect(emissions).toEqual(["value", "value2", "value3"]); + }); + + it("should continue to follow the state of the user that was active when called, even if active user changes", async () => { + const state = singleUserStateProvider.getFake(userId, keyDefinition); + state.nextState("value"); + const emissions = trackEmissions(methodUnderTest(keyDefinition)); + + activeAccountAccessor.switch("newUserId" as UserId); + const newUserEmissions = trackEmissions(sut.getUserState$(keyDefinition)); + state.nextState("value2"); + state.nextState("value3"); + + expect(emissions).toEqual(["value", "value2", "value3"]); + expect(newUserEmissions).toEqual([null]); + }); + }, + ); + + describe("getUserState$", () => { + const keyDefinition = new UserKeyDefinition( + new StateDefinition("test", "disk"), + "test", + { + deserializer: (s) => s, + clearOn: [], + }, + ); + + it("should not emit any values until a truthy user id is supplied", async () => { + activeAccountAccessor.switch(null); + const state = singleUserStateProvider.getFake(userId, keyDefinition); + state.nextState("value"); + + const emissions = trackEmissions(sut.getUserState$(keyDefinition)); + + await awaitAsync(); + + expect(emissions).toHaveLength(0); + + activeAccountAccessor.switch(userId); + + await awaitAsync(); + + expect(emissions).toEqual(["value"]); + }); + }); + + describe("getUserStateOrDefault$", () => { + const keyDefinition = new UserKeyDefinition( + new StateDefinition("test", "disk"), + "test", + { + deserializer: (s) => s, + clearOn: [], + }, + ); + + it("should emit default value if no userId supplied and first active user id emission in falsy", async () => { + activeAccountAccessor.switch(null); + + const emissions = trackEmissions( + sut.getUserStateOrDefault$(keyDefinition, { + userId: undefined, + defaultValue: "I'm default!", + }), + ); + + expect(emissions).toEqual(["I'm default!"]); + }); + }); + + describe("setUserState", () => { + const keyDefinition = new UserKeyDefinition( + new StateDefinition("test", "disk"), + "test", + { + deserializer: (s) => s, + clearOn: [], + }, + ); + + it("should set the state for the active user if no userId is provided", async () => { + const value = "value"; + await sut.setUserState(keyDefinition, value); + const state = activeUserStateProvider.getFake(keyDefinition); + expect(state.nextMock).toHaveBeenCalledWith([expect.any(String), value]); + }); + + it("should not set state for a single user if no userId is provided", async () => { + const value = "value"; + await sut.setUserState(keyDefinition, value); + const state = singleUserStateProvider.getFake(userId, keyDefinition); + expect(state.nextMock).not.toHaveBeenCalled(); + }); + + it("should set the state for the provided userId", async () => { + const value = "value"; + await sut.setUserState(keyDefinition, value, userId); + const state = singleUserStateProvider.getFake(userId, keyDefinition); + expect(state.nextMock).toHaveBeenCalledWith(value); + }); + + it("should not set the active user state if userId is provided", async () => { + const value = "value"; + await sut.setUserState(keyDefinition, value, userId); + const state = activeUserStateProvider.getFake(keyDefinition); + expect(state.nextMock).not.toHaveBeenCalled(); + }); + }); + + it("should bind the activeUserStateProvider", () => { + const keyDefinition = new UserKeyDefinition(new StateDefinition("test", "disk"), "test", { + deserializer: () => null, + clearOn: [], + }); + const existing = activeUserStateProvider.get(keyDefinition); + const actual = sut.getActive(keyDefinition); + expect(actual).toBe(existing); + }); + + it("should bind the singleUserStateProvider", () => { + const userId = "user" as UserId; + const keyDefinition = new UserKeyDefinition(new StateDefinition("test", "disk"), "test", { + deserializer: () => null, + clearOn: [], + }); + const existing = singleUserStateProvider.get(userId, keyDefinition); + const actual = sut.getUser(userId, keyDefinition); + expect(actual).toBe(existing); + }); + + it("should bind the globalStateProvider", () => { + const keyDefinition = new KeyDefinition(new StateDefinition("test", "disk"), "test", { + deserializer: () => null, + }); + const existing = globalStateProvider.get(keyDefinition); + const actual = sut.getGlobal(keyDefinition); + expect(actual).toBe(existing); + }); + + it("should bind the derivedStateProvider", () => { + const derivedDefinition = new DeriveDefinition(new StateDefinition("test", "disk"), "test", { + derive: () => null, + deserializer: () => null, + }); + const parentState$ = of(null); + const existing = derivedStateProvider.get(parentState$, derivedDefinition, {}); + const actual = sut.getDerived(parentState$, derivedDefinition, {}); + expect(actual).toBe(existing); + }); +}); diff --git a/libs/state/src/core/implementations/default-active-user-state.provider.ts b/libs/state/src/core/implementations/default-active-user-state.provider.ts new file mode 100644 index 00000000000..e7e456f7401 --- /dev/null +++ b/libs/state/src/core/implementations/default-active-user-state.provider.ts @@ -0,0 +1,37 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Observable, distinctUntilChanged } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +import { ActiveUserAccessor } from "../active-user.accessor"; +import { UserKeyDefinition } from "../user-key-definition"; +import { ActiveUserState } from "../user-state"; +import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider"; + +import { DefaultActiveUserState } from "./default-active-user-state"; + +export class DefaultActiveUserStateProvider implements ActiveUserStateProvider { + activeUserId$: Observable; + + constructor( + private readonly activeAccountAccessor: ActiveUserAccessor, + private readonly singleUserStateProvider: SingleUserStateProvider, + ) { + this.activeUserId$ = this.activeAccountAccessor.activeUserId$.pipe( + // To avoid going to storage when we don't need to, only get updates when there is a true change. + distinctUntilChanged((a, b) => (a == null || b == null ? a == b : a === b)), // Treat null and undefined as equal + ); + } + + get(keyDefinition: UserKeyDefinition): ActiveUserState { + // All other providers cache the creation of their corresponding `State` objects, this instance + // doesn't need to do that since it calls `SingleUserStateProvider` it will go through their caching + // layer, because of that, the creation of this instance is quite simple and not worth caching. + return new DefaultActiveUserState( + keyDefinition, + this.activeUserId$, + this.singleUserStateProvider, + ); + } +} diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts b/libs/state/src/core/implementations/default-active-user-state.spec.ts similarity index 96% rename from libs/common/src/platform/state/implementations/default-active-user-state.spec.ts rename to libs/state/src/core/implementations/default-active-user-state.spec.ts index 1cb1453a509..0c3834ee574 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts +++ b/libs/state/src/core/implementations/default-active-user-state.spec.ts @@ -3,16 +3,15 @@ * @jest-environment ../shared/test.environment.ts */ import { any, mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, map, of, timeout } from "rxjs"; +import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs"; import { Jsonify } from "type-fest"; +import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils"; +import { LogService } from "@bitwarden/logging"; import { StorageServiceProvider } from "@bitwarden/storage-core"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; +import { UserId } from "@bitwarden/user-core"; -import { awaitAsync, trackEmissions } from "../../../../spec"; -import { FakeStorageService } from "../../../../spec/fake-storage.service"; -import { Account } from "../../../auth/abstractions/account.service"; -import { UserId } from "../../../types/guid"; -import { LogService } from "../../abstractions/log.service"; import { StateDefinition } from "../state-definition"; import { StateEventRegistrarService } from "../state-event-registrar.service"; import { UserKeyDefinition } from "../user-key-definition"; @@ -48,7 +47,7 @@ describe("DefaultActiveUserState", () => { const storageServiceProvider = mock(); const stateEventRegistrarService = mock(); const logService = mock(); - let activeAccountSubject: BehaviorSubject; + let activeAccountSubject: BehaviorSubject; let singleUserStateProvider: DefaultSingleUserStateProvider; @@ -64,11 +63,11 @@ describe("DefaultActiveUserState", () => { logService, ); - activeAccountSubject = new BehaviorSubject(null); + activeAccountSubject = new BehaviorSubject(null); userState = new DefaultActiveUserState( testKeyDefinition, - activeAccountSubject.asObservable().pipe(map((a) => a?.id)), + activeAccountSubject.asObservable(), singleUserStateProvider, ); }); @@ -83,12 +82,7 @@ describe("DefaultActiveUserState", () => { const changeActiveUser = async (id: string) => { const userId = makeUserId(id); - activeAccountSubject.next({ - id: userId, - email: `test${id}@example.com`, - emailVerified: false, - name: `Test User ${id}`, - }); + activeAccountSubject.next(userId); await awaitAsync(); }; @@ -588,7 +582,7 @@ describe("DefaultActiveUserState", () => { }); it("does not await updates if the active user changes", async () => { - const initialUserId = (await firstValueFrom(activeAccountSubject)).id; + const initialUserId = activeAccountSubject.value; expect(initialUserId).toBe(userId); trackEmissions(userState.state$); await awaitAsync(); // storage updates are behind a promise diff --git a/libs/state/src/core/implementations/default-active-user-state.ts b/libs/state/src/core/implementations/default-active-user-state.ts new file mode 100644 index 00000000000..aa8b1e401da --- /dev/null +++ b/libs/state/src/core/implementations/default-active-user-state.ts @@ -0,0 +1,65 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Observable, map, switchMap, firstValueFrom, timeout, throwError, NEVER } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +import { StateUpdateOptions } from "../state-update-options"; +import { UserKeyDefinition } from "../user-key-definition"; +import { ActiveUserState, CombinedState, activeMarker } from "../user-state"; +import { SingleUserStateProvider } from "../user-state.provider"; + +export class DefaultActiveUserState implements ActiveUserState { + [activeMarker]: true; + combinedState$: Observable>; + state$: Observable; + + constructor( + protected keyDefinition: UserKeyDefinition, + private activeUserId$: Observable, + private singleUserStateProvider: SingleUserStateProvider, + ) { + this.combinedState$ = this.activeUserId$.pipe( + switchMap((userId) => + userId != null + ? this.singleUserStateProvider.get(userId, this.keyDefinition).combinedState$ + : NEVER, + ), + ); + + // State should just be combined state without the user id + this.state$ = this.combinedState$.pipe(map(([_userId, state]) => state)); + } + + async update( + configureState: (state: T, dependency: TCombine) => T, + options: StateUpdateOptions = {}, + ): Promise<[UserId, T]> { + const userId = await firstValueFrom( + this.activeUserId$.pipe( + timeout({ + first: 1000, + with: () => + throwError( + () => + new Error( + `Timeout while retrieving active user for key ${this.keyDefinition.fullName}.`, + ), + ), + }), + ), + ); + if (userId == null) { + throw new Error( + `Error storing ${this.keyDefinition.fullName} for the active user: No active user at this time.`, + ); + } + + return [ + userId, + await this.singleUserStateProvider + .get(userId, this.keyDefinition) + .update(configureState, options), + ]; + } +} diff --git a/libs/state/src/core/implementations/default-derived-state.provider.ts b/libs/state/src/core/implementations/default-derived-state.provider.ts new file mode 100644 index 00000000000..04883f63117 --- /dev/null +++ b/libs/state/src/core/implementations/default-derived-state.provider.ts @@ -0,0 +1,53 @@ +import { Observable } from "rxjs"; + +import { DerivedStateDependencies } from "../../types/state"; +import { DeriveDefinition } from "../derive-definition"; +import { DerivedState } from "../derived-state"; +import { DerivedStateProvider } from "../derived-state.provider"; + +import { DefaultDerivedState } from "./default-derived-state"; + +export class DefaultDerivedStateProvider implements DerivedStateProvider { + /** + * The cache uses a WeakMap to maintain separate derived states per user. + * Each user's state Observable acts as a unique key, without needing to + * pass around `userId`. Also, when a user's state Observable is cleaned up + * (like during an account swap) their cache is automatically garbage + * collected. + */ + private cache = new WeakMap, Record>>(); + + constructor() {} + + get( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + let stateCache = this.cache.get(parentState$); + if (!stateCache) { + stateCache = {}; + this.cache.set(parentState$, stateCache); + } + + const cacheKey = deriveDefinition.buildCacheKey(); + const existingDerivedState = stateCache[cacheKey]; + if (existingDerivedState != null) { + // I have to cast out of the unknown generic but this should be safe if rules + // around domain token are made + return existingDerivedState as DefaultDerivedState; + } + + const newDerivedState = this.buildDerivedState(parentState$, deriveDefinition, dependencies); + stateCache[cacheKey] = newDerivedState; + return newDerivedState; + } + + protected buildDerivedState( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + return new DefaultDerivedState(parentState$, deriveDefinition, dependencies); + } +} diff --git a/libs/common/src/platform/state/implementations/default-derived-state.spec.ts b/libs/state/src/core/implementations/default-derived-state.spec.ts similarity index 98% rename from libs/common/src/platform/state/implementations/default-derived-state.spec.ts rename to libs/state/src/core/implementations/default-derived-state.spec.ts index 6fcc1c408cb..052a04ed19a 100644 --- a/libs/common/src/platform/state/implementations/default-derived-state.spec.ts +++ b/libs/state/src/core/implementations/default-derived-state.spec.ts @@ -4,7 +4,8 @@ */ import { Subject, firstValueFrom } from "rxjs"; -import { awaitAsync, trackEmissions } from "../../../../spec"; +import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils"; + import { DeriveDefinition } from "../derive-definition"; import { StateDefinition } from "../state-definition"; diff --git a/libs/state/src/core/implementations/default-derived-state.ts b/libs/state/src/core/implementations/default-derived-state.ts new file mode 100644 index 00000000000..377a9e4dda3 --- /dev/null +++ b/libs/state/src/core/implementations/default-derived-state.ts @@ -0,0 +1,50 @@ +import { Observable, ReplaySubject, Subject, concatMap, merge, share, timer } from "rxjs"; + +import { DerivedStateDependencies } from "../../types/state"; +import { DeriveDefinition } from "../derive-definition"; +import { DerivedState } from "../derived-state"; + +/** + * Default derived state + */ +export class DefaultDerivedState + implements DerivedState +{ + private readonly storageKey: string; + private forcedValueSubject = new Subject(); + + state$: Observable; + + constructor( + private parentState$: Observable, + protected deriveDefinition: DeriveDefinition, + private dependencies: TDeps, + ) { + this.storageKey = deriveDefinition.storageKey; + + const derivedState$ = this.parentState$.pipe( + concatMap(async (state) => { + let derivedStateOrPromise = this.deriveDefinition.derive(state, this.dependencies); + if (derivedStateOrPromise instanceof Promise) { + derivedStateOrPromise = await derivedStateOrPromise; + } + const derivedState = derivedStateOrPromise; + return derivedState; + }), + ); + + this.state$ = merge(this.forcedValueSubject, derivedState$).pipe( + share({ + connector: () => { + return new ReplaySubject(1); + }, + resetOnRefCountZero: () => timer(this.deriveDefinition.cleanupDelayMs), + }), + ); + } + + async forceValue(value: TTo) { + this.forcedValueSubject.next(value); + return value; + } +} diff --git a/libs/state/src/core/implementations/default-global-state.provider.ts b/libs/state/src/core/implementations/default-global-state.provider.ts new file mode 100644 index 00000000000..f0828736147 --- /dev/null +++ b/libs/state/src/core/implementations/default-global-state.provider.ts @@ -0,0 +1,46 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { LogService } from "@bitwarden/logging"; +import { StorageServiceProvider } from "@bitwarden/storage-core"; + +import { GlobalState } from "../global-state"; +import { GlobalStateProvider } from "../global-state.provider"; +import { KeyDefinition } from "../key-definition"; + +import { DefaultGlobalState } from "./default-global-state"; + +export class DefaultGlobalStateProvider implements GlobalStateProvider { + private globalStateCache: Record> = {}; + + constructor( + private storageServiceProvider: StorageServiceProvider, + private readonly logService: LogService, + ) {} + + get(keyDefinition: KeyDefinition): GlobalState { + const [location, storageService] = this.storageServiceProvider.get( + keyDefinition.stateDefinition.defaultStorageLocation, + keyDefinition.stateDefinition.storageLocationOverrides, + ); + const cacheKey = this.buildCacheKey(location, keyDefinition); + const existingGlobalState = this.globalStateCache[cacheKey]; + if (existingGlobalState != null) { + // The cast into the actual generic is safe because of rules around key definitions + // being unique. + return existingGlobalState as DefaultGlobalState; + } + + const newGlobalState = new DefaultGlobalState( + keyDefinition, + storageService, + this.logService, + ); + + this.globalStateCache[cacheKey] = newGlobalState; + return newGlobalState; + } + + private buildCacheKey(location: string, keyDefinition: KeyDefinition) { + return `${location}_${keyDefinition.fullName}`; + } +} diff --git a/libs/common/src/platform/state/implementations/default-global-state.spec.ts b/libs/state/src/core/implementations/default-global-state.spec.ts similarity index 94% rename from libs/common/src/platform/state/implementations/default-global-state.spec.ts rename to libs/state/src/core/implementations/default-global-state.spec.ts index 0f8e7028af4..ecfbc001cf0 100644 --- a/libs/common/src/platform/state/implementations/default-global-state.spec.ts +++ b/libs/state/src/core/implementations/default-global-state.spec.ts @@ -7,9 +7,10 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; import { Jsonify } from "type-fest"; -import { trackEmissions, awaitAsync } from "../../../../spec"; -import { FakeStorageService } from "../../../../spec/fake-storage.service"; -import { LogService } from "../../abstractions/log.service"; +import { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils"; +import { LogService } from "@bitwarden/logging"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; + import { KeyDefinition, globalKeyBuilder } from "../key-definition"; import { StateDefinition } from "../state-definition"; @@ -343,9 +344,7 @@ describe("DefaultGlobalState", () => { expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(1); // Still be listening to storage updates - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - diskStorageService.save(globalKey, newData); + await diskStorageService.save(globalKey, newData); await awaitAsync(); // storage updates are behind a promise expect(sub2Emissions).toEqual([null, newData]); @@ -367,9 +366,7 @@ describe("DefaultGlobalState", () => { const emissions = trackEmissions(globalState.state$); await awaitAsync(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - diskStorageService.save(globalKey, newData); + await diskStorageService.save(globalKey, newData); await awaitAsync(); expect(emissions).toEqual([null, newData]); diff --git a/libs/state/src/core/implementations/default-global-state.ts b/libs/state/src/core/implementations/default-global-state.ts new file mode 100644 index 00000000000..cb6c6c41a02 --- /dev/null +++ b/libs/state/src/core/implementations/default-global-state.ts @@ -0,0 +1,20 @@ +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; + +import { GlobalState } from "../global-state"; +import { KeyDefinition, globalKeyBuilder } from "../key-definition"; + +import { StateBase } from "./state-base"; + +export class DefaultGlobalState + extends StateBase> + implements GlobalState +{ + constructor( + keyDefinition: KeyDefinition, + chosenLocation: AbstractStorageService & ObservableStorageService, + logService: LogService, + ) { + super(globalKeyBuilder(keyDefinition), chosenLocation, keyDefinition, logService); + } +} diff --git a/libs/state/src/core/implementations/default-single-user-state.provider.ts b/libs/state/src/core/implementations/default-single-user-state.provider.ts new file mode 100644 index 00000000000..252ea1fa3e7 --- /dev/null +++ b/libs/state/src/core/implementations/default-single-user-state.provider.ts @@ -0,0 +1,54 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { LogService } from "@bitwarden/logging"; +import { StorageServiceProvider } from "@bitwarden/storage-core"; +import { UserId } from "@bitwarden/user-core"; + +import { StateEventRegistrarService } from "../state-event-registrar.service"; +import { UserKeyDefinition } from "../user-key-definition"; +import { SingleUserState } from "../user-state"; +import { SingleUserStateProvider } from "../user-state.provider"; + +import { DefaultSingleUserState } from "./default-single-user-state"; + +export class DefaultSingleUserStateProvider implements SingleUserStateProvider { + private cache: Record> = {}; + + constructor( + private readonly storageServiceProvider: StorageServiceProvider, + private readonly stateEventRegistrarService: StateEventRegistrarService, + private readonly logService: LogService, + ) {} + + get(userId: UserId, keyDefinition: UserKeyDefinition): SingleUserState { + const [location, storageService] = this.storageServiceProvider.get( + keyDefinition.stateDefinition.defaultStorageLocation, + keyDefinition.stateDefinition.storageLocationOverrides, + ); + const cacheKey = this.buildCacheKey(location, userId, keyDefinition); + const existingUserState = this.cache[cacheKey]; + if (existingUserState != null) { + // I have to cast out of the unknown generic but this should be safe if rules + // around domain token are made + return existingUserState as SingleUserState; + } + + const newUserState = new DefaultSingleUserState( + userId, + keyDefinition, + storageService, + this.stateEventRegistrarService, + this.logService, + ); + this.cache[cacheKey] = newUserState; + return newUserState; + } + + private buildCacheKey( + location: string, + userId: UserId, + keyDefinition: UserKeyDefinition, + ) { + return `${location}_${keyDefinition.fullName}_${userId}`; + } +} diff --git a/libs/common/src/platform/state/implementations/default-single-user-state.spec.ts b/libs/state/src/core/implementations/default-single-user-state.spec.ts similarity index 95% rename from libs/common/src/platform/state/implementations/default-single-user-state.spec.ts rename to libs/state/src/core/implementations/default-single-user-state.spec.ts index 0a98c55970b..c6262581fa6 100644 --- a/libs/common/src/platform/state/implementations/default-single-user-state.spec.ts +++ b/libs/state/src/core/implementations/default-single-user-state.spec.ts @@ -7,11 +7,12 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; import { Jsonify } from "type-fest"; -import { trackEmissions, awaitAsync } from "../../../../spec"; -import { FakeStorageService } from "../../../../spec/fake-storage.service"; -import { UserId } from "../../../types/guid"; -import { LogService } from "../../abstractions/log.service"; -import { Utils } from "../../misc/utils"; +import { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils"; +import { newGuid } from "@bitwarden/guid"; +import { LogService } from "@bitwarden/logging"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; +import { UserId } from "@bitwarden/user-core"; + import { StateDefinition } from "../state-definition"; import { StateEventRegistrarService } from "../state-event-registrar.service"; import { UserKeyDefinition } from "../user-key-definition"; @@ -39,7 +40,7 @@ const testKeyDefinition = new UserKeyDefinition(testStateDefinition, cleanupDelayMs, clearOn: [], }); -const userId = Utils.newGuid() as UserId; +const userId = newGuid() as UserId; const userKey = testKeyDefinition.buildKey(userId); describe("DefaultSingleUserState", () => { @@ -524,9 +525,7 @@ describe("DefaultSingleUserState", () => { expect(diskStorageService["updatesSubject"]["observers"]).toHaveLength(1); // Still be listening to storage updates - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - diskStorageService.save(userKey, newData); + await diskStorageService.save(userKey, newData); await awaitAsync(); // storage updates are behind a promise expect(sub2Emissions).toEqual([null, newData]); @@ -548,9 +547,7 @@ describe("DefaultSingleUserState", () => { const emissions = trackEmissions(userState.state$); await awaitAsync(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - diskStorageService.save(userKey, newData); + await diskStorageService.save(userKey, newData); await awaitAsync(); expect(emissions).toEqual([null, newData]); diff --git a/libs/state/src/core/implementations/default-single-user-state.ts b/libs/state/src/core/implementations/default-single-user-state.ts new file mode 100644 index 00000000000..b177faf011d --- /dev/null +++ b/libs/state/src/core/implementations/default-single-user-state.ts @@ -0,0 +1,36 @@ +import { Observable, combineLatest, of } from "rxjs"; + +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; +import { UserId } from "@bitwarden/user-core"; + +import { StateEventRegistrarService } from "../state-event-registrar.service"; +import { UserKeyDefinition } from "../user-key-definition"; +import { CombinedState, SingleUserState } from "../user-state"; + +import { StateBase } from "./state-base"; + +export class DefaultSingleUserState + extends StateBase> + implements SingleUserState +{ + readonly combinedState$: Observable>; + + constructor( + readonly userId: UserId, + keyDefinition: UserKeyDefinition, + chosenLocation: AbstractStorageService & ObservableStorageService, + private stateEventRegistrarService: StateEventRegistrarService, + logService: LogService, + ) { + super(keyDefinition.buildKey(userId), chosenLocation, keyDefinition, logService); + this.combinedState$ = combineLatest([of(userId), this.state$]); + } + + protected override async doStorageSave(newState: T, oldState: T): Promise { + await super.doStorageSave(newState, oldState); + if (newState != null && oldState == null) { + await this.stateEventRegistrarService.registerEvents(this.keyDefinition); + } + } +} diff --git a/libs/common/src/platform/state/implementations/default-state.provider.spec.ts b/libs/state/src/core/implementations/default-state.provider.spec.ts similarity index 87% rename from libs/common/src/platform/state/implementations/default-state.provider.spec.ts rename to libs/state/src/core/implementations/default-state.provider.spec.ts index b3190bd532e..419daeb1ecc 100644 --- a/libs/common/src/platform/state/implementations/default-state.provider.spec.ts +++ b/libs/state/src/core/implementations/default-state.provider.spec.ts @@ -4,16 +4,16 @@ */ import { Observable, of } from "rxjs"; -import { awaitAsync, trackEmissions } from "../../../../spec"; -import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service"; +import { awaitAsync, trackEmissions } from "@bitwarden/core-test-utils"; import { + FakeActiveUserAccessor, FakeActiveUserStateProvider, FakeDerivedStateProvider, FakeGlobalStateProvider, FakeSingleUserStateProvider, -} from "../../../../spec/fake-state-provider"; -import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; -import { UserId } from "../../../types/guid"; +} from "@bitwarden/state-test-utils"; +import { UserId } from "@bitwarden/user-core"; + import { DeriveDefinition } from "../derive-definition"; import { KeyDefinition } from "../key-definition"; import { StateDefinition } from "../state-definition"; @@ -27,12 +27,12 @@ describe("DefaultStateProvider", () => { let singleUserStateProvider: FakeSingleUserStateProvider; let globalStateProvider: FakeGlobalStateProvider; let derivedStateProvider: FakeDerivedStateProvider; - let accountService: FakeAccountService; + let activeAccountAccessor: FakeActiveUserAccessor; const userId = "fakeUserId" as UserId; beforeEach(() => { - accountService = mockAccountServiceWith(userId); - activeUserStateProvider = new FakeActiveUserStateProvider(accountService); + activeAccountAccessor = new FakeActiveUserAccessor(userId); + activeUserStateProvider = new FakeActiveUserStateProvider(activeAccountAccessor); singleUserStateProvider = new FakeSingleUserStateProvider(); globalStateProvider = new FakeGlobalStateProvider(); derivedStateProvider = new FakeDerivedStateProvider(); @@ -70,12 +70,6 @@ describe("DefaultStateProvider", () => { userId?: UserId, ) => Observable, ) => { - const accountInfo = { - email: "email", - emailVerified: false, - name: "name", - status: AuthenticationStatus.LoggedOut, - }; const keyDefinition = new UserKeyDefinition( new StateDefinition("test", "disk"), "test", @@ -97,7 +91,7 @@ describe("DefaultStateProvider", () => { }); it("should follow the current active user if no userId is provided", async () => { - accountService.activeAccountSubject.next({ id: userId, ...accountInfo }); + activeAccountAccessor.switch(userId); const state = singleUserStateProvider.getFake(userId, keyDefinition); state.nextState("value"); const emissions = trackEmissions(methodUnderTest(keyDefinition)); @@ -113,7 +107,7 @@ describe("DefaultStateProvider", () => { state.nextState("value"); const emissions = trackEmissions(methodUnderTest(keyDefinition)); - accountService.activeAccountSubject.next({ id: "newUserId" as UserId, ...accountInfo }); + activeAccountAccessor.switch("newUserId" as UserId); const newUserEmissions = trackEmissions(sut.getUserState$(keyDefinition)); state.nextState("value2"); state.nextState("value3"); @@ -125,12 +119,6 @@ describe("DefaultStateProvider", () => { ); describe("getUserState$", () => { - const accountInfo = { - email: "email", - emailVerified: false, - name: "name", - status: AuthenticationStatus.LoggedOut, - }; const keyDefinition = new UserKeyDefinition( new StateDefinition("test", "disk"), "test", @@ -141,7 +129,7 @@ describe("DefaultStateProvider", () => { ); it("should not emit any values until a truthy user id is supplied", async () => { - accountService.activeAccountSubject.next(null); + activeAccountAccessor.switch(null); const state = singleUserStateProvider.getFake(userId, keyDefinition); state.nextState("value"); @@ -151,7 +139,7 @@ describe("DefaultStateProvider", () => { expect(emissions).toHaveLength(0); - accountService.activeAccountSubject.next({ id: userId, ...accountInfo }); + activeAccountAccessor.switch(userId); await awaitAsync(); @@ -170,7 +158,7 @@ describe("DefaultStateProvider", () => { ); it("should emit default value if no userId supplied and first active user id emission in falsy", async () => { - accountService.activeAccountSubject.next(null); + activeAccountAccessor.switch(null); const emissions = trackEmissions( sut.getUserStateOrDefault$(keyDefinition, { diff --git a/libs/state/src/core/implementations/default-state.provider.ts b/libs/state/src/core/implementations/default-state.provider.ts new file mode 100644 index 00000000000..45a8bc8e8d3 --- /dev/null +++ b/libs/state/src/core/implementations/default-state.provider.ts @@ -0,0 +1,80 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Observable, filter, of, switchMap, take } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +import { DerivedStateDependencies } from "../../types/state"; +import { DeriveDefinition } from "../derive-definition"; +import { DerivedState } from "../derived-state"; +import { DerivedStateProvider } from "../derived-state.provider"; +import { GlobalStateProvider } from "../global-state.provider"; +import { StateProvider } from "../state.provider"; +import { UserKeyDefinition } from "../user-key-definition"; +import { ActiveUserStateProvider, SingleUserStateProvider } from "../user-state.provider"; + +export class DefaultStateProvider implements StateProvider { + activeUserId$: Observable; + constructor( + private readonly activeUserStateProvider: ActiveUserStateProvider, + private readonly singleUserStateProvider: SingleUserStateProvider, + private readonly globalStateProvider: GlobalStateProvider, + private readonly derivedStateProvider: DerivedStateProvider, + ) { + this.activeUserId$ = this.activeUserStateProvider.activeUserId$; + } + + getUserState$(userKeyDefinition: UserKeyDefinition, userId?: UserId): Observable { + if (userId) { + return this.getUser(userId, userKeyDefinition).state$; + } else { + return this.activeUserId$.pipe( + filter((userId) => userId != null), // Filter out null-ish user ids since we can't get state for a null user id + take(1), + switchMap((userId) => this.getUser(userId, userKeyDefinition).state$), + ); + } + } + + getUserStateOrDefault$( + userKeyDefinition: UserKeyDefinition, + config: { userId: UserId | undefined; defaultValue?: T }, + ): Observable { + const { userId, defaultValue = null } = config; + if (userId) { + return this.getUser(userId, userKeyDefinition).state$; + } else { + return this.activeUserId$.pipe( + take(1), + switchMap((userId) => + userId != null ? this.getUser(userId, userKeyDefinition).state$ : of(defaultValue), + ), + ); + } + } + + async setUserState( + userKeyDefinition: UserKeyDefinition, + value: T | null, + userId?: UserId, + ): Promise<[UserId, T | null]> { + if (userId) { + return [userId, await this.getUser(userId, userKeyDefinition).update(() => value)]; + } else { + return await this.getActive(userKeyDefinition).update(() => value); + } + } + + getActive: InstanceType["get"] = + this.activeUserStateProvider.get.bind(this.activeUserStateProvider); + getUser: InstanceType["get"] = + this.singleUserStateProvider.get.bind(this.singleUserStateProvider); + getGlobal: InstanceType["get"] = this.globalStateProvider.get.bind( + this.globalStateProvider, + ); + getDerived: ( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ) => DerivedState = this.derivedStateProvider.get.bind(this.derivedStateProvider); +} diff --git a/libs/state/src/core/implementations/index.ts b/libs/state/src/core/implementations/index.ts new file mode 100644 index 00000000000..bb2544ad6dc --- /dev/null +++ b/libs/state/src/core/implementations/index.ts @@ -0,0 +1,12 @@ +export * from "./default-active-user-state.provider"; +export * from "./default-active-user-state"; +export * from "./default-derived-state.provider"; +export * from "./default-derived-state"; +export * from "./default-global-state.provider"; +export * from "./default-global-state"; +export * from "./default-single-user-state.provider"; +export * from "./default-single-user-state"; +export * from "./default-state.provider"; +export * from "./inline-derived-state"; +export * from "./state-base"; +export * from "./util"; diff --git a/libs/common/src/platform/state/implementations/inline-derived-state.spec.ts b/libs/state/src/core/implementations/inline-derived-state.spec.ts similarity index 100% rename from libs/common/src/platform/state/implementations/inline-derived-state.spec.ts rename to libs/state/src/core/implementations/inline-derived-state.spec.ts diff --git a/libs/state/src/core/implementations/inline-derived-state.ts b/libs/state/src/core/implementations/inline-derived-state.ts new file mode 100644 index 00000000000..1202839d5c0 --- /dev/null +++ b/libs/state/src/core/implementations/inline-derived-state.ts @@ -0,0 +1,37 @@ +import { Observable, concatMap } from "rxjs"; + +import { DerivedStateDependencies } from "../../types/state"; +import { DeriveDefinition } from "../derive-definition"; +import { DerivedState } from "../derived-state"; +import { DerivedStateProvider } from "../derived-state.provider"; + +export class InlineDerivedStateProvider implements DerivedStateProvider { + get( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState { + return new InlineDerivedState(parentState$, deriveDefinition, dependencies); + } +} + +export class InlineDerivedState + implements DerivedState +{ + constructor( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ) { + this.state$ = parentState$.pipe( + concatMap(async (value) => await deriveDefinition.derive(value, dependencies)), + ); + } + + state$: Observable; + + forceValue(value: TTo): Promise { + // No need to force anything, we don't keep a cache + return Promise.resolve(value); + } +} diff --git a/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts b/libs/state/src/core/implementations/specific-state.provider.spec.ts similarity index 95% rename from libs/common/src/platform/state/implementations/specific-state.provider.spec.ts rename to libs/state/src/core/implementations/specific-state.provider.spec.ts index 6674c2577d7..287fd8702eb 100644 --- a/libs/common/src/platform/state/implementations/specific-state.provider.spec.ts +++ b/libs/state/src/core/implementations/specific-state.provider.spec.ts @@ -1,11 +1,11 @@ import { mock } from "jest-mock-extended"; +import { LogService } from "@bitwarden/logging"; +import { FakeActiveUserAccessor } from "@bitwarden/state-test-utils"; import { StorageServiceProvider } from "@bitwarden/storage-core"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; +import { UserId } from "@bitwarden/user-core"; -import { mockAccountServiceWith } from "../../../../spec/fake-account-service"; -import { FakeStorageService } from "../../../../spec/fake-storage.service"; -import { UserId } from "../../../types/guid"; -import { LogService } from "../../abstractions/log.service"; import { KeyDefinition } from "../key-definition"; import { StateDefinition } from "../state-definition"; import { StateEventRegistrarService } from "../state-event-registrar.service"; @@ -39,7 +39,7 @@ describe("Specific State Providers", () => { stateEventRegistrarService, logService, ); - activeSut = new DefaultActiveUserStateProvider(mockAccountServiceWith(null), singleSut); + activeSut = new DefaultActiveUserStateProvider(new FakeActiveUserAccessor(null), singleSut); globalSut = new DefaultGlobalStateProvider(storageServiceProvider, logService); }); diff --git a/libs/state/src/core/implementations/state-base.ts b/libs/state/src/core/implementations/state-base.ts new file mode 100644 index 00000000000..72da2075e71 --- /dev/null +++ b/libs/state/src/core/implementations/state-base.ts @@ -0,0 +1,137 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { + defer, + filter, + firstValueFrom, + merge, + Observable, + ReplaySubject, + share, + switchMap, + tap, + timeout, + timer, +} from "rxjs"; +import { Jsonify } from "type-fest"; + +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService, ObservableStorageService } from "@bitwarden/storage-core"; + +import { StorageKey } from "../../types/state"; +import { DebugOptions } from "../key-definition"; +import { populateOptionsWithDefault, StateUpdateOptions } from "../state-update-options"; + +import { getStoredValue } from "./util"; + +// The parts of a KeyDefinition this class cares about to make it work +type KeyDefinitionRequirements = { + deserializer: (jsonState: Jsonify) => T | null; + cleanupDelayMs: number; + debug: Required; +}; + +export abstract class StateBase> { + private updatePromise: Promise; + + readonly state$: Observable; + + constructor( + protected readonly key: StorageKey, + protected readonly storageService: AbstractStorageService & ObservableStorageService, + protected readonly keyDefinition: KeyDef, + protected readonly logService: LogService, + ) { + const storageUpdate$ = storageService.updates$.pipe( + filter((storageUpdate) => storageUpdate.key === key), + switchMap(async (storageUpdate) => { + if (storageUpdate.updateType === "remove") { + return null; + } + + return await getStoredValue(key, storageService, keyDefinition.deserializer); + }), + ); + + let state$ = merge( + defer(() => getStoredValue(key, storageService, keyDefinition.deserializer)), + storageUpdate$, + ); + + if (keyDefinition.debug.enableRetrievalLogging) { + state$ = state$.pipe( + tap({ + next: (v) => { + this.logService.info( + `Retrieving '${key}' from storage, value is ${v == null ? "null" : "non-null"}`, + ); + }, + }), + ); + } + + // If 0 cleanup is chosen, treat this as absolutely no cache + if (keyDefinition.cleanupDelayMs !== 0) { + state$ = state$.pipe( + share({ + connector: () => new ReplaySubject(1), + resetOnRefCountZero: () => timer(keyDefinition.cleanupDelayMs), + }), + ); + } + + this.state$ = state$; + } + + async update( + configureState: (state: T | null, dependency: TCombine) => T | null, + options: StateUpdateOptions = {}, + ): Promise { + options = populateOptionsWithDefault(options); + if (this.updatePromise != null) { + await this.updatePromise; + } + + try { + this.updatePromise = this.internalUpdate(configureState, options); + return await this.updatePromise; + } finally { + this.updatePromise = null; + } + } + + private async internalUpdate( + configureState: (state: T | null, dependency: TCombine) => T | null, + options: StateUpdateOptions, + ): Promise { + const currentState = await this.getStateForUpdate(); + const combinedDependencies = + options.combineLatestWith != null + ? await firstValueFrom(options.combineLatestWith.pipe(timeout(options.msTimeout))) + : null; + + if (!options.shouldUpdate(currentState, combinedDependencies)) { + return currentState; + } + + const newState = configureState(currentState, combinedDependencies); + await this.doStorageSave(newState, currentState); + return newState; + } + + protected async doStorageSave(newState: T | null, oldState: T) { + if (this.keyDefinition.debug.enableUpdateLogging) { + this.logService.info( + `Updating '${this.key}' from ${oldState == null ? "null" : "non-null"} to ${newState == null ? "null" : "non-null"}`, + ); + } + await this.storageService.save(this.key, newState); + } + + /** For use in update methods, does not wait for update to complete before yielding state. + * The expectation is that that await is already done + */ + private async getStateForUpdate() { + return await getStoredValue(this.key, this.storageService, this.keyDefinition.deserializer); + } +} diff --git a/libs/common/src/platform/state/implementations/util.spec.ts b/libs/state/src/core/implementations/util.spec.ts similarity index 59% rename from libs/common/src/platform/state/implementations/util.spec.ts rename to libs/state/src/core/implementations/util.spec.ts index 266e517702b..447fbef2f8c 100644 --- a/libs/common/src/platform/state/implementations/util.spec.ts +++ b/libs/state/src/core/implementations/util.spec.ts @@ -1,4 +1,4 @@ -import { FakeStorageService } from "../../../../spec/fake-storage.service"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; import { getStoredValue } from "./util"; @@ -19,9 +19,7 @@ describe("getStoredValue", () => { }); it("should deserialize", async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - storageService.save(key, value); + await storageService.save(key, value); const result = await getStoredValue(key, storageService, deserializer); @@ -34,9 +32,7 @@ describe("getStoredValue", () => { }); it("should not deserialize", async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - storageService.save(key, value); + await storageService.save(key, value); const result = await getStoredValue(key, storageService, deserializer); @@ -44,9 +40,7 @@ describe("getStoredValue", () => { }); it("should convert undefined to null", async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - storageService.save(key, undefined); + await storageService.save(key, undefined); const result = await getStoredValue(key, storageService, deserializer); diff --git a/libs/common/src/platform/state/implementations/util.ts b/libs/state/src/core/implementations/util.ts similarity index 100% rename from libs/common/src/platform/state/implementations/util.ts rename to libs/state/src/core/implementations/util.ts diff --git a/libs/state/src/core/index.ts b/libs/state/src/core/index.ts new file mode 100644 index 00000000000..6cf92b7ecc5 --- /dev/null +++ b/libs/state/src/core/index.ts @@ -0,0 +1,19 @@ +export { DeriveDefinition } from "./derive-definition"; +export { DerivedStateProvider } from "./derived-state.provider"; +export { DerivedState } from "./derived-state"; +export { GlobalState } from "./global-state"; +export { StateProvider } from "./state.provider"; +export { GlobalStateProvider } from "./global-state.provider"; +export { ActiveUserState, SingleUserState, CombinedState } from "./user-state"; +export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider"; +export { KeyDefinition, KeyDefinitionOptions } from "./key-definition"; +export { StateUpdateOptions } from "./state-update-options"; +export { UserKeyDefinitionOptions, UserKeyDefinition } from "./user-key-definition"; +export { StateEventRunnerService } from "./state-event-runner.service"; +export { activeMarker } from "./user-state"; +export { StateDefinition } from "./state-definition"; +export { ActiveUserAccessor } from "./active-user.accessor"; + +export * from "./state-definitions"; +export * from "./implementations"; +export * from "./state-event-registrar.service"; diff --git a/libs/common/src/platform/state/key-definition.spec.ts b/libs/state/src/core/key-definition.spec.ts similarity index 100% rename from libs/common/src/platform/state/key-definition.spec.ts rename to libs/state/src/core/key-definition.spec.ts diff --git a/libs/state/src/core/key-definition.ts b/libs/state/src/core/key-definition.ts new file mode 100644 index 00000000000..be52368ac83 --- /dev/null +++ b/libs/state/src/core/key-definition.ts @@ -0,0 +1,183 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Jsonify } from "type-fest"; + +import { array, record } from "@bitwarden/serialization"; + +import { StorageKey } from "../types/state"; + +import { StateDefinition } from "./state-definition"; + +export type DebugOptions = { + /** + * When true, logs will be written that look like the following: + * + * ``` + * "Updating 'global_myState_myKey' from null to non-null" + * "Updating 'user_32265eda-62ff-4797-9ead-22214772f888_myState_myKey' from non-null to null." + * ``` + * + * It does not include the value of the data, only whether it is null or non-null. + */ + enableUpdateLogging?: boolean; + + /** + * When true, logs will be written that look like the following everytime a value is retrieved from storage. + * + * "Retrieving 'global_myState_myKey' from storage, value is null." + * "Retrieving 'user_32265eda-62ff-4797-9ead-22214772f888_myState_myKey' from storage, value is non-null." + */ + enableRetrievalLogging?: boolean; +}; + +/** + * A set of options for customizing the behavior of a {@link KeyDefinition} + */ +export type KeyDefinitionOptions = { + /** + * A function to use to safely convert your type from json to your expected type. + * + * **Important:** Your data may be serialized/deserialized at any time and this + * callback needs to be able to faithfully re-initialize from the JSON object representation of your type. + * + * @param jsonValue The JSON object representation of your state. + * @returns The fully typed version of your state. + */ + readonly deserializer: (jsonValue: Jsonify) => T | null; + /** + * The number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. + * Defaults to 1000ms. + */ + readonly cleanupDelayMs?: number; + + /** + * Options for configuring the debugging behavior, see individual options for more info. + */ + readonly debug?: DebugOptions; +}; + +/** + * KeyDefinitions describe the precise location to store data for a given piece of state. + * The StateDefinition is used to describe the domain of the state, and the KeyDefinition + * sub-divides that domain into specific keys. + */ +export class KeyDefinition { + readonly debug: Required; + + /** + * Creates a new instance of a KeyDefinition + * @param stateDefinition The state definition for which this key belongs to. + * @param key The name of the key, this should be unique per domain. + * @param options A set of options to customize the behavior of {@link KeyDefinition}. All options are required. + * @param options.deserializer A function to use to safely convert your type from json to your expected type. + * Your data may be serialized/deserialized at any time and this needs callback needs to be able to faithfully re-initialize + * from the JSON object representation of your type. + */ + constructor( + readonly stateDefinition: StateDefinition, + readonly key: string, + private readonly options: KeyDefinitionOptions, + ) { + if (options.deserializer == null) { + throw new Error(`'deserializer' is a required property on key ${this.errorKeyName}`); + } + + if (options.cleanupDelayMs < 0) { + throw new Error( + `'cleanupDelayMs' must be greater than or equal to 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `, + ); + } + + // Normalize optional debug options + const { enableUpdateLogging = false, enableRetrievalLogging = false } = options.debug ?? {}; + this.debug = { + enableUpdateLogging, + enableRetrievalLogging, + }; + } + + /** + * Gets the deserializer configured for this {@link KeyDefinition} + */ + get deserializer() { + return this.options.deserializer; + } + + /** + * Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. + */ + get cleanupDelayMs() { + return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000); + } + + /** + * Creates a {@link KeyDefinition} for state that is an array. + * @param stateDefinition The state definition to be added to the KeyDefinition + * @param key The key to be added to the KeyDefinition + * @param options The options to customize the final {@link KeyDefinition}. + * @returns A {@link KeyDefinition} initialized for arrays, the options run + * the deserializer on the provided options for each element of an array. + * + * @example + * ```typescript + * const MY_KEY = KeyDefinition.array(MY_STATE, "key", { + * deserializer: (myJsonElement) => convertToElement(myJsonElement), + * }); + * ``` + */ + static array( + stateDefinition: StateDefinition, + key: string, + // We have them provide options for the element of the array, depending on future options we add, this could get a little weird. + options: KeyDefinitionOptions, // The array helper forces an initialValue of an empty array + ) { + return new KeyDefinition(stateDefinition, key, { + ...options, + deserializer: array((e) => options.deserializer(e)), + }); + } + + /** + * Creates a {@link KeyDefinition} for state that is a record. + * @param stateDefinition The state definition to be added to the KeyDefinition + * @param key The key to be added to the KeyDefinition + * @param options The options to customize the final {@link KeyDefinition}. + * @returns A {@link KeyDefinition} that contains a serializer that will run the provided deserializer for each + * value in a record and returns every key as a string. + * + * @example + * ```typescript + * const MY_KEY = KeyDefinition.record(MY_STATE, "key", { + * deserializer: (myJsonValue) => convertToValue(myJsonValue), + * }); + * ``` + */ + static record( + stateDefinition: StateDefinition, + key: string, + // We have them provide options for the value of the record, depending on future options we add, this could get a little weird. + options: KeyDefinitionOptions, // The array helper forces an initialValue of an empty record + ) { + return new KeyDefinition>(stateDefinition, key, { + ...options, + deserializer: record((v) => options.deserializer(v)), + }); + } + + get fullName() { + return `${this.stateDefinition.name}_${this.key}`; + } + + protected get errorKeyName() { + return `${this.stateDefinition.name} > ${this.key}`; + } +} + +/** + * Creates a {@link StorageKey} + * @param keyDefinition The key definition of which data the key should point to. + * @returns A key that is ready to be used in a storage service to get data. + */ +export function globalKeyBuilder(keyDefinition: KeyDefinition): StorageKey { + return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}` as StorageKey; +} diff --git a/libs/state/src/core/state-definition.ts b/libs/state/src/core/state-definition.ts new file mode 100644 index 00000000000..de28d7d11f8 --- /dev/null +++ b/libs/state/src/core/state-definition.ts @@ -0,0 +1,21 @@ +import { StorageLocation, ClientLocations } from "@bitwarden/storage-core"; + +/** + * Defines the base location and instruction of where this state is expected to be located. + */ +export class StateDefinition { + readonly storageLocationOverrides: Partial; + + /** + * Creates a new instance of {@link StateDefinition}, the creation of which is owned by the platform team. + * @param name The name of the state, this needs to be unique from all other {@link StateDefinition}'s. + * @param defaultStorageLocation The location of where this state should be stored. + */ + constructor( + readonly name: string, + readonly defaultStorageLocation: StorageLocation, + storageLocationOverrides?: Partial, + ) { + this.storageLocationOverrides = storageLocationOverrides ?? {}; + } +} diff --git a/libs/common/src/platform/state/state-definitions.spec.ts b/libs/state/src/core/state-definitions.spec.ts similarity index 100% rename from libs/common/src/platform/state/state-definitions.spec.ts rename to libs/state/src/core/state-definitions.spec.ts diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/state/src/core/state-definitions.ts similarity index 97% rename from libs/common/src/platform/state/state-definitions.ts rename to libs/state/src/core/state-definitions.ts index a1c3ee35c5c..5fcb39e0356 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -164,9 +164,13 @@ export const SEND_ACCESS_AUTH_MEMORY = new StateDefinition("sendAccessAuth", "me // Vault -export const COLLECTION_DATA = new StateDefinition("collection", "disk", { +export const COLLECTION_DISK = new StateDefinition("collection", "disk", { web: "memory", }); +export const COLLECTION_MEMORY = new StateDefinition("decryptedCollections", "memory", { + browser: "memory-large-object", +}); + export const FOLDER_DISK = new StateDefinition("folder", "disk", { web: "memory" }); export const FOLDER_MEMORY = new StateDefinition("decryptedFolders", "memory", { browser: "memory-large-object", diff --git a/libs/common/src/platform/state/state-event-registrar.service.spec.ts b/libs/state/src/core/state-event-registrar.service.spec.ts similarity index 97% rename from libs/common/src/platform/state/state-event-registrar.service.spec.ts rename to libs/state/src/core/state-event-registrar.service.spec.ts index b022e2ce413..e79269077d3 100644 --- a/libs/common/src/platform/state/state-event-registrar.service.spec.ts +++ b/libs/state/src/core/state-event-registrar.service.spec.ts @@ -1,13 +1,12 @@ import { mock } from "jest-mock-extended"; +import { FakeGlobalStateProvider } from "@bitwarden/state-test-utils"; import { AbstractStorageService, ObservableStorageService, StorageServiceProvider, } from "@bitwarden/storage-core"; -import { FakeGlobalStateProvider } from "../../../spec"; - import { StateDefinition } from "./state-definition"; import { STATE_LOCK_EVENT, StateEventRegistrarService } from "./state-event-registrar.service"; import { UserKeyDefinition } from "./user-key-definition"; diff --git a/libs/state/src/core/state-event-registrar.service.ts b/libs/state/src/core/state-event-registrar.service.ts new file mode 100644 index 00000000000..5e21fe1fcf7 --- /dev/null +++ b/libs/state/src/core/state-event-registrar.service.ts @@ -0,0 +1,76 @@ +import { PossibleLocation, StorageServiceProvider } from "@bitwarden/storage-core"; + +import { GlobalState } from "./global-state"; +import { GlobalStateProvider } from "./global-state.provider"; +import { KeyDefinition } from "./key-definition"; +import { CLEAR_EVENT_DISK } from "./state-definitions"; +import { ClearEvent, UserKeyDefinition } from "./user-key-definition"; + +export type StateEventInfo = { + state: string; + key: string; + location: PossibleLocation; +}; + +export const STATE_LOCK_EVENT = KeyDefinition.array(CLEAR_EVENT_DISK, "lock", { + deserializer: (e) => e, +}); + +export const STATE_LOGOUT_EVENT = KeyDefinition.array(CLEAR_EVENT_DISK, "logout", { + deserializer: (e) => e, +}); + +export class StateEventRegistrarService { + private readonly stateEventStateMap: { [Prop in ClearEvent]: GlobalState }; + + constructor( + globalStateProvider: GlobalStateProvider, + private storageServiceProvider: StorageServiceProvider, + ) { + this.stateEventStateMap = { + lock: globalStateProvider.get(STATE_LOCK_EVENT), + logout: globalStateProvider.get(STATE_LOGOUT_EVENT), + }; + } + + async registerEvents(keyDefinition: UserKeyDefinition) { + for (const clearEvent of keyDefinition.clearOn) { + const eventState = this.stateEventStateMap[clearEvent]; + // Determine the storage location for this + const [storageLocation] = this.storageServiceProvider.get( + keyDefinition.stateDefinition.defaultStorageLocation, + keyDefinition.stateDefinition.storageLocationOverrides, + ); + + const newEvent: StateEventInfo = { + state: keyDefinition.stateDefinition.name, + key: keyDefinition.key, + location: storageLocation, + }; + + // Only update the event state if the existing list doesn't have a matching entry + await eventState.update( + (existingTickets) => { + existingTickets ??= []; + existingTickets.push(newEvent); + return existingTickets; + }, + { + shouldUpdate: (currentTickets) => { + return ( + // If the current tickets are null, then it will for sure be added + currentTickets == null || + // If an existing match couldn't be found, we also need to add one + currentTickets.findIndex( + (e) => + e.state === newEvent.state && + e.key === newEvent.key && + e.location === newEvent.location, + ) === -1 + ); + }, + }, + ); + } + } +} diff --git a/libs/common/src/platform/state/state-event-runner.service.spec.ts b/libs/state/src/core/state-event-runner.service.spec.ts similarity index 95% rename from libs/common/src/platform/state/state-event-runner.service.spec.ts rename to libs/state/src/core/state-event-runner.service.spec.ts index 4aef3d8516c..7a7ddb2d9f5 100644 --- a/libs/common/src/platform/state/state-event-runner.service.spec.ts +++ b/libs/state/src/core/state-event-runner.service.spec.ts @@ -1,13 +1,12 @@ import { mock } from "jest-mock-extended"; +import { FakeGlobalStateProvider } from "@bitwarden/state-test-utils"; import { AbstractStorageService, ObservableStorageService, StorageServiceProvider, } from "@bitwarden/storage-core"; - -import { FakeGlobalStateProvider } from "../../../spec"; -import { UserId } from "../../types/guid"; +import { UserId } from "@bitwarden/user-core"; import { STATE_LOCK_EVENT } from "./state-event-registrar.service"; import { StateEventRunnerService } from "./state-event-runner.service"; diff --git a/libs/state/src/core/state-event-runner.service.ts b/libs/state/src/core/state-event-runner.service.ts new file mode 100644 index 00000000000..046816a2ce9 --- /dev/null +++ b/libs/state/src/core/state-event-runner.service.ts @@ -0,0 +1,82 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { firstValueFrom } from "rxjs"; + +import { StorageServiceProvider, StorageLocation } from "@bitwarden/storage-core"; +import { UserId } from "@bitwarden/user-core"; + +import { GlobalState } from "./global-state"; +import { GlobalStateProvider } from "./global-state.provider"; +import { StateDefinition } from "./state-definition"; +import { + STATE_LOCK_EVENT, + STATE_LOGOUT_EVENT, + StateEventInfo, +} from "./state-event-registrar.service"; +import { ClearEvent, UserKeyDefinition } from "./user-key-definition"; + +export class StateEventRunnerService { + private readonly stateEventMap: { [Prop in ClearEvent]: GlobalState }; + + constructor( + globalStateProvider: GlobalStateProvider, + private storageServiceProvider: StorageServiceProvider, + ) { + this.stateEventMap = { + lock: globalStateProvider.get(STATE_LOCK_EVENT), + logout: globalStateProvider.get(STATE_LOGOUT_EVENT), + }; + } + + async handleEvent(event: ClearEvent, userId: UserId) { + let tickets = await firstValueFrom(this.stateEventMap[event].state$); + tickets ??= []; + + const failures: string[] = []; + + for (const ticket of tickets) { + try { + const [, service] = this.storageServiceProvider.get( + ticket.location, + {}, // The storage location is already the computed storage location for this client + ); + + const ticketStorageKey = this.storageKeyFor(userId, ticket); + + // Evaluate current value so we can avoid writing to state if we don't need to + const currentValue = await service.get(ticketStorageKey); + if (currentValue != null) { + await service.remove(ticketStorageKey); + } + } catch (err: unknown) { + let errorMessage = "Unknown Error"; + if (typeof err === "object" && "message" in err && typeof err.message === "string") { + errorMessage = err.message; + } + + failures.push( + `${errorMessage} in ${ticket.state} > ${ticket.key} located ${ticket.location}`, + ); + } + } + + if (failures.length > 0) { + // Throw aggregated error + throw new Error( + `One or more errors occurred while handling event '${event}' for user ${userId}.\n${failures.join("\n")}`, + ); + } + } + + private storageKeyFor(userId: UserId, ticket: StateEventInfo) { + const userKey = new UserKeyDefinition( + new StateDefinition(ticket.state, ticket.location as unknown as StorageLocation), + ticket.key, + { + deserializer: (v) => v, + clearOn: [], + }, + ); + return userKey.buildKey(userId); + } +} diff --git a/libs/common/src/platform/state/state-update-options.ts b/libs/state/src/core/state-update-options.ts similarity index 100% rename from libs/common/src/platform/state/state-update-options.ts rename to libs/state/src/core/state-update-options.ts diff --git a/libs/state/src/core/state.provider.ts b/libs/state/src/core/state.provider.ts new file mode 100644 index 00000000000..c6d9942931d --- /dev/null +++ b/libs/state/src/core/state.provider.ts @@ -0,0 +1,81 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +import { DerivedStateDependencies } from "../types/state"; + +import { DeriveDefinition } from "./derive-definition"; +import { DerivedState } from "./derived-state"; +import { GlobalState } from "./global-state"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs +import { GlobalStateProvider } from "./global-state.provider"; +import { KeyDefinition } from "./key-definition"; +import { UserKeyDefinition } from "./user-key-definition"; +import { ActiveUserState, SingleUserState } from "./user-state"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in docs +import { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider"; + +/** Convenience wrapper class for {@link ActiveUserStateProvider}, {@link SingleUserStateProvider}, + * and {@link GlobalStateProvider}. + */ +export abstract class StateProvider { + /** @see{@link ActiveUserStateProvider.activeUserId$} */ + abstract activeUserId$: Observable; + + /** + * Gets a state observable for a given key and userId. + * + * @remarks If userId is falsy the observable returned will attempt to point to the currently active user _and not update if the active user changes_. + * This is different to how `getActive` works and more similar to `getUser` for whatever user happens to be active at the time of the call. + * If no user happens to be active at the time this method is called with a falsy userId then this observable will not emit a value until + * a user becomes active. If you are not confident a user is active at the time this method is called, you may want to pipe a call to `timeout` + * or instead call {@link getUserStateOrDefault$} and supply a value you would rather have given in the case of no passed in userId and no active user. + * + * @param keyDefinition - The key definition for the state you want to get. + * @param userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned. + */ + abstract getUserState$(keyDefinition: UserKeyDefinition, userId?: UserId): Observable; + + /** + * Gets a state observable for a given key and userId + * + * @remarks If userId is falsy the observable return will first attempt to point to the currently active user but will not follow subsequent active user changes, + * if there is no immediately available active user, then it will fallback to returning a default value in an observable that immediately completes. + * + * @param keyDefinition - The key definition for the state you want to get. + * @param config.userId - The userId for which you want the state for. If not provided, the state for the currently active user will be returned. + * @param config.defaultValue - The default value that should be wrapped in an observable if no active user is immediately available and no truthy userId is passed in. + */ + abstract getUserStateOrDefault$( + keyDefinition: UserKeyDefinition, + config: { userId: UserId | undefined; defaultValue?: T }, + ): Observable; + + /** + * Sets the state for a given key and userId. + * + * @overload + * @param keyDefinition - The key definition for the state you want to set. + * @param value - The value to set the state to. + * @param userId - The userId for which you want to set the state for. If not provided, the state for the currently active user will be set. + */ + abstract setUserState( + keyDefinition: UserKeyDefinition, + value: T | null, + userId?: UserId, + ): Promise<[UserId, T | null]>; + + /** @see{@link ActiveUserStateProvider.get} */ + abstract getActive(userKeyDefinition: UserKeyDefinition): ActiveUserState; + + /** @see{@link SingleUserStateProvider.get} */ + abstract getUser(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState; + + /** @see{@link GlobalStateProvider.get} */ + abstract getGlobal(keyDefinition: KeyDefinition): GlobalState; + abstract getDerived( + parentState$: Observable, + deriveDefinition: DeriveDefinition, + dependencies: TDeps, + ): DerivedState; +} diff --git a/libs/state/src/core/storage/memory-storage.service.ts b/libs/state/src/core/storage/memory-storage.service.ts new file mode 100644 index 00000000000..53810f11d22 --- /dev/null +++ b/libs/state/src/core/storage/memory-storage.service.ts @@ -0,0 +1 @@ +export { SerializedMemoryStorageService as MemoryStorageService } from "@bitwarden/storage-core"; diff --git a/libs/state/src/core/user-key-definition.ts b/libs/state/src/core/user-key-definition.ts new file mode 100644 index 00000000000..e1c4b02d86a --- /dev/null +++ b/libs/state/src/core/user-key-definition.ts @@ -0,0 +1,143 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { isGuid } from "@bitwarden/guid"; +import { array, record } from "@bitwarden/serialization"; +import { UserId } from "@bitwarden/user-core"; + +import { StorageKey } from "../types/state"; + +import { DebugOptions, KeyDefinitionOptions } from "./key-definition"; +import { StateDefinition } from "./state-definition"; + +export type ClearEvent = "lock" | "logout"; + +export type UserKeyDefinitionOptions = KeyDefinitionOptions & { + clearOn: ClearEvent[]; +}; + +const USER_KEY_DEFINITION_MARKER: unique symbol = Symbol("UserKeyDefinition"); + +export class UserKeyDefinition { + readonly [USER_KEY_DEFINITION_MARKER] = true; + /** + * A unique array of events that the state stored at this key should be cleared on. + */ + readonly clearOn: ClearEvent[]; + + /** + * Normalized options used for debugging purposes. + */ + readonly debug: Required; + + constructor( + readonly stateDefinition: StateDefinition, + readonly key: string, + private readonly options: UserKeyDefinitionOptions, + ) { + if (options.deserializer == null) { + throw new Error(`'deserializer' is a required property on key ${this.errorKeyName}`); + } + + if (options.cleanupDelayMs < 0) { + throw new Error( + `'cleanupDelayMs' must be greater than or equal to 0. Value of ${options.cleanupDelayMs} passed to key ${this.errorKeyName} `, + ); + } + + // Filter out repeat values + this.clearOn = Array.from(new Set(options.clearOn)); + + // Normalize optional debug options + const { enableUpdateLogging = false, enableRetrievalLogging = false } = options.debug ?? {}; + this.debug = { + enableUpdateLogging, + enableRetrievalLogging, + }; + } + + /** + * Gets the deserializer configured for this {@link KeyDefinition} + */ + get deserializer() { + return this.options.deserializer; + } + + /** + * Gets the number of milliseconds to wait before cleaning up the state after the last subscriber has unsubscribed. + */ + get cleanupDelayMs() { + return this.options.cleanupDelayMs < 0 ? 0 : (this.options.cleanupDelayMs ?? 1000); + } + + /** + * Creates a {@link UserKeyDefinition} for state that is an array. + * @param stateDefinition The state definition to be added to the UserKeyDefinition + * @param key The key to be added to the KeyDefinition + * @param options The options to customize the final {@link UserKeyDefinition}. + * @returns A {@link UserKeyDefinition} initialized for arrays, the options run + * the deserializer on the provided options for each element of an array + * **unless that array is null, in which case it will return an empty list.** + * + * @example + * ```typescript + * const MY_KEY = UserKeyDefinition.array(MY_STATE, "key", { + * deserializer: (myJsonElement) => convertToElement(myJsonElement), + * }); + * ``` + */ + static array( + stateDefinition: StateDefinition, + key: string, + // We have them provide options for the element of the array, depending on future options we add, this could get a little weird. + options: UserKeyDefinitionOptions, + ) { + return new UserKeyDefinition(stateDefinition, key, { + ...options, + deserializer: array((e) => options.deserializer(e)), + }); + } + + /** + * Creates a {@link UserKeyDefinition} for state that is a record. + * @param stateDefinition The state definition to be added to the UserKeyDefinition + * @param key The key to be added to the KeyDefinition + * @param options The options to customize the final {@link UserKeyDefinition}. + * @returns A {@link UserKeyDefinition} that contains a serializer that will run the provided deserializer for each + * value in a record and returns every key as a string **unless that record is null, in which case it will return an record.** + * + * @example + * ```typescript + * const MY_KEY = UserKeyDefinition.record(MY_STATE, "key", { + * deserializer: (myJsonValue) => convertToValue(myJsonValue), + * }); + * ``` + */ + static record( + stateDefinition: StateDefinition, + key: string, + // We have them provide options for the value of the record, depending on future options we add, this could get a little weird. + options: UserKeyDefinitionOptions, // The array helper forces an initialValue of an empty record + ) { + return new UserKeyDefinition>(stateDefinition, key, { + ...options, + deserializer: record((v) => options.deserializer(v)), + }); + } + + get fullName() { + return `${this.stateDefinition.name}_${this.key}`; + } + + buildKey(userId: UserId) { + if (!isGuid(userId)) { + throw new Error( + `You cannot build a user key without a valid UserId, building for key ${this.fullName}`, + ); + } + return `user_${userId}_${this.stateDefinition.name}_${this.key}` as StorageKey; + } + + private get errorKeyName() { + return `${this.stateDefinition.name} > ${this.key}`; + } +} diff --git a/libs/state/src/core/user-state.provider.ts b/libs/state/src/core/user-state.provider.ts new file mode 100644 index 00000000000..82a2f873613 --- /dev/null +++ b/libs/state/src/core/user-state.provider.ts @@ -0,0 +1,35 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +import { UserKeyDefinition } from "./user-key-definition"; +import { ActiveUserState, SingleUserState } from "./user-state"; + +/** A provider for getting an implementation of state scoped to a given key and userId */ +export abstract class SingleUserStateProvider { + /** + * Gets a {@link SingleUserState} scoped to the given {@link UserKeyDefinition} and {@link UserId} + * + * @param userId - The {@link UserId} for which you want the user state for. + * @param userKeyDefinition - The {@link UserKeyDefinition} for which you want the user state for. + */ + abstract get(userId: UserId, userKeyDefinition: UserKeyDefinition): SingleUserState; +} + +/** A provider for getting an implementation of state scoped to a given key, but always pointing + * to the currently active user + */ +export abstract class ActiveUserStateProvider { + /** + * Convenience re-emission of active user ID from {@link AccountService.activeAccount$} + */ + abstract activeUserId$: Observable; + + /** + * Gets a {@link ActiveUserState} scoped to the given {@link KeyDefinition}, but updates when active user changes such + * that the emitted values always represents the state for the currently active user. + * + * @param keyDefinition - The {@link UserKeyDefinition} for which you want the user state for. + */ + abstract get(userKeyDefinition: UserKeyDefinition): ActiveUserState; +} diff --git a/libs/state/src/core/user-state.ts b/libs/state/src/core/user-state.ts new file mode 100644 index 00000000000..43c989ca22c --- /dev/null +++ b/libs/state/src/core/user-state.ts @@ -0,0 +1,64 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/user-core"; + +import { StateUpdateOptions } from "./state-update-options"; + +export type CombinedState = readonly [userId: UserId, state: T]; + +/** A helper object for interacting with state that is scoped to a specific user. */ +export interface UserState { + /** Emits a stream of data. Emits null if the user does not have specified state. */ + readonly state$: Observable; + + /** Emits a stream of tuples, with the first element being a user id and the second element being the data for that user. */ + readonly combinedState$: Observable>; +} + +export const activeMarker: unique symbol = Symbol("active"); + +export interface ActiveUserState extends UserState { + readonly [activeMarker]: true; + + /** + * Emits a stream of data. Emits null if the user does not have specified state. + * Note: Will not emit if there is no active user. + */ + readonly state$: Observable; + + /** + * Updates backing stores for the active user. + * @param configureState function that takes the current state and returns the new state + * @param options Defaults to @see {module:state-update-options#DEFAULT_OPTIONS} + * @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true + * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null + * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. + * + * @returns A promise that must be awaited before your next action to ensure the update has been written to state. + * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. + */ + readonly update: ( + configureState: (state: T | null, dependencies: TCombine) => T | null, + options?: StateUpdateOptions, + ) => Promise<[UserId, T | null]>; +} + +export interface SingleUserState extends UserState { + readonly userId: UserId; + + /** + * Updates backing stores for the active user. + * @param configureState function that takes the current state and returns the new state + * @param options Defaults to @see {module:state-update-options#DEFAULT_OPTIONS} + * @param options.shouldUpdate A callback for determining if you want to update state. Defaults to () => true + * @param options.combineLatestWith An observable that you want to combine with the current state for callbacks. Defaults to null + * @param options.msTimeout A timeout for how long you are willing to wait for a `combineLatestWith` option to complete. Defaults to 1000ms. Only applies if `combineLatestWith` is set. + * + * @returns A promise that must be awaited before your next action to ensure the update has been written to state. + * Resolves to the new state. If `shouldUpdate` returns false, the promise will resolve to the current state. + */ + readonly update: ( + configureState: (state: T | null, dependencies: TCombine) => T | null, + options?: StateUpdateOptions, + ) => Promise; +} diff --git a/libs/state/src/index.ts b/libs/state/src/index.ts new file mode 100644 index 00000000000..d74e7fc137d --- /dev/null +++ b/libs/state/src/index.ts @@ -0,0 +1,4 @@ +// Root barrel for @bitwarden/state +export * from "./core"; +export * from "./state-migrations"; +export * from "./types/state"; diff --git a/libs/state/src/state-migrations/index.ts b/libs/state/src/state-migrations/index.ts new file mode 100644 index 00000000000..cb48631af97 --- /dev/null +++ b/libs/state/src/state-migrations/index.ts @@ -0,0 +1,4 @@ +export * from "./migrate"; +export * from "./migration-builder"; +export * from "./migration-helper"; +export * from "./migrator"; diff --git a/libs/common/src/state-migrations/migrate.spec.ts b/libs/state/src/state-migrations/migrate.spec.ts similarity index 78% rename from libs/common/src/state-migrations/migrate.spec.ts rename to libs/state/src/state-migrations/migrate.spec.ts index a3e1b7ac57c..c0484cce372 100644 --- a/libs/common/src/state-migrations/migrate.spec.ts +++ b/libs/state/src/state-migrations/migrate.spec.ts @@ -1,9 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages -import { LogService } from "../platform/abstractions/log.service"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations -import { AbstractStorageService } from "../platform/abstractions/storage.service"; +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService } from "@bitwarden/storage-core"; import { currentVersion } from "./migrate"; diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/state/src/state-migrations/migrate.ts similarity index 97% rename from libs/common/src/state-migrations/migrate.ts rename to libs/state/src/state-migrations/migrate.ts index 2b484a0fbde..620c2d3bb19 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/state/src/state-migrations/migrate.ts @@ -1,7 +1,5 @@ -// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages -import { LogService } from "../platform/abstractions/log.service"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations -import { AbstractStorageService } from "../platform/abstractions/storage.service"; +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService } from "@bitwarden/storage-core"; import { MigrationBuilder } from "./migration-builder"; import { EverHadUserKeyMigrator } from "./migrations/10-move-ever-had-user-key-to-state-providers"; diff --git a/libs/common/src/state-migrations/migration-builder.spec.ts b/libs/state/src/state-migrations/migration-builder.spec.ts similarity index 98% rename from libs/common/src/state-migrations/migration-builder.spec.ts rename to libs/state/src/state-migrations/migration-builder.spec.ts index 59d85609e03..15e526b9456 100644 --- a/libs/common/src/state-migrations/migration-builder.spec.ts +++ b/libs/state/src/state-migrations/migration-builder.spec.ts @@ -1,7 +1,6 @@ import { mock } from "jest-mock-extended"; -// eslint-disable-next-line import/no-restricted-paths -import { ClientType } from "../enums"; +import { ClientType } from "@bitwarden/client-type"; import { MigrationBuilder } from "./migration-builder"; import { MigrationHelper } from "./migration-helper"; diff --git a/libs/state/src/state-migrations/migration-builder.ts b/libs/state/src/state-migrations/migration-builder.ts new file mode 100644 index 00000000000..b9a1c67cd6d --- /dev/null +++ b/libs/state/src/state-migrations/migration-builder.ts @@ -0,0 +1,106 @@ +import { MigrationHelper } from "./migration-helper"; +import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator"; + +export class MigrationBuilder { + /** Create a new MigrationBuilder with an empty buffer of migrations to perform. + * + * Add migrations to the buffer with {@link with} and {@link rollback}. + * @returns A new MigrationBuilder. + */ + static create(): MigrationBuilder<0> { + return new MigrationBuilder([]); + } + + private constructor( + private migrations: readonly { migrator: Migrator; direction: Direction }[], + ) {} + + /** Add a migrator to the MigrationBuilder. Types are updated such that the chained MigrationBuilder must currently be + * at state version equal to the from version of the migrator. Return as MigrationBuilder where TTo is the to + * version of the migrator, so that the next migrator can be chained. + * + * @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is + * required to instantiate version numbers unless a default constructor is defined. + * @returns A new MigrationBuilder with the to version of the migrator as the current version. + */ + with< + TMigrator extends Migrator, + TFrom extends VersionFrom & TCurrent, + TTo extends VersionTo, + >( + ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo] + ): MigrationBuilder { + return this.addMigrator(migrate, "up"); + } + + /** Add a migrator to rollback on the MigrationBuilder's list of migrations. As with {@link with}, types of + * MigrationBuilder and Migrator must align. However, this time the migration is reversed so TCurrent of the + * MigrationBuilder must be equal to the to version of the migrator. Return as MigrationBuilder where TFrom + * is the from version of the migrator, so that the next migrator can be chained. + * + * @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is + * required to instantiate version numbers unless a default constructor is defined. + * @returns A new MigrationBuilder with the from version of the migrator as the current version. + */ + rollback< + TMigrator extends Migrator, + TFrom extends VersionFrom, + TTo extends VersionTo & TCurrent, + >( + ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom] + ): MigrationBuilder { + if (migrate.length === 3) { + migrate = [migrate[0], migrate[2], migrate[1]]; + } + return this.addMigrator(migrate, "down"); + } + + /** Execute the migrations as defined in the MigrationBuilder's migrator buffer */ + migrate(helper: MigrationHelper): Promise { + return this.migrations.reduce( + (promise, migrator) => + promise.then(async () => { + await this.runMigrator(migrator.migrator, helper, migrator.direction); + }), + Promise.resolve(), + ); + } + + private addMigrator< + TMigrator extends Migrator, + TFrom extends VersionFrom & TCurrent, + TTo extends VersionTo, + >( + migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo], + direction: Direction = "up", + ) { + const newMigration = + migrate.length === 1 + ? { migrator: new migrate[0](), direction } + : { migrator: new migrate[0](migrate[1], migrate[2]), direction }; + + return new MigrationBuilder([...this.migrations, newMigration]); + } + + private async runMigrator( + migrator: Migrator, + helper: MigrationHelper, + direction: Direction, + ): Promise { + const shouldMigrate = await migrator.shouldMigrate(helper, direction); + helper.info( + `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) should migrate: ${shouldMigrate} - ${direction}`, + ); + if (shouldMigrate) { + const method = direction === "up" ? migrator.migrate : migrator.rollback; + await method.bind(migrator)(helper); + helper.info( + `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}`, + ); + await migrator.updateVersion(helper, direction); + helper.info( + `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) updated version - ${direction}`, + ); + } + } +} diff --git a/libs/common/src/state-migrations/migration-helper.spec.ts b/libs/state/src/state-migrations/migration-helper.spec.ts similarity index 93% rename from libs/common/src/state-migrations/migration-helper.spec.ts rename to libs/state/src/state-migrations/migration-helper.spec.ts index 11126c6723a..d811354b0b0 100644 --- a/libs/common/src/state-migrations/migration-helper.spec.ts +++ b/libs/state/src/state-migrations/migration-helper.spec.ts @@ -1,14 +1,10 @@ import { MockProxy, mock } from "jest-mock-extended"; -import { FakeStorageService } from "../../spec/fake-storage.service"; -// eslint-disable-next-line import/no-restricted-paths -- Needed client type enum -import { ClientType } from "../enums"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages -import { LogService } from "../platform/abstractions/log.service"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations -import { AbstractStorageService } from "../platform/abstractions/storage.service"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to generate unique strings for injection -import { Utils } from "../platform/misc/utils"; +import { ClientType } from "@bitwarden/client-type"; +import { newGuid } from "@bitwarden/guid"; +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService } from "@bitwarden/storage-core"; +import { FakeStorageService } from "@bitwarden/storage-test-utils"; import { MigrationHelper, MigrationHelperType } from "./migration-helper"; import { Migrator } from "./migrator"; @@ -294,8 +290,8 @@ function injectData(data: Record, path: string[]): InjectedData } } - const propertyName = `__injectedProperty__${Utils.newGuid()}`; - const propertyValue = `__injectedValue__${Utils.newGuid()}`; + const propertyName = `__injectedProperty__${newGuid()}`; + const propertyValue = `__injectedValue__${newGuid()}`; injectedData.push({ propertyName: propertyName, diff --git a/libs/state/src/state-migrations/migration-helper.ts b/libs/state/src/state-migrations/migration-helper.ts new file mode 100644 index 00000000000..f853671956e --- /dev/null +++ b/libs/state/src/state-migrations/migration-helper.ts @@ -0,0 +1,258 @@ +import { ClientType } from "@bitwarden/client-type"; +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService } from "@bitwarden/storage-core"; + +export type StateDefinitionLike = { name: string }; +export type KeyDefinitionLike = { + stateDefinition: StateDefinitionLike; + key: string; +}; + +export type MigrationHelperType = "general" | "web-disk-local"; + +export class MigrationHelper { + constructor( + public currentVersion: number, + private storageService: AbstractStorageService, + public logService: LogService, + type: MigrationHelperType, + public clientType: ClientType, + ) { + this.type = type; + } + + /** + * On some clients, migrations are ran multiple times without direct action from the migration writer. + * + * All clients will run through migrations at least once, this run is referred to as `"general"`. If a migration is + * ran more than that single time, they will get a unique name if that the write can make conditional logic based on which + * migration run this is. + * + * @remarks The preferrable way of writing migrations is ALWAYS to be defensive and reflect on the data you are given back. This + * should really only be used when reflecting on the data given isn't enough. + */ + type: MigrationHelperType; + + /** + * Gets a value from the storage service at the given key. + * + * This is a brute force method to just get a value from the storage service. If you can use {@link getFromGlobal} or {@link getFromUser}, you should. + * @param key location + * @returns the value at the location + */ + get(key: string): Promise { + return this.storageService.get(key); + } + + /** + * Sets a value in the storage service at the given key. + * + * This is a brute force method to just set a value in the storage service. If you can use {@link setToGlobal} or {@link setToUser}, you should. + * @param key location + * @param value the value to set + * @returns + */ + set(key: string, value: T): Promise { + this.logService.info(`Setting ${key}`); + return this.storageService.save(key, value); + } + + /** + * Remove a value in the storage service at the given key. + * + * This is a brute force method to just remove a value in the storage service. If you can use {@link removeFromGlobal} or {@link removeFromUser}, you should. + * @param key location + * @returns void + */ + remove(key: string): Promise { + this.logService.info(`Removing ${key}`); + return this.storageService.remove(key); + } + + /** + * Gets a globally scoped value from a location derived through the key definition + * + * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, + * use {@link get} for those. + * @param keyDefinition unique key definition + * @returns value from store + */ + getFromGlobal(keyDefinition: KeyDefinitionLike): Promise { + return this.get(this.getGlobalKey(keyDefinition)); + } + + /** + * Sets a globally scoped value to a location derived through the key definition + * + * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, + * use {@link set} for those. + * @param keyDefinition unique key definition + * @param value value to store + * @returns void + */ + setToGlobal(keyDefinition: KeyDefinitionLike, value: T): Promise { + return this.set(this.getGlobalKey(keyDefinition), value); + } + + /** + * Remove a globally scoped location derived through the key definition + * + * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, + * use {@link remove} for those. + * @param keyDefinition unique key definition + * @returns void + */ + removeFromGlobal(keyDefinition: KeyDefinitionLike): Promise { + return this.remove(this.getGlobalKey(keyDefinition)); + } + + /** + * Gets a user scoped value from a location derived through the user id and key definition + * + * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, + * use {@link get} for those. + * @param userId userId to use in the key + * @param keyDefinition unique key definition + * @returns value from store + */ + getFromUser(userId: string, keyDefinition: KeyDefinitionLike): Promise { + return this.get(this.getUserKey(userId, keyDefinition)); + } + + /** + * Sets a user scoped value to a location derived through the user id and key definition + * + * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, + * use {@link set} for those. + * @param userId userId to use in the key + * @param keyDefinition unique key definition + * @param value value to store + * @returns void + */ + setToUser(userId: string, keyDefinition: KeyDefinitionLike, value: T): Promise { + return this.set(this.getUserKey(userId, keyDefinition), value); + } + + /** + * Remove a user scoped location derived through the key definition + * + * This is for use with the state providers framework, DO NOT use for values stored with {@link StateService}, + * use {@link remove} for those. + * @param keyDefinition unique key definition + * @returns void + */ + removeFromUser(userId: string, keyDefinition: KeyDefinitionLike): Promise { + return this.remove(this.getUserKey(userId, keyDefinition)); + } + + info(message: string): void { + this.logService.info(message); + } + + /** + * Helper method to read all Account objects stored by the State Service. + * + * This is useful from creating migrations off of this paradigm, but should not be used once a value is migrated to a state provider. + * + * @returns a list of all accounts that have been authenticated with state service, cast the expected type. + */ + async getAccounts(): Promise< + { userId: string; account: ExpectedAccountType }[] + > { + const userIds = await this.getKnownUserIds(); + return Promise.all( + userIds.map(async (userId) => ({ + userId, + account: await this.get(userId), + })), + ); + } + + /** + * Helper method to read known users ids. + */ + async getKnownUserIds(): Promise { + if (this.currentVersion < 60) { + return knownAccountUserIdsBuilderPre60(this.storageService); + } else { + return knownAccountUserIdsBuilder(this.storageService); + } + } + + /** + * Builds a user storage key appropriate for the current version. + * + * @param userId userId to use in the key + * @param keyDefinition state and key to use in the key + * @returns + */ + private getUserKey(userId: string, keyDefinition: KeyDefinitionLike): string { + if (this.currentVersion < 9) { + return userKeyBuilderPre9(); + } else { + return userKeyBuilder(userId, keyDefinition); + } + } + + /** + * Builds a global storage key appropriate for the current version. + * + * @param keyDefinition state and key to use in the key + * @returns + */ + private getGlobalKey(keyDefinition: KeyDefinitionLike): string { + if (this.currentVersion < 9) { + return globalKeyBuilderPre9(); + } else { + return globalKeyBuilder(keyDefinition); + } + } +} + +/** + * When this is updated, rename this function to `userKeyBuilderXToY` where `X` is the version number it + * became relevant, and `Y` prior to the version it was updated. + * + * Be sure to update the map in `MigrationHelper` to point to the appropriate function for the current version. + * @param userId The userId of the user you want the key to be for. + * @param keyDefinition the key definition of which data the key should point to. + * @returns + */ +function userKeyBuilder(userId: string, keyDefinition: KeyDefinitionLike): string { + return `user_${userId}_${keyDefinition.stateDefinition.name}_${keyDefinition.key}`; +} + +function userKeyBuilderPre9(): string { + throw Error("No key builder should be used for versions prior to 9."); +} + +/** + * When this is updated, rename this function to `globalKeyBuilderXToY` where `X` is the version number + * it became relevant, and `Y` prior to the version it was updated. + * + * Be sure to update the map in `MigrationHelper` to point to the appropriate function for the current version. + * @param keyDefinition the key definition of which data the key should point to. + * @returns + */ +function globalKeyBuilder(keyDefinition: KeyDefinitionLike): string { + return `global_${keyDefinition.stateDefinition.name}_${keyDefinition.key}`; +} + +function globalKeyBuilderPre9(): string { + throw Error("No key builder should be used for versions prior to 9."); +} + +async function knownAccountUserIdsBuilderPre60( + storageService: AbstractStorageService, +): Promise { + return (await storageService.get("authenticatedAccounts")) ?? []; +} + +async function knownAccountUserIdsBuilder( + storageService: AbstractStorageService, +): Promise { + const accounts = await storageService.get>( + globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }), + ); + return Object.keys(accounts ?? {}); +} diff --git a/libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts b/libs/state/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/10-move-ever-had-user-key-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/11-move-org-keys-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/11-move-org-keys-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/11-move-org-keys-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/11-move-org-keys-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/11-move-org-keys-to-state-providers.ts b/libs/state/src/state-migrations/migrations/11-move-org-keys-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/11-move-org-keys-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/11-move-org-keys-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts b/libs/state/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts rename to libs/state/src/state-migrations/migrations/12-move-environment-state-to-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.ts b/libs/state/src/state-migrations/migrations/12-move-environment-state-to-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/12-move-environment-state-to-providers.ts rename to libs/state/src/state-migrations/migrations/12-move-environment-state-to-providers.ts diff --git a/libs/common/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.ts b/libs/state/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/13-move-provider-keys-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.spec.ts b/libs/state/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.spec.ts rename to libs/state/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.ts b/libs/state/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.ts rename to libs/state/src/state-migrations/migrations/14-move-biometric-client-key-half-state-to-providers.ts diff --git a/libs/common/src/state-migrations/migrations/15-move-folder-state-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/15-move-folder-state-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/15-move-folder-state-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/15-move-folder-state-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/15-move-folder-state-to-state-provider.ts b/libs/state/src/state-migrations/migrations/15-move-folder-state-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/15-move-folder-state-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/15-move-folder-state-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/16-move-last-sync-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/16-move-last-sync-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/16-move-last-sync-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/16-move-last-sync-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/16-move-last-sync-to-state-provider.ts b/libs/state/src/state-migrations/migrations/16-move-last-sync-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/16-move-last-sync-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/16-move-last-sync-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.ts b/libs/state/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/17-move-enable-passkeys-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.ts b/libs/state/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/18-move-autofill-settings-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/19-migrate-require-password-on-start.spec.ts b/libs/state/src/state-migrations/migrations/19-migrate-require-password-on-start.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/19-migrate-require-password-on-start.spec.ts rename to libs/state/src/state-migrations/migrations/19-migrate-require-password-on-start.spec.ts diff --git a/libs/common/src/state-migrations/migrations/19-migrate-require-password-on-start.ts b/libs/state/src/state-migrations/migrations/19-migrate-require-password-on-start.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/19-migrate-require-password-on-start.ts rename to libs/state/src/state-migrations/migrations/19-migrate-require-password-on-start.ts diff --git a/libs/common/src/state-migrations/migrations/20-move-private-key-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/20-move-private-key-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/20-move-private-key-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/20-move-private-key-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/20-move-private-key-to-state-providers.ts b/libs/state/src/state-migrations/migrations/20-move-private-key-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/20-move-private-key-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/20-move-private-key-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/21-move-collections-state-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/21-move-collections-state-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/21-move-collections-state-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/21-move-collections-state-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/21-move-collections-state-to-state-provider.ts b/libs/state/src/state-migrations/migrations/21-move-collections-state-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/21-move-collections-state-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/21-move-collections-state-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.ts b/libs/state/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/22-move-collapsed-groupings-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.ts b/libs/state/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/23-move-biometric-prompts-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.ts b/libs/state/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/24-move-sm-onboarding-key-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.ts b/libs/state/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.ts rename to libs/state/src/state-migrations/migrations/25-move-clear-clipboard-to-autofill-settings-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.ts b/libs/state/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/26-revert-move-last-sync-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.ts b/libs/state/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/27-move-badge-settings-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.ts b/libs/state/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/28-move-biometric-unlock-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/28-move-provider-state-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/28-move-provider-state-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/28-move-provider-state-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/28-move-provider-state-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/28-move-provider-state-to-state-provider.ts b/libs/state/src/state-migrations/migrations/28-move-provider-state-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/28-move-provider-state-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/28-move-provider-state-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.ts b/libs/state/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/29-move-user-notification-settings-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/30-move-policy-state-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/30-move-policy-state-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/30-move-policy-state-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/30-move-policy-state-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/30-move-policy-state-to-state-provider.ts b/libs/state/src/state-migrations/migrations/30-move-policy-state-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/30-move-policy-state-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/30-move-policy-state-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.ts b/libs/state/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.ts rename to libs/state/src/state-migrations/migrations/31-move-enable-context-menu-to-autofill-settings-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/32-move-preferred-language.spec.ts b/libs/state/src/state-migrations/migrations/32-move-preferred-language.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/32-move-preferred-language.spec.ts rename to libs/state/src/state-migrations/migrations/32-move-preferred-language.spec.ts diff --git a/libs/common/src/state-migrations/migrations/32-move-preferred-language.ts b/libs/state/src/state-migrations/migrations/32-move-preferred-language.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/32-move-preferred-language.ts rename to libs/state/src/state-migrations/migrations/32-move-preferred-language.ts diff --git a/libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/33-move-app-id-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts b/libs/state/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/33-move-app-id-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts b/libs/state/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/34-move-domain-settings-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/35-move-theme-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.ts b/libs/state/src/state-migrations/migrations/35-move-theme-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/35-move-theme-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/35-move-theme-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts b/libs/state/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/36-move-show-card-and-identity-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts b/libs/state/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/37-move-avatar-color-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts b/libs/state/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts b/libs/state/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/39-move-billing-account-profile-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts b/libs/state/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts rename to libs/state/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts diff --git a/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts b/libs/state/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts rename to libs/state/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts diff --git a/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/40-move-organization-state-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts b/libs/state/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/40-move-organization-state-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts b/libs/state/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts b/libs/state/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts rename to libs/state/src/state-migrations/migrations/42-move-enable-favicon-to-domain-settings-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.ts b/libs/state/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/43-move-auto-confirm-finger-prints-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.ts b/libs/state/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/44-move-user-decryption-options-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/45-merge-environment-state.spec.ts b/libs/state/src/state-migrations/migrations/45-merge-environment-state.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/45-merge-environment-state.spec.ts rename to libs/state/src/state-migrations/migrations/45-merge-environment-state.spec.ts diff --git a/libs/common/src/state-migrations/migrations/45-merge-environment-state.ts b/libs/state/src/state-migrations/migrations/45-merge-environment-state.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/45-merge-environment-state.ts rename to libs/state/src/state-migrations/migrations/45-merge-environment-state.ts diff --git a/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts b/libs/state/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts rename to libs/state/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.spec.ts diff --git a/libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts b/libs/state/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts rename to libs/state/src/state-migrations/migrations/46-delete-orphaned-biometric-prompt-data.ts diff --git a/libs/common/src/state-migrations/migrations/47-move-desktop-settings.spec.ts b/libs/state/src/state-migrations/migrations/47-move-desktop-settings.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/47-move-desktop-settings.spec.ts rename to libs/state/src/state-migrations/migrations/47-move-desktop-settings.spec.ts diff --git a/libs/common/src/state-migrations/migrations/47-move-desktop-settings.ts b/libs/state/src/state-migrations/migrations/47-move-desktop-settings.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/47-move-desktop-settings.ts rename to libs/state/src/state-migrations/migrations/47-move-desktop-settings.ts diff --git a/libs/common/src/state-migrations/migrations/48-move-ddg-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/48-move-ddg-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/48-move-ddg-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/48-move-ddg-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/48-move-ddg-to-state-provider.ts b/libs/state/src/state-migrations/migrations/48-move-ddg-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/48-move-ddg-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/48-move-ddg-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/49-move-account-server-configs.spec.ts b/libs/state/src/state-migrations/migrations/49-move-account-server-configs.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/49-move-account-server-configs.spec.ts rename to libs/state/src/state-migrations/migrations/49-move-account-server-configs.spec.ts diff --git a/libs/common/src/state-migrations/migrations/49-move-account-server-configs.ts b/libs/state/src/state-migrations/migrations/49-move-account-server-configs.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/49-move-account-server-configs.ts rename to libs/state/src/state-migrations/migrations/49-move-account-server-configs.ts diff --git a/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts b/libs/state/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts rename to libs/state/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts diff --git a/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts b/libs/state/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts rename to libs/state/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts diff --git a/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts b/libs/state/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts b/libs/state/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts b/libs/state/src/state-migrations/migrations/52-delete-installed-version.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts rename to libs/state/src/state-migrations/migrations/52-delete-installed-version.spec.ts diff --git a/libs/common/src/state-migrations/migrations/52-delete-installed-version.ts b/libs/state/src/state-migrations/migrations/52-delete-installed-version.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/52-delete-installed-version.ts rename to libs/state/src/state-migrations/migrations/52-delete-installed-version.ts diff --git a/libs/common/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.spec.ts b/libs/state/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.spec.ts rename to libs/state/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.ts b/libs/state/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.ts rename to libs/state/src/state-migrations/migrations/53-migrate-device-trust-svc-to-state-providers.ts diff --git a/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts b/libs/state/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts rename to libs/state/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts diff --git a/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts b/libs/state/src/state-migrations/migrations/54-move-encrypted-sends.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts rename to libs/state/src/state-migrations/migrations/54-move-encrypted-sends.ts diff --git a/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts b/libs/state/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts rename to libs/state/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts b/libs/state/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts rename to libs/state/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts diff --git a/libs/common/src/state-migrations/migrations/56-move-auth-requests.spec.ts b/libs/state/src/state-migrations/migrations/56-move-auth-requests.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/56-move-auth-requests.spec.ts rename to libs/state/src/state-migrations/migrations/56-move-auth-requests.spec.ts diff --git a/libs/common/src/state-migrations/migrations/56-move-auth-requests.ts b/libs/state/src/state-migrations/migrations/56-move-auth-requests.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/56-move-auth-requests.ts rename to libs/state/src/state-migrations/migrations/56-move-auth-requests.ts diff --git a/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts b/libs/state/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.spec.ts b/libs/state/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.spec.ts rename to libs/state/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.spec.ts diff --git a/libs/common/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.ts b/libs/state/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.ts rename to libs/state/src/state-migrations/migrations/58-remove-refresh-token-migrated-state-provider-flag.ts diff --git a/libs/common/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.ts b/libs/state/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/59-move-kdf-config-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts b/libs/state/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts rename to libs/state/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts diff --git a/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts b/libs/state/src/state-migrations/migrations/6-remove-legacy-etm-key.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts rename to libs/state/src/state-migrations/migrations/6-remove-legacy-etm-key.ts diff --git a/libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts b/libs/state/src/state-migrations/migrations/60-known-accounts.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/60-known-accounts.spec.ts rename to libs/state/src/state-migrations/migrations/60-known-accounts.spec.ts diff --git a/libs/common/src/state-migrations/migrations/60-known-accounts.ts b/libs/state/src/state-migrations/migrations/60-known-accounts.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/60-known-accounts.ts rename to libs/state/src/state-migrations/migrations/60-known-accounts.ts diff --git a/libs/common/src/state-migrations/migrations/61-move-pin-state-to-providers.spec.ts b/libs/state/src/state-migrations/migrations/61-move-pin-state-to-providers.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/61-move-pin-state-to-providers.spec.ts rename to libs/state/src/state-migrations/migrations/61-move-pin-state-to-providers.spec.ts diff --git a/libs/common/src/state-migrations/migrations/61-move-pin-state-to-providers.ts b/libs/state/src/state-migrations/migrations/61-move-pin-state-to-providers.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/61-move-pin-state-to-providers.ts rename to libs/state/src/state-migrations/migrations/61-move-pin-state-to-providers.ts diff --git a/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts b/libs/state/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts rename to libs/state/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts diff --git a/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts b/libs/state/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts rename to libs/state/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts diff --git a/libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts b/libs/state/src/state-migrations/migrations/63-migrate-password-settings.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts rename to libs/state/src/state-migrations/migrations/63-migrate-password-settings.spec.ts diff --git a/libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts b/libs/state/src/state-migrations/migrations/63-migrate-password-settings.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts rename to libs/state/src/state-migrations/migrations/63-migrate-password-settings.ts diff --git a/libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts b/libs/state/src/state-migrations/migrations/64-migrate-generator-history.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts rename to libs/state/src/state-migrations/migrations/64-migrate-generator-history.spec.ts diff --git a/libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts b/libs/state/src/state-migrations/migrations/64-migrate-generator-history.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts rename to libs/state/src/state-migrations/migrations/64-migrate-generator-history.ts diff --git a/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts b/libs/state/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts rename to libs/state/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts diff --git a/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts b/libs/state/src/state-migrations/migrations/65-migrate-forwarder-settings.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts rename to libs/state/src/state-migrations/migrations/65-migrate-forwarder-settings.ts diff --git a/libs/common/src/state-migrations/migrations/66-move-final-desktop-settings.spec.ts b/libs/state/src/state-migrations/migrations/66-move-final-desktop-settings.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/66-move-final-desktop-settings.spec.ts rename to libs/state/src/state-migrations/migrations/66-move-final-desktop-settings.spec.ts diff --git a/libs/common/src/state-migrations/migrations/66-move-final-desktop-settings.ts b/libs/state/src/state-migrations/migrations/66-move-final-desktop-settings.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/66-move-final-desktop-settings.ts rename to libs/state/src/state-migrations/migrations/66-move-final-desktop-settings.ts diff --git a/libs/common/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.spec.ts b/libs/state/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.spec.ts rename to libs/state/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.spec.ts diff --git a/libs/common/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.ts b/libs/state/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.ts rename to libs/state/src/state-migrations/migrations/67-remove-unassigned-items-banner-dismissed.ts diff --git a/libs/common/src/state-migrations/migrations/68-move-last-sync-date.spec.ts b/libs/state/src/state-migrations/migrations/68-move-last-sync-date.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/68-move-last-sync-date.spec.ts rename to libs/state/src/state-migrations/migrations/68-move-last-sync-date.spec.ts diff --git a/libs/common/src/state-migrations/migrations/68-move-last-sync-date.ts b/libs/state/src/state-migrations/migrations/68-move-last-sync-date.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/68-move-last-sync-date.ts rename to libs/state/src/state-migrations/migrations/68-move-last-sync-date.ts diff --git a/libs/common/src/state-migrations/migrations/69-migrate-incorrect-folder-key.spec.ts b/libs/state/src/state-migrations/migrations/69-migrate-incorrect-folder-key.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/69-migrate-incorrect-folder-key.spec.ts rename to libs/state/src/state-migrations/migrations/69-migrate-incorrect-folder-key.spec.ts diff --git a/libs/common/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts b/libs/state/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts similarity index 87% rename from libs/common/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts rename to libs/state/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts index 046c0cf0dfa..0600170eb90 100644 --- a/libs/common/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts +++ b/libs/state/src/state-migrations/migrations/69-migrate-incorrect-folder-key.ts @@ -1,8 +1,5 @@ -import { - KeyDefinitionLike, - MigrationHelper, -} from "@bitwarden/common/state-migrations/migration-helper"; -import { Migrator } from "@bitwarden/common/state-migrations/migrator"; +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; const BAD_FOLDER_KEY: KeyDefinitionLike = { key: "folder", // We inadvertently changed the key from "folders" to "folder" diff --git a/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts b/libs/state/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts rename to libs/state/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts diff --git a/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts b/libs/state/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts rename to libs/state/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts diff --git a/libs/common/src/state-migrations/migrations/70-remove-ac-banner-dismissed.spec.ts b/libs/state/src/state-migrations/migrations/70-remove-ac-banner-dismissed.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/70-remove-ac-banner-dismissed.spec.ts rename to libs/state/src/state-migrations/migrations/70-remove-ac-banner-dismissed.spec.ts diff --git a/libs/common/src/state-migrations/migrations/70-remove-ac-banner-dismissed.ts b/libs/state/src/state-migrations/migrations/70-remove-ac-banner-dismissed.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/70-remove-ac-banner-dismissed.ts rename to libs/state/src/state-migrations/migrations/70-remove-ac-banner-dismissed.ts diff --git a/libs/common/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.spec.ts b/libs/state/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.spec.ts rename to libs/state/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.spec.ts diff --git a/libs/common/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.ts b/libs/state/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.ts rename to libs/state/src/state-migrations/migrations/71-remove-new-customization-options-callout-dismissed.ts diff --git a/libs/common/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.spec.ts b/libs/state/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.spec.ts rename to libs/state/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.spec.ts diff --git a/libs/common/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.ts b/libs/state/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.ts rename to libs/state/src/state-migrations/migrations/72-remove-account-deprovisioning-banner-dismissed.ts diff --git a/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts b/libs/state/src/state-migrations/migrations/8-move-state-version.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts rename to libs/state/src/state-migrations/migrations/8-move-state-version.spec.ts diff --git a/libs/common/src/state-migrations/migrations/8-move-state-version.ts b/libs/state/src/state-migrations/migrations/8-move-state-version.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/8-move-state-version.ts rename to libs/state/src/state-migrations/migrations/8-move-state-version.ts diff --git a/libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts b/libs/state/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts rename to libs/state/src/state-migrations/migrations/9-move-browser-settings-to-global.spec.ts diff --git a/libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.ts b/libs/state/src/state-migrations/migrations/9-move-browser-settings-to-global.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/9-move-browser-settings-to-global.ts rename to libs/state/src/state-migrations/migrations/9-move-browser-settings-to-global.ts diff --git a/libs/common/src/state-migrations/migrations/min-version.spec.ts b/libs/state/src/state-migrations/migrations/min-version.spec.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/min-version.spec.ts rename to libs/state/src/state-migrations/migrations/min-version.spec.ts diff --git a/libs/common/src/state-migrations/migrations/min-version.ts b/libs/state/src/state-migrations/migrations/min-version.ts similarity index 100% rename from libs/common/src/state-migrations/migrations/min-version.ts rename to libs/state/src/state-migrations/migrations/min-version.ts diff --git a/libs/common/src/state-migrations/migrator.spec.ts b/libs/state/src/state-migrations/migrator.spec.ts similarity index 84% rename from libs/common/src/state-migrations/migrator.spec.ts rename to libs/state/src/state-migrations/migrator.spec.ts index 4079dc3fda7..762a608dba7 100644 --- a/libs/common/src/state-migrations/migrator.spec.ts +++ b/libs/state/src/state-migrations/migrator.spec.ts @@ -1,11 +1,8 @@ import { mock, MockProxy } from "jest-mock-extended"; -// eslint-disable-next-line import/no-restricted-paths -- Needed client type enum -import { ClientType } from "../enums"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages -import { LogService } from "../platform/abstractions/log.service"; -// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations -import { AbstractStorageService } from "../platform/abstractions/storage.service"; +import { ClientType } from "@bitwarden/client-type"; +import { LogService } from "@bitwarden/logging"; +import { AbstractStorageService } from "@bitwarden/storage-core"; import { MigrationHelper } from "./migration-helper"; import { Migrator } from "./migrator"; diff --git a/libs/common/src/state-migrations/migrator.ts b/libs/state/src/state-migrations/migrator.ts similarity index 100% rename from libs/common/src/state-migrations/migrator.ts rename to libs/state/src/state-migrations/migrator.ts diff --git a/libs/state/src/state.spec.ts b/libs/state/src/state.spec.ts new file mode 100644 index 00000000000..535af9ba987 --- /dev/null +++ b/libs/state/src/state.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("state", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/state/src/types/state.ts b/libs/state/src/types/state.ts new file mode 100644 index 00000000000..b98e3a4e791 --- /dev/null +++ b/libs/state/src/types/state.ts @@ -0,0 +1,5 @@ +import { Opaque } from "type-fest"; + +export type StorageKey = Opaque; + +export type DerivedStateDependencies = Record; diff --git a/libs/state/state_diagram.svg b/libs/state/state_diagram.svg new file mode 100644 index 00000000000..0ba405b6df1 --- /dev/null +++ b/libs/state/state_diagram.svg @@ -0,0 +1,21 @@ + + + + + + + + StateGlobalUserPlatform owned & Platform managedStateDefinitionPlatform owned & Team managedKeyDefinitionTeam owned & Team managedglobaluser_ac06d663-bbbc-4a51-a764-5d105ae6f7cbglobal_stateuser_ac06d663-bbbc-4a51-a764-5d105ae6f7cb_stateglobal_state_keyuser_ac06d663-bbbc-4a51-a764-5d105ae6f7cb_state_key \ No newline at end of file diff --git a/libs/state/tsconfig.eslint.json b/libs/state/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/state/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/state/tsconfig.json b/libs/state/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/state/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/state/tsconfig.lib.json b/libs/state/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/state/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/state/tsconfig.spec.json b/libs/state/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/state/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts index 9a64298ffba..c11ab3d9e6b 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts @@ -1,8 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index 29dccb74f14..7552f675c64 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -2,7 +2,6 @@ import { mock, MockProxy } from "jest-mock-extended"; import * as JSZip from "jszip"; import { BehaviorSubject, of } from "rxjs"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -11,6 +10,7 @@ import { EncryptedString, EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index 4d4ef217c66..2a6a97f5d41 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -4,12 +4,12 @@ import * as JSZip from "jszip"; import * as papa from "papaparse"; import { firstValueFrom } from "rxjs"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { CipherWithIdExport, FolderWithIdExport } from "@bitwarden/common/models/export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index 61fbcd261f4..ba04748b864 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import * as papa from "papaparse"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; import { CollectionService, @@ -10,12 +10,12 @@ import { CollectionDetailsResponse, CollectionView, } from "@bitwarden/admin-console/common"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { CipherWithIdExport, CollectionWithIdExport } from "@bitwarden/common/models/export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -225,15 +225,8 @@ export class OrganizationVaultExportService ): Promise { let decCiphers: CipherView[] = []; let allDecCiphers: CipherView[] = []; - let decCollections: CollectionView[] = []; const promises = []; - promises.push( - this.collectionService.getAllDecrypted().then(async (collections) => { - decCollections = collections.filter((c) => c.organizationId == organizationId && c.manage); - }), - ); - promises.push( this.cipherService.getAllDecrypted(activeUserId).then((ciphers) => { allDecCiphers = ciphers; @@ -241,6 +234,16 @@ export class OrganizationVaultExportService ); await Promise.all(promises); + const decCollections: CollectionView[] = await firstValueFrom( + this.collectionService + .decryptedCollections$(activeUserId) + .pipe( + map((collections) => + collections.filter((c) => c.organizationId == organizationId && c.manage), + ), + ), + ); + const restrictions = await firstValueFrom(this.restrictedItemTypesService.restricted$); decCiphers = allDecCiphers.filter( @@ -263,15 +266,8 @@ export class OrganizationVaultExportService ): Promise { let encCiphers: Cipher[] = []; let allCiphers: Cipher[] = []; - let encCollections: Collection[] = []; const promises = []; - promises.push( - this.collectionService.getAll().then((collections) => { - encCollections = collections.filter((c) => c.organizationId == organizationId && c.manage); - }), - ); - promises.push( this.cipherService.getAll(activeUserId).then((ciphers) => { allCiphers = ciphers; @@ -280,6 +276,15 @@ export class OrganizationVaultExportService await Promise.all(promises); + const encCollections: Collection[] = await firstValueFrom( + this.collectionService.encryptedCollections$(activeUserId).pipe( + map((collections) => collections ?? []), + map((collections) => + collections.filter((c) => c.organizationId == organizationId && c.manage), + ), + ), + ); + const restrictions = await firstValueFrom(this.restrictedItemTypesService.restricted$); encCiphers = allCiphers.filter( diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts index d6300bb9a06..b86486d45e7 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts @@ -1,7 +1,6 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; -import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -10,6 +9,7 @@ import { EncryptedString, EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { UserId } from "@bitwarden/common/types/guid"; diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 6af6d5121fb..0b5d3d70834 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -272,25 +272,29 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { return; } - this.organizations$ = combineLatest({ - collections: this.collectionService.decryptedCollections$, - memberOrganizations: this.accountService.activeAccount$.pipe( + this.organizations$ = this.accountService.activeAccount$ + .pipe( getUserId, - switchMap((userId) => this.organizationService.memberOrganizations$(userId)), - ), - }).pipe( - map(({ collections, memberOrganizations }) => { - const managedCollectionsOrgIds = new Set( - collections.filter((c) => c.manage).map((c) => c.organizationId), - ); - // Filter organizations that exist in managedCollectionsOrgIds - const filteredOrgs = memberOrganizations.filter((org) => - managedCollectionsOrgIds.has(org.id), - ); - // Sort the filtered organizations based on the name - return filteredOrgs.sort(Utils.getSortFunction(this.i18nService, "name")); - }), - ); + switchMap((userId) => + combineLatest({ + collections: this.collectionService.decryptedCollections$(userId), + memberOrganizations: this.organizationService.memberOrganizations$(userId), + }), + ), + ) + .pipe( + map(({ collections, memberOrganizations }) => { + const managedCollectionsOrgIds = new Set( + collections.filter((c) => c.manage).map((c) => c.organizationId), + ); + // Filter organizations that exist in managedCollectionsOrgIds + const filteredOrgs = memberOrganizations.filter((org) => + managedCollectionsOrgIds.has(org.id), + ); + // Sort the filtered organizations based on the name + return filteredOrgs.sort(Utils.getSortFunction(this.i18nService, "name")); + }), + ); combineLatest([ this.disablePersonalVaultExportPolicy$, diff --git a/libs/tools/generator/components/src/generator-services.module.ts b/libs/tools/generator/components/src/generator-services.module.ts index 3a7b771a25d..1088e97e80f 100644 --- a/libs/tools/generator/components/src/generator-services.module.ts +++ b/libs/tools/generator/components/src/generator-services.module.ts @@ -1,11 +1,14 @@ import { NgModule } from "@angular/core"; +import { from, take } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -83,6 +86,7 @@ const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken("S registry: ExtensionRegistry, logger: LogService, environment: PlatformUtilsService, + configService: ConfigService, ) => { let log: LogProvider; if (environment.isDev()) { @@ -102,6 +106,7 @@ const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken("S policy, extension, log, + configService, }; }, deps: [ @@ -111,6 +116,7 @@ const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken("S ExtensionRegistry, LogService, PlatformUtilsService, + ConfigService, ], }), safeProvider({ @@ -130,17 +136,26 @@ const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken("S now: Date.now, } satisfies UserStateSubjectDependencyProvider; + const featureFlagObs$ = from( + system.configService.getFeatureFlag(FeatureFlag.UseSdkPasswordGenerators), + ); + let featureFlag: boolean = false; + featureFlagObs$.pipe(take(1)).subscribe((ff) => (featureFlag = ff)); const metadata = new providers.GeneratorMetadataProvider( userStateDeps, system, Object.values(BuiltIn), ); + + const sdkService = featureFlag ? system.sdk : undefined; const profile = new providers.GeneratorProfileProvider(userStateDeps, system.policy); const generator: providers.GeneratorDependencyProvider = { randomizer: random, client: new RestClient(api, i18n), i18nService: i18n, + sdk: sdkService, + now: Date.now, }; const userState: UserStateSubjectDependencyProvider = { diff --git a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts index a36c4bb5352..1d9f58fddd7 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.spec.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.spec.ts @@ -30,7 +30,7 @@ describe("PasswordRandomizer", () => { describe("randomAscii", () => { it("returns the empty string when no character sets are specified", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 1, @@ -41,7 +41,7 @@ describe("PasswordRandomizer", () => { }); it("generates an uppercase ascii password", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -54,7 +54,7 @@ describe("PasswordRandomizer", () => { }); it("generates an uppercase ascii password without ambiguous characters", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -67,7 +67,7 @@ describe("PasswordRandomizer", () => { }); it("generates a lowercase ascii password", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -80,7 +80,7 @@ describe("PasswordRandomizer", () => { }); it("generates a lowercase ascii password without ambiguous characters", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -93,7 +93,7 @@ describe("PasswordRandomizer", () => { }); it("generates a numeric ascii password", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -106,7 +106,7 @@ describe("PasswordRandomizer", () => { }); it("generates a numeric password without ambiguous characters", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -119,7 +119,7 @@ describe("PasswordRandomizer", () => { }); it("generates a special character password", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -132,7 +132,7 @@ describe("PasswordRandomizer", () => { }); it("generates a special character password without ambiguous characters", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -148,7 +148,7 @@ describe("PasswordRandomizer", () => { [2, "AA"], [3, "AAA"], ])("includes %p uppercase characters", async (uppercase, expected) => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -163,7 +163,7 @@ describe("PasswordRandomizer", () => { [2, "aa"], [3, "aaa"], ])("includes %p lowercase characters", async (lowercase, expected) => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -178,7 +178,7 @@ describe("PasswordRandomizer", () => { [2, "00"], [3, "000"], ])("includes %p digits", async (digits, expected) => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -193,7 +193,7 @@ describe("PasswordRandomizer", () => { [2, "!!"], [3, "!!!"], ])("includes %p special characters", async (special, expected) => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomAscii({ all: 0, @@ -212,7 +212,7 @@ describe("PasswordRandomizer", () => { ])( "mixes character sets for the remaining characters (=%p)", async (setting: Partial, set: CharacterSet) => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); await password.randomAscii({ ...setting, @@ -225,7 +225,7 @@ describe("PasswordRandomizer", () => { ); it("shuffles the password characters", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); // Typically `shuffle` randomizes the order of the array it's been // given. In the password generator, the array is generated from the @@ -247,7 +247,7 @@ describe("PasswordRandomizer", () => { describe("randomEffLongWords", () => { it("generates the empty string when no words are passed", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomEffLongWords({ numberOfWords: 0, @@ -263,7 +263,7 @@ describe("PasswordRandomizer", () => { [1, "foo"], [2, "foofoo"], ])("generates a %i-length word list", async (words, expected) => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomEffLongWords({ numberOfWords: words, @@ -280,7 +280,7 @@ describe("PasswordRandomizer", () => { }); it("capitalizes the word list", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); randomizer.pickWord.mockResolvedValueOnce("Foo"); const result = await password.randomEffLongWords({ @@ -298,7 +298,7 @@ describe("PasswordRandomizer", () => { }); it("includes a random number on a random word", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); randomizer.pickWord.mockResolvedValueOnce("foo"); randomizer.pickWord.mockResolvedValueOnce("foo1"); @@ -324,7 +324,7 @@ describe("PasswordRandomizer", () => { }); it("includes a separator", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.randomEffLongWords({ numberOfWords: 2, @@ -339,7 +339,7 @@ describe("PasswordRandomizer", () => { describe("generate", () => { it("processes password generation options", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.generate( { algorithm: Algorithm.password }, @@ -352,7 +352,7 @@ describe("PasswordRandomizer", () => { }); it("processes passphrase generation options", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = await password.generate( { algorithm: Algorithm.passphrase }, @@ -365,7 +365,7 @@ describe("PasswordRandomizer", () => { }); it("throws when it cannot recognize the options type", async () => { - const password = new PasswordRandomizer(randomizer); + const password = new PasswordRandomizer(randomizer, Date.now); const result = password.generate({ algorithm: Algorithm.username }, {}); diff --git a/libs/tools/generator/core/src/engine/password-randomizer.ts b/libs/tools/generator/core/src/engine/password-randomizer.ts index dc61ee064e1..c5e747477e2 100644 --- a/libs/tools/generator/core/src/engine/password-randomizer.ts +++ b/libs/tools/generator/core/src/engine/password-randomizer.ts @@ -25,7 +25,10 @@ export class PasswordRandomizer /** Instantiates the password randomizer * @param randomizer data source for random data */ - constructor(private randomizer: Randomizer) {} + constructor( + private randomizer: Randomizer, + private currentTime: () => number, + ) {} /** create a password from ASCII codepoints * @param request refines the generated password @@ -88,7 +91,7 @@ export class PasswordRandomizer return new GeneratedCredential( password, Type.password, - Date.now(), + this.currentTime(), request.source, request.website, ); @@ -99,7 +102,7 @@ export class PasswordRandomizer return new GeneratedCredential( passphrase, Type.password, - Date.now(), + this.currentTime(), request.source, request.website, ); diff --git a/libs/tools/generator/core/src/metadata/data.ts b/libs/tools/generator/core/src/metadata/data.ts index 5ac6cac7222..2b9dad50557 100644 --- a/libs/tools/generator/core/src/metadata/data.ts +++ b/libs/tools/generator/core/src/metadata/data.ts @@ -8,12 +8,6 @@ export const Algorithm = Object.freeze({ /** A password composed of random words from the EFF word list */ passphrase: "passphrase", - /** A password composed of random characters, retrieved from SDK */ - sdkPassword: "sdkPassword", - - /** A password composed of random words from the EFF word list, retrieved from SDK */ - sdkPassphrase: "sdkPassphrase", - /** A username composed of words from the EFF word list */ username: "username", @@ -44,12 +38,7 @@ export const Profile = Object.freeze({ /** Credential generation algorithms grouped by purpose. */ export const AlgorithmsByType = deepFreeze({ /** Algorithms that produce passwords */ - [Type.password]: [ - Algorithm.password, - Algorithm.passphrase, - Algorithm.sdkPassword, - Algorithm.sdkPassphrase, - ] as const, + [Type.password]: [Algorithm.password, Algorithm.passphrase] as const, /** Algorithms that produce usernames */ [Type.username]: [Algorithm.username] as const, diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts index 0c0693af272..bdf021c50f3 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts @@ -3,7 +3,7 @@ import { mock } from "jest-mock-extended"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { PasswordRandomizer } from "../../engine"; +import { PasswordRandomizer, SdkPasswordRandomizer } from "../../engine"; import { PassphrasePolicyConstraints } from "../../policies"; import { GeneratorDependencyProvider } from "../../providers"; import { PassphraseGenerationOptions } from "../../types"; @@ -17,8 +17,18 @@ const dependencyProvider = mock(); describe("password - eff words generator metadata", () => { describe("engine.create", () => { - it("returns an email randomizer", () => { - expect(effPassphrase.engine.create(dependencyProvider)).toBeInstanceOf(PasswordRandomizer); + it("returns an sdk password randomizer", () => { + expect(effPassphrase.engine.create(dependencyProvider)).toBeInstanceOf(SdkPasswordRandomizer); + }); + }); + + describe("engine.create", () => { + const nonSdkDependencyProvider = mock(); + nonSdkDependencyProvider.sdk = undefined; + it("returns a password randomizer", () => { + expect(effPassphrase.engine.create(nonSdkDependencyProvider)).toBeInstanceOf( + PasswordRandomizer, + ); }); }); diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts index 021112f7c89..fc96ce46c2b 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.ts @@ -3,7 +3,7 @@ import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; -import { PasswordRandomizer } from "../../engine"; +import { PasswordRandomizer, SdkPasswordRandomizer } from "../../engine"; import { passphraseLeastPrivilege, PassphrasePolicyConstraints } from "../../policies"; import { GeneratorDependencyProvider } from "../../providers"; import { CredentialGenerator, PassphraseGenerationOptions } from "../../types"; @@ -30,7 +30,10 @@ const passphrase: GeneratorMetadata = { create( dependencies: GeneratorDependencyProvider, ): CredentialGenerator { - return new PasswordRandomizer(dependencies.randomizer); + if (dependencies.sdk == undefined) { + return new PasswordRandomizer(dependencies.randomizer, dependencies.now); + } + return new SdkPasswordRandomizer(dependencies.sdk, dependencies.now); }, }, profiles: { diff --git a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts index b22f3e9356d..9efd5350c21 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts @@ -3,7 +3,7 @@ import { mock } from "jest-mock-extended"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { PasswordRandomizer } from "../../engine"; +import { PasswordRandomizer, SdkPasswordRandomizer } from "../../engine"; import { DynamicPasswordPolicyConstraints } from "../../policies"; import { GeneratorDependencyProvider } from "../../providers"; import { PasswordGenerationOptions } from "../../types"; @@ -17,8 +17,16 @@ const dependencyProvider = mock(); describe("password - characters generator metadata", () => { describe("engine.create", () => { - it("returns an email randomizer", () => { - expect(password.engine.create(dependencyProvider)).toBeInstanceOf(PasswordRandomizer); + it("returns an sdk password randomizer", () => { + expect(password.engine.create(dependencyProvider)).toBeInstanceOf(SdkPasswordRandomizer); + }); + }); + + describe("engine.create", () => { + const nonSdkDependencyProvider = mock(); + nonSdkDependencyProvider.sdk = undefined; + it("returns a password randomizer", () => { + expect(password.engine.create(nonSdkDependencyProvider)).toBeInstanceOf(PasswordRandomizer); }); }); diff --git a/libs/tools/generator/core/src/metadata/password/random-password.ts b/libs/tools/generator/core/src/metadata/password/random-password.ts index e446f1962a5..721be8dc3f0 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.ts @@ -3,7 +3,7 @@ import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { deepFreeze } from "@bitwarden/common/tools/util"; -import { PasswordRandomizer } from "../../engine"; +import { PasswordRandomizer, SdkPasswordRandomizer } from "../../engine"; import { DynamicPasswordPolicyConstraints, passwordLeastPrivilege } from "../../policies"; import { GeneratorDependencyProvider } from "../../providers"; import { CredentialGenerator, PasswordGeneratorSettings } from "../../types"; @@ -30,7 +30,10 @@ const password: GeneratorMetadata = deepFreeze({ create( dependencies: GeneratorDependencyProvider, ): CredentialGenerator { - return new PasswordRandomizer(dependencies.randomizer); + if (dependencies.sdk == undefined) { + return new PasswordRandomizer(dependencies.randomizer, dependencies.now); + } + return new SdkPasswordRandomizer(dependencies.sdk, dependencies.now); }, }, profiles: { diff --git a/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.spec.ts deleted file mode 100644 index 29378b4cdd3..00000000000 --- a/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { mock } from "jest-mock-extended"; - -import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; - -import { SdkPasswordRandomizer } from "../../engine"; -import { PassphrasePolicyConstraints } from "../../policies"; -import { GeneratorDependencyProvider } from "../../providers"; -import { PassphraseGenerationOptions } from "../../types"; -import { Profile } from "../data"; -import { CoreProfileMetadata } from "../profile-metadata"; -import { isCoreProfile } from "../util"; - -import sdkEffPassphrase from "./sdk-eff-word-list"; - -const dependencyProvider = mock(); - -describe("password - eff words generator metadata", () => { - describe("engine.create", () => { - it("returns an email randomizer", () => { - expect(sdkEffPassphrase.engine.create(dependencyProvider)).toBeInstanceOf( - SdkPasswordRandomizer, - ); - }); - }); - - describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata | null = null; - beforeEach(() => { - const profile = sdkEffPassphrase.profiles[Profile.account]; - if (isCoreProfile(profile!)) { - accountProfile = profile; - } else { - accountProfile = null; - } - }); - - describe("storage.options.deserializer", () => { - it("returns its input", () => { - const value: PassphraseGenerationOptions = { ...accountProfile!.storage.initial }; - - const result = accountProfile!.storage.options.deserializer(value); - - expect(result).toBe(value); - }); - }); - - describe("constraints.create", () => { - // these tests check that the wiring is correct by exercising the behavior - // of functionality encapsulated by `create`. These methods may fail if the - // enclosed behaviors change. - - it("creates a passphrase policy constraints", () => { - const context = { defaultConstraints: accountProfile!.constraints.default }; - - const constraints = accountProfile!.constraints.create([], context); - - expect(constraints).toBeInstanceOf(PassphrasePolicyConstraints); - }); - - it("forwards the policy to the constraints", () => { - const context = { defaultConstraints: accountProfile!.constraints.default }; - const policies = [ - { - type: PolicyType.PasswordGenerator, - data: { - minNumberWords: 6, - capitalize: false, - includeNumber: false, - }, - }, - ] as Policy[]; - - const constraints = accountProfile!.constraints.create(policies, context); - - expect(constraints.constraints.numWords?.min).toEqual(6); - }); - - it("combines multiple policies in the constraints", () => { - const context = { defaultConstraints: accountProfile!.constraints.default }; - const policies = [ - { - type: PolicyType.PasswordGenerator, - data: { - minNumberWords: 6, - capitalize: false, - includeNumber: false, - }, - }, - { - type: PolicyType.PasswordGenerator, - data: { - minNumberWords: 3, - capitalize: true, - includeNumber: false, - }, - }, - ] as Policy[]; - - const constraints = accountProfile!.constraints.create(policies, context); - - expect(constraints.constraints.numWords?.min).toEqual(6); - expect(constraints.constraints.capitalize?.requiredValue).toEqual(true); - }); - }); - }); -}); diff --git a/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.ts b/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.ts deleted file mode 100644 index 9a653f88ede..00000000000 --- a/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; -import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; -import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; -import { BitwardenClient } from "@bitwarden/sdk-internal"; - -import { SdkPasswordRandomizer } from "../../engine"; -import { passphraseLeastPrivilege, PassphrasePolicyConstraints } from "../../policies"; -import { GeneratorDependencyProvider } from "../../providers"; -import { CredentialGenerator, PassphraseGenerationOptions } from "../../types"; -import { Algorithm, Profile, Type } from "../data"; -import { GeneratorMetadata } from "../generator-metadata"; - -const sdkPassphrase: GeneratorMetadata = { - id: Algorithm.sdkPassphrase, - type: Type.password, - weight: 130, - i18nKeys: { - name: "passphrase", - credentialType: "passphrase", - generateCredential: "generatePassphrase", - credentialGenerated: "passphraseGenerated", - copyCredential: "copyPassphrase", - useCredential: "useThisPassphrase", - }, - capabilities: { - autogenerate: false, - fields: [], - }, - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new SdkPasswordRandomizer(new BitwardenClient(null), Date.now); // @TODO hook up a real SDK client - }, - }, - profiles: { - [Profile.account]: { - type: "core", - storage: { - key: "passphraseGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "numWords", - "wordSeparator", - "capitalize", - "includeNumber", - ]), - state: GENERATOR_DISK, - initial: { - numWords: 6, - wordSeparator: "-", - capitalize: false, - includeNumber: false, - }, - options: { - deserializer(value) { - return value; - }, - clearOn: ["logout"], - }, - } satisfies ObjectKey, - constraints: { - type: PolicyType.PasswordGenerator, - default: { - wordSeparator: { maxLength: 1 }, - numWords: { - min: 3, - max: 20, - recommendation: 6, - }, - }, - create(policies, context) { - const initial = { - minNumberWords: 0, - capitalize: false, - includeNumber: false, - }; - const policy = policies.reduce(passphraseLeastPrivilege, initial); - const constraints = new PassphrasePolicyConstraints(policy, context.defaultConstraints); - return constraints; - }, - }, - }, - }, -}; - -export default sdkPassphrase; diff --git a/libs/tools/generator/core/src/metadata/password/sdk-random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/sdk-random-password.spec.ts deleted file mode 100644 index 1e9cf6dbd87..00000000000 --- a/libs/tools/generator/core/src/metadata/password/sdk-random-password.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { mock } from "jest-mock-extended"; - -import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; - -import { SdkPasswordRandomizer } from "../../engine"; -import { DynamicPasswordPolicyConstraints } from "../../policies"; -import { GeneratorDependencyProvider } from "../../providers"; -import { PasswordGenerationOptions } from "../../types"; -import { Profile } from "../data"; -import { CoreProfileMetadata } from "../profile-metadata"; -import { isCoreProfile } from "../util"; - -import sdkPassword from "./sdk-random-password"; - -const dependencyProvider = mock(); - -describe("password - characters generator metadata", () => { - describe("engine.create", () => { - it("returns an email randomizer", () => { - expect(sdkPassword.engine.create(dependencyProvider)).toBeInstanceOf(SdkPasswordRandomizer); - }); - }); - - describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null!; - beforeEach(() => { - const profile = sdkPassword.profiles[Profile.account]; - if (isCoreProfile(profile!)) { - accountProfile = profile; - } else { - throw new Error("this branch should never run"); - } - }); - - describe("storage.options.deserializer", () => { - it("returns its input", () => { - const value: PasswordGenerationOptions = { ...accountProfile.storage.initial }; - - const result = accountProfile.storage.options.deserializer(value); - - expect(result).toBe(value); - }); - }); - - describe("constraints.create", () => { - // these tests check that the wiring is correct by exercising the behavior - // of functionality encapsulated by `create`. These methods may fail if the - // enclosed behaviors change. - - it("creates a passphrase policy constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; - - const constraints = accountProfile.constraints.create([], context); - - expect(constraints).toBeInstanceOf(DynamicPasswordPolicyConstraints); - }); - - it("forwards the policy to the constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; - const policies = [ - { - type: PolicyType.PasswordGenerator, - enabled: true, - data: { - minLength: 10, - capitalize: false, - useNumbers: false, - }, - }, - ] as Policy[]; - - const constraints = accountProfile.constraints.create(policies, context); - - expect(constraints.constraints.length?.min).toEqual(10); - }); - - it("combines multiple policies in the constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; - const policies = [ - { - type: PolicyType.PasswordGenerator, - enabled: true, - data: { - minLength: 14, - useSpecial: false, - useNumbers: false, - }, - }, - { - type: PolicyType.PasswordGenerator, - enabled: true, - data: { - minLength: 10, - useSpecial: true, - includeNumber: false, - }, - }, - ] as Policy[]; - - const constraints = accountProfile.constraints.create(policies, context); - - expect(constraints.constraints.length?.min).toEqual(14); - expect(constraints.constraints.special?.requiredValue).toEqual(true); - }); - }); - }); -}); diff --git a/libs/tools/generator/core/src/metadata/password/sdk-random-password.ts b/libs/tools/generator/core/src/metadata/password/sdk-random-password.ts deleted file mode 100644 index d9e06408d1d..00000000000 --- a/libs/tools/generator/core/src/metadata/password/sdk-random-password.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; -import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; -import { deepFreeze } from "@bitwarden/common/tools/util"; -import { BitwardenClient } from "@bitwarden/sdk-internal"; - -import { SdkPasswordRandomizer } from "../../engine"; -import { DynamicPasswordPolicyConstraints, passwordLeastPrivilege } from "../../policies"; -import { GeneratorDependencyProvider } from "../../providers"; -import { CredentialGenerator, PasswordGeneratorSettings } from "../../types"; -import { Algorithm, Profile, Type } from "../data"; -import { GeneratorMetadata } from "../generator-metadata"; - -const sdkPassword: GeneratorMetadata = deepFreeze({ - id: Algorithm.sdkPassword, - type: Type.password, - weight: 120, - i18nKeys: { - name: "password", - generateCredential: "generatePassword", - credentialGenerated: "passwordGenerated", - credentialType: "password", - copyCredential: "copyPassword", - useCredential: "useThisPassword", - }, - capabilities: { - autogenerate: true, - fields: [], - }, - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new SdkPasswordRandomizer(new BitwardenClient(null), Date.now); // @TODO hook up a real SDK client - }, - }, - profiles: { - [Profile.account]: { - type: "core", - storage: { - key: "passwordGeneratorSettings", - target: "object", - format: "plain", - classifier: new PublicClassifier([ - "length", - "ambiguous", - "uppercase", - "minUppercase", - "lowercase", - "minLowercase", - "number", - "minNumber", - "special", - "minSpecial", - ]), - state: GENERATOR_DISK, - initial: { - length: 14, - ambiguous: true, - uppercase: true, - minUppercase: 1, - lowercase: true, - minLowercase: 1, - number: true, - minNumber: 1, - special: false, - minSpecial: 0, - }, - options: { - deserializer(value) { - return value; - }, - clearOn: ["logout"], - }, - }, - constraints: { - type: PolicyType.PasswordGenerator, - default: { - length: { - min: 5, - max: 128, - recommendation: 14, - }, - minNumber: { - min: 0, - max: 9, - }, - minSpecial: { - min: 0, - max: 9, - }, - }, - create(policies, context) { - const initial = { - minLength: 0, - useUppercase: false, - useLowercase: false, - useNumbers: false, - numberCount: 0, - useSpecial: false, - specialCount: 0, - }; - const policy = policies.reduce(passwordLeastPrivilege, initial); - const constraints = new DynamicPasswordPolicyConstraints( - policy, - context.defaultConstraints, - ); - return constraints; - }, - }, - }, - }, -}); - -export default sdkPassword; diff --git a/libs/tools/generator/core/src/providers/generator-dependency-provider.ts b/libs/tools/generator/core/src/providers/generator-dependency-provider.ts index 14942698cdb..a6dbbeaa537 100644 --- a/libs/tools/generator/core/src/providers/generator-dependency-provider.ts +++ b/libs/tools/generator/core/src/providers/generator-dependency-provider.ts @@ -1,5 +1,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { RestClient } from "@bitwarden/common/tools/integration/rpc"; +import { BitwardenClient } from "@bitwarden/sdk-internal"; import { Randomizer } from "../abstractions"; @@ -9,4 +10,6 @@ export type GeneratorDependencyProvider = { // FIXME: introduce `I18nKeyOrLiteral` into forwarder // structures and remove this dependency i18nService: I18nService; + sdk?: BitwardenClient; + now: () => number; }; diff --git a/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts index 71fced46fa6..376b46cd6e8 100644 --- a/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts +++ b/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts @@ -5,6 +5,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 { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction"; import { @@ -22,6 +23,7 @@ import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subje import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; import { deepFreeze } from "@bitwarden/common/tools/util"; import { UserId } from "@bitwarden/common/types/guid"; +import { BitwardenClient } from "@bitwarden/sdk-internal"; import { FakeAccountService, FakeStateProvider } from "../../../../../common/spec"; import { Algorithm, AlgorithmsByType, CredentialAlgorithm, Type, Types } from "../metadata"; @@ -89,6 +91,10 @@ const SomePolicyService = mock(); const SomeExtensionService = mock(); +const SomeConfigService = mock; + +const SomeSdkService = mock; + const ApplicationProvider = { /** Policy configured by the administrative console */ policy: SomePolicyService, @@ -98,7 +104,13 @@ const ApplicationProvider = { /** Event monitoring and diagnostic interfaces */ log: disabledSemanticLoggerProvider, -} as SystemServiceProvider; + + /** Feature flag retrieval */ + configService: SomeConfigService, + + /** SDK access for password generation */ + sdk: SomeSdkService, +} as unknown as SystemServiceProvider; describe("GeneratorMetadataProvider", () => { beforeEach(() => { diff --git a/libs/tools/generator/extensions/legacy/src/create-legacy-password-generation-service.ts b/libs/tools/generator/extensions/legacy/src/create-legacy-password-generation-service.ts index 0df37617b88..0048ce15499 100644 --- a/libs/tools/generator/extensions/legacy/src/create-legacy-password-generation-service.ts +++ b/libs/tools/generator/extensions/legacy/src/create-legacy-password-generation-service.ts @@ -25,7 +25,7 @@ export function legacyPasswordGenerationServiceFactory( stateProvider: StateProvider, ): PasswordGenerationServiceAbstraction { const randomizer = new KeyServiceRandomizer(keyService); - const passwordRandomizer = new PasswordRandomizer(randomizer); + const passwordRandomizer = new PasswordRandomizer(randomizer, Date.now); const passwords = new DefaultGeneratorService( new PasswordGeneratorStrategy(passwordRandomizer, stateProvider), diff --git a/libs/vault/src/cipher-form/cipher-form-container.ts b/libs/vault/src/cipher-form/cipher-form-container.ts index cef5e102afe..db923d9fd6b 100644 --- a/libs/vault/src/cipher-form/cipher-form-container.ts +++ b/libs/vault/src/cipher-form/cipher-form-container.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { Observable } from "rxjs"; + import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherFormConfig } from "@bitwarden/vault"; @@ -70,4 +72,14 @@ export abstract class CipherFormContainer { /** Returns true when the `CipherFormContainer` was initialized with a cached cipher available. */ abstract initializedWithCachedCipher(): boolean; + + abstract disableFormFields(): void; + + abstract enableFormFields(): void; + + /** + * An observable that emits when the form status changes to enabled. + * This can be used to disable child forms when the parent form is enabled. + */ + formEnabled$: Observable; } diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index b8815235ee8..6b0f9929464 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -112,6 +112,12 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci @Output() formReady = this.formReadySubject.asObservable(); + /** + * Emitted when the form is enabled + */ + private formEnabledSubject = new Subject(); + formEnabled$ = this.formEnabledSubject.asObservable(); + /** * The original cipher being edited or cloned. Null for add mode. */ @@ -150,6 +156,15 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci } } + disableFormFields(): void { + this.cipherForm.disable({ emitEvent: false }); + } + + enableFormFields(): void { + this.cipherForm.enable({ emitEvent: false }); + this.formEnabledSubject.next(); + } + /** * Registers a child form group with the parent form group. Used by child components to add their form groups to * the parent form for validation. diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html index c61312c13eb..4d575634b1c 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html @@ -22,7 +22,7 @@ {{ "owner" | i18n }} diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index db8e2007c61..3c513a2f067 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -572,6 +572,7 @@ describe("ItemDetailsSectionComponent", () => { it("returns matching default when flag & policy match", async () => { const def = createMockCollection("def1", "Def", "orgA"); component.config.collections = [def] as CollectionView[]; + component.config.organizationDataOwnershipDisabled = false; component.config.initialValues = { collectionIds: [] } as OptionalInitialValues; mockConfigService.getFeatureFlag.mockResolvedValue(true); mockPolicyService.policiesByType$.mockReturnValue(of([{ organizationId: "orgA" } as Policy])); diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 1064980050f..4fd999ae601 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -19,7 +19,7 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CardComponent, @@ -80,6 +80,8 @@ export class ItemDetailsSectionComponent implements OnInit { protected organizations: Organization[] = []; + protected userId: UserId; + @Input({ required: true }) config: CipherFormConfig; @@ -96,7 +98,7 @@ export class ItemDetailsSectionComponent implements OnInit { return this.config.mode === "partial-edit"; } - get organizationDataOwnershipDisabled() { + get allowPersonalOwnership() { return this.config.organizationDataOwnershipDisabled; } @@ -109,16 +111,19 @@ export class ItemDetailsSectionComponent implements OnInit { } /** - * Show the organization data ownership option in the Owner dropdown when: - * - organization data ownership is disabled - * - The `organizationId` control is disabled. This avoids the scenario - * where a the dropdown is empty because the user personally owns the cipher - * but cannot edit the ownership. + * Show the personal ownership option in the Owner dropdown when any of the following: + * - personal ownership is allowed + * - `organizationId` control is disabled + * - personal ownership is not allowed AND the user is editing a cipher that is not + * currently owned by an organization */ - get showOrganizationDataOwnershipOption() { + get showPersonalOwnershipOption() { return ( - this.organizationDataOwnershipDisabled || - !this.itemDetailsForm.controls.organizationId.enabled + this.allowPersonalOwnership || + this.itemDetailsForm.controls.organizationId.disabled || + (!this.allowPersonalOwnership && + this.config.originalCipher && + this.itemDetailsForm.controls.organizationId.value === null) ); } @@ -170,7 +175,7 @@ export class ItemDetailsSectionComponent implements OnInit { } // If personal ownership is allowed and there is at least one organization, allow ownership change. - if (this.organizationDataOwnershipDisabled) { + if (this.allowPersonalOwnership) { return this.organizations.length > 0; } @@ -189,7 +194,7 @@ export class ItemDetailsSectionComponent implements OnInit { } get defaultOwner() { - return this.organizationDataOwnershipDisabled ? null : this.organizations[0].id; + return this.allowPersonalOwnership ? null : this.organizations[0].id; } async ngOnInit() { @@ -197,7 +202,9 @@ export class ItemDetailsSectionComponent implements OnInit { Utils.getSortFunction(this.i18nService, "name"), ); - if (!this.organizationDataOwnershipDisabled && this.organizations.length === 0) { + this.userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + if (!this.allowPersonalOwnership && this.organizations.length === 0) { throw new Error("No organizations available for ownership."); } @@ -216,43 +223,68 @@ export class ItemDetailsSectionComponent implements OnInit { }); await this.updateCollectionOptions(this.initialValues?.collectionIds); } + this.setFormState(); if (!this.allowOwnershipChange) { this.itemDetailsForm.controls.organizationId.disable(); } this.itemDetailsForm.controls.organizationId.valueChanges .pipe( takeUntilDestroyed(this.destroyRef), - concatMap(async () => await this.updateCollectionOptions()), + concatMap(async () => { + await this.updateCollectionOptions(); + this.setFormState(); + }), ) .subscribe(); } + /** + * When the cipher does not belong to an organization but the user's organization + * requires all ciphers to be owned by an organization, disable the entire form + * until the user selects an organization. + */ + private setFormState() { + if (this.config.originalCipher && !this.allowPersonalOwnership) { + if (this.itemDetailsForm.controls.organizationId.value === null) { + this.cipherFormContainer.disableFormFields(); + this.itemDetailsForm.controls.organizationId.enable(); + } else { + this.cipherFormContainer.enableFormFields(); + } + } + } + /** * Gets the default collection IDs for the selected organization. * Returns null if any of the following apply: * - the feature flag is disabled + * - the "no private data policy" doesn't apply to the user * - no org is currently selected * - the selected org doesn't have the "no private data policy" enabled */ private async getDefaultCollectionId(orgId?: OrganizationId) { - if (!orgId) { + if (!orgId || this.allowPersonalOwnership) { return; } + const isFeatureEnabled = await this.configService.getFeatureFlag( FeatureFlag.CreateDefaultLocation, ); + if (!isFeatureEnabled) { return; } - const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const selectedOrgHasPolicyEnabled = ( await firstValueFrom( - this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId), + this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, this.userId), ) ).find((p) => p.organizationId); + if (!selectedOrgHasPolicyEnabled) { return; } + const defaultUserCollection = this.collections.find( (c) => c.organizationId === orgId && c.type === CollectionTypes.DefaultUserCollection, ); @@ -284,7 +316,7 @@ export class ItemDetailsSectionComponent implements OnInit { ); } - if (!this.organizationDataOwnershipDisabled && prefillCipher.organizationId == null) { + if (!this.allowPersonalOwnership && prefillCipher.organizationId == null) { this.itemDetailsForm.controls.organizationId.setValue(this.defaultOwner); } } diff --git a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html index 585f11c2ffe..b0c501c53ed 100644 --- a/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html +++ b/libs/vault/src/cipher-form/components/login-details-section/login-details-section.component.html @@ -103,7 +103,9 @@ -

{{ (canCaptureTotp ? "totpHelperWithCapture" : "totpHelper") | i18n }}

+

+ {{ (canCaptureTotp ? "totpHelperWithCapture" : "totpHelper") | i18n }} +

diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts index 71f85e0e1b3..f83f93267c9 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, Input, OnInit } from "@angular/core"; +import { Component, DestroyRef, inject, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { firstValueFrom } from "rxjs"; @@ -60,6 +60,7 @@ export class SshKeySectionComponent implements OnInit { }); showImport = false; + private destroyRef = inject(DestroyRef); constructor( private cipherFormContainer: CipherFormContainer, @@ -94,6 +95,12 @@ export class SshKeySectionComponent implements OnInit { if (this.platformUtilsService.getClientType() !== ClientType.Web) { this.showImport = true; } + + // Disable the form if the cipher form container is enabled + // to prevent user interaction + this.cipherFormContainer.formEnabled$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.sshKeyForm.disable()); } /** Set form initial form values from the current cipher */ diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts index c47e5842987..04a2bb957ec 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts @@ -48,9 +48,10 @@ export class DefaultCipherFormConfigService implements CipherFormConfigService { await firstValueFrom( combineLatest([ this.organizations$(activeUserId), - this.collectionService.encryptedCollections$.pipe( + this.collectionService.encryptedCollections$(activeUserId).pipe( + map((collections) => collections ?? []), switchMap((c) => - this.collectionService.decryptedCollections$.pipe( + this.collectionService.decryptedCollections$(activeUserId).pipe( filter((d) => d.length === c.length), // Ensure all collections have been decrypted ), ), diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 66910ad8ac7..4f54e5d393a 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -16,7 +16,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { isCardExpired } from "@bitwarden/common/autofill/utils"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { CipherId, CollectionId, EmergencyAccessId, UserId } from "@bitwarden/common/types/guid"; +import { getByIds } from "@bitwarden/common/platform/misc"; +import { CipherId, EmergencyAccessId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -143,6 +144,8 @@ export class CipherViewComponent implements OnChanges, OnDestroy { return; } + const userId = await firstValueFrom(this.activeUserId$); + // Load collections if not provided and the cipher has collectionIds if ( this.cipher.collectionIds && @@ -150,14 +153,12 @@ export class CipherViewComponent implements OnChanges, OnDestroy { (!this.collections || this.collections.length === 0) ) { this.collections = await firstValueFrom( - this.collectionService.decryptedCollectionViews$( - this.cipher.collectionIds as CollectionId[], - ), + this.collectionService + .decryptedCollections$(userId) + .pipe(getByIds(this.cipher.collectionIds)), ); } - const userId = await firstValueFrom(this.activeUserId$); - if (this.cipher.organizationId) { this.organization$ = this.organizationService .organizations$(userId) diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts index cdbffb67e6f..68b0d9dfcf5 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts @@ -29,7 +29,7 @@ describe("AddEditFolderDialogComponent", () => { const save = jest.fn().mockResolvedValue(null); const deleteFolder = jest.fn().mockResolvedValue(null); const openSimpleDialog = jest.fn().mockResolvedValue(true); - const getUserKeyWithLegacySupport = jest.fn().mockResolvedValue(""); + const getUserKey = jest.fn().mockResolvedValue(""); const error = jest.fn(); const close = jest.fn(); const showToast = jest.fn(); @@ -66,7 +66,7 @@ describe("AddEditFolderDialogComponent", () => { { provide: KeyService, useValue: { - getUserKeyWithLegacySupport, + getUserKey, }, }, { provide: LogService, useValue: { error } }, diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts index 381893d54af..0442bcd1f76 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.ts @@ -121,7 +121,7 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit { try { const activeUserId = await firstValueFrom(this.activeUserId$); - const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId!); + const userKey = await this.keyService.getUserKey(activeUserId!); const folder = await this.folderService.encrypt(this.folder, userKey); await this.folderApiService.save(folder, activeUserId!); diff --git a/libs/vault/src/components/assign-collections.component.ts b/libs/vault/src/components/assign-collections.component.ts index 124dc783034..b2bd6e31ee5 100644 --- a/libs/vault/src/components/assign-collections.component.ts +++ b/libs/vault/src/components/assign-collections.component.ts @@ -435,12 +435,14 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI * @returns An observable of the collections for the organization. */ private getCollectionsForOrganization(orgId: OrganizationId): Observable { - return combineLatest([ - this.collectionService.decryptedCollections$, - this.accountService.activeAccount$.pipe( - switchMap((account) => this.organizationService.organizations$(account?.id)), + return this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + combineLatest([ + this.collectionService.decryptedCollections$(userId), + this.organizationService.organizations$(userId), + ]), ), - ]).pipe( map(([collections, organizations]) => { const org = organizations.find((o) => o.id === orgId); this.orgName = org.name; diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html index 38b5875a605..00cfa701529 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html @@ -1,4 +1,4 @@ - +