diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2665f345568..187f500828c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -143,6 +143,7 @@ apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-dev apps/desktop/desktop_native/windows_plugin_authenticator @bitwarden/team-autofill-dev apps/desktop/desktop_native/autotype @bitwarden/team-autofill-dev +.github/workflows/test-browser-interactions.yml @bitwarden/team-autofill-dev # DuckDuckGo integration apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-dev diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index be140b9a20e..43661d50910 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -232,7 +232,7 @@ jobs: npm --version - name: Download browser source - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: browser-source-${{ env._BUILD_NUMBER }}.zip diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 73b765f207a..22ba3a3e7be 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -499,7 +499,7 @@ jobs: echo "BW Package Version: $_PACKAGE_VERSION" - name: Get bw linux cli - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: bw-linux-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/snap diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 366d439fb45..e6c77b366b1 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1079,7 +1079,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1344,7 +1344,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index e3eb9090cb7..eb6af20f9ee 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -1035,7 +1035,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Download all artifacts - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: path: apps/desktop/artifacts diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml new file mode 100644 index 00000000000..c6427b2e0d8 --- /dev/null +++ b/.github/workflows/test-browser-interactions.yml @@ -0,0 +1,83 @@ +name: Autofill BIT checks +run-name: Autofill BIT checks on ${{ github.event.workflow_run.head_branch }} build + +on: + workflow_run: + workflows: ["Build Browser"] + types: + - completed + +jobs: + check-files: + name: Check files + runs-on: ubuntu-22.04 + permissions: + actions: write + contents: read + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Check for job requirements + if: ${{ !github.event.workflow_run.pull_requests || !github.event.workflow_run.head_branch }} + env: + GH_TOKEN: ${{ github.token }} + run: | + gh run cancel ${{ github.run_id }} + gh run watch ${{ github.run_id }} + + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + + - name: Generate GH App token + uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 + id: app-token + with: + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + owner: bitwarden + repositories: browser-interactions-testing + permission-actions: write + + - name: Get changed files + id: changed-files + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + list-files: shell + ref: ${{ github.event.workflow_run.head_branch }} + token: ${{ secrets.GITHUB_TOKEN }} + filters: | + monitored: + - 'apps/browser/src/autofill/**' + - 'apps/browser/src/background/**' + - 'apps/browser/src/platform/services/browser-script-injector.service.ts' + + - name: Trigger test-all workflow in browser-interactions-testing + if: steps.changed-files.outputs.monitored == 'true' + uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0 + with: + token: ${{ steps.app-token.outputs.token }} + repository: "bitwarden/browser-interactions-testing" + event-type: trigger-bit-tests + client-payload: |- + { + "origin_issue": ${{ github.event.workflow_run.pull_requests[0].number }}, + "origin_branch": "${{ github.event.workflow_run.pull_requests[0].head.ref }}" + } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8bfd368884..64c4e0dff13 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -170,13 +170,13 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Download jest coverage - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: jest-coverage path: ./ - name: Download rust coverage - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 0c117fded02..916f765ea21 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -338,7 +338,7 @@ "message": "Pokračovat na bitwarden.com?" }, "bitwardenForBusiness": { - "message": "Bitwarden pro byznys" + "message": "Bitwarden pro podnikání" }, "bitwardenAuthenticator": { "message": "Autentifikátor Bitwarden" @@ -398,7 +398,7 @@ "message": "Název složky" }, "folderHintText": { - "message": "Vnořit složku přidáním názvu nadřazené složky následovaného znakem \"/\". Příklad: Sociální/Fóra" + "message": "Složku vnoříte přidáním názvu nadřazené složky následovaného znakem \"/\". Příklad: Sociální/Fóra" }, "noFoldersAdded": { "message": "Nebyly přidány žádné složky" @@ -407,7 +407,7 @@ "message": "Vytvořte složky pro organizaci Vašich položek trezoru" }, "deleteFolderPermanently": { - "message": "Opravdu chcete trvale smazat tuto složku?" + "message": "Opravdu chcete tuto složku trvale smazat?" }, "deleteFolder": { "message": "Smazat složku" @@ -531,10 +531,10 @@ "message": "Zahrnout číslice" }, "minNumbers": { - "message": "Minimální počet číslic" + "message": "Minimálně číslic" }, "minSpecial": { - "message": "Minimální počet speciálních znaků" + "message": "Minimálně speciálních znaků" }, "avoidAmbiguous": { "message": "Nepoužívat zaměnitelné znaky", @@ -1016,7 +1016,7 @@ "description": "This is the folder for uncategorized items" }, "enableAddLoginNotification": { - "message": "Ptát se na přidání přihlášení" + "message": "Zeptat se na přidání přihlášení" }, "vaultSaveOptionsTitle": { "message": "Uložit do voleb trezoru" @@ -1049,7 +1049,7 @@ "message": "Klepněte na položky pro automatické vyplnění v zobrazení trezoru" }, "clickToAutofill": { - "message": "Klepněte na položky v návrhu automatického vyplňování pro vyplnění" + "message": "Klepnout na položky v návrhu automatického vyplňování pro vyplnění" }, "clearClipboard": { "message": "Vymazat schránku", @@ -1305,7 +1305,7 @@ "message": "Sdílené" }, "bitwardenForBusinessPageDesc": { - "message": "Bitwarden pro bvyznys Vám umožňuje sdílet položky v trezoru s ostatními prostřednictvím organizace. Více informací naleznete na bitwarden.com." + "message": "Bitwarden pro podnikání Vám umožňuje sdílet položky v trezoru s ostatními prostřednictvím organizace. Více informací naleznete na bitwarden.com." }, "moveToOrganization": { "message": "Přesunout do organizace" @@ -1660,7 +1660,7 @@ "description": "Overlay appearance select option for showing the field on click of the overlay icon" }, "enableAutoFillOnPageLoadSectionTitle": { - "message": "Automaticky vyplnit údaje při načtení stránky" + "message": "Automatické vyplnění údajů při načtení stránky" }, "enableAutoFillOnPageLoad": { "message": "Automaticky vyplnit údaje při načtení stránky" @@ -1678,7 +1678,7 @@ "message": "Více informací o automatickém vyplňování" }, "defaultAutoFillOnPageLoad": { - "message": "Výchozí nastavení automatického vyplňování pro položky přihlášení" + "message": "Výchozí nastavení autom. vyplňování" }, "defaultAutoFillOnPageLoadDesc": { "message": "Můžete vypnout automatické vyplňování při načtení stránky pro jednotlivé přihlašovací položky v zobrazení pro úpravu položky." @@ -1690,10 +1690,10 @@ "message": "Použít výchozí nastavení" }, "autoFillOnPageLoadYes": { - "message": "Automatické vyplnění při načtení stránky" + "message": "Automatické vyplnění při načtení" }, "autoFillOnPageLoadNo": { - "message": "Nevyplňovat automaticky při načtení stránky" + "message": "Nevyplňovat automaticky při načtení" }, "commandOpenPopup": { "message": "Otevřít vyskakovací okno trezoru" @@ -1851,7 +1851,7 @@ "message": "Slečna" }, "dr": { - "message": "MUDr." + "message": "Dr." }, "mx": { "message": "Neutrální" @@ -1875,7 +1875,7 @@ "message": "Společnost" }, "ssn": { - "message": "Číslo sociálního pojištění" + "message": "Rodné číslo" }, "passportNumber": { "message": "Číslo cestovního pasu" @@ -2099,7 +2099,7 @@ "message": "Nic k zobrazení" }, "nothingGeneratedRecently": { - "message": "Nedávno jste nic nevygenerovali" + "message": "Ještě jste nic nevygenerovali" }, "remove": { "message": "Odebrat" @@ -2233,7 +2233,7 @@ "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": "pro vytvoření silného jedinečného hesla", + "message": "pro vytvoření silného jedinečného hesla.", "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": { @@ -2685,7 +2685,7 @@ "message": "Změny v zablokovaných doménách byly uloženy" }, "excludedDomainsSavedSuccess": { - "message": "Vyloučené změny domény byly uloženy" + "message": "Změny vyloučené domény byly uloženy" }, "limitSendViews": { "message": "Omezit zobrazení" @@ -2911,7 +2911,7 @@ "message": "Došlo k chybě při ukládání datumu smzání a vypršení platnosti." }, "hideYourEmail": { - "message": "Skryje Vaši e-mailovou adresu před zobrazením." + "message": "Skrýt Vaši e-mailovou adresu" }, "passwordPrompt": { "message": "Zeptat se znovu na hlavní heslo" @@ -3228,7 +3228,7 @@ "description": "Labels the domain name email forwarder service option" }, "forwarderDomainNameHint": { - "message": "Vyberte doménu, která je podporována vybranou službou", + "message": "Vyberte doménu, která je podporována vybranou službou.", "description": "Guidance provided for email forwarding services that support multiple email domains." }, "forwarderError": { @@ -4132,7 +4132,7 @@ "message": "Zvolte sbírku" }, "importTargetHint": { - "message": "Pokud chcete obsah importovaného souboru přesunout do složky $DESTINATION$, vyberte tuto volbu", + "message": "Pokud chcete obsah importovaného souboru přesunout do: \"$DESTINATION$\", vyberte tuto volbu", "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": { @@ -4407,7 +4407,7 @@ "description": "Title for dialog which asks if the user wants to proceed to a relevant Help Center page" }, "confirmContinueToHelpCenterPasswordManagementContent": { - "message": "Změňte nastavení automatického vyplňování a správy hesel.", + "message": "Změna nastavení automatického vyplňování a nastavení správy hesel.", "description": "Body content for dialog which asks if the user wants to proceed to the Help Center's page about browser password management settings" }, "confirmContinueToHelpCenterKeyboardShortcutsContent": { @@ -4415,7 +4415,7 @@ "description": "Body content for dialog which asks if the user wants to proceed to the Help Center's page about browser keyboard shortcut settings" }, "confirmContinueToBrowserPasswordManagementSettingsContent": { - "message": "Změňte nastavení automatického vyplňování a správy hesel.", + "message": "Změna nastavení automatického vyplňování a nastavení správy hesel.", "description": "Body content for dialog which asks if the user wants to proceed to the browser's password management settings page" }, "confirmContinueToBrowserKeyboardShortcutSettingsContent": { @@ -4945,13 +4945,13 @@ "message": "Popis pole" }, "textHelpText": { - "message": "Použijte textová pole pro data jako bezpečnostní otázky" + "message": "Použijte textová pole pro data (jako např. bezpečnostní otázky)." }, "hiddenHelpText": { - "message": "Použijte skrytá pole pro citlivá data, jako je heslo" + "message": "Použijte skrytá pole pro citlivá data, jako je heslo." }, "checkBoxHelpText": { - "message": "Použijte zaškrtávací políčka, pokud chcete automaticky vyplnit zaškrtávací políčko formuláře (např. pro zapamatování e-mailu)" + "message": "Použijte zaškrtávací políčka, pokud chcete automaticky zvolit zaškrtávací políčko formuláře (např. pro zapamatování e-mailu)." }, "linkedHelpText": { "message": "Použijte propojené pole, pokud máte problémy s automatickým vyplňováním na konkrétní webové stránce." @@ -5121,7 +5121,7 @@ "message": "Zobrazit akce rychlé kopie v trezoru" }, "systemDefault": { - "message": "Systémový výchozí" + "message": "Výchozí systémový" }, "enterprisePolicyRequirementsApplied": { "message": "Na toto nastavení byly uplatněny požadavky podnikových zásad" @@ -5178,7 +5178,7 @@ "message": "Položky, které smažete, se zde zobrazí a budou trvale smazány po 30 dnech." }, "trashWarning": { - "message": "Položky, které byly v koši déle než 30 dní, budou automaticky smazány." + "message": "Položky, které byly v koši déle než 30 dnů, budou automaticky smazány." }, "restore": { "message": "Obnovit" @@ -5394,10 +5394,10 @@ "message": "Šířka rozšíření" }, "wide": { - "message": "Šířka" + "message": "Široké" }, "extraWide": { - "message": "Extra široký" + "message": "Extra široké" }, "sshKeyWrongPassword": { "message": "Zadané heslo není správné." diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 4ce77543a45..3d46b63ec45 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -84,7 +84,7 @@ "message": "Υπόδειξη κύριου κωδικού πρόσβασης (προαιρετικό)" }, "passwordStrengthScore": { - "message": "Password strength score $SCORE$", + "message": "Βαθμολογία ισχύς κωδικού πρόσβασης $SCORE$", "placeholders": { "score": { "content": "$1", @@ -468,13 +468,13 @@ "message": "Ο κωδικός δημιουργήθηκε" }, "passphraseGenerated": { - "message": "Passphrase generated" + "message": "Το συνθηματικό δημιουργήθηκε" }, "usernameGenerated": { - "message": "Username generated" + "message": "Το όνομα χρήστη δημιουργήθηκε" }, "emailGenerated": { - "message": "Email generated" + "message": "Το email δημιουργήθηκε" }, "regeneratePassword": { "message": "Επαναδημιουργία κωδικού πρόσβασης" @@ -548,7 +548,7 @@ "message": "Αναζήτηση στο vault" }, "resetSearch": { - "message": "Reset search" + "message": "Επαναφορά αναζήτησης" }, "edit": { "message": "Επεξεργασία" @@ -659,10 +659,10 @@ "message": "Το πρόγραμμα περιήγησης ιστού δεν υποστηρίζει εύκολη αντιγραφή πρόχειρου. Αντιγράψτε το με το χέρι αντ'αυτού." }, "verifyYourIdentity": { - "message": "Verify your identity" + "message": "Επαλήθευση ταυτότητας" }, "weDontRecognizeThisDevice": { - "message": "We don't recognize this device. Enter the code sent to your email to verify your identity." + "message": "Δεν αναγνωρίζουμε αυτή τη συσκευή. Εισάγετε τον κωδικό που στάλθηκε στο email σας για να επαληθεύσετε την ταυτότητά σας." }, "continueLoggingIn": { "message": "Continue logging in" @@ -875,22 +875,22 @@ "message": "Σύνδεση στο Bitwarden" }, "enterTheCodeSentToYourEmail": { - "message": "Enter the code sent to your email" + "message": "Εισάγετε τον κωδικό που στάλθηκε στο email σας" }, "enterTheCodeFromYourAuthenticatorApp": { - "message": "Enter the code from your authenticator app" + "message": "Εισαγάγετε τον κωδικό μιας χρήσης από την εφαρμογή αυθεντικοποίησης" }, "pressYourYubiKeyToAuthenticate": { - "message": "Press your YubiKey to authenticate" + "message": "Πιέστε το YubiKey σας για ταυτοποίηση" }, "duoTwoFactorRequiredPageSubtitle": { - "message": "Duo two-step login is required for your account. Follow the steps below to finish logging in." + "message": "Απαιτείται σύνδεση Duo δύο βημάτων για το λογαριασμό σας. Ακολουθήστε τα παρακάτω βήματα για να ολοκληρώσετε τη σύνδεση." }, "followTheStepsBelowToFinishLoggingIn": { - "message": "Follow the steps below to finish logging in." + "message": "Ακολουθήστε τα παρακάτω βήματα για να ολοκληρώσετε τη σύνδεση." }, "followTheStepsBelowToFinishLoggingInWithSecurityKey": { - "message": "Follow the steps below to finish logging in with your security key." + "message": "Ακολουθήστε τα παρακάτω βήματα για να ολοκληρώσετε τη σύνδεση με το κλειδί ασφαλείας σας." }, "restartRegistration": { "message": "Επανεκκίνηση εγγραφής" diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 1a304a94d48..d38461ab553 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -5575,9 +5575,9 @@ "description": "'WebAssembly' is a technical term and should not be translated." }, "showMore": { - "message": "Show more" + "message": "Prikaži više" }, "showLess": { - "message": "Show less" + "message": "Pokaži manje" } } diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 89f502c5e49..68d5a0db2e3 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -605,7 +605,7 @@ "message": "Izdzēst vienumu" }, "viewItem": { - "message": "Skatīt vienumu" + "message": "Apskatīt vienumu" }, "launch": { "message": "Palaist" @@ -1180,7 +1180,7 @@ "message": "Pēc savas paroles nomainīšanas būs nepieciešams pieteikties ar jauno paroli. Spēkā esošajās sesijās citās ierīcēs stundas laikā notiks atteikšanās." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Jānomaina sava galvenā'parole, lai pabeigtu konta atkopi." + "message": "Jānomaina sava galvenā parole, lai pabeigtu konta atkopi." }, "enableChangedPasswordNotification": { "message": "Vaicāt atjaunināt esošu pieteikšanās vienumu" @@ -1490,7 +1490,7 @@ "description": "Select another two-step login method" }, "useYourRecoveryCode": { - "message": "Izmantot savu atkopes kodu" + "message": "Izmanto savu atkopes kodu" }, "insertU2f": { "message": "Ievieto savu drošības atslēgu datora USB ligzdā! Ja tai ir poga, pieskaries tai!" @@ -1523,10 +1523,10 @@ "message": "Atlasīt divpakāpju pieteikšanās veidu" }, "recoveryCodeDesc": { - "message": "Zaudēta piekļuve visiem divpakāpju nodrošinātājiem? Izmanto atkopšanas kodus, lai atspējotu visus sava konta divpakāpju nodrošinātājus!" + "message": "Zaudēta piekļuve visiem divpakāpju nodrošinātājiem? Izmanto atkopes kodus, lai atspējotu visus sava konta divpakāpju nodrošinātājus!" }, "recoveryCodeTitle": { - "message": "Atgūšanas kods" + "message": "Atkopes kods" }, "authenticatorAppTitle": { "message": "Autentificētāja lietotne" @@ -3437,7 +3437,7 @@ "message": "Atkārtoti nosūtīt paziņojumu" }, "viewAllLogInOptions": { - "message": "Skatīt visas pieteikšanās iespējas" + "message": "Apskatīt visas pieteikšanās iespējas" }, "notificationSentDevice": { "message": "Uz ierīci ir nosūtīts paziņojums." @@ -3728,7 +3728,7 @@ "description": "European Union" }, "accessDenied": { - "message": "Piekļuve liegta. Nav nepieciešamo atļauju, lai skatītu šo lapu." + "message": "Piekļuve liegta. Nav nepieciešamo atļauju, lai apskatītu šo lapu." }, "general": { "message": "Vispārīgi" @@ -4387,7 +4387,7 @@ "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { - "message": "\"Sākas ar' ir lietpratējiem paredzēta iespēja ar paaugstinātu piekļuves datu atklāšanas bīstamību.", + "message": "“Sākas ar” ir lietpratējiem paredzēta iespēja ar paaugstinātu piekļuves datu atklāšanas bīstamību.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { @@ -4411,7 +4411,7 @@ "description": "Body content for dialog which asks if the user wants to proceed to the Help Center's page about browser password management settings" }, "confirmContinueToHelpCenterKeyboardShortcutsContent": { - "message": "Paplašinājuma īsinājumtaustiņus skatīt un iestatīt var pārlūka iestatījumos.", + "message": "Paplašinājuma īsinājumtaustiņus apskatīt un iestatīt var pārlūka iestatījumos.", "description": "Body content for dialog which asks if the user wants to proceed to the Help Center's page about browser keyboard shortcut settings" }, "confirmContinueToBrowserPasswordManagementSettingsContent": { @@ -4419,7 +4419,7 @@ "description": "Body content for dialog which asks if the user wants to proceed to the browser's password management settings page" }, "confirmContinueToBrowserKeyboardShortcutSettingsContent": { - "message": "Paplašinājuma īsinājumtaustiņus skatīt un iestatīt var pārlūka iestatījumos.", + "message": "Paplašinājuma īsinājumtaustiņus apskatīt un iestatīt var pārlūka iestatījumos.", "description": "Body content for dialog which asks if the user wants to proceed to the browser's keyboard shortcut settings page" }, "overrideDefaultBrowserAutofillTitle": { @@ -4534,7 +4534,7 @@ } }, "viewItemTitle": { - "message": "Skatīt vienumu - $ITEMNAME$", + "message": "Apskatīt vienumu - $ITEMNAME$", "description": "Title for a link that opens a view for an item.", "placeholders": { "itemname": { @@ -4629,7 +4629,7 @@ "message": "Kļūda mērķa mapes piešķiršanā." }, "viewItemsIn": { - "message": "Skatīt $NAME$ vienumus", + "message": "Apskatīt $NAME$ vienumus", "description": "Button to view the contents of a folder or collection", "placeholders": { "name": { diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 6e1029c613d..97875409529 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -1153,7 +1153,7 @@ "description": "Shown to user after login is updated." }, "loginUpdateTaskSuccessAdditional": { - "message": "Dziękujemy za dbanie o bezpieczeństwo $ORGANIZATION$. Pozostało $TASK_COUNT$ haseł do zaktualizowania.", + "message": "Dziękujemy za zwiększenie bezpieczeństwa organizacji $ORGANIZATION$. Zaktualizuj hasła dla jeszcze $TASK_COUNT$ danych logowania.", "placeholders": { "organization": { "content": "$1" @@ -1773,7 +1773,7 @@ "message": "Pokaż licznik na ikonie" }, "badgeCounterDesc": { - "message": "Wskaż, ile masz danych logowania do bieżącej strony internetowej." + "message": "Pokazuje liczbę danych logowania dla obecnej strony internetowej." }, "cardholderName": { "message": "Właściciel karty" @@ -2482,7 +2482,7 @@ "message": "Uprawnienie nie zostało przyznane" }, "nativeMessaginPermissionErrorDesc": { - "message": "Bez uprawnienia do komunikowania się z aplikacją desktopową Bitwarden nie możemy dostarczyć obsługi danych biometrycznych w rozszerzeniu przeglądarki. Spróbuj ponownie." + "message": "Odblokowanie biometrią jest dostępne dopiero po połączeniu rozszerzenia przeglądarki z aplikacją desktopową Bitwarden. Spróbuj ponownie." }, "nativeMessaginPermissionSidebarTitle": { "message": "Wystąpił błąd żądania uprawnienia" @@ -2610,7 +2610,7 @@ "message": "Sprawdź i zmień 1 zagrożone hasło" }, "reviewAndChangeAtRiskPasswordsPlural": { - "message": "Przejrzyj i zmień $COUNT$ zagrożonych haseł ", + "message": "Sprawdź i zmień zagrożone hasła ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -3380,7 +3380,7 @@ "message": "Nie można uzyskać dostępu do elementów w zawieszonych organizacjach. Skontaktuj się z właścicielem organizacji, aby uzyskać pomoc." }, "loggingInTo": { - "message": "Logowanie na $DOMAIN$", + "message": "Serwer: $DOMAIN$", "placeholders": { "domain": { "content": "$1", @@ -4354,7 +4354,7 @@ "message": "serwer" }, "hostedAt": { - "message": "hostowany w" + "message": "serwer" }, "useDeviceOrHardwareKey": { "message": "Użyj urządzenia lub klucza sprzętowego" @@ -5025,7 +5025,7 @@ "message": "Element zostanie przeniesiony do organizacji. Nie będziesz już właścicielem elementu." }, "personalItemsTransferWarningPlural": { - "message": "$PERSONAL_ITEMS_COUNT$ elementów zostanie trwale przeniesionych do wybranej organizacji. Nie będziesz już posiadać tych elementów.", + "message": "Nie będziesz już właścicielem $PERSONAL_ITEMS_COUNT$ elementów przeniesionych do organizacji.", "placeholders": { "personal_items_count": { "content": "$1", @@ -5043,7 +5043,7 @@ } }, "personalItemsWithOrgTransferWarningPlural": { - "message": "$PERSONAL_ITEMS_COUNT$ elementy zostaną trwale przeniesione do $ORG$. Nie będziesz już posiadać tych elementów.", + "message": "Nie będziesz już właścicielem $PERSONAL_ITEMS_COUNT$ elementów przeniesionych do organizacji $ORG$.", "placeholders": { "personal_items_count": { "content": "$1", diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index aa926ac77b8..d3ce5ec3e48 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -4383,7 +4383,7 @@ "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": "A \"expressão regular\" é uma opção avançada com um risco acrescido de exposição de credenciais.", + "message": "A \"Expressão regular\" é uma opção avançada com um risco acrescido de exposição de credenciais.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, "startsWithAdvancedOptionWarning": { diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 641205fffb7..7a596d7c23d 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -1836,7 +1836,7 @@ "message": "số thẻ" }, "ex": { - "message": "Ví dụ:" + "message": "ví dụ." }, "title": { "message": "Tiêu đề" @@ -2458,7 +2458,7 @@ "message": "Nhận dạng sinh trắc học không được hỗ trợ" }, "biometricsNotSupportedDesc": { - "message": "Nhận dạng sinh trắc học trên trình duyệt không được hỗ trợ trên thiết bị này" + "message": "Nhận dạng sinh trắc học trên trình duyệt không được hỗ trợ trên thiết bị này." }, "biometricsNotUnlockedTitle": { "message": "Người dùng đã khoá hoặc đã đăng xuất" diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.html b/apps/browser/src/auth/popup/account-switching/current-account.component.html index 09342c58756..c16abdadf29 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.html +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.html @@ -2,7 +2,7 @@ `, @@ -343,7 +343,7 @@ export default { generator: "Generator", send: "Send", settings: "Settings", - labelWithNotification: (label: string) => `${label}: New Notification`, + labelWithNotification: (label: string | undefined) => `${label}: New Notification`, }); }, }, diff --git a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts index b177497305b..21f6debc02f 100644 --- a/apps/browser/src/platform/services/browser-script-injector.service.spec.ts +++ b/apps/browser/src/platform/services/browser-script-injector.service.spec.ts @@ -1,11 +1,10 @@ -import { mock, MockProxy } from "jest-mock-extended"; +import { mock } from "jest-mock-extended"; import { of } from "rxjs"; import { DomainSettingsService, DefaultDomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -54,14 +53,11 @@ describe("ScriptInjectorService", () => { const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); - let configService: MockProxy; let domainSettingsService: DomainSettingsService; beforeEach(() => { jest.spyOn(BrowserApi, "getTab").mockImplementation(async () => tabMock); - configService = mock(); - configService.getFeatureFlag$.mockImplementation(() => of(false)); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); domainSettingsService.blockedInteractionsUris$ = of({}); scriptInjectorService = new BrowserScriptInjectorService( diff --git a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts index 34ee4fa0f77..f8b4050a5ce 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.spec.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.spec.ts @@ -4,8 +4,8 @@ import { Subject } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncOptions } from "@bitwarden/common/platform/sync/sync.service"; @@ -22,7 +22,7 @@ import { FullSyncFinishedMessage } from "./sync-service.listener"; describe("ForegroundSyncService", () => { const userId = Utils.newGuid() as UserId; - const stateService = mock(); + const tokenService = mock(); const folderService = mock(); const folderApiService = mock(); const messageSender = mock(); @@ -38,7 +38,7 @@ describe("ForegroundSyncService", () => { const stateProvider = new FakeStateProvider(accountService); const sut = new ForegroundSyncService( - stateService, + tokenService, folderService, folderApiService, messageSender, diff --git a/apps/browser/src/platform/sync/foreground-sync.service.ts b/apps/browser/src/platform/sync/foreground-sync.service.ts index 2ac75bbec2c..01b1f35239b 100644 --- a/apps/browser/src/platform/sync/foreground-sync.service.ts +++ b/apps/browser/src/platform/sync/foreground-sync.service.ts @@ -4,8 +4,8 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CommandDefinition, MessageListener, @@ -31,7 +31,7 @@ export const DO_FULL_SYNC = new CommandDefinition("doFullSync") export class ForegroundSyncService extends CoreSyncService { constructor( - stateService: StateService, + tokenService: TokenService, folderService: InternalFolderService, folderApiService: FolderApiServiceAbstraction, messageSender: MessageSender, @@ -47,7 +47,7 @@ export class ForegroundSyncService extends CoreSyncService { stateProvider: StateProvider, ) { super( - stateService, + tokenService, folderService, folderApiService, messageSender, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index f01809433e3..e2f4561e86c 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -2,11 +2,7 @@ import { Injectable, NgModule } from "@angular/core"; import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; -import { - EnvironmentSelectorComponent, - EnvironmentSelectorRouteData, - ExtensionDefaultOverlayPosition, -} from "@bitwarden/angular/auth/components/environment-selector.component"; +import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/environment-selector/environment-selector.component"; import { activeAuthGuard, authGuard, @@ -400,9 +396,6 @@ const routes: Routes = [ path: "", component: EnvironmentSelectorComponent, outlet: "environment-selector", - data: { - overlayPosition: ExtensionDefaultOverlayPosition, - } satisfies EnvironmentSelectorRouteData, }, ], }, @@ -425,9 +418,6 @@ const routes: Routes = [ path: "", component: EnvironmentSelectorComponent, outlet: "environment-selector", - data: { - overlayPosition: ExtensionDefaultOverlayPosition, - } satisfies EnvironmentSelectorRouteData, }, ], }, @@ -474,9 +464,6 @@ const routes: Routes = [ path: "", component: EnvironmentSelectorComponent, outlet: "environment-selector", - data: { - overlayPosition: ExtensionDefaultOverlayPosition, - } satisfies EnvironmentSelectorRouteData, }, ], }, diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 6a26476de43..fa1e6c237c9 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -28,13 +28,13 @@ import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; import { LogoutReason, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -102,7 +102,7 @@ export class AppComponent implements OnInit, OnDestroy { private authService: AuthService, private i18nService: I18nService, private router: Router, - private stateService: StateService, + private readonly tokenService: TokenService, private vaultBrowserStateService: VaultBrowserStateService, private cipherService: CipherService, private changeDetectorRef: ChangeDetectorRef, @@ -321,7 +321,7 @@ export class AppComponent implements OnInit, OnDestroy { } private async clearComponentStates() { - if (!(await this.stateService.getIsAuthenticated())) { + if (!(await firstValueFrom(this.tokenService.hasAccessToken$(this.activeUserId)))) { return; } diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 5150c51d765..2aacd3d3632 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -9,7 +9,6 @@ import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { BrowserModule } from "@angular/platform-browser"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; -import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe"; import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; @@ -96,7 +95,6 @@ import "../platform/popup/locales"; TabsV2Component, UserVerificationComponent, RemovePasswordComponent, - EnvironmentSelectorComponent, ], exports: [], providers: [CurrencyPipe, DatePipe], diff --git a/apps/browser/src/popup/scss/tailwind.css b/apps/browser/src/popup/scss/tailwind.css index b49fe912861..54139990356 100644 --- a/apps/browser/src/popup/scss/tailwind.css +++ b/apps/browser/src/popup/scss/tailwind.css @@ -1,9 +1,5 @@ @import "../../../../../libs/components/src/tw-theme.css"; -@tailwind base; -@tailwind components; -@tailwind utilities; - @layer components { /** Safari Support */ html.browser_safari .tw-styled-scrollbar::-webkit-scrollbar { diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index 9e750ae7341..1930dbd1d4b 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -8,6 +8,7 @@ import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { BrowserApi } from "../../platform/browser/browser-api"; import BrowserPopupUtils from "../../platform/browser/browser-popup-utils"; @@ -27,13 +28,14 @@ export class InitService { private themingService: AbstractThemingService, private sdkLoadService: SdkLoadService, private viewCacheService: PopupViewCacheService, + private readonly migrationRunner: MigrationRunner, @Inject(DOCUMENT) private document: Document, ) {} init() { return async () => { await this.sdkLoadService.loadAndInit(); - await this.stateService.init({ runMigrations: false }); // Browser background is responsible for migrations + await this.migrationRunner.waitForCompletion(); // Browser background is responsible for migrations 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 7c67e672889..f531ebd5ca7 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -47,6 +47,7 @@ import { import { AuthService } from "@bitwarden/common/auth/abstractions/auth.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 { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AutofillSettingsService, @@ -62,6 +63,7 @@ import { } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; @@ -79,7 +81,6 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -333,7 +334,7 @@ const safeProviders: SafeProvider[] = [ provide: SyncService, useClass: ForegroundSyncService, deps: [ - StateService, + TokenService, InternalFolderService, FolderApiServiceAbstraction, MessageSender, @@ -352,7 +353,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DomainSettingsService, useClass: DefaultDomainSettingsService, - deps: [StateProvider, ConfigService], + deps: [StateProvider], }), safeProvider({ provide: AbstractStorageService, diff --git a/apps/browser/src/popup/tabs-v2.component.ts b/apps/browser/src/popup/tabs-v2.component.ts index 3d93f5d4e04..860b71794ff 100644 --- a/apps/browser/src/popup/tabs-v2.component.ts +++ b/apps/browser/src/popup/tabs-v2.component.ts @@ -1,11 +1,9 @@ import { Component } from "@angular/core"; -import { combineLatest, map, Observable, startWith, switchMap } from "rxjs"; +import { map, Observable, startWith, switchMap } from "rxjs"; import { NudgesService } from "@bitwarden/angular/vault"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Icons } from "@bitwarden/components"; import { NavButton } from "../platform/popup/layout/popup-tab-navigation.component"; @@ -19,12 +17,9 @@ export class TabsV2Component { private hasActiveBadges$ = this.accountService.activeAccount$ .pipe(getUserId) .pipe(switchMap((userId) => this.nudgesService.hasActiveBadges$(userId))); - protected navButtons$: Observable = combineLatest([ - this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge), - this.hasActiveBadges$, - ]).pipe( - startWith([false, false]), - map(([onboardingFeatureEnabled, hasBadges]) => { + protected navButtons$: Observable = this.hasActiveBadges$.pipe( + startWith(false), + map((hasBadges) => { return [ { label: "vault", @@ -49,7 +44,7 @@ export class TabsV2Component { page: "/tabs/settings", icon: Icons.SettingsInactive, iconActive: Icons.SettingsActive, - showBerry: onboardingFeatureEnabled && hasBadges, + showBerry: hasBadges, }, ]; }), @@ -57,6 +52,5 @@ export class TabsV2Component { constructor( private nudgesService: NudgesService, private accountService: AccountService, - private readonly configService: ConfigService, ) {} } diff --git a/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.html b/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.html index 839681889a8..9bba3994357 100644 --- a/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.html +++ b/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.html @@ -23,12 +23,6 @@ - - - {{ "moreFromBitwarden" | i18n }} - - - - +  {{ organization.name }} - + { // Arrange const collections: CollectionView[] = []; - const parentCollection = new CollectionView(); - parentCollection.name = "Parent"; + const parentCollection = new CollectionView({ + name: "Parent", + organizationId: "orgId" as OrganizationId, + id: newGuid() as CollectionId, + }); - const childCollection = new CollectionView(); - childCollection.name = "Parent/Child"; + const childCollection = new CollectionView({ + name: "Parent/Child", + organizationId: "orgId" as OrganizationId, + id: newGuid() as CollectionId, + }); collections.push(childCollection); collections.push(parentCollection); @@ -41,12 +49,14 @@ describe("CollectionUtils Service", () => { describe("getFlatCollectionTree", () => { it("should flatten a tree node with no children", () => { // Arrange - const collection = new CollectionView(); - collection.name = "Test Collection"; - collection.id = "test-id"; + const collection = new CollectionView({ + name: "Test Collection", + id: "test-id" as CollectionId, + organizationId: "orgId" as OrganizationId, + }); const treeNodes: TreeNode[] = [ - new TreeNode(collection, null), + new TreeNode(collection, {} as TreeNode), ]; // Act @@ -59,23 +69,34 @@ describe("CollectionUtils Service", () => { it("should flatten a tree node with children", () => { // Arrange - const parentCollection = new CollectionView(); - parentCollection.name = "Parent"; - parentCollection.id = "parent-id"; + const parentCollection = new CollectionView({ + name: "Parent", + id: "parent-id" as CollectionId, + organizationId: "orgId" as OrganizationId, + }); - const child1Collection = new CollectionView(); - child1Collection.name = "Child 1"; - child1Collection.id = "child1-id"; + const child1Collection = new CollectionView({ + name: "Child 1", + id: "child1-id" as CollectionId, + organizationId: "orgId" as OrganizationId, + }); - const child2Collection = new CollectionView(); - child2Collection.name = "Child 2"; - child2Collection.id = "child2-id"; + const child2Collection = new CollectionView({ + name: "Child 2", + id: "child2-id" as CollectionId, + organizationId: "orgId" as OrganizationId, + }); - const grandchildCollection = new CollectionView(); - grandchildCollection.name = "Grandchild"; - grandchildCollection.id = "grandchild-id"; + const grandchildCollection = new CollectionView({ + name: "Grandchild", + id: "grandchild-id" as CollectionId, + organizationId: "orgId" as OrganizationId, + }); - const parentNode = new TreeNode(parentCollection, null); + const parentNode = new TreeNode( + parentCollection, + {} as TreeNode, + ); const child1Node = new TreeNode(child1Collection, parentNode); const child2Node = new TreeNode(child2Collection, parentNode); const grandchildNode = new TreeNode(grandchildCollection, child1Node); diff --git a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts index 0697659c976..8a0fab520e3 100644 --- a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts +++ b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts @@ -22,7 +22,7 @@ export function getNestedCollectionTree( // Collections need to be cloned because ServiceUtils.nestedTraverse actively // modifies the names of collections. // These changes risk affecting collections store in StateService. - const clonedCollections = collections + const clonedCollections: CollectionView[] | CollectionAdminView[] = collections .sort((a, b) => a.name.localeCompare(b.name)) .map(cloneCollection); @@ -37,6 +37,21 @@ export function getNestedCollectionTree( return nodes; } +export function cloneCollection(collection: CollectionView): CollectionView; +export function cloneCollection(collection: CollectionAdminView): CollectionAdminView; +export function cloneCollection( + collection: CollectionView | CollectionAdminView, +): CollectionView | CollectionAdminView { + let cloned; + + if (collection instanceof CollectionAdminView) { + cloned = Object.assign(new CollectionAdminView({ ...collection }), collection); + } else { + cloned = Object.assign(new CollectionView({ ...collection }), collection); + } + return cloned; +} + export function getFlatCollectionTree( nodes: TreeNode[], ): CollectionAdminView[]; @@ -57,32 +72,3 @@ export function getFlatCollectionTree( return [node.node, ...children]; }); } - -function cloneCollection(collection: CollectionView): CollectionView; -function cloneCollection(collection: CollectionAdminView): CollectionAdminView; -function cloneCollection( - collection: CollectionView | CollectionAdminView, -): CollectionView | CollectionAdminView { - let cloned; - - if (collection instanceof CollectionAdminView) { - cloned = new CollectionAdminView(); - cloned.groups = [...collection.groups]; - cloned.users = [...collection.users]; - cloned.assigned = collection.assigned; - cloned.unmanaged = collection.unmanaged; - } else { - cloned = new CollectionView(); - } - - cloned.id = collection.id; - cloned.externalId = collection.externalId; - cloned.hidePasswords = collection.hidePasswords; - cloned.name = collection.name; - cloned.organizationId = collection.organizationId; - cloned.readOnly = collection.readOnly; - cloned.manage = collection.manage; - cloned.type = collection.type; - - return cloned; -} diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts index 121a5c03ffe..1be16c65cb8 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-header/vault-header.component.ts @@ -5,7 +5,7 @@ import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, switchMap } from "rxjs"; import { CollectionAdminService, @@ -14,6 +14,8 @@ import { } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -99,6 +101,7 @@ export class VaultHeaderComponent { private dialogService: DialogService, private collectionAdminService: CollectionAdminService, private router: Router, + private accountService: AccountService, ) {} get title() { @@ -199,7 +202,14 @@ export class VaultHeaderComponent { async addCollection() { if (this.organization.productTierType === ProductTierType.Free) { - const collections = await this.collectionAdminService.getAll(this.organization.id); + const collections = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.collectionAdminService.collectionAdminViews$(this.organization.id, userId), + ), + ), + ); if (collections.length === this.organization.maxCollections) { this.showFreeOrgUpgradeDialog(); return; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 9653c405490..87f309c6f66 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -79,8 +79,11 @@ import { DecryptionFailureDialogComponent, PasswordRepromptService, } from "@bitwarden/vault"; -import { OrganizationResellerRenewalWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components/organization-reseller-renewal-warning.component"; -import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/warnings/services/organization-warnings.service"; +import { + OrganizationFreeTrialWarningComponent, + OrganizationResellerRenewalWarningComponent, +} from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { VaultItemsComponent } from "@bitwarden/web-vault/app/vault/components/vault-items/vault-items.component"; import { BillingNotificationService } from "../../../billing/services/billing-notification.service"; @@ -90,7 +93,6 @@ import { } from "../../../billing/services/reseller-warning.service"; import { TrialFlowService } from "../../../billing/services/trial-flow.service"; import { FreeTrial } from "../../../billing/types/free-trial"; -import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components/organization-free-trial-warning.component"; import { SharedModule } from "../../../shared"; import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections"; import { @@ -363,7 +365,12 @@ export class VaultComponent implements OnInit, OnDestroy { this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe( switchMap(() => organizationId$), - switchMap((orgId) => this.collectionAdminService.getAll(orgId)), + switchMap((orgId) => + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)), + ), + ), shareReplay({ refCount: false, bufferSize: 1 }), ); @@ -386,11 +393,13 @@ export class VaultComponent implements OnInit, OnDestroy { // FIXME: We should not assert that the Unassigned type is a CollectionId. // Instead we should consider representing the Unassigned collection as a different object, given that // it is not actually a collection. - const noneCollection = new CollectionAdminView(); - noneCollection.name = this.i18nService.t("unassigned"); - noneCollection.id = Unassigned as CollectionId; - noneCollection.organizationId = organizationId; - return allCollections.concat(noneCollection); + return allCollections.concat( + new CollectionAdminView({ + name: this.i18nService.t("unassigned"), + id: Unassigned as CollectionId, + organizationId, + }), + ); }), ); @@ -667,6 +676,15 @@ export class VaultComponent implements OnInit, OnDestroy { ) .subscribe(); + organization$ + .pipe( + switchMap((organization) => + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + takeUntil(this.destroy$), + ) + .subscribe(); + const freeTrial$ = combineLatest([ organization$, this.hasSubscription$.pipe(filter((hasSubscription) => hasSubscription !== null)), diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index cbb4e1cf064..be9a85ffe4b 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -150,6 +150,12 @@ > {{ "accessingUsingProvider" | i18n: organization.providerName }} + + diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index 89f62ed8975..4b6e9a431b4 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -28,6 +28,11 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; import { BannerModule, IconModule, AdminConsoleLogo } from "@bitwarden/components"; +import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; +import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; import { FreeFamiliesPolicyService } from "../../../billing/services/free-families-policy.service"; import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component"; @@ -44,6 +49,9 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module"; IconModule, OrgSwitcherComponent, BannerModule, + TaxIdWarningComponent, + TaxIdWarningComponent, + OrganizationWarningsModule, ], }) export class OrganizationLayoutComponent implements OnInit { @@ -58,7 +66,6 @@ export class OrganizationLayoutComponent implements OnInit { showPaymentAndHistory$: Observable; hideNewOrgButton$: Observable; organizationIsUnmanaged$: Observable; - enterpriseOrganization$: Observable; protected isBreadcrumbEventLogsEnabled$: Observable; protected showSponsoredFamiliesDropdown$: Observable; @@ -69,6 +76,9 @@ export class OrganizationLayoutComponent implements OnInit { textKey: string; }>; + protected subscriber$: Observable; + protected getTaxIdWarning$: () => Observable; + constructor( private route: ActivatedRoute, private organizationService: OrganizationService, @@ -79,6 +89,7 @@ export class OrganizationLayoutComponent implements OnInit { private accountService: AccountService, private freeFamiliesPolicyService: FreeFamiliesPolicyService, private organizationBillingService: OrganizationBillingServiceAbstraction, + private organizationWarningsService: OrganizationWarningsService, ) {} async ngOnInit() { @@ -150,6 +161,20 @@ export class OrganizationLayoutComponent implements OnInit { : { route: "billing/payment-method", textKey: "paymentMethod" }, ), ); + + this.subscriber$ = this.organization$.pipe( + map((organization) => ({ + type: "organization", + data: organization, + })), + ); + + this.getTaxIdWarning$ = () => + this.organization$.pipe( + switchMap((organization) => + this.organizationWarningsService.getTaxIdWarning$(organization), + ), + ); } canShowVaultTab(organization: Organization): boolean { @@ -179,4 +204,6 @@ export class OrganizationLayoutComponent implements OnInit { getReportTabLabel(organization: Organization): string { return organization.useEvents ? "reporting" : "reports"; } + + refreshTaxIdWarning = () => this.organizationWarningsService.refreshTaxIdWarning(); } diff --git a/apps/web/src/app/admin-console/organizations/manage/events.component.ts b/apps/web/src/app/admin-console/organizations/manage/events.component.ts index 3daa6c17d07..07f6be7d7f6 100644 --- a/apps/web/src/app/admin-console/organizations/manage/events.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/events.component.ts @@ -34,6 +34,8 @@ import { openChangePlanDialog, } from "../../../billing/organizations/change-plan-dialog.component"; import { EventService } from "../../../core"; +import { HeaderModule } from "../../../layouts/header/header.module"; +import { SharedModule } from "../../../shared"; import { EventExportService } from "../../../tools/event-export"; import { BaseEventsComponent } from "../../common/base.events.component"; @@ -46,9 +48,8 @@ const EVENT_SYSTEM_USER_TO_TRANSLATION: Record = { }; @Component({ - selector: "app-org-events", templateUrl: "events.component.html", - standalone: false, + imports: [SharedModule, HeaderModule], }) export class EventsComponent extends BaseEventsComponent implements OnInit, OnDestroy { exportFileName = "org-events"; diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts index ca7d07220b2..9b9be4e50b3 100644 --- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts @@ -28,6 +28,7 @@ import { } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -156,7 +157,11 @@ export class GroupAddEditComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); - private orgCollections$ = from(this.collectionAdminService.getAll(this.organizationId)).pipe( + private orgCollections$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.collectionAdminService.collectionAdminViews$(this.organizationId, userId), + ), shareReplay({ refCount: true, bufferSize: 1 }), ); diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts index f48860c69a6..23e92056c95 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts @@ -253,8 +253,8 @@ export class GroupsComponent { private toCollectionMap( response: ListResponse, ): Observable> { - const collections = response.data.map( - (r) => new Collection(new CollectionData(r as CollectionDetailsResponse)), + const collections = response.data.map((r) => + Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)), ); return this.accountService.activeAccount$.pipe( diff --git a/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts b/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts index 03b77cfaa71..16543cdb58c 100644 --- a/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/user-confirm.component.ts @@ -8,6 +8,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { SharedModule } from "../../../shared"; + export type UserConfirmDialogData = { name: string; userId: string; @@ -16,9 +18,8 @@ export type UserConfirmDialogData = { }; @Component({ - selector: "app-user-confirm", templateUrl: "user-confirm.component.html", - standalone: false, + imports: [SharedModule], }) export class UserConfirmComponent implements OnInit { name: string; diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index c6a60165fe1..b951f73d953 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -32,6 +32,7 @@ import { import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -276,9 +277,16 @@ export class MemberDialogComponent implements OnDestroy { ), ); + const collections = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.collectionAdminService.collectionAdminViews$(this.params.organizationId, userId), + ), + ); + combineLatest({ organization: this.organization$, - collections: this.collectionAdminService.getAll(this.params.organizationId), + collections, userDetails: userDetails$, groups: groups$, }) diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 2aac4d0a5c8..dedf13720bf 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -10,10 +10,10 @@ import { from, lastValueFrom, map, + merge, Observable, shareReplay, switchMap, - tap, } from "rxjs"; import { @@ -57,12 +57,12 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { ChangePlanDialogResultType, openChangePlanDialog, } from "../../../billing/organizations/change-plan-dialog.component"; -import { OrganizationWarningsService } from "../../../billing/warnings/services"; import { BaseMembersComponent } from "../../common/base-members.component"; import { PeopleTableDataSource } from "../../common/people-table-data-source"; import { GroupApiService } from "../core"; @@ -200,7 +200,14 @@ export class MembersComponent extends BaseMembersComponent this.organization.canManageUsersPassword && !this.organization.hasPublicAndPrivateKeys ) { - const orgShareKey = await this.keyService.getOrgKey(this.organization.id); + const orgShareKey = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.orgKeys$(userId)), + map((orgKeys) => orgKeys[this.organization.id] ?? null), + ), + ); + const orgKeys = await this.keyService.makeKeyPair(orgShareKey); const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); const response = await this.organizationApiService.updateKeys( @@ -246,11 +253,16 @@ export class MembersComponent extends BaseMembersComponent this.showUserManagementControls$ = organization$.pipe( map((organization) => organization.canManageUsers), ); + organization$ .pipe( + switchMap((organization) => + merge( + this.organizationWarningsService.showInactiveSubscriptionDialog$(organization), + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + ), takeUntilDestroyed(), - tap((org) => (this.organization = org)), - switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)), ) .subscribe(); } @@ -305,7 +317,9 @@ export class MembersComponent extends BaseMembersComponent async getCollectionNameMap() { const response = from(this.apiService.getCollections(this.organization.id)).pipe( map((res) => - res.data.map((r) => new Collection(new CollectionData(r as CollectionDetailsResponse))), + res.data.map((r) => + Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)), + ), ), ); @@ -353,7 +367,13 @@ export class MembersComponent extends BaseMembersComponent this.organizationUserService.confirmUser(this.organization, user, publicKey), ); } else { - const orgKey = await this.keyService.getOrgKey(this.organization.id); + const orgKey = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.orgKeys$(userId)), + map((orgKeys) => orgKeys[this.organization.id] ?? null), + ), + ); const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey); const request = new OrganizationUserConfirmRequest(); request.key = key.encryptedString; diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index d9c5ae356a2..efc091cb335 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -4,8 +4,8 @@ import { NgModule } from "@angular/core"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; import { PasswordCalloutComponent } from "@bitwarden/auth/angular"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; -import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components"; import { LooseComponentsModule } from "../../../shared"; import { SharedOrganizationModule } from "../shared"; diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts index db94eb2535f..afc16e72373 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts @@ -17,8 +17,9 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key"; import { KdfType, KeyService } from "@bitwarden/key-management"; @@ -36,6 +37,8 @@ describe("OrganizationUserResetPasswordService", () => { let organizationUserApiService: MockProxy; let organizationApiService: MockProxy; let i18nService: MockProxy; + const mockUserId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; beforeAll(() => { keyService = mock(); @@ -44,6 +47,7 @@ describe("OrganizationUserResetPasswordService", () => { organizationUserApiService = mock(); organizationApiService = mock(); i18nService = mock(); + accountService = mockAccountServiceWith(mockUserId); sut = new OrganizationUserResetPasswordService( keyService, @@ -52,6 +56,7 @@ describe("OrganizationUserResetPasswordService", () => { organizationUserApiService, organizationApiService, i18nService, + accountService, ); }); @@ -142,7 +147,10 @@ describe("OrganizationUserResetPasswordService", () => { const mockRandomBytes = new Uint8Array(64) as CsprngArray; const mockOrgKey = new SymmetricCryptoKey(mockRandomBytes) as OrgKey; - keyService.getOrgKey.mockResolvedValue(mockOrgKey); + keyService.orgKeys$.mockReturnValue( + of({ [mockOrgId]: mockOrgKey } as Record), + ); + encryptService.decryptToBytes.mockResolvedValue(mockRandomBytes); encryptService.rsaDecrypt.mockResolvedValue(mockRandomBytes); @@ -170,7 +178,7 @@ describe("OrganizationUserResetPasswordService", () => { }); it("should throw an error if the org key is null", async () => { - keyService.getOrgKey.mockResolvedValue(null); + keyService.orgKeys$.mockReturnValue(of(null)); await expect( sut.resetMasterPassword(mockNewMP, mockEmail, mockOrgUserId, mockOrgId), ).rejects.toThrow(); diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts index a1727a8cc59..df5e7e8a25c 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map, switchMap } from "rxjs"; import { OrganizationUserApiService, @@ -10,6 +10,8 @@ import { } from "@bitwarden/admin-console/common"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptedString, @@ -47,6 +49,7 @@ export class OrganizationUserResetPasswordService private organizationUserApiService: OrganizationUserApiService, private organizationApiService: OrganizationApiServiceAbstraction, private i18nService: I18nService, + private accountService: AccountService, ) {} /** @@ -111,7 +114,14 @@ export class OrganizationUserResetPasswordService } // Decrypt Organization's encrypted Private Key with org key - const orgSymKey = await this.keyService.getOrgKey(orgId); + const orgSymKey = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.orgKeys$(userId)), + map((orgKeys) => orgKeys[orgId as OrganizationId] ?? null), + ), + ); + if (orgSymKey == null) { throw new Error("No org key found"); } diff --git a/apps/web/src/app/admin-console/organizations/organization.module.ts b/apps/web/src/app/admin-console/organizations/organization.module.ts index 687361760c9..d956174149b 100644 --- a/apps/web/src/app/admin-console/organizations/organization.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization.module.ts @@ -2,6 +2,7 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { NgModule } from "@angular/core"; import { ScrollLayoutDirective } from "@bitwarden/components"; +import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { LooseComponentsModule } from "../../shared"; @@ -21,6 +22,7 @@ import { AccessSelectorModule } from "./shared/components/access-selector"; LooseComponentsModule, ScrollingModule, ScrollLayoutDirective, + OrganizationWarningsModule, ], declarations: [GroupsComponent, GroupAddEditComponent], }) diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.ts b/apps/web/src/app/admin-console/organizations/settings/account.component.ts index ecb2dbc54a2..21424e86521 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.ts @@ -8,6 +8,7 @@ import { firstValueFrom, from, lastValueFrom, + map, of, Subject, switchMap, @@ -28,6 +29,7 @@ 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 { Utils } from "@bitwarden/common/platform/misc/utils"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; @@ -179,7 +181,13 @@ export class AccountComponent implements OnInit, OnDestroy { // Backfill pub/priv key if necessary if (!this.org.hasPublicAndPrivateKeys) { - const orgShareKey = await this.keyService.getOrgKey(this.organizationId); + const orgShareKey = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.orgKeys$(userId)), + 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/admin-console/organizations/shared/components/access-selector/access-selector.component.html b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html index e9b7ba39aa5..e0ffc9a4bce 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html @@ -50,7 +50,7 @@ >
- +
{{ item.labelName }} @@ -58,7 +58,10 @@ {{ "invited" | i18n }}
-
+
{{ $any(item).email }}
@@ -77,10 +80,10 @@ - {{ item.labelName }} {{ "permission" | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index a0964a90fca..59d042cae52 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -240,9 +240,15 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { return this.groupService.getAll(orgId); }), ); + + const collections = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)), + ); + combineLatest({ organization: organization$, - collections: this.collectionAdminService.getAll(orgId), + collections, groups: groups$, users: this.organizationUserApiService.getAllMiniUserDetails(orgId), }) @@ -393,9 +399,14 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { return; } - const collectionView = new CollectionAdminView(); - collectionView.id = this.params.collectionId; - collectionView.organizationId = this.formGroup.controls.selectedOrg.value; + const parent = this.formGroup.controls.parent?.value; + const collectionView = new CollectionAdminView({ + id: this.params.collectionId as CollectionId, + organizationId: this.formGroup.controls.selectedOrg.value, + name: parent + ? `${parent}/${this.formGroup.controls.name.value}` + : this.formGroup.controls.name.value, + }); collectionView.externalId = this.formGroup.controls.externalId.value; collectionView.groups = this.formGroup.controls.access.value .filter((v) => v.type === AccessItemType.Group) @@ -404,13 +415,6 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { .filter((v) => v.type === AccessItemType.Member) .map(convertToSelectionView); - const parent = this.formGroup.controls.parent.value; - if (parent) { - collectionView.name = `${parent}/${this.formGroup.controls.name.value}`; - } else { - collectionView.name = this.formGroup.controls.name.value; - } - const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const savedCollection = await this.collectionAdminService.save(collectionView, userId); diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts index b0c89cd30ab..2ca566a0af2 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts +++ b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.ts @@ -1,12 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; import { Params } from "@angular/router"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { OrganizationSponsorshipResponse } from "@bitwarden/common/admin-console/models/response/organization-sponsorship.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { Icons, ToastService } from "@bitwarden/components"; +import { IconModule, Icons, ToastService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { BaseAcceptComponent } from "../../../common/base.accept.component"; @@ -16,9 +18,8 @@ import { BaseAcceptComponent } from "../../../common/base.accept.component"; * personal email address." - https://bitwarden.com/learning/free-families-plan-for-enterprise/ */ @Component({ - selector: "app-accept-family-sponsorship", templateUrl: "accept-family-sponsorship.component.html", - standalone: false, + imports: [CommonModule, I18nPipe, IconModule], }) export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent { protected logo = Icons.BitwardenLogo; diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 694d0c6eb9a..ae20670c2dd 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -12,6 +12,7 @@ import { EventUploadService } from "@bitwarden/common/abstractions/event/event-u import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; @@ -89,6 +90,7 @@ export class AppComponent implements OnDestroy, OnInit { private deviceTrustToastService: DeviceTrustToastService, private readonly destoryRef: DestroyRef, private readonly documentLangSetter: DocumentLangSetter, + private readonly tokenService: TokenService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -297,6 +299,7 @@ export class AppComponent implements OnDestroy, OnInit { await this.searchService.clearIndex(userId); this.authService.logOut(async () => { await this.stateService.clean({ userId: userId }); + await this.tokenService.clearAccessToken(userId); await this.accountService.clean(userId); await this.accountService.switchAccount(null); diff --git a/apps/web/src/app/auth/verify-email-token.component.ts b/apps/web/src/app/auth/verify-email-token.component.ts index 9e44cc7a713..2c4fa7f447c 100644 --- a/apps/web/src/app/auth/verify-email-token.component.ts +++ b/apps/web/src/app/auth/verify-email-token.component.ts @@ -2,14 +2,15 @@ // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { VerifyEmailRequest } from "@bitwarden/common/models/request/verify-email.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ToastService } from "@bitwarden/components"; @Component({ @@ -25,7 +26,7 @@ export class VerifyEmailTokenComponent implements OnInit { private route: ActivatedRoute, private apiService: ApiService, private logService: LogService, - private stateService: StateService, + private tokenService: TokenService, private toastService: ToastService, ) {} @@ -37,7 +38,7 @@ export class VerifyEmailTokenComponent implements OnInit { await this.apiService.postAccountVerifyEmailToken( new VerifyEmailRequest(qParams.userId, qParams.token), ); - if (await this.stateService.getIsAuthenticated()) { + if (await firstValueFrom(this.tokenService.hasAccessToken$(qParams.userId))) { await this.apiService.refreshIdentityToken(); } this.toastService.showToast({ diff --git a/apps/web/src/app/billing/clients/index.ts b/apps/web/src/app/billing/clients/index.ts new file mode 100644 index 00000000000..ff962abcbf3 --- /dev/null +++ b/apps/web/src/app/billing/clients/index.ts @@ -0,0 +1,2 @@ +export * from "./organization-billing.client"; +export * from "./subscriber-billing.client"; diff --git a/apps/web/src/app/billing/clients/organization-billing.client.ts b/apps/web/src/app/billing/clients/organization-billing.client.ts new file mode 100644 index 00000000000..a8b3b31a4a4 --- /dev/null +++ b/apps/web/src/app/billing/clients/organization-billing.client.ts @@ -0,0 +1,22 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationWarningsResponse } from "@bitwarden/web-vault/app/billing/organizations/warnings/types"; + +@Injectable() +export class OrganizationBillingClient { + constructor(private apiService: ApiService) {} + + getWarnings = async (organizationId: OrganizationId): Promise => { + const response = await this.apiService.send( + "GET", + `/organizations/${organizationId}/billing/vnext/warnings`, + null, + true, + true, + ); + + return new OrganizationWarningsResponse(response); + }; +} diff --git a/apps/web/src/app/billing/services/billing.client.ts b/apps/web/src/app/billing/clients/subscriber-billing.client.ts similarity index 72% rename from apps/web/src/app/billing/services/billing.client.ts rename to apps/web/src/app/billing/clients/subscriber-billing.client.ts index 69f82eab19a..18ca215ef0c 100644 --- a/apps/web/src/app/billing/services/billing.client.ts +++ b/apps/web/src/app/billing/clients/subscriber-billing.client.ts @@ -10,7 +10,7 @@ import { MaskedPaymentMethodResponse, TokenizedPaymentMethod, } from "../payment/types"; -import { BillableEntity } from "../types"; +import { BitwardenSubscriber } from "../types"; type Result = | { @@ -23,28 +23,28 @@ type Result = }; @Injectable() -export class BillingClient { +export class SubscriberBillingClient { constructor(private apiService: ApiService) {} - private getEndpoint = (entity: BillableEntity): string => { - switch (entity.type) { + private getEndpoint = (subscriber: BitwardenSubscriber): string => { + switch (subscriber.type) { case "account": { return "/account/billing/vnext"; } case "organization": { - return `/organizations/${entity.data.id}/billing/vnext`; + return `/organizations/${subscriber.data.id}/billing/vnext`; } case "provider": { - return `/providers/${entity.data.id}/billing/vnext`; + return `/providers/${subscriber.data.id}/billing/vnext`; } } }; addCreditWithBitPay = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, credit: { amount: number; redirectUrl: string }, ): Promise> => { - const path = `${this.getEndpoint(owner)}/credit/bitpay`; + const path = `${this.getEndpoint(subscriber)}/credit/bitpay`; try { const data = await this.apiService.send("POST", path, credit, true, true); return { @@ -62,29 +62,31 @@ export class BillingClient { } }; - getBillingAddress = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/address`; + getBillingAddress = async (subscriber: BitwardenSubscriber): Promise => { + const path = `${this.getEndpoint(subscriber)}/address`; const data = await this.apiService.send("GET", path, null, true, true); return data ? new BillingAddressResponse(data) : null; }; - getCredit = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/credit`; + getCredit = async (subscriber: BitwardenSubscriber): Promise => { + const path = `${this.getEndpoint(subscriber)}/credit`; const data = await this.apiService.send("GET", path, null, true, true); return data ? (data as number) : null; }; - getPaymentMethod = async (owner: BillableEntity): Promise => { - const path = `${this.getEndpoint(owner)}/payment-method`; + getPaymentMethod = async ( + subscriber: BitwardenSubscriber, + ): Promise => { + const path = `${this.getEndpoint(subscriber)}/payment-method`; const data = await this.apiService.send("GET", path, null, true, true); return data ? new MaskedPaymentMethodResponse(data).value : null; }; updateBillingAddress = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, billingAddress: BillingAddress, ): Promise> => { - const path = `${this.getEndpoint(owner)}/address`; + const path = `${this.getEndpoint(subscriber)}/address`; try { const data = await this.apiService.send("PUT", path, billingAddress, true, true); return { @@ -103,11 +105,11 @@ export class BillingClient { }; updatePaymentMethod = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, paymentMethod: TokenizedPaymentMethod, billingAddress: Pick | null, ): Promise> => { - const path = `${this.getEndpoint(owner)}/payment-method`; + const path = `${this.getEndpoint(subscriber)}/payment-method`; try { const request = { ...paymentMethod, @@ -130,10 +132,10 @@ export class BillingClient { }; verifyBankAccount = async ( - owner: BillableEntity, + subscriber: BitwardenSubscriber, descriptorCode: string, ): Promise> => { - const path = `${this.getEndpoint(owner)}/payment-method/verify-bank-account`; + const path = `${this.getEndpoint(subscriber)}/payment-method/verify-bank-account`; try { const data = await this.apiService.send("POST", path, { descriptorCode }, true, true); return { diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html index c10590d8b1b..5bb47cd8a2e 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.html @@ -12,13 +12,13 @@ } @else { diff --git a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts index 4a4d0f60c0b..9f46d9d3909 100644 --- a/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts +++ b/apps/web/src/app/billing/individual/payment-details/account-payment-details.component.ts @@ -20,13 +20,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; +import { SubscriberBillingClient } from "../../clients"; import { DisplayAccountCreditComponent, DisplayPaymentMethodComponent, } from "../../payment/components"; import { MaskedPaymentMethod } from "../../payment/types"; -import { BillingClient } from "../../services"; -import { accountToBillableEntity, BillableEntity } from "../../types"; +import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types"; class RedirectError { constructor( @@ -36,7 +36,7 @@ class RedirectError { } type View = { - account: BillableEntity; + account: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; credit: number | null; }; @@ -50,7 +50,7 @@ type View = { HeaderModule, SharedModule, ], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class AccountPaymentDetailsComponent { private viewState$ = new BehaviorSubject(null); @@ -68,7 +68,7 @@ export class AccountPaymentDetailsComponent { }), ), ), - accountToBillableEntity, + mapAccountToSubscriber, switchMap(async (account) => { const [paymentMethod, credit] = await Promise.all([ this.billingClient.getPaymentMethod(account), @@ -100,7 +100,7 @@ export class AccountPaymentDetailsComponent { constructor( private accountService: AccountService, private activatedRoute: ActivatedRoute, - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private configService: ConfigService, private router: Router, ) {} diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html index 17f4349fdd5..cd31f1f33be 100644 --- a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.html @@ -21,19 +21,20 @@ } @else { diff --git a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts index e357444b943..d1dfea40fe2 100644 --- a/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts +++ b/apps/web/src/app/billing/organizations/payment-details/organization-payment-details.component.ts @@ -1,8 +1,9 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { BehaviorSubject, catchError, + combineLatest, EMPTY, filter, firstValueFrom, @@ -11,8 +12,12 @@ import { map, merge, Observable, + of, shareReplay, + Subject, switchMap, + take, + takeUntil, tap, } from "rxjs"; @@ -26,19 +31,26 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; - -import { HeaderModule } from "../../../layouts/header/header.module"; -import { SharedModule } from "../../../shared"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { ChangePaymentMethodDialogComponent, DisplayAccountCreditComponent, DisplayBillingAddressComponent, DisplayPaymentMethodComponent, -} from "../../payment/components"; -import { BillingAddress, MaskedPaymentMethod } from "../../payment/types"; -import { BillingClient } from "../../services"; -import { BillableEntity, organizationToBillableEntity } from "../../types"; -import { OrganizationFreeTrialWarningComponent } from "../../warnings/components"; +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + BillingAddress, + MaskedPaymentMethod, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { + BitwardenSubscriber, + mapOrganizationToSubscriber, +} from "@bitwarden/web-vault/app/billing/types"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; class RedirectError { constructor( @@ -48,93 +60,100 @@ class RedirectError { } type View = { - organization: BillableEntity; + organization: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; billingAddress: BillingAddress | null; credit: number | null; + taxIdWarning: TaxIdWarningType | null; }; @Component({ templateUrl: "./organization-payment-details.component.html", standalone: true, imports: [ - DisplayBillingAddressComponent, DisplayAccountCreditComponent, + DisplayBillingAddressComponent, DisplayPaymentMethodComponent, HeaderModule, OrganizationFreeTrialWarningComponent, SharedModule, ], - providers: [BillingClient], }) -export class OrganizationPaymentDetailsComponent implements OnInit { - @ViewChild(OrganizationFreeTrialWarningComponent) - organizationFreeTrialWarningComponent!: OrganizationFreeTrialWarningComponent; - +export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { private viewState$ = new BehaviorSubject(null); - private load$: Observable = this.accountService.activeAccount$ - .pipe( - getUserId, - switchMap((userId) => - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), - ), - ) - .pipe( - switchMap((organization) => - this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => { - if (!managePaymentDetailsOutsideCheckout) { - throw new RedirectError(["../payment-method"], this.activatedRoute); - } - return organization; - }), - ), - ), - organizationToBillableEntity, - switchMap(async (organization) => { - const [paymentMethod, billingAddress, credit] = await Promise.all([ - this.billingClient.getPaymentMethod(organization), - this.billingClient.getBillingAddress(organization), - this.billingClient.getCredit(organization), - ]); + protected organization$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.organizationService + .organizations$(userId) + .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), + ), + filter((organization): organization is Organization => !!organization), + ); - return { - organization, - paymentMethod, - billingAddress, - credit, - }; - }), - catchError((error: unknown) => { - if (error instanceof RedirectError) { - return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( - switchMap(() => EMPTY), - ); - } - throw error; - }), - ); + private load$: Observable = this.organization$.pipe( + switchMap((organization) => + this.configService + .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) + .pipe( + map((managePaymentDetailsOutsideCheckout) => { + if (!managePaymentDetailsOutsideCheckout) { + throw new RedirectError(["../payment-method"], this.activatedRoute); + } + return organization; + }), + ), + ), + mapOrganizationToSubscriber, + switchMap(async (organization) => { + const getTaxIdWarning = firstValueFrom( + this.organizationWarningsService.getTaxIdWarning$(organization.data as Organization), + ); + + const [paymentMethod, billingAddress, credit, taxIdWarning] = await Promise.all([ + this.subscriberBillingClient.getPaymentMethod(organization), + this.subscriberBillingClient.getBillingAddress(organization), + this.subscriberBillingClient.getCredit(organization), + getTaxIdWarning, + ]); + + return { + organization, + paymentMethod, + billingAddress, + credit, + taxIdWarning, + }; + }), + catchError((error: unknown) => { + if (error instanceof RedirectError) { + return from(this.router.navigate(error.path, { relativeTo: error.relativeTo })).pipe( + switchMap(() => EMPTY), + ); + } + throw error; + }), + ); view$: Observable = merge( this.load$.pipe(tap((view) => this.viewState$.next(view))), this.viewState$.pipe(filter((view): view is View => view !== null)), ).pipe(shareReplay({ bufferSize: 1, refCount: true })); - organization$ = this.view$.pipe(map((view) => view.organization.data as Organization)); + private destroy$ = new Subject(); + + protected enableTaxIdWarning!: boolean; constructor( private accountService: AccountService, private activatedRoute: ActivatedRoute, - private billingClient: BillingClient, private configService: ConfigService, private dialogService: DialogService, private organizationService: OrganizationService, + private organizationWarningsService: OrganizationWarningsService, private router: Router, + private subscriberBillingClient: SubscriberBillingClient, ) {} async ngOnInit() { @@ -145,24 +164,66 @@ export class OrganizationPaymentDetailsComponent implements OnInit { history.replaceState({ ...history.state, launchPaymentModalAutomatically: false }, ""); await this.changePaymentMethod(); } + + this.enableTaxIdWarning = await this.configService.getFeatureFlag( + FeatureFlag.PM22415_TaxIDWarnings, + ); + + if (this.enableTaxIdWarning) { + this.organizationWarningsService.taxIdWarningRefreshed$ + .pipe( + switchMap((warning) => + combineLatest([ + of(warning), + this.organization$.pipe(take(1)).pipe( + mapOrganizationToSubscriber, + switchMap((organization) => + this.subscriberBillingClient.getBillingAddress(organization), + ), + ), + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe(([taxIdWarning, billingAddress]) => { + if (this.viewState$.value) { + this.viewState$.next({ + ...this.viewState$.value, + taxIdWarning, + billingAddress, + }); + } + }); + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } changePaymentMethod = async () => { const view = await firstValueFrom(this.view$); const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { data: { - owner: view.organization, + subscriber: view.organization, }, }); const result = await lastValueFrom(dialogRef.closed); if (result?.type === "success") { await this.setPaymentMethod(result.paymentMethod); - this.organizationFreeTrialWarningComponent.refresh(); + this.organizationWarningsService.refreshFreeTrialWarning(); } }; setBillingAddress = (billingAddress: BillingAddress) => { if (this.viewState$.value) { + if ( + this.enableTaxIdWarning && + this.viewState$.value.billingAddress?.taxId !== billingAddress.taxId + ) { + this.organizationWarningsService.refreshTaxIdWarning(); + } this.viewState$.next({ ...this.viewState$.value, billingAddress, @@ -174,7 +235,7 @@ export class OrganizationPaymentDetailsComponent implements OnInit { if (this.viewState$.value) { const billingAddress = this.viewState$.value.billingAddress ?? - (await this.billingClient.getBillingAddress(this.viewState$.value.organization)); + (await this.subscriberBillingClient.getBillingAddress(this.viewState$.value.organization)); this.viewState$.next({ ...this.viewState$.value, diff --git a/apps/web/src/app/billing/organizations/warnings/components/index.ts b/apps/web/src/app/billing/organizations/warnings/components/index.ts new file mode 100644 index 00000000000..1e1e0682e62 --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/components/index.ts @@ -0,0 +1,2 @@ +export * from "./organization-free-trial-warning.component"; +export * from "./organization-reseller-renewal-warning.component"; diff --git a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts similarity index 54% rename from apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts rename to apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts index a7ce53c9998..4925e4bc01d 100644 --- a/apps/web/src/app/billing/warnings/components/organization-free-trial-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-free-trial-warning.component.ts @@ -1,12 +1,9 @@ -import { AsyncPipe } from "@angular/common"; -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { Observable, Subject } from "rxjs"; -import { takeUntil } from "rxjs/operators"; +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationId } from "@bitwarden/common/types/guid"; -import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; +import { BannerModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationFreeTrialWarning } from "../types"; @@ -37,33 +34,17 @@ import { OrganizationFreeTrialWarning } from "../types"; } `, - imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe], + imports: [BannerModule, SharedModule], }) -export class OrganizationFreeTrialWarningComponent implements OnInit, OnDestroy { +export class OrganizationFreeTrialWarningComponent implements OnInit { @Input({ required: true }) organization!: Organization; @Output() clicked = new EventEmitter(); - warning$!: Observable; - private destroy$ = new Subject(); + warning$!: Observable; constructor(private organizationWarningsService: OrganizationWarningsService) {} ngOnInit() { this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization); - this.organizationWarningsService - .refreshWarningsForOrganization$(this.organization.id as OrganizationId) - .pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.refresh(); - }); } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - refresh = () => { - this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization, true); - }; } diff --git a/apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts similarity index 82% rename from apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts rename to apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts index f45dd443dda..4eba9f3daf5 100644 --- a/apps/web/src/app/billing/warnings/components/organization-reseller-renewal-warning.component.ts +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-reseller-renewal-warning.component.ts @@ -1,9 +1,9 @@ -import { AsyncPipe } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { BannerComponent } from "@bitwarden/components"; +import { BannerModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { OrganizationWarningsService } from "../services"; import { OrganizationResellerRenewalWarning } from "../types"; @@ -25,12 +25,12 @@ import { OrganizationResellerRenewalWarning } from "../types"; } `, - imports: [AsyncPipe, BannerComponent], + imports: [BannerModule, SharedModule], }) export class OrganizationResellerRenewalWarningComponent implements OnInit { @Input({ required: true }) organization!: Organization; - warning$!: Observable; + warning$!: Observable; constructor(private organizationWarningsService: OrganizationWarningsService) {} diff --git a/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts b/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts new file mode 100644 index 00000000000..6defee7e78b --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/organization-warnings.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from "@angular/core"; + +import { + OrganizationBillingClient, + SubscriberBillingClient, +} from "@bitwarden/web-vault/app/billing/clients"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; + +@NgModule({ + providers: [OrganizationBillingClient, OrganizationWarningsService, SubscriberBillingClient], +}) +export class OrganizationWarningsModule {} diff --git a/apps/web/src/app/billing/warnings/services/index.ts b/apps/web/src/app/billing/organizations/warnings/services/index.ts similarity index 100% rename from apps/web/src/app/billing/warnings/services/index.ts rename to apps/web/src/app/billing/organizations/warnings/services/index.ts diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts new file mode 100644 index 00000000000..c7a297cc28b --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts @@ -0,0 +1,682 @@ +jest.mock("@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component", () => ({ + ChangePlanDialogResultType: { + Submitted: "submitted", + Cancelled: "cancelled", + }, + openChangePlanDialog: jest.fn(), +})); + +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.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 { DialogRef, DialogService } from "@bitwarden/components"; +import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + ChangePlanDialogResultType, + openChangePlanDialog, +} from "@bitwarden/web-vault/app/billing/organizations/change-plan-dialog.component"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services/organization-warnings.service"; +import { OrganizationWarningsResponse } from "@bitwarden/web-vault/app/billing/organizations/warnings/types"; +import { + TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, + TrialPaymentDialogComponent, + TrialPaymentDialogResultType, +} from "@bitwarden/web-vault/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component"; +import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/types"; + +describe("OrganizationWarningsService", () => { + let service: OrganizationWarningsService; + let configService: MockProxy; + let dialogService: MockProxy; + let i18nService: MockProxy; + let organizationApiService: MockProxy; + let organizationBillingClient: MockProxy; + let router: MockProxy; + + const organization = { + id: "org-id-123", + name: "Test Organization", + providerName: "Test Reseller Inc", + productTierType: ProductTierType.Enterprise, + } as Organization; + + const format = (date: Date): string => + date.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); + + beforeEach(() => { + configService = mock(); + dialogService = mock(); + i18nService = mock(); + organizationApiService = mock(); + organizationBillingClient = mock(); + router = mock(); + + (openChangePlanDialog as jest.Mock).mockReset(); + + i18nService.t.mockImplementation((key: string, ...args: any[]) => { + switch (key) { + case "freeTrialEndPromptCount": + return `Your free trial ends in ${args[0]} days.`; + case "freeTrialEndPromptTomorrowNoOrgName": + return "Your free trial ends tomorrow."; + case "freeTrialEndingTodayWithoutOrgName": + return "Your free trial ends today."; + case "resellerRenewalWarningMsg": + return `Your subscription will renew soon. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[1]}.`; + case "resellerOpenInvoiceWarningMgs": + return `An invoice for your subscription was issued on ${args[1]}. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[2]}.`; + case "resellerPastDueWarningMsg": + return `The invoice for your subscription has not been paid. To ensure uninterrupted service, contact ${args[0]} to confirm your renewal before ${args[1]}.`; + case "suspendedOrganizationTitle": + return `${args[0]} subscription suspended`; + case "close": + return "Close"; + case "continue": + return "Continue"; + default: + return key; + } + }); + + TestBed.configureTestingModule({ + providers: [ + OrganizationWarningsService, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, + { provide: I18nService, useValue: i18nService }, + { provide: OrganizationApiServiceAbstraction, useValue: organizationApiService }, + { provide: OrganizationBillingClient, useValue: organizationBillingClient }, + { provide: Router, useValue: router }, + ], + }); + + service = TestBed.inject(OrganizationWarningsService); + }); + + describe("getFreeTrialWarning$", () => { + it("should return null when no free trial warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return warning with count message when remaining trial days >= 2", (done) => { + const warning = { remainingTrialDays: 5 }; + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends in 5 days.", + }); + expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndPromptCount", 5); + done(); + }); + }); + + it("should return warning with tomorrow message when remaining trial days = 1", (done) => { + const warning = { remainingTrialDays: 1 }; + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends tomorrow.", + }); + expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndPromptTomorrowNoOrgName"); + done(); + }); + }); + + it("should return warning with today message when remaining trial days = 0", (done) => { + const warning = { remainingTrialDays: 0 }; + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + service.getFreeTrialWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends today.", + }); + expect(i18nService.t).toHaveBeenCalledWith("freeTrialEndingTodayWithoutOrgName"); + done(); + }); + }); + + it("should refresh warning when refreshFreeTrialWarning is called", (done) => { + const initialWarning = { remainingTrialDays: 3 }; + const refreshedWarning = { remainingTrialDays: 2 }; + let invocationCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({ + freeTrial: initialWarning, + } as OrganizationWarningsResponse) + .mockResolvedValueOnce({ + freeTrial: refreshedWarning, + } as OrganizationWarningsResponse); + + const subscription = service.getFreeTrialWarning$(organization).subscribe((result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends in 3 days.", + }); + } else if (invocationCount === 2) { + expect(result).toEqual({ + organization: organization, + message: "Your free trial ends in 2 days.", + }); + subscription.unsubscribe(); + done(); + } + }); + + setTimeout(() => { + service.refreshFreeTrialWarning(); + }, 10); + }); + }); + + describe("getResellerRenewalWarning$", () => { + it("should return null when no reseller renewal warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return upcoming warning with correct type and message", (done) => { + const renewalDate = new Date(2024, 11, 31); + const warning = { + type: "upcoming" as const, + upcoming: { renewalDate }, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + resellerRenewal: warning, + } as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + const expectedFormattedDate = format(renewalDate); + + expect(result).toEqual({ + type: "info", + message: `Your subscription will renew soon. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedFormattedDate}.`, + }); + expect(i18nService.t).toHaveBeenCalledWith( + "resellerRenewalWarningMsg", + "Test Reseller Inc", + expectedFormattedDate, + ); + done(); + }); + }); + + it("should return issued warning with correct type and message", (done) => { + const issuedDate = new Date(2024, 10, 15); + const dueDate = new Date(2024, 11, 15); + const warning = { + type: "issued" as const, + issued: { issuedDate, dueDate }, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + resellerRenewal: warning, + } as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + const expectedIssuedDate = format(issuedDate); + const expectedDueDate = format(dueDate); + + expect(result).toEqual({ + type: "info", + message: `An invoice for your subscription was issued on ${expectedIssuedDate}. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedDueDate}.`, + }); + expect(i18nService.t).toHaveBeenCalledWith( + "resellerOpenInvoiceWarningMgs", + "Test Reseller Inc", + expectedIssuedDate, + expectedDueDate, + ); + done(); + }); + }); + + it("should return past_due warning with correct type and message", (done) => { + const suspensionDate = new Date(2024, 11, 1); + const warning = { + type: "past_due" as const, + pastDue: { suspensionDate }, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + resellerRenewal: warning, + } as OrganizationWarningsResponse); + + service.getResellerRenewalWarning$(organization).subscribe((result) => { + const expectedSuspensionDate = format(suspensionDate); + + expect(result).toEqual({ + type: "warning", + message: `The invoice for your subscription has not been paid. To ensure uninterrupted service, contact Test Reseller Inc to confirm your renewal before ${expectedSuspensionDate}.`, + }); + expect(i18nService.t).toHaveBeenCalledWith( + "resellerPastDueWarningMsg", + "Test Reseller Inc", + expectedSuspensionDate, + ); + done(); + }); + }); + }); + + describe("getTaxIdWarning$", () => { + it("should return null when no tax ID warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return tax_id_missing type when tax ID is missing", (done) => { + const warning = { type: TaxIdWarningTypes.Missing }; + organizationBillingClient.getWarnings.mockResolvedValue({ + taxId: warning, + } as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.Missing); + done(); + }); + }); + + it("should return tax_id_pending_verification type when tax ID verification is pending", (done) => { + const warning = { type: TaxIdWarningTypes.PendingVerification }; + organizationBillingClient.getWarnings.mockResolvedValue({ + taxId: warning, + } as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.PendingVerification); + done(); + }); + }); + + it("should return tax_id_failed_verification type when tax ID verification failed", (done) => { + const warning = { type: TaxIdWarningTypes.FailedVerification }; + organizationBillingClient.getWarnings.mockResolvedValue({ + taxId: warning, + } as OrganizationWarningsResponse); + + service.getTaxIdWarning$(organization).subscribe((result) => { + expect(result).toBe(TaxIdWarningTypes.FailedVerification); + done(); + }); + }); + + it("should refresh warning and update taxIdWarningRefreshedSubject when refreshTaxIdWarning is called", (done) => { + const initialWarning = { type: TaxIdWarningTypes.Missing }; + const refreshedWarning = { type: TaxIdWarningTypes.FailedVerification }; + let invocationCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({ + taxId: initialWarning, + } as OrganizationWarningsResponse) + .mockResolvedValueOnce({ + taxId: refreshedWarning, + } as OrganizationWarningsResponse); + + const subscription = service.getTaxIdWarning$(organization).subscribe((result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toBe(TaxIdWarningTypes.Missing); + } else if (invocationCount === 2) { + expect(result).toBe(TaxIdWarningTypes.FailedVerification); + subscription.unsubscribe(); + done(); + } + }); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + + it("should update taxIdWarningRefreshedSubject with warning type when refresh returns a warning", (done) => { + const refreshedWarning = { type: TaxIdWarningTypes.Missing }; + let refreshedCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({} as OrganizationWarningsResponse) + .mockResolvedValueOnce({ + taxId: refreshedWarning, + } as OrganizationWarningsResponse); + + const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => { + refreshedCount++; + if (refreshedCount === 2) { + expect(refreshedType).toBe(TaxIdWarningTypes.Missing); + taxIdSubscription.unsubscribe(); + done(); + } + }); + + service.getTaxIdWarning$(organization).subscribe(); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + + it("should update taxIdWarningRefreshedSubject with null when refresh returns no warning", (done) => { + const initialWarning = { type: TaxIdWarningTypes.Missing }; + let refreshedCount = 0; + + organizationBillingClient.getWarnings + .mockResolvedValueOnce({ + taxId: initialWarning, + } as OrganizationWarningsResponse) + .mockResolvedValueOnce({} as OrganizationWarningsResponse); + + const taxIdSubscription = service.taxIdWarningRefreshed$.subscribe((refreshedType) => { + refreshedCount++; + if (refreshedCount === 2) { + expect(refreshedType).toBeNull(); + taxIdSubscription.unsubscribe(); + done(); + } + }); + + service.getTaxIdWarning$(organization).subscribe(); + + setTimeout(() => { + service.refreshTaxIdWarning(); + }, 10); + }); + }); + + describe("showInactiveSubscriptionDialog$", () => { + it("should not show dialog when no inactive subscription warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should show contact provider dialog for contact_provider resolution", (done) => { + const warning = { resolution: "contact_provider" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { + key: "suspendedManagedOrgMessage", + placeholders: ["Test Reseller Inc"], + }, + type: "danger", + acceptButtonText: "Close", + cancelButtonText: null, + }); + done(); + }, + }); + }); + + it("should show add payment method dialog and navigate when confirmed", (done) => { + const warning = { resolution: "add_payment_method" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + configService.getFeatureFlag.mockResolvedValue(false); + router.navigate.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: "Continue", + cancelButtonText: "Close", + }); + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + ); + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", "org-id-123", "billing", "payment-method"], + { state: { launchPaymentModalAutomatically: true } }, + ); + done(); + }, + }); + }); + + it("should navigate to payment-details when feature flag is enabled", (done) => { + const warning = { resolution: "add_payment_method" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + configService.getFeatureFlag.mockResolvedValue(true); + router.navigate.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", "org-id-123", "billing", "payment-details"], + { state: { launchPaymentModalAutomatically: true } }, + ); + done(); + }, + }); + }); + + it("should not navigate when add payment method dialog is cancelled", (done) => { + const warning = { resolution: "add_payment_method" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(false); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + expect(configService.getFeatureFlag).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should open change plan dialog for resubscribe resolution", (done) => { + const warning = { resolution: "resubscribe" }; + const subscription = { id: "sub-123" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of("submitted"), + } as DialogRef; + + (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id); + expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, { + data: { + organizationId: organization.id, + subscription: subscription, + productTierType: organization.productTierType, + }, + }); + done(); + }, + }); + }); + + it("should show contact owner dialog for contact_owner resolution", (done) => { + const warning = { resolution: "contact_owner" }; + organizationBillingClient.getWarnings.mockResolvedValue({ + inactiveSubscription: warning, + } as OrganizationWarningsResponse); + + dialogService.openSimpleDialog.mockResolvedValue(true); + + service.showInactiveSubscriptionDialog$(organization).subscribe({ + complete: () => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { key: "suspendedUserOrgMessage" }, + type: "danger", + acceptButtonText: "Close", + cancelButtonText: null, + }); + done(); + }, + }); + }); + }); + + describe("showSubscribeBeforeFreeTrialEndsDialog$", () => { + it("should not show dialog when no free trial warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).not.toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should open trial payment dialog when free trial warning exists", (done) => { + const warning = { remainingTrialDays: 2 }; + const subscription = { id: "sub-123" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.CLOSED), + } as DialogRef; + + const openSpy = jest + .spyOn(TrialPaymentDialogComponent, "open") + .mockReturnValue(mockDialogRef); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id); + expect(openSpy).toHaveBeenCalledWith(dialogService, { + data: { + organizationId: organization.id, + subscription: subscription, + productTierType: organization.productTierType, + }, + }); + done(); + }, + }); + }); + + it("should refresh free trial warning when dialog result is SUBMITTED", (done) => { + const warning = { remainingTrialDays: 1 }; + const subscription = { id: "sub-456" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED), + } as DialogRef; + + jest.spyOn(TrialPaymentDialogComponent, "open").mockReturnValue(mockDialogRef); + + const refreshTriggerSpy = jest.spyOn(service["refreshFreeTrialWarningTrigger"], "next"); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(refreshTriggerSpy).toHaveBeenCalled(); + done(); + }, + }); + }); + + it("should not refresh free trial warning when dialog result is CLOSED", (done) => { + const warning = { remainingTrialDays: 3 }; + const subscription = { id: "sub-789" } as OrganizationSubscriptionResponse; + + organizationBillingClient.getWarnings.mockResolvedValue({ + freeTrial: warning, + } as OrganizationWarningsResponse); + + organizationApiService.getSubscription.mockResolvedValue(subscription); + + const mockDialogRef = { + closed: of(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.CLOSED), + } as DialogRef; + + jest.spyOn(TrialPaymentDialogComponent, "open").mockReturnValue(mockDialogRef); + const refreshSpy = jest.spyOn(service, "refreshFreeTrialWarning"); + + service.showSubscribeBeforeFreeTrialEndsDialog$(organization).subscribe({ + complete: () => { + expect(refreshSpy).not.toHaveBeenCalled(); + done(); + }, + }); + }); + }); +}); diff --git a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts similarity index 63% rename from apps/web/src/app/billing/warnings/services/organization-warnings.service.ts rename to apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts index 78c17a5d384..5b466dfe41d 100644 --- a/apps/web/src/app/billing/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts @@ -1,26 +1,39 @@ -import { Location } from "@angular/common"; import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { filter, from, lastValueFrom, map, Observable, Subject, switchMap, takeWhile } from "rxjs"; +import { + BehaviorSubject, + filter, + from, + lastValueFrom, + map, + merge, + Observable, + Subject, + switchMap, + tap, +} from "rxjs"; import { take } from "rxjs/operators"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.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 { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; +import { OrganizationBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; -import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component"; import { TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, TrialPaymentDialogComponent, -} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; -import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types"; +} from "../../../shared/trial-payment-dialog/trial-payment-dialog.component"; +import { openChangePlanDialog } from "../../change-plan-dialog.component"; +import { + OrganizationFreeTrialWarning, + OrganizationResellerRenewalWarning, + OrganizationWarningsResponse, +} from "../types"; const format = (date: Date) => date.toLocaleDateString("en-US", { @@ -29,28 +42,39 @@ const format = (date: Date) => year: "numeric", }); -@Injectable({ providedIn: "root" }) +@Injectable() export class OrganizationWarningsService { private cache$ = new Map>(); - private refreshWarnings$ = new Subject(); + + private refreshFreeTrialWarningTrigger = new Subject(); + private refreshTaxIdWarningTrigger = new Subject(); + + private taxIdWarningRefreshedSubject = new BehaviorSubject(null); + taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable(); constructor( private configService: ConfigService, private dialogService: DialogService, private i18nService: I18nService, private organizationApiService: OrganizationApiServiceAbstraction, - private organizationBillingApiService: OrganizationBillingApiServiceAbstraction, + private organizationBillingClient: OrganizationBillingClient, private router: Router, - private location: Location, - protected syncService: SyncService, ) {} getFreeTrialWarning$ = ( organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.freeTrial, bypassCache).pipe( + ): Observable => + merge( + this.getWarning$(organization, (response) => response.freeTrial), + this.refreshFreeTrialWarningTrigger.pipe( + switchMap(() => this.getWarning$(organization, (response) => response.freeTrial, true)), + ), + ).pipe( map((warning) => { + if (!warning) { + return null; + } + const { remainingTrialDays } = warning; if (remainingTrialDays >= 2) { @@ -76,10 +100,12 @@ export class OrganizationWarningsService { getResellerRenewalWarning$ = ( organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.resellerRenewal, bypassCache).pipe( - map((warning): OrganizationResellerRenewalWarning | null => { + ): Observable => + this.getWarning$(organization, (response) => response.resellerRenewal).pipe( + map((warning) => { + if (!warning) { + return null; + } switch (warning.type) { case "upcoming": { return { @@ -114,14 +140,27 @@ export class OrganizationWarningsService { } } }), - filter((result): result is NonNullable => result !== null), ); - showInactiveSubscriptionDialog$ = ( - organization: Organization, - bypassCache: boolean = false, - ): Observable => - this.getWarning$(organization, (response) => response.inactiveSubscription, bypassCache).pipe( + getTaxIdWarning$ = (organization: Organization): Observable => + merge( + this.getWarning$(organization, (response) => response.taxId), + this.refreshTaxIdWarningTrigger.pipe( + switchMap(() => + this.getWarning$(organization, (response) => response.taxId, true).pipe( + tap((warning) => this.taxIdWarningRefreshedSubject.next(warning ? warning.type : null)), + ), + ), + ), + ).pipe(map((warning) => (warning ? warning.type : null))); + + refreshFreeTrialWarning = () => this.refreshFreeTrialWarningTrigger.next(); + + refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next(); + + showInactiveSubscriptionDialog$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.inactiveSubscription).pipe( + filter((warning) => warning !== null), switchMap(async (warning) => { switch (warning.resolution) { case "contact_provider": { @@ -183,43 +222,43 @@ export class OrganizationWarningsService { }); break; } - case "add_payment_method_optional_trial": { - const organizationSubscriptionResponse = - await this.organizationApiService.getSubscription(organization.id); - - const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { - data: { - organizationId: organization.id, - subscription: organizationSubscriptionResponse, - productTierType: organization?.productTierType, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { - this.refreshWarnings$.next(organization.id as OrganizationId); - } - } } }), ); - refreshWarningsForOrganization$(organizationId: OrganizationId): Observable { - return this.refreshWarnings$.pipe( - filter((id) => id === organizationId), - map((): void => void 0), - ); - } + showSubscribeBeforeFreeTrialEndsDialog$ = (organization: Organization): Observable => + this.getWarning$(organization, (response) => response.freeTrial).pipe( + filter((warning) => warning !== null), + switchMap(async () => { + const organizationSubscriptionResponse = await this.organizationApiService.getSubscription( + organization.id, + ); - private getResponse$ = ( + const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { + data: { + organizationId: organization.id, + subscription: organizationSubscriptionResponse, + productTierType: organization?.productTierType, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { + this.refreshFreeTrialWarningTrigger.next(); + } + }), + ); + + private readThroughWarnings$ = ( organization: Organization, bypassCache: boolean = false, ): Observable => { - const existing = this.cache$.get(organization.id as OrganizationId); + const organizationId = organization.id as OrganizationId; + const existing = this.cache$.get(organizationId); if (existing && !bypassCache) { return existing; } - const response$ = from(this.organizationBillingApiService.getWarnings(organization.id)); - this.cache$.set(organization.id as OrganizationId, response$); + const response$ = from(this.organizationBillingClient.getWarnings(organizationId)); + this.cache$.set(organizationId, response$); return response$; }; @@ -227,10 +266,12 @@ export class OrganizationWarningsService { organization: Organization, extract: (response: OrganizationWarningsResponse) => T | null | undefined, bypassCache: boolean = false, - ): Observable => - this.getResponse$(organization, bypassCache).pipe( - map(extract), - takeWhile((warning): warning is T => !!warning), + ): Observable => + this.readThroughWarnings$(organization, bypassCache).pipe( + map((response) => { + const value = extract(response); + return value ? value : null; + }), take(1), ); } diff --git a/apps/web/src/app/billing/organizations/warnings/types/index.ts b/apps/web/src/app/billing/organizations/warnings/types/index.ts new file mode 100644 index 00000000000..fc0c7d278ed --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/types/index.ts @@ -0,0 +1 @@ +export * from "./organization-warnings"; diff --git a/libs/common/src/billing/models/response/organization-warnings.response.ts b/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts similarity index 80% rename from libs/common/src/billing/models/response/organization-warnings.response.ts rename to apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts index ff70298101e..0c0097d5b09 100644 --- a/libs/common/src/billing/models/response/organization-warnings.response.ts +++ b/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts @@ -1,9 +1,22 @@ -import { BaseResponse } from "../../../models/response/base.response"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { TaxIdWarningResponse } from "@bitwarden/web-vault/app/billing/warnings/types"; + +export type OrganizationFreeTrialWarning = { + organization: Pick; + message: string; +}; + +export type OrganizationResellerRenewalWarning = { + type: "info" | "warning"; + message: string; +}; export class OrganizationWarningsResponse extends BaseResponse { freeTrial?: FreeTrialWarningResponse; inactiveSubscription?: InactiveSubscriptionWarningResponse; resellerRenewal?: ResellerRenewalWarningResponse; + taxId?: TaxIdWarningResponse; constructor(response: any) { super(response); @@ -21,6 +34,10 @@ export class OrganizationWarningsResponse extends BaseResponse { if (resellerWarning) { this.resellerRenewal = new ResellerRenewalWarningResponse(resellerWarning); } + const taxIdWarning = this.getResponseProperty("TaxId"); + if (taxIdWarning) { + this.taxId = new TaxIdWarningResponse(taxIdWarning); + } } } diff --git a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts index 2030d0e73ec..a83a00e8158 100644 --- a/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/add-account-credit-dialog.component.ts @@ -14,13 +14,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; }; type DialogResult = "cancelled" | "error" | "launched"; @@ -125,7 +125,7 @@ const positiveNumberValidator = `, standalone: true, imports: [SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class AddAccountCreditDialogComponent { @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm!: ElementRef; @@ -143,22 +143,22 @@ export class AddAccountCreditDialogComponent { protected payPalCustom$ = this.configService.cloudRegion$.pipe( map((cloudRegion) => { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { - return `user_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`; + return `user_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } case "organization": { - return `organization_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`; + return `organization_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } case "provider": { - return `provider_id=${this.dialogParams.owner.data.id},account_credit=1,region=${cloudRegion}`; + return `provider_id:${this.dialogParams.subscriber.data.id},account_credit:1,region:${cloudRegion}`; } } }), ); constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private configService: ConfigService, @Inject(DIALOG_DATA) private dialogParams: DialogParams, private dialogRef: DialogRef, @@ -175,7 +175,7 @@ export class AddAccountCreditDialogComponent { } if (this.formGroup.value.paymentMethod === "bitPay") { - const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.owner, { + const result = await this.billingClient.addCreditWithBitPay(this.dialogParams.subscriber, { amount: this.amount!, redirectUrl: this.redirectUrl, }); @@ -225,13 +225,13 @@ export class AddAccountCreditDialogComponent { } get payPalSubject(): string { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { - return this.dialogParams.owner.data.email; + return this.dialogParams.subscriber.data.email; } case "organization": case "provider": { - return this.dialogParams.owner.data.name; + return this.dialogParams.subscriber.data.name; } } } 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 15c63d8f99f..4d2fadaa894 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 @@ -3,10 +3,10 @@ import { Component, Inject } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; import { @@ -15,7 +15,7 @@ import { } from "./submit-payment-method-dialog.component"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; }; @Component({ @@ -28,7 +28,7 @@ type DialogParams = {
@@ -51,20 +51,20 @@ type DialogParams = { `, standalone: true, imports: [EnterPaymentMethodComponent, SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class ChangePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { - protected override owner: BillableEntity; + protected override subscriber: BitwardenSubscriber; constructor( - billingClient: BillingClient, + billingClient: SubscriberBillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, dialogRef: DialogRef, i18nService: I18nService, toastService: ToastService, ) { super(billingClient, dialogRef, i18nService, toastService); - this.owner = this.dialogParams.owner; + this.subscriber = this.dialogParams.subscriber; } static open = (dialogService: DialogService, dialogConfig: DialogConfig) => diff --git a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts index 7cbe3a27f30..f6aa0ef58bb 100644 --- a/apps/web/src/app/billing/payment/components/display-account-credit.component.ts +++ b/apps/web/src/app/billing/payment/components/display-account-credit.component.ts @@ -3,10 +3,10 @@ import { Component, Input } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.component"; @@ -23,14 +23,14 @@ import { AddAccountCreditDialogComponent } from "./add-account-credit-dialog.com `, standalone: true, imports: [SharedModule], - providers: [BillingClient, CurrencyPipe], + providers: [SubscriberBillingClient, CurrencyPipe], }) export class DisplayAccountCreditComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) credit!: number | null; constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private currencyPipe: CurrencyPipe, private dialogService: DialogService, private i18nService: I18nService, @@ -38,8 +38,8 @@ export class DisplayAccountCreditComponent { ) {} addAccountCredit = async () => { - if (this.owner.type !== "account") { - const billingAddress = await this.billingClient.getBillingAddress(this.owner); + if (this.subscriber.type !== "account") { + const billingAddress = await this.billingClient.getBillingAddress(this.subscriber); if (!billingAddress) { this.toastService.showToast({ variant: "error", @@ -51,7 +51,7 @@ export class DisplayAccountCreditComponent { AddAccountCreditDialogComponent.open(this.dialogService, { data: { - owner: this.owner, + subscriber: this.subscriber, }, }); }; diff --git a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts index f0a11321e5d..03d21a79003 100644 --- a/apps/web/src/app/billing/payment/components/display-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/display-billing-address.component.ts @@ -2,23 +2,38 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { lastValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; - -import { SharedModule } from "../../../shared"; -import { BillableEntity } from "../../types"; -import { AddressPipe } from "../pipes"; -import { BillingAddress } from "../types"; - -import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog.component"; +import { EditBillingAddressDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components/edit-billing-address-dialog.component"; +import { AddressPipe } from "@bitwarden/web-vault/app/billing/payment/pipes"; +import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; +import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; @Component({ selector: "app-display-billing-address", template: ` -

{{ "billingAddress" | i18n }}

+

+ {{ "billingAddress" | i18n }} + @if (showMissingTaxIdBadge) { + {{ "missingTaxId" | i18n }} + } +

@if (billingAddress) {

{{ billingAddress | address }}

@if (billingAddress.taxId) { -

{{ "taxId" | i18n: billingAddress.taxId.value }}

+

+ {{ "taxId" | i18n: billingAddress.taxId.value }} + @if (showTaxIdPendingVerificationBadge) { + {{ "pendingVerification" | i18n }} + } + @if (showUnverifiedTaxIdBadge) { + {{ "unverified" | i18n }} + } +

} } @else {

{{ "noBillingAddress" | i18n }}

@@ -33,8 +48,9 @@ import { EditBillingAddressDialogComponent } from "./edit-billing-address-dialog imports: [AddressPipe, SharedModule], }) export class DisplayBillingAddressComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) billingAddress!: BillingAddress | null; + @Input() taxIdWarning?: TaxIdWarningType; @Output() updated = new EventEmitter(); constructor(private dialogService: DialogService) {} @@ -42,8 +58,9 @@ export class DisplayBillingAddressComponent { editBillingAddress = async (): Promise => { const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, { data: { - owner: this.owner, + subscriber: this.subscriber, billingAddress: this.billingAddress, + taxIdWarning: this.taxIdWarning, }, }); @@ -53,4 +70,22 @@ export class DisplayBillingAddressComponent { this.updated.emit(result.billingAddress); } }; + + get showMissingTaxIdBadge(): boolean { + return this.subscriber.type !== "account" && this.taxIdWarning === TaxIdWarningTypes.Missing; + } + + get showTaxIdPendingVerificationBadge(): boolean { + return ( + this.subscriber.type !== "account" && + this.taxIdWarning === TaxIdWarningTypes.PendingVerification + ); + } + + get showUnverifiedTaxIdBadge(): boolean { + return ( + this.subscriber.type !== "account" && + this.taxIdWarning === TaxIdWarningTypes.FailedVerification + ); + } } diff --git a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts index 769472bcfcf..df42d04b802 100644 --- a/apps/web/src/app/billing/payment/components/display-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/display-payment-method.component.ts @@ -4,7 +4,7 @@ import { lastValueFrom } from "rxjs"; import { DialogService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component"; @@ -19,7 +19,10 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component"; @switch (paymentMethod.type) { @case ("bankAccount") { @if (!paymentMethod.verified) { - + } @@ -63,7 +66,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account.component"; imports: [SharedModule, VerifyBankAccountComponent], }) export class DisplayPaymentMethodComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null; @Output() updated = new EventEmitter(); @@ -82,7 +85,7 @@ export class DisplayPaymentMethodComponent { changePaymentMethod = async (): Promise => { const dialogRef = ChangePaymentMethodDialogComponent.open(this.dialogService, { data: { - owner: this.owner, + subscriber: this.subscriber, }, }); diff --git a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts index c844d08df58..de2f2f94497 100644 --- a/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts +++ b/apps/web/src/app/billing/payment/components/edit-billing-address-dialog.component.ts @@ -3,18 +3,31 @@ import { Component, Inject } from "@angular/core"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; - -import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; -import { BillingAddress, getTaxIdTypeForCountry } from "../types"; +import { + CalloutTypes, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + BillingAddress, + getTaxIdTypeForCountry, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { EnterBillingAddressComponent } from "./enter-billing-address.component"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; billingAddress: BillingAddress | null; + taxIdWarning?: TaxIdWarningType; }; type DialogResult = @@ -30,11 +43,18 @@ type DialogResult = {{ "editBillingAddress" | i18n }}
+ @let callout = taxIdWarningCallout; + @if (callout) { + + {{ callout.message }} + + } @@ -57,13 +77,13 @@ type DialogResult = `, standalone: true, imports: [EnterBillingAddressComponent, SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class EditBillingAddressDialogComponent { protected formGroup = EnterBillingAddressComponent.getFormGroup(); constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, private dialogRef: DialogRef, private i18nService: I18nService, @@ -93,7 +113,7 @@ export class EditBillingAddressDialogComponent { : { ...addressFields, taxId: null }; const result = await this.billingClient.updateBillingAddress( - this.dialogParams.owner, + this.dialogParams.subscriber, billingAddress, ); @@ -125,7 +145,7 @@ export class EditBillingAddressDialogComponent { }; get supportsTaxId(): boolean { - switch (this.dialogParams.owner.type) { + switch (this.dialogParams.subscriber.type) { case "account": { return false; } @@ -134,7 +154,7 @@ export class EditBillingAddressDialogComponent { ProductTierType.TeamsStarter, ProductTierType.Teams, ProductTierType.Enterprise, - ].includes(this.dialogParams.owner.data.productTierType); + ].includes(this.dialogParams.subscriber.data.productTierType); } case "provider": { return true; @@ -142,6 +162,37 @@ export class EditBillingAddressDialogComponent { } } + get taxIdWarningCallout(): { + type: CalloutTypes; + title: string; + message: string; + } | null { + if ( + !this.supportsTaxId || + !this.dialogParams.taxIdWarning || + this.dialogParams.taxIdWarning === TaxIdWarningTypes.PendingVerification + ) { + return null; + } + + switch (this.dialogParams.taxIdWarning) { + case TaxIdWarningTypes.Missing: { + return { + type: "warning", + title: this.i18nService.t("missingTaxIdCalloutTitle"), + message: this.i18nService.t("missingTaxIdCalloutDescription"), + }; + } + case TaxIdWarningTypes.FailedVerification: { + return { + type: "warning", + title: this.i18nService.t("unverifiedTaxIdCalloutTitle"), + message: this.i18nService.t("unverifiedTaxIdCalloutDescription"), + }; + } + } + } + static open = (dialogService: DialogService, dialogConfig: DialogConfig) => dialogService.open(EditBillingAddressDialogComponent, dialogConfig); } diff --git a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts index ab59e965b4e..7659b7ed5ca 100644 --- a/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts @@ -3,9 +3,14 @@ import { FormControl, FormGroup, Validators } from "@angular/forms"; import { map, Observable, startWith, Subject, takeUntil } from "rxjs"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; import { SharedModule } from "../../../shared"; -import { BillingAddress, selectableCountries, taxIdTypes } from "../types"; +import { BillingAddress, getTaxIdTypeForCountry, selectableCountries, taxIdTypes } from "../types"; export interface BillingAddressControls { country: string; @@ -28,6 +33,7 @@ type Scenario = type: "update"; existing?: BillingAddress; supportsTaxId: boolean; + taxIdWarning?: TaxIdWarningType; }; @Component({ @@ -110,7 +116,7 @@ type Scenario =
@if (supportsTaxId$ | async) { -
+
{{ "taxIdNumber" | i18n }} + @let hint = taxIdWarningHint; + @if (hint) { + {{ hint }} + }
} @@ -137,6 +154,8 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); + constructor(private i18nService: I18nService) {} + ngOnInit() { switch (this.scenario.type) { case "checkout": { @@ -185,6 +204,40 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { this.group.controls.state.disable(); }; + get taxIdWarningHint() { + if ( + this.scenario.type === "checkout" || + !this.scenario.supportsTaxId || + !this.group.value.country || + this.scenario.taxIdWarning !== TaxIdWarningTypes.FailedVerification + ) { + return null; + } + + const taxIdType = getTaxIdTypeForCountry(this.group.value.country); + + if (!taxIdType) { + return null; + } + + const checkInputFormat = this.i18nService.t("checkInputFormat"); + + switch (taxIdType.code) { + case "au_abn": { + const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "ABN", taxIdType.example); + return `${checkInputFormat} ${exampleFormat}`; + } + case "eu_vat": { + const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "EU VAT", taxIdType.example); + return `${checkInputFormat} ${exampleFormat}`; + } + case "gb_vat": { + const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "GB VAT", taxIdType.example); + return `${checkInputFormat} ${exampleFormat}`; + } + } + } + static getFormGroup = (): BillingAddressFormGroup => new FormGroup({ country: new FormControl("", { 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 index 72585badca0..b1ca1922775 100644 --- 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 @@ -9,10 +9,10 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; import { @@ -21,7 +21,7 @@ import { } from "./submit-payment-method-dialog.component"; type DialogParams = { - owner: BillableEntity; + subscriber: BitwardenSubscriber; callout: { type: CalloutTypes; title: string; @@ -53,20 +53,20 @@ type DialogParams = { `, standalone: true, imports: [EnterPaymentMethodComponent, SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class RequirePaymentMethodDialogComponent extends SubmitPaymentMethodDialogComponent { - protected override owner: BillableEntity; + protected override subscriber: BitwardenSubscriber; constructor( - billingClient: BillingClient, + billingClient: SubscriberBillingClient, @Inject(DIALOG_DATA) protected dialogParams: DialogParams, dialogRef: DialogRef, i18nService: I18nService, toastService: ToastService, ) { super(billingClient, dialogRef, i18nService, toastService); - this.owner = this.dialogParams.owner; + this.subscriber = this.dialogParams.subscriber; } static open = (dialogService: DialogService, dialogConfig: DialogConfig) => 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 index 0a0a5bf26d9..62d2b775eb5 100644 --- 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 @@ -2,9 +2,9 @@ import { Component, ViewChild } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DialogRef, ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; import { EnterPaymentMethodComponent } from "./enter-payment-method.component"; @@ -20,10 +20,10 @@ export abstract class SubmitPaymentMethodDialogComponent { private enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected formGroup = EnterPaymentMethodComponent.getFormGroup(); - protected abstract owner: BillableEntity; + protected abstract subscriber: BitwardenSubscriber; protected constructor( - protected billingClient: BillingClient, + protected billingClient: SubscriberBillingClient, protected dialogRef: DialogRef, protected i18nService: I18nService, protected toastService: ToastService, @@ -43,7 +43,7 @@ export abstract class SubmitPaymentMethodDialogComponent { : null; const result = await this.billingClient.updatePaymentMethod( - this.owner, + this.subscriber, paymentMethod, billingAddress, ); diff --git a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts index f79e9a1b5fc..b1a2814daf2 100644 --- a/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts +++ b/apps/web/src/app/billing/payment/components/verify-bank-account.component.ts @@ -3,10 +3,10 @@ import { FormControl, FormGroup, Validators } from "@angular/forms"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; import { SharedModule } from "../../../shared"; -import { BillingClient } from "../../services"; -import { BillableEntity } from "../../types"; +import { BitwardenSubscriber } from "../../types"; import { MaskedPaymentMethod } from "../types"; @Component({ @@ -32,10 +32,10 @@ import { MaskedPaymentMethod } from "../types"; `, standalone: true, imports: [SharedModule], - providers: [BillingClient], + providers: [SubscriberBillingClient], }) export class VerifyBankAccountComponent { - @Input({ required: true }) owner!: BillableEntity; + @Input({ required: true }) subscriber!: BitwardenSubscriber; @Output() verified = new EventEmitter(); protected formGroup = new FormGroup({ @@ -47,7 +47,7 @@ export class VerifyBankAccountComponent { }); constructor( - private billingClient: BillingClient, + private billingClient: SubscriberBillingClient, private i18nService: I18nService, private toastService: ToastService, ) {} @@ -60,7 +60,7 @@ export class VerifyBankAccountComponent { } const result = await this.billingClient.verifyBankAccount( - this.owner, + this.subscriber, this.formGroup.value.descriptorCode!, ); diff --git a/apps/web/src/app/billing/services/index.ts b/apps/web/src/app/billing/services/index.ts index dcd2c05034a..e291ca6a454 100644 --- a/apps/web/src/app/billing/services/index.ts +++ b/apps/web/src/app/billing/services/index.ts @@ -1,4 +1,3 @@ -export * from "./billing.client"; export * from "./billing-services.module"; export * from "./braintree.service"; export * from "./stripe.service"; diff --git a/apps/web/src/app/billing/types/billable-entity.ts b/apps/web/src/app/billing/types/bitwarden-subscriber.ts similarity index 67% rename from apps/web/src/app/billing/types/billable-entity.ts rename to apps/web/src/app/billing/types/bitwarden-subscriber.ts index 79ed12a4161..3454d6a9651 100644 --- a/apps/web/src/app/billing/types/billable-entity.ts +++ b/apps/web/src/app/billing/types/bitwarden-subscriber.ts @@ -4,12 +4,14 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -export type BillableEntity = +export type BitwardenSubscriber = | { type: "account"; data: Account } | { type: "organization"; data: Organization } | { type: "provider"; data: Provider }; -export const accountToBillableEntity = map((account) => { +export type NonIndividualSubscriber = Exclude; + +export const mapAccountToSubscriber = map((account) => { if (!account) { throw new Error("Account not found"); } @@ -19,7 +21,7 @@ export const accountToBillableEntity = map((acco }; }); -export const organizationToBillableEntity = map( +export const mapOrganizationToSubscriber = map( (organization) => { if (!organization) { throw new Error("Organization not found"); @@ -31,7 +33,7 @@ export const organizationToBillableEntity = map((provider) => { +export const mapProviderToSubscriber = map((provider) => { if (!provider) { throw new Error("Organization not found"); } diff --git a/apps/web/src/app/billing/types/index.ts b/apps/web/src/app/billing/types/index.ts index 1278e0f2e14..50c007677f3 100644 --- a/apps/web/src/app/billing/types/index.ts +++ b/apps/web/src/app/billing/types/index.ts @@ -1,2 +1,2 @@ -export * from "./billable-entity"; +export * from "./bitwarden-subscriber"; export * from "./free-trial"; diff --git a/apps/web/src/app/billing/warnings/components/index.ts b/apps/web/src/app/billing/warnings/components/index.ts index 1e1e0682e62..5edefadb1ee 100644 --- a/apps/web/src/app/billing/warnings/components/index.ts +++ b/apps/web/src/app/billing/warnings/components/index.ts @@ -1,2 +1 @@ -export * from "./organization-free-trial-warning.component"; -export * from "./organization-reseller-renewal-warning.component"; +export * from "./tax-id-warning.component"; diff --git a/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts b/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts new file mode 100644 index 00000000000..7527ef8f0b7 --- /dev/null +++ b/apps/web/src/app/billing/warnings/components/tax-id-warning.component.ts @@ -0,0 +1,286 @@ +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { + BehaviorSubject, + combineLatest, + filter, + firstValueFrom, + lastValueFrom, + map, + Observable, + switchMap, +} from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { BannerModule, DialogService } from "@bitwarden/components"; +import { BILLING_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state"; +import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { EditBillingAddressDialogComponent } from "@bitwarden/web-vault/app/billing/payment/components"; +import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { + TaxIdWarningType, + TaxIdWarningTypes, +} from "@bitwarden/web-vault/app/billing/warnings/types"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +type DismissalCounts = { + [TaxIdWarningTypes.Missing]?: number; + [TaxIdWarningTypes.FailedVerification]?: number; +}; + +const DISMISSALS_COUNT_KEY = new UserKeyDefinition( + BILLING_DISK, + "taxIdWarningDismissalCounts", + { + deserializer: (dismissalCounts) => dismissalCounts, + clearOn: [], + }, +); + +type DismissedThisSession = { + [TaxIdWarningTypes.Missing]?: boolean; + [TaxIdWarningTypes.FailedVerification]?: boolean; +}; + +const DISMISSED_THIS_SESSION_KEY = new UserKeyDefinition( + BILLING_DISK, + "taxIdWarningDismissedThisSession", + { + deserializer: (dismissedThisSession) => dismissedThisSession, + clearOn: ["logout"], + }, +); + +type Dismissals = { + [TaxIdWarningTypes.Missing]: { + count: number; + dismissedThisSession: boolean; + }; + [TaxIdWarningTypes.FailedVerification]: { + count: number; + dismissedThisSession: boolean; + }; +}; + +const shouldShowWarning = ( + warning: Exclude, + dismissals: Dismissals, +) => { + const dismissalsForType = dismissals[warning]; + if (dismissalsForType.dismissedThisSession) { + return false; + } + return dismissalsForType.count < 3; +}; + +type View = { + message: string; + callToAction: string; +}; + +type GetWarning$ = () => Observable; + +@Component({ + selector: "app-tax-id-warning", + template: ` + @if (enableTaxIdWarning$ | async) { + @let view = view$ | async; + + @if (view) { + + {{ view.message }} +
+ {{ view.callToAction }} + + + } + } + `, + imports: [BannerModule, SharedModule], +}) +export class TaxIdWarningComponent implements OnInit { + @Input({ required: true }) subscriber!: NonIndividualSubscriber; + @Input({ required: true }) getWarning$!: GetWarning$; + @Output() billingAddressUpdated = new EventEmitter(); + + protected enableTaxIdWarning$ = this.configService.getFeatureFlag$( + FeatureFlag.PM22415_TaxIDWarnings, + ); + + protected userId$ = this.accountService.activeAccount$.pipe( + filter((account): account is Account => account !== null), + getUserId, + ); + + protected dismissals$: Observable = this.userId$.pipe( + switchMap((userId) => + combineLatest([ + this.stateProvider.getUser(userId, DISMISSALS_COUNT_KEY).state$.pipe( + map((dismissalCounts) => { + if (!dismissalCounts) { + return { + [TaxIdWarningTypes.Missing]: 0, + [TaxIdWarningTypes.FailedVerification]: 0, + }; + } + return { + [TaxIdWarningTypes.Missing]: dismissalCounts[TaxIdWarningTypes.Missing] ?? 0, + [TaxIdWarningTypes.FailedVerification]: + dismissalCounts[TaxIdWarningTypes.FailedVerification] ?? 0, + }; + }), + ), + this.stateProvider.getUser(userId, DISMISSED_THIS_SESSION_KEY).state$.pipe( + map((dismissedThisSession) => { + if (!dismissedThisSession) { + return { + [TaxIdWarningTypes.Missing]: false, + [TaxIdWarningTypes.FailedVerification]: false, + }; + } + return { + [TaxIdWarningTypes.Missing]: dismissedThisSession[TaxIdWarningTypes.Missing] ?? false, + [TaxIdWarningTypes.FailedVerification]: + dismissedThisSession[TaxIdWarningTypes.FailedVerification] ?? false, + }; + }), + ), + ]), + ), + map(([dismissalCounts, dismissedThisSession]) => ({ + [TaxIdWarningTypes.Missing]: { + count: dismissalCounts[TaxIdWarningTypes.Missing], + dismissedThisSession: dismissedThisSession[TaxIdWarningTypes.Missing], + }, + [TaxIdWarningTypes.FailedVerification]: { + count: dismissalCounts[TaxIdWarningTypes.FailedVerification], + dismissedThisSession: dismissedThisSession[TaxIdWarningTypes.FailedVerification], + }, + })), + ); + + protected getWarningSubject = new BehaviorSubject(null); + + protected warning$ = this.getWarningSubject.pipe(switchMap(() => this.getWarning$())); + + protected view$: Observable = combineLatest([this.warning$, this.dismissals$]).pipe( + map(([warning, dismissals]) => { + if (!warning || warning === TaxIdWarningTypes.PendingVerification) { + return null; + } + + if (!shouldShowWarning(warning, dismissals)) { + return null; + } + + switch (warning) { + case TaxIdWarningTypes.Missing: { + return { + message: this.i18nService.t("missingTaxIdWarning"), + callToAction: this.i18nService.t("addTaxId"), + }; + } + case TaxIdWarningTypes.FailedVerification: { + return { + message: this.i18nService.t("unverifiedTaxIdWarning"), + callToAction: this.i18nService.t("editTaxId"), + }; + } + } + }), + ); + + constructor( + private accountService: AccountService, + private configService: ConfigService, + private dialogService: DialogService, + private i18nService: I18nService, + private subscriberBillingClient: SubscriberBillingClient, + private stateProvider: StateProvider, + ) {} + + ngOnInit() { + this.getWarningSubject.next(this.getWarning$); + } + + editBillingAddress = async () => { + const billingAddress = await this.subscriberBillingClient.getBillingAddress(this.subscriber); + const warning = (await firstValueFrom(this.warning$)) ?? undefined; + + const dialogRef = EditBillingAddressDialogComponent.open(this.dialogService, { + data: { + subscriber: this.subscriber, + billingAddress, + taxIdWarning: warning, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result?.type === "success") { + this.billingAddressUpdated.emit(); + } + }; + + trackDismissal = async () => { + const warning = await firstValueFrom(this.warning$); + if (!warning || warning === TaxIdWarningTypes.PendingVerification) { + return; + } + const userId = await firstValueFrom(this.userId$); + const updateDismissalCounts = this.stateProvider + .getUser(userId, DISMISSALS_COUNT_KEY) + .update((dismissalCounts) => { + if (!dismissalCounts) { + return { + [warning]: 1, + }; + } + const dismissalsByType = dismissalCounts[warning]; + if (!dismissalsByType) { + return { + ...dismissalCounts, + [warning]: 1, + }; + } + return { + ...dismissalCounts, + [warning]: dismissalsByType + 1, + }; + }); + const updateDismissedThisSession = this.stateProvider + .getUser(userId, DISMISSED_THIS_SESSION_KEY) + .update((dismissedThisSession) => { + if (!dismissedThisSession) { + return { + [warning]: true, + }; + } + const dismissedThisSessionByType = dismissedThisSession[warning]; + if (!dismissedThisSessionByType) { + return { + ...dismissedThisSession, + }; + } + return { + ...dismissedThisSession, + [warning]: dismissedThisSessionByType, + }; + }); + await Promise.all([updateDismissalCounts, updateDismissedThisSession]); + }; +} diff --git a/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts deleted file mode 100644 index c75dde0c9e5..00000000000 --- a/apps/web/src/app/billing/warnings/services/organization-warnings.service.spec.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { Router } from "@angular/router"; -import { mock, MockProxy } from "jest-mock-extended"; -import { firstValueFrom, lastValueFrom } from "rxjs"; - -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; - -import { OrganizationWarningsService } from "./organization-warnings.service"; - -// Skipped since Angular complains about `TypeError: Cannot read properties of undefined (reading 'ngModule')` -// which is typically a sign of circular dependencies. The problem seems to be originating from `ChangePlanDialogComponent`. -describe.skip("OrganizationWarningsService", () => { - let dialogService: MockProxy; - let i18nService: MockProxy; - let organizationApiService: MockProxy; - let organizationBillingApiService: MockProxy; - let router: MockProxy; - - let organizationWarningsService: OrganizationWarningsService; - - const respond = (responseBody: any) => - Promise.resolve(new OrganizationWarningsResponse(responseBody)); - - const empty = () => Promise.resolve(new OrganizationWarningsResponse({})); - - beforeEach(() => { - dialogService = mock(); - i18nService = mock(); - organizationApiService = mock(); - organizationBillingApiService = mock(); - router = mock(); - - organizationWarningsService = new OrganizationWarningsService( - dialogService, - i18nService, - organizationApiService, - organizationBillingApiService, - router, - ); - }); - - describe("cache$", () => { - it("should only request warnings once for a specific organization and replay the cached result for multiple subscriptions", async () => { - const response1 = respond({ - freeTrial: { - remainingTrialDays: 1, - }, - }); - - const organization1 = { - id: "1", - name: "Test", - } as Organization; - - const response2 = respond({ - freeTrial: { - remainingTrialDays: 2, - }, - }); - - const organization2 = { - id: "2", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization1.id) { - return response1; - } - - if (id === organization2.id) { - return response2; - } - - return empty(); - }); - - const oneDayRemainingTranslation = "oneDayRemaining"; - const twoDaysRemainingTranslation = "twoDaysRemaining"; - - i18nService.t.mockImplementation((id, p1) => { - if (id === "freeTrialEndPromptTomorrowNoOrgName") { - return oneDayRemainingTranslation; - } - - if (id === "freeTrialEndPromptCount" && p1 === 2) { - return twoDaysRemainingTranslation; - } - - return ""; - }); - - const organization1Subscription1 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization1), - ); - - const organization1Subscription2 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization1), - ); - - expect(organization1Subscription1).toEqual({ - organization: organization1, - message: oneDayRemainingTranslation, - }); - - expect(organization1Subscription2).toEqual(organization1Subscription1); - - const organization2Subscription1 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization2), - ); - - const organization2Subscription2 = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization2), - ); - - expect(organization2Subscription1).toEqual({ - organization: organization2, - message: twoDaysRemainingTranslation, - }); - - expect(organization2Subscription2).toEqual(organization2Subscription1); - - expect(organizationBillingApiService.getWarnings).toHaveBeenCalledTimes(2); - }); - }); - - describe("getFreeTrialWarning$", () => { - it("should not emit a free trial warning when none is included in the warnings response", (done) => { - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockReturnValue(empty()); - - const warning$ = organizationWarningsService.getFreeTrialWarning$(organization); - - warning$.subscribe({ - next: () => { - fail("Observable should not emit a value."); - }, - complete: () => { - done(); - }, - }); - }); - - it("should emit a free trial warning when one is included in the warnings response", async () => { - const response = respond({ - freeTrial: { - remainingTrialDays: 1, - }, - }); - - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization.id) { - return response; - } else { - return empty(); - } - }); - - const translation = "translation"; - i18nService.t.mockImplementation((id) => { - if (id === "freeTrialEndPromptTomorrowNoOrgName") { - return translation; - } else { - return ""; - } - }); - - const warning = await firstValueFrom( - organizationWarningsService.getFreeTrialWarning$(organization), - ); - - expect(warning).toEqual({ - organization, - message: translation, - }); - }); - }); - - describe("getResellerRenewalWarning$", () => { - it("should not emit a reseller renewal warning when none is included in the warnings response", (done) => { - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockReturnValue(empty()); - - const warning$ = organizationWarningsService.getResellerRenewalWarning$(organization); - - warning$.subscribe({ - next: () => { - fail("Observable should not emit a value."); - }, - complete: () => { - done(); - }, - }); - }); - - it("should emit a reseller renewal warning when one is included in the warnings response", async () => { - const response = respond({ - resellerRenewal: { - type: "upcoming", - upcoming: { - renewalDate: "2026-01-01T00:00:00.000Z", - }, - }, - }); - - const organization = { - id: "1", - name: "Test", - providerName: "Provider", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization.id) { - return response; - } else { - return empty(); - } - }); - - const formattedDate = new Date("2026-01-01T00:00:00.000Z").toLocaleDateString("en-US", { - month: "short", - day: "2-digit", - year: "numeric", - }); - - const translation = "translation"; - i18nService.t.mockImplementation((id, p1, p2) => { - if ( - id === "resellerRenewalWarningMsg" && - p1 === organization.providerName && - p2 === formattedDate - ) { - return translation; - } else { - return ""; - } - }); - - const warning = await firstValueFrom( - organizationWarningsService.getResellerRenewalWarning$(organization), - ); - - expect(warning).toEqual({ - type: "info", - message: translation, - }); - }); - }); - - describe("showInactiveSubscriptionDialog$", () => { - it("should not emit the opening of a dialog for an inactive subscription warning when the warning is not included in the warnings response", (done) => { - const organization = { - id: "1", - name: "Test", - } as Organization; - - organizationBillingApiService.getWarnings.mockReturnValue(empty()); - - const warning$ = organizationWarningsService.showInactiveSubscriptionDialog$(organization); - - warning$.subscribe({ - next: () => { - fail("Observable should not emit a value."); - }, - complete: () => { - done(); - }, - }); - }); - - it("should emit the opening of a dialog for an inactive subscription warning when the warning is included in the warnings response", async () => { - const response = respond({ - inactiveSubscription: { - resolution: "add_payment_method", - }, - }); - - const organization = { - id: "1", - name: "Test", - providerName: "Provider", - } as Organization; - - organizationBillingApiService.getWarnings.mockImplementation((id) => { - if (id === organization.id) { - return response; - } else { - return empty(); - } - }); - - const titleTranslation = "title"; - const continueTranslation = "continue"; - const closeTranslation = "close"; - - i18nService.t.mockImplementation((id, param) => { - if (id === "suspendedOrganizationTitle" && param === organization.name) { - return titleTranslation; - } - if (id === "continue") { - return continueTranslation; - } - if (id === "close") { - return closeTranslation; - } - return ""; - }); - - const expectedOptions = { - title: titleTranslation, - content: { - key: "suspendedOwnerOrgMessage", - }, - type: "danger", - acceptButtonText: continueTranslation, - cancelButtonText: closeTranslation, - } as SimpleDialogOptions; - - dialogService.openSimpleDialog.mockImplementation((options) => { - if (JSON.stringify(options) == JSON.stringify(expectedOptions)) { - return Promise.resolve(true); - } else { - return Promise.resolve(false); - } - }); - - const observable$ = organizationWarningsService.showInactiveSubscriptionDialog$(organization); - - const routerNavigateSpy = jest.spyOn(router, "navigate").mockResolvedValue(true); - - await lastValueFrom(observable$); - - expect(routerNavigateSpy).toHaveBeenCalledWith( - ["organizations", `${organization.id}`, "billing", "payment-method"], - { - state: { launchPaymentModalAutomatically: true }, - }, - ); - }); - }); -}); diff --git a/apps/web/src/app/billing/warnings/types/index.ts b/apps/web/src/app/billing/warnings/types/index.ts index fc0c7d278ed..1d7b17fcd28 100644 --- a/apps/web/src/app/billing/warnings/types/index.ts +++ b/apps/web/src/app/billing/warnings/types/index.ts @@ -1 +1 @@ -export * from "./organization-warnings"; +export * from "./tax-id-warning-type"; diff --git a/apps/web/src/app/billing/warnings/types/organization-warnings.ts b/apps/web/src/app/billing/warnings/types/organization-warnings.ts deleted file mode 100644 index 96bf5aff6f1..00000000000 --- a/apps/web/src/app/billing/warnings/types/organization-warnings.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; - -export type OrganizationFreeTrialWarning = { - organization: Pick; - message: string; -}; - -export type OrganizationResellerRenewalWarning = { - type: "info" | "warning"; - message: string; -}; diff --git a/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts b/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts new file mode 100644 index 00000000000..86bc76708aa --- /dev/null +++ b/apps/web/src/app/billing/warnings/types/tax-id-warning-type.ts @@ -0,0 +1,19 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export const TaxIdWarningTypes = { + Missing: "tax_id_missing", + PendingVerification: "tax_id_pending_verification", + FailedVerification: "tax_id_failed_verification", +} as const; + +export type TaxIdWarningType = (typeof TaxIdWarningTypes)[keyof typeof TaxIdWarningTypes]; + +export class TaxIdWarningResponse extends BaseResponse { + type: TaxIdWarningType; + + constructor(response: any) { + super(response); + + this.type = this.getResponseProperty("Type"); + } +} diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index f4d05171d56..57d9918aad7 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -11,10 +11,10 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; 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"; import { IpcService } from "@bitwarden/common/platform/ipc"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; +import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { TaskService } from "@bitwarden/common/vault/tasks"; @@ -31,7 +31,6 @@ export class InitService { private i18nService: I18nServiceAbstraction, private eventUploadService: EventUploadServiceAbstraction, private twoFactorService: TwoFactorServiceAbstraction, - private stateService: StateServiceAbstraction, private keyService: KeyServiceAbstraction, private themingService: AbstractThemingService, private encryptService: EncryptService, @@ -41,13 +40,14 @@ export class InitService { private ipcService: IpcService, private sdkLoadService: SdkLoadService, private taskService: TaskService, + private readonly migrationRunner: MigrationRunner, @Inject(DOCUMENT) private document: Document, ) {} init() { return async () => { await this.sdkLoadService.loadAndInit(); - await this.stateService.init(); + await this.migrationRunner.run(); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); if (activeAccount) { diff --git a/apps/web/src/app/layouts/header/web-header.component.html b/apps/web/src/app/layouts/header/web-header.component.html index 2f0d39c7b6c..91c31853648 100644 --- a/apps/web/src/app/layouts/header/web-header.component.html +++ b/apps/web/src/app/layouts/header/web-header.component.html @@ -30,7 +30,7 @@ diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index 0b7304a3657..cc919a929a9 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -179,7 +179,7 @@ type Story = StoryObj< const Template: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -191,7 +191,7 @@ const Template: Story = {
- +
diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 637e1b77ce0..d7dbdbc4ae5 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -8,10 +8,7 @@ import { import { LayoutComponent, NavigationModule } from "@bitwarden/components"; import { OrganizationLayoutComponent } from "../admin-console/organizations/layouts/organization-layout.component"; -import { EventsComponent as OrgEventsComponent } from "../admin-console/organizations/manage/events.component"; -import { UserConfirmComponent as OrgUserConfirmComponent } from "../admin-console/organizations/manage/user-confirm.component"; import { VerifyRecoverDeleteOrgComponent } from "../admin-console/organizations/manage/verify-recover-delete-org.component"; -import { AcceptFamilySponsorshipComponent } from "../admin-console/organizations/sponsorships/accept-family-sponsorship.component"; import { RecoverDeleteComponent } from "../auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component"; import { DangerZoneComponent } from "../auth/settings/account/danger-zone.component"; @@ -61,13 +58,10 @@ import { SharedModule } from "./shared.module"; PremiumBadgeComponent, ], declarations: [ - AcceptFamilySponsorshipComponent, - OrgEventsComponent, OrgExposedPasswordsReportComponent, OrgInactiveTwoFactorReportComponent, OrgReusedPasswordsReportComponent, OrgUnsecuredWebsitesReportComponent, - OrgUserConfirmComponent, OrgWeakPasswordsReportComponent, RecoverDeleteComponent, RecoverTwoFactorComponent, @@ -82,12 +76,10 @@ import { SharedModule } from "./shared.module"; UserVerificationModule, PremiumBadgeComponent, OrganizationLayoutComponent, - OrgEventsComponent, OrgExposedPasswordsReportComponent, OrgInactiveTwoFactorReportComponent, OrgReusedPasswordsReportComponent, OrgUnsecuredWebsitesReportComponent, - OrgUserConfirmComponent, OrgWeakPasswordsReportComponent, PremiumBadgeComponent, RecoverDeleteComponent, diff --git a/apps/web/src/app/tools/import/import-collection-admin.service.ts b/apps/web/src/app/tools/import/import-collection-admin.service.ts index 64050eb9c06..b63cd15047b 100644 --- a/apps/web/src/app/tools/import/import-collection-admin.service.ts +++ b/apps/web/src/app/tools/import/import-collection-admin.service.ts @@ -1,13 +1,20 @@ import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common"; import { ImportCollectionServiceAbstraction } from "@bitwarden/importer-core"; +import { UserId } from "@bitwarden/user-core"; @Injectable() export class ImportCollectionAdminService implements ImportCollectionServiceAbstraction { constructor(private collectionAdminService: CollectionAdminService) {} - async getAllAdminCollections(organizationId: string): Promise { - return await this.collectionAdminService.getAll(organizationId); + async getAllAdminCollections( + organizationId: string, + userId: UserId, + ): Promise { + return await firstValueFrom( + this.collectionAdminService.collectionAdminViews$(organizationId, userId), + ); } } 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 a8dd0056806..8fde2eb44e4 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 @@ -224,7 +224,7 @@ export class VaultItemsComponent { } protected canEditCollection(collection: CollectionView): boolean { - // Only allow allow deletion if collection editing is enabled and not deleting "Unassigned" + // Only allow deletion if collection editing is enabled and not deleting "Unassigned" if (collection.id === Unassigned) { return false; } @@ -235,7 +235,7 @@ export class VaultItemsComponent { } protected canDeleteCollection(collection: CollectionView): boolean { - // Only allow allow deletion if collection editing is enabled and not deleting "Unassigned" + // Only allow deletion if collection editing is enabled and not deleting "Unassigned" if (collection.id === Unassigned) { return false; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index c114cb6d7c2..043ae900b40 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -262,9 +262,11 @@ export const OrganizationTrash: Story = { }, }; -const unassignedCollection = new CollectionAdminView(); -unassignedCollection.id = Unassigned as CollectionId; -unassignedCollection.name = "Unassigned"; +const unassignedCollection = new CollectionAdminView({ + id: Unassigned as CollectionId, + name: "Unassigned", + organizationId: "org id" as OrganizationId, +}); export const OrganizationTopLevelCollection: Story = { args: { ciphers: [], @@ -327,11 +329,11 @@ function createCipherView(i: number, deleted = false): CipherView { function createCollectionView(i: number): CollectionAdminView { const organization = organizations[i % (organizations.length + 1)]; const group = groups[i % (groups.length + 1)]; - const view = new CollectionAdminView(); - view.id = `collection-${i}` as CollectionId; - view.name = `Collection ${i}`; - view.organizationId = organization?.id; - view.manage = true; + const view = new CollectionAdminView({ + id: `collection-${i}` as CollectionId, + name: `Collection ${i}`, + organizationId: organization?.id ?? ("orgId" as OrganizationId), + }); if (group !== undefined) { view.groups = [ @@ -344,6 +346,7 @@ function createCollectionView(i: number): CollectionAdminView { ]; } + view.manage = true; return view; } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html index 3ac5e708e8c..7722ba1ad86 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html @@ -9,6 +9,7 @@ > {{ "filters" | i18n }} { orgId: string, type?: CollectionType, ): CollectionView { - const collection = new CollectionView(); - collection.id = id; - collection.name = name; - collection.organizationId = orgId; - collection.type = type || CollectionTypes.SharedCollection; + const collection = new CollectionView({ + id: id as CollectionId, + name, + organizationId: orgId as OrganizationId, + }); + + if (type) { + collection.type = type; + } + return collection; } }); 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 11e074db985..ec77ff97a11 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 @@ -12,11 +12,7 @@ import { switchMap, } from "rxjs"; -import { - CollectionAdminView, - CollectionService, - CollectionView, -} from "@bitwarden/admin-console/common"; +import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { sortDefaultCollections } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.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"; @@ -38,6 +34,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state"; import { CipherListView } from "@bitwarden/sdk-internal"; +import { cloneCollection } from "@bitwarden/web-vault/app/admin-console/organizations/collections"; import { CipherTypeFilter, @@ -253,14 +250,8 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { } collections.forEach((c) => { - const collectionCopy = new CollectionView() as CollectionFilter; - collectionCopy.id = c.id; - collectionCopy.organizationId = c.organizationId; + const collectionCopy = cloneCollection(new CollectionView({ ...c })) as CollectionFilter; collectionCopy.icon = "bwi-collection-shared"; - if (c instanceof CollectionAdminView) { - collectionCopy.groups = c.groups; - collectionCopy.assigned = c.assigned; - } const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); }); @@ -274,7 +265,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { } protected getCollectionFilterHead(): TreeNode { - const head = new CollectionView() as CollectionFilter; + const head = CollectionView.vaultFilterHead() as CollectionFilter; return new TreeNode(head, null, "collections", "AllCollections"); } 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 01a38a02d51..f7078d2a67a 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 @@ -84,7 +84,7 @@ >  {{ f.node.name }} - + -
- - -
- -
-
- diff --git a/libs/angular/src/auth/components/environment-selector.component.ts b/libs/angular/src/auth/components/environment-selector.component.ts deleted file mode 100644 index 1831e513301..00000000000 --- a/libs/angular/src/auth/components/environment-selector.component.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { animate, state, style, transition, trigger } from "@angular/animations"; -import { ConnectedPosition } from "@angular/cdk/overlay"; -import { Component, EventEmitter, Output, Input, OnInit, OnDestroy } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; -import { Observable, map, Subject, takeUntil } 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 { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular"; -import { - EnvironmentService, - Region, - RegionConfig, -} from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DialogService, ToastService } from "@bitwarden/components"; - -export const ExtensionDefaultOverlayPosition: ConnectedPosition[] = [ - { - originX: "start", - originY: "top", - overlayX: "start", - overlayY: "bottom", - }, -]; -export const DesktopDefaultOverlayPosition: ConnectedPosition[] = [ - { - originX: "start", - originY: "top", - overlayX: "start", - overlayY: "bottom", - }, -]; - -export interface EnvironmentSelectorRouteData { - overlayPosition?: ConnectedPosition[]; -} - -@Component({ - selector: "environment-selector", - templateUrl: "environment-selector.component.html", - animations: [ - trigger("transformPanel", [ - state( - "void", - style({ - opacity: 0, - }), - ), - transition( - "void => open", - animate( - "100ms linear", - style({ - opacity: 1, - }), - ), - ), - transition("* => void", animate("100ms linear", style({ opacity: 0 }))), - ]), - ], - standalone: false, -}) -export class EnvironmentSelectorComponent implements OnInit, OnDestroy { - @Output() onOpenSelfHostedSettings = new EventEmitter(); - @Input() overlayPosition: ConnectedPosition[] = [ - { - originX: "start", - originY: "bottom", - overlayX: "start", - overlayY: "top", - }, - ]; - - protected isOpen = false; - protected ServerEnvironmentType = Region; - protected availableRegions = this.environmentService.availableRegions(); - protected selectedRegion$: Observable = - this.environmentService.environment$.pipe( - map((e) => e.getRegion()), - map((r) => this.availableRegions.find((ar) => ar.key === r)), - ); - - private destroy$ = new Subject(); - - constructor( - protected environmentService: EnvironmentService, - private route: ActivatedRoute, - private dialogService: DialogService, - private toastService: ToastService, - private i18nService: I18nService, - ) {} - - ngOnInit() { - this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => { - if (data && data["overlayPosition"]) { - this.overlayPosition = data["overlayPosition"]; - } - }); - } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - async toggle(option: Region) { - this.isOpen = !this.isOpen; - if (option === null) { - return; - } - - /** - * Opens the self-hosted settings dialog when the self-hosted option is selected. - */ - if (option === Region.SelfHosted) { - const dialogResult = await SelfHostedEnvConfigDialogComponent.open(this.dialogService); - if (dialogResult) { - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("environmentSaved"), - }); - } - // Don't proceed to setEnvironment when the self-hosted dialog is cancelled - return; - } - - await this.environmentService.setEnvironment(option); - } - - close() { - this.isOpen = false; - } -} diff --git a/libs/angular/src/auth/environment-selector/environment-selector.component.html b/libs/angular/src/auth/environment-selector/environment-selector.component.html new file mode 100644 index 00000000000..f6484ea1e5f --- /dev/null +++ b/libs/angular/src/auth/environment-selector/environment-selector.component.html @@ -0,0 +1,48 @@ + +
+ + + + +
+ {{ "accessing" | i18n }}: + +
+
+
diff --git a/libs/angular/src/auth/environment-selector/environment-selector.component.ts b/libs/angular/src/auth/environment-selector/environment-selector.component.ts new file mode 100644 index 00000000000..6fe3eaa92a0 --- /dev/null +++ b/libs/angular/src/auth/environment-selector/environment-selector.component.ts @@ -0,0 +1,75 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy } from "@angular/core"; +import { Observable, map, Subject } 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 { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular"; +import { + EnvironmentService, + Region, + RegionConfig, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + DialogService, + LinkModule, + MenuModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +@Component({ + selector: "environment-selector", + templateUrl: "environment-selector.component.html", + standalone: true, + imports: [CommonModule, I18nPipe, MenuModule, LinkModule, TypographyModule], +}) +export class EnvironmentSelectorComponent implements OnDestroy { + protected ServerEnvironmentType = Region; + protected availableRegions = this.environmentService.availableRegions(); + protected selectedRegion$: Observable = + this.environmentService.environment$.pipe( + map((e) => e.getRegion()), + map((r) => this.availableRegions.find((ar) => ar.key === r)), + ); + + private destroy$ = new Subject(); + + constructor( + public environmentService: EnvironmentService, + private dialogService: DialogService, + private toastService: ToastService, + private i18nService: I18nService, + ) {} + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + async toggle(option: Region) { + if (option === null) { + return; + } + + /** + * Opens the self-hosted settings dialog when the self-hosted option is selected. + */ + if (option === Region.SelfHosted) { + const dialogResult = await SelfHostedEnvConfigDialogComponent.open(this.dialogService); + if (dialogResult) { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("environmentSaved"), + }); + } + // Don't proceed to setEnvironment when the self-hosted dialog is cancelled + return; + } + + await this.environmentService.setEnvironment(option); + } +} diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts index 04dbf3dee8d..0b87f3f931d 100644 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts +++ b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts @@ -153,7 +153,8 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy, OnChang ngOnChanges(changes: SimpleChanges): void { // Clear the value of the tax-id if states have been changed in the parent component - if (!changes.showTaxIdField.currentValue) { + const showTaxIdField = changes["showTaxIdField"]; + if (showTaxIdField && !showTaxIdField.currentValue) { this.formGroup.controls.taxId.setValue(null); } } diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 2122506890a..6bf3ab77252 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -13,7 +13,6 @@ import { ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; import { Theme } from "@bitwarden/common/platform/enums"; -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Message } from "@bitwarden/common/platform/messaging"; import { HttpOperations } from "@bitwarden/common/services/api.service"; import { SafeInjectionToken } from "@bitwarden/ui-common"; @@ -33,7 +32,6 @@ export const OBSERVABLE_DISK_LOCAL_STORAGE = new SafeInjectionToken< >("OBSERVABLE_DISK_LOCAL_STORAGE"); export const MEMORY_STORAGE = new SafeInjectionToken("MEMORY_STORAGE"); export const SECURE_STORAGE = new SafeInjectionToken("SECURE_STORAGE"); -export const STATE_FACTORY = new SafeInjectionToken("STATE_FACTORY"); export const LOGOUT_CALLBACK = new SafeInjectionToken< (logoutReason: LogoutReason, userId?: string) => Promise >("LOGOUT_CALLBACK"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6a145fb3210..d6e4e901b50 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -149,6 +149,10 @@ 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 { + DefaultKeyGenerationService, + KeyGenerationService, +} from "@bitwarden/common/key-management/crypto"; 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 { EncryptServiceImplementation } from "@bitwarden/common/key-management/crypto/services/encrypt.service.implementation"; @@ -184,7 +188,6 @@ import { } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -193,13 +196,10 @@ import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.serv import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service"; -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags"; -import { Account } from "@bitwarden/common/platform/models/domain/account"; -import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; // eslint-disable-next-line no-restricted-imports -- Needed for service creation import { @@ -222,17 +222,16 @@ import { DefaultBroadcasterService } from "@bitwarden/common/platform/services/d import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service"; import { DefaultServerSettingsService } from "@bitwarden/common/platform/services/default-server-settings.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; -import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service"; -import { StateService } from "@bitwarden/common/platform/services/state.service"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { ActiveUserAccessor, ActiveUserStateProvider, + DefaultStateService, DerivedStateProvider, GlobalStateProvider, SingleUserStateProvider, @@ -369,12 +368,10 @@ import { LOCKED_CALLBACK, LOG_MAC_FAILURES, LOGOUT_CALLBACK, - MEMORY_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE, REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, SECURE_STORAGE, - STATE_FACTORY, SUPPORTS_SECURE_STORAGE, SYSTEM_LANGUAGE, SYSTEM_THEME_OBSERVABLE, @@ -412,10 +409,6 @@ const safeProviders: SafeProvider[] = [ useFactory: (window: Window) => window.navigator.language, deps: [WINDOW], }), - safeProvider({ - provide: STATE_FACTORY, - useValue: new StateFactory(GlobalState, Account), - }), // TODO: PM-21212 - Deprecate LogoutCallback in favor of LogoutService safeProvider({ provide: LOGOUT_CALLBACK, @@ -518,7 +511,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DomainSettingsService, useClass: DefaultDomainSettingsService, - deps: [StateProvider, ConfigService], + deps: [StateProvider], }), safeProvider({ provide: CipherServiceAbstraction, @@ -528,7 +521,6 @@ const safeProviders: SafeProvider[] = [ apiService: ApiServiceAbstraction, i18nService: I18nServiceAbstraction, searchService: SearchServiceAbstraction, - stateService: StateServiceAbstraction, autofillSettingsService: AutofillSettingsServiceAbstraction, encryptService: EncryptService, fileUploadService: CipherFileUploadServiceAbstraction, @@ -545,7 +537,6 @@ const safeProviders: SafeProvider[] = [ apiService, i18nService, searchService, - stateService, autofillSettingsService, encryptService, fileUploadService, @@ -562,7 +553,6 @@ const safeProviders: SafeProvider[] = [ ApiServiceAbstraction, I18nServiceAbstraction, SearchServiceAbstraction, - StateServiceAbstraction, AutofillSettingsServiceAbstraction, EncryptService, CipherFileUploadServiceAbstraction, @@ -660,15 +650,15 @@ const safeProviders: SafeProvider[] = [ GlobalStateProvider, SUPPORTS_SECURE_STORAGE, SECURE_STORAGE, - KeyGenerationServiceAbstraction, + KeyGenerationService, EncryptService, LogService, LOGOUT_CALLBACK, ], }), safeProvider({ - provide: KeyGenerationServiceAbstraction, - useClass: KeyGenerationService, + provide: KeyGenerationService, + useClass: DefaultKeyGenerationService, deps: [CryptoFunctionServiceAbstraction], }), safeProvider({ @@ -677,7 +667,7 @@ const safeProviders: SafeProvider[] = [ deps: [ PinServiceAbstraction, InternalMasterPasswordServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, CryptoFunctionServiceAbstraction, EncryptService, PlatformUtilsServiceAbstraction, @@ -767,7 +757,7 @@ const safeProviders: SafeProvider[] = [ deps: [ KeyService, I18nServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, SendStateProviderAbstraction, EncryptService, ], @@ -799,7 +789,6 @@ const safeProviders: SafeProvider[] = [ InternalSendService, LogService, KeyConnectorServiceAbstraction, - StateServiceAbstraction, ProviderServiceAbstraction, FolderApiServiceAbstraction, InternalOrganizationServiceAbstraction, @@ -847,6 +836,7 @@ const safeProviders: SafeProvider[] = [ MessagingServiceAbstraction, SearchServiceAbstraction, StateServiceAbstraction, + TokenServiceAbstraction, AuthServiceAbstraction, VaultTimeoutSettingsService, StateEventRunnerService, @@ -866,24 +856,10 @@ const safeProviders: SafeProvider[] = [ useClass: SsoLoginService, deps: [StateProvider, LogService], }), - safeProvider({ - provide: STATE_FACTORY, - useValue: new StateFactory(GlobalState, Account), - }), safeProvider({ provide: StateServiceAbstraction, - useClass: StateService, - deps: [ - AbstractStorageService, - SECURE_STORAGE, - MEMORY_STORAGE, - LogService, - STATE_FACTORY, - AccountServiceAbstraction, - EnvironmentService, - TokenServiceAbstraction, - MigrationRunner, - ], + useClass: DefaultStateService, + deps: [AbstractStorageService, SECURE_STORAGE, ActiveUserAccessor], }), safeProvider({ provide: IndividualVaultExportServiceAbstraction, @@ -1013,7 +989,7 @@ const safeProviders: SafeProvider[] = [ deps: [ StateProvider, StateServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, EncryptService, LogService, CryptoFunctionServiceAbstraction, @@ -1035,7 +1011,7 @@ const safeProviders: SafeProvider[] = [ TokenServiceAbstraction, LogService, OrganizationServiceAbstraction, - KeyGenerationServiceAbstraction, + KeyGenerationService, LOGOUT_CALLBACK, StateProvider, ], @@ -1194,7 +1170,7 @@ const safeProviders: SafeProvider[] = [ provide: DeviceTrustServiceAbstraction, useClass: DeviceTrustService, deps: [ - KeyGenerationServiceAbstraction, + KeyGenerationService, CryptoFunctionServiceAbstraction, KeyService, EncryptService, @@ -1230,7 +1206,7 @@ const safeProviders: SafeProvider[] = [ CryptoFunctionServiceAbstraction, EncryptService, KdfConfigService, - KeyGenerationServiceAbstraction, + KeyGenerationService, LogService, StateProvider, ], diff --git a/libs/angular/src/vault/services/nudges.service.spec.ts b/libs/angular/src/vault/services/nudges.service.spec.ts index 7c4c8ba8b74..cba973bd894 100644 --- a/libs/angular/src/vault/services/nudges.service.spec.ts +++ b/libs/angular/src/vault/services/nudges.service.spec.ts @@ -8,7 +8,6 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli 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"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; @@ -31,10 +30,6 @@ describe("Vault Nudges Service", () => { let fakeStateProvider: FakeStateProvider; let testBed: TestBed; - const mockConfigService = { - getFeatureFlag$: jest.fn().mockReturnValue(of(true)), - getFeatureFlag: jest.fn().mockReturnValue(true), - }; const nudgeServices = [ EmptyVaultNudgeService, @@ -58,7 +53,6 @@ describe("Vault Nudges Service", () => { provide: StateProvider, useValue: fakeStateProvider, }, - { provide: ConfigService, useValue: mockConfigService }, { provide: HasItemsNudgeService, useValue: mock(), diff --git a/libs/angular/src/vault/services/nudges.service.ts b/libs/angular/src/vault/services/nudges.service.ts index 6cb7ae4abf1..4c77ff38bf6 100644 --- a/libs/angular/src/vault/services/nudges.service.ts +++ b/libs/angular/src/vault/services/nudges.service.ts @@ -1,8 +1,6 @@ import { inject, Injectable } from "@angular/core"; -import { combineLatest, map, Observable, of, shareReplay, switchMap } from "rxjs"; +import { combineLatest, map, Observable, shareReplay } from "rxjs"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { UserKeyDefinition, NUDGES_DISK } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; @@ -83,7 +81,6 @@ export class NudgesService { * @private */ private defaultNudgeService = inject(DefaultSingleNudgeService); - private configService = inject(ConfigService); private getNudgeService(nudge: NudgeType): SingleNudgeService { return this.customNudgeServices[nudge] ?? this.defaultNudgeService; @@ -95,16 +92,9 @@ export class NudgesService { * @param userId */ showNudgeSpotlight$(nudge: NudgeType, userId: UserId): Observable { - return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe( - switchMap((hasVaultNudgeFlag) => { - if (!hasVaultNudgeFlag) { - return of(false); - } - return this.getNudgeService(nudge) - .nudgeStatus$(nudge, userId) - .pipe(map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed)); - }), - ); + return this.getNudgeService(nudge) + .nudgeStatus$(nudge, userId) + .pipe(map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed)); } /** @@ -113,16 +103,9 @@ export class NudgesService { * @param userId */ showNudgeBadge$(nudge: NudgeType, userId: UserId): Observable { - return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe( - switchMap((hasVaultNudgeFlag) => { - if (!hasVaultNudgeFlag) { - return of(false); - } - return this.getNudgeService(nudge) - .nudgeStatus$(nudge, userId) - .pipe(map((nudgeStatus) => !nudgeStatus.hasBadgeDismissed)); - }), - ); + return this.getNudgeService(nudge) + .nudgeStatus$(nudge, userId) + .pipe(map((nudgeStatus) => !nudgeStatus.hasBadgeDismissed)); } /** @@ -131,14 +114,7 @@ export class NudgesService { * @param userId */ showNudgeStatus$(nudge: NudgeType, userId: UserId) { - return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe( - switchMap((hasVaultNudgeFlag) => { - if (!hasVaultNudgeFlag) { - return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true } as NudgeStatus); - } - return this.getNudgeService(nudge).nudgeStatus$(nudge, userId); - }), - ); + return this.getNudgeService(nudge).nudgeStatus$(nudge, userId); } /** diff --git a/libs/auth/src/angular/input-password/input-password.component.html b/libs/auth/src/angular/input-password/input-password.component.html index bf3a51b98bb..d39215b2d68 100644 --- a/libs/auth/src/angular/input-password/input-password.component.html +++ b/libs/auth/src/angular/input-password/input-password.component.html @@ -133,6 +133,7 @@ {{ "rotateAccountEncKey" | i18n }}
{ refreshToken, ); - expect(stateService.addAccount).toHaveBeenCalledWith( - new Account({ - profile: { - ...new AccountProfile(), - ...{ - userId: userId, - name: name, - email: email, - }, - }, - }), - ); + expect(environmentService.seedUserEnvironment).toHaveBeenCalled(); + expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith( UserDecryptionOptions.fromResponse(idTokenResponse), ); @@ -388,7 +377,8 @@ describe("LoginStrategy", () => { const result = await passwordLoginStrategy.logIn(credentials); - expect(stateService.addAccount).not.toHaveBeenCalled(); + expect(environmentService.seedUserEnvironment).not.toHaveBeenCalled(); + expect(accountService.mock.switchAccount).not.toHaveBeenCalled(); expect(messagingService.send).not.toHaveBeenCalled(); expect(tokenService.clearTwoFactorToken).toHaveBeenCalled(); @@ -422,7 +412,7 @@ describe("LoginStrategy", () => { const result = await passwordLoginStrategy.logIn(credentials); - expect(stateService.addAccount).not.toHaveBeenCalled(); + expect(environmentService.seedUserEnvironment).not.toHaveBeenCalled(); expect(messagingService.send).not.toHaveBeenCalled(); const expected = new AuthResult(); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 53e34147d9f..4c7a38254d7 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -32,7 +32,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag 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 { KeyService, @@ -198,19 +197,6 @@ export abstract class LoginStrategy { await this.accountService.switchAccount(userId); - await this.stateService.addAccount( - new Account({ - profile: { - ...new AccountProfile(), - ...{ - userId, - name: accountInformation.name, - email: accountInformation.email, - }, - }, - }), - ); - await this.verifyAccountAdded(userId); // We must set user decryption options before retrieving vault timeout settings diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index 7114afbf94f..a6446401f70 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -170,7 +170,7 @@ describe("UserApiLoginStrategy", () => { mockVaultTimeoutAction, mockVaultTimeout, ); - expect(stateService.addAccount).toHaveBeenCalled(); + expect(environmentService.seedUserEnvironment).toHaveBeenCalled(); }); it("sets the encrypted user key and private key from the identity token response", async () => { diff --git a/libs/common/src/auth/services/account.service.ts b/libs/common/src/auth/services/account.service.ts index 50ba2455d78..1be11b03461 100644 --- a/libs/common/src/auth/services/account.service.ts +++ b/libs/common/src/auth/services/account.service.ts @@ -232,9 +232,11 @@ export class AccountServiceImplementation implements InternalAccountService { return; } - await this.singleUserStateProvider.get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN).update(() => { - return setVerifyNewDeviceLogin; - }); + await this.singleUserStateProvider + .get(userId, ACCOUNT_VERIFY_NEW_DEVICE_LOGIN) + .update(() => setVerifyNewDeviceLogin, { + shouldUpdate: (previousValue) => previousValue !== setVerifyNewDeviceLogin, + }); } async removeAccountActivity(userId: UserId): Promise { diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts index e67e522368f..7ed375da377 100644 --- a/libs/common/src/auth/services/token.service.spec.ts +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -1476,8 +1476,14 @@ describe("TokenService", () => { expect(logoutCallback).not.toHaveBeenCalled(); }); - it("does not error and fallback to disk storage when passed a null value for the refresh token", async () => { + it("does not error and does not fallback to disk storage when passed a null value for the refresh token", async () => { // Arrange + + // We must have an initial value in disk so that we can assert that it gets cleaned up + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .nextState(refreshToken); + secureStorageService.get.mockResolvedValue(null); // Act diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index 4076cd68d3c..80e61d4636f 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -356,7 +356,10 @@ export class TokenService implements TokenServiceAbstraction { // Save the encrypted access token to disk await this.singleUserStateProvider .get(userId, ACCESS_TOKEN_DISK) - .update((_) => encryptedAccessToken.encryptedString); + .update((_) => encryptedAccessToken.encryptedString, { + shouldUpdate: (previousValue) => + previousValue !== encryptedAccessToken.encryptedString, + }); // If we've successfully stored the encrypted access token to disk, we can return the decrypted access token // so that the caller can use it immediately. @@ -375,7 +378,9 @@ export class TokenService implements TokenServiceAbstraction { // Fall back to disk storage for unecrypted access token decryptedAccessToken = await this.singleUserStateProvider .get(userId, ACCESS_TOKEN_DISK) - .update((_) => accessToken); + .update((_) => accessToken, { + shouldUpdate: (previousValue) => previousValue !== accessToken, + }); } return decryptedAccessToken; @@ -384,7 +389,9 @@ export class TokenService implements TokenServiceAbstraction { // Access token stored on disk unencrypted as platform does not support secure storage return await this.singleUserStateProvider .get(userId, ACCESS_TOKEN_DISK) - .update((_) => accessToken); + .update((_) => accessToken, { + shouldUpdate: (previousValue) => previousValue !== accessToken, + }); case TokenStorageLocation.Memory: // Access token stored in memory due to vault timeout settings return await this.singleUserStateProvider @@ -439,7 +446,9 @@ export class TokenService implements TokenServiceAbstraction { } // Platform doesn't support secure storage, so use state provider implementation - await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null); + await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null, { + shouldUpdate: (previousValue) => previousValue !== null, + }); await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); } @@ -586,7 +595,9 @@ export class TokenService implements TokenServiceAbstraction { // TODO: PM-6408 // 2024-02-20: Remove refresh token from memory and disk so that we migrate to secure storage over time. // Remove these 2 calls to remove the refresh token from memory and disk after 3 months. - await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null); + await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null, { + shouldUpdate: (previousValue) => previousValue !== null, + }); await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null); } catch (error) { // This case could be hit for both Linux users who don't have secure storage configured @@ -599,7 +610,9 @@ export class TokenService implements TokenServiceAbstraction { // Fall back to disk storage for refresh token decryptedRefreshToken = await this.singleUserStateProvider .get(userId, REFRESH_TOKEN_DISK) - .update((_) => refreshToken); + .update((_) => refreshToken, { + shouldUpdate: (previousValue) => previousValue !== refreshToken, + }); } return decryptedRefreshToken; @@ -607,7 +620,9 @@ export class TokenService implements TokenServiceAbstraction { case TokenStorageLocation.Disk: return await this.singleUserStateProvider .get(userId, REFRESH_TOKEN_DISK) - .update((_) => refreshToken); + .update((_) => refreshToken, { + shouldUpdate: (previousValue) => previousValue !== refreshToken, + }); case TokenStorageLocation.Memory: return await this.singleUserStateProvider @@ -687,7 +702,9 @@ export class TokenService implements TokenServiceAbstraction { // Platform doesn't support secure storage, so use state provider implementation await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null); - await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null); + await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null, { + shouldUpdate: (previousValue) => previousValue !== null, + }); } async setClientId( diff --git a/libs/common/src/autofill/services/domain-settings.service.spec.ts b/libs/common/src/autofill/services/domain-settings.service.spec.ts index 36f7d0eacec..12a34b70913 100644 --- a/libs/common/src/autofill/services/domain-settings.service.spec.ts +++ b/libs/common/src/autofill/services/domain-settings.service.spec.ts @@ -1,8 +1,6 @@ -import { MockProxy, mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; import { FakeStateProvider, FakeAccountService, mockAccountServiceWith } from "../../../spec"; -import { ConfigService } from "../../platform/abstractions/config/config.service"; import { Utils } from "../../platform/misc/utils"; import { UserId } from "../../types/guid"; @@ -10,7 +8,6 @@ import { DefaultDomainSettingsService, DomainSettingsService } from "./domain-se describe("DefaultDomainSettingsService", () => { let domainSettingsService: DomainSettingsService; - let configService: MockProxy; const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); const fakeStateProvider: FakeStateProvider = new FakeStateProvider(accountService); @@ -22,9 +19,7 @@ describe("DefaultDomainSettingsService", () => { ]; beforeEach(() => { - configService = mock(); - configService.getFeatureFlag$.mockImplementation(() => of(false)); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider, configService); + domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); jest.spyOn(domainSettingsService, "getUrlEquivalentDomains"); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); diff --git a/libs/common/src/autofill/services/domain-settings.service.ts b/libs/common/src/autofill/services/domain-settings.service.ts index b2833b9ee25..bc86f9b4d64 100644 --- a/libs/common/src/autofill/services/domain-settings.service.ts +++ b/libs/common/src/autofill/services/domain-settings.service.ts @@ -1,15 +1,13 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { map, Observable, switchMap, of } from "rxjs"; +import { map, Observable } from "rxjs"; -import { FeatureFlag } from "../../enums/feature-flag.enum"; import { NeverDomains, EquivalentDomains, UriMatchStrategySetting, UriMatchStrategy, } from "../../models/domain/domain-service"; -import { ConfigService } from "../../platform/abstractions/config/config.service"; import { Utils } from "../../platform/misc/utils"; import { DOMAIN_SETTINGS_DISK, @@ -111,10 +109,7 @@ export class DefaultDomainSettingsService implements DomainSettingsService { private defaultUriMatchStrategyState: ActiveUserState; readonly defaultUriMatchStrategy$: Observable; - constructor( - private stateProvider: StateProvider, - private configService: ConfigService, - ) { + constructor(private stateProvider: StateProvider) { this.showFaviconsState = this.stateProvider.getGlobal(SHOW_FAVICONS); this.showFavicons$ = this.showFaviconsState.state$.pipe(map((x) => x ?? true)); @@ -123,15 +118,9 @@ export class DefaultDomainSettingsService implements DomainSettingsService { // Needs to be global to prevent pre-login injections this.blockedInteractionsUrisState = this.stateProvider.getGlobal(BLOCKED_INTERACTIONS_URIS); - - this.blockedInteractionsUris$ = this.configService - .getFeatureFlag$(FeatureFlag.BlockBrowserInjectionsByDomain) - .pipe( - switchMap((featureIsEnabled) => - featureIsEnabled ? this.blockedInteractionsUrisState.state$ : of({} as NeverDomains), - ), - map((disabledUris) => (Object.keys(disabledUris).length ? disabledUris : {})), - ); + this.blockedInteractionsUris$ = this.blockedInteractionsUrisState.state$.pipe( + map((x) => x ?? ({} as NeverDomains)), + ); this.equivalentDomainsState = this.stateProvider.getActive(EQUIVALENT_DOMAINS); this.equivalentDomains$ = this.equivalentDomainsState.state$.pipe(map((x) => x ?? null)); diff --git a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts index 29301e626b9..f89025e7d4a 100644 --- a/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/organizations/organization-billing-api.service.abstraction.ts @@ -1,5 +1,4 @@ import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { BillingInvoiceResponse, @@ -18,8 +17,6 @@ export abstract class OrganizationBillingApiServiceAbstraction { startAfter?: string, ) => Promise; - abstract getWarnings: (id: string) => Promise; - abstract setupBusinessUnit: ( id: string, request: { diff --git a/libs/common/src/billing/services/organization/organization-billing-api.service.ts b/libs/common/src/billing/services/organization/organization-billing-api.service.ts index e9456f61026..40424c236e7 100644 --- a/libs/common/src/billing/services/organization/organization-billing-api.service.ts +++ b/libs/common/src/billing/services/organization/organization-billing-api.service.ts @@ -1,5 +1,4 @@ import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request"; -import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/response/organization-warnings.response"; import { ApiService } from "../../../abstractions/api.service"; import { OrganizationBillingApiServiceAbstraction } from "../../abstractions/organizations/organization-billing-api.service.abstraction"; @@ -53,18 +52,6 @@ export class OrganizationBillingApiService implements OrganizationBillingApiServ return r?.map((i: any) => new BillingTransactionResponse(i)) || []; } - async getWarnings(id: string): Promise { - const response = await this.apiService.send( - "GET", - `/organizations/${id}/billing/warnings`, - null, - true, - true, - ); - - return new OrganizationWarningsResponse(response); - } - async setupBusinessUnit( id: string, request: { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index e992ac64d46..5a4e5ff5dde 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -17,7 +17,6 @@ export enum FeatureFlag { PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals", /* Autofill */ - BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain", EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill", NotificationRefresh = "notification-refresh", UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection", @@ -33,6 +32,7 @@ export enum FeatureFlag { AllowTrialLengthZero = "pm-20322-allow-trial-length-0", PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", + PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -47,13 +47,10 @@ export enum FeatureFlag { EventBasedOrganizationIntegrations = "event-based-organization-integrations", /* Vault */ - PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge", - PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form", PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", - EndUserNotifications = "pm-10609-end-user-notifications", RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy", PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp", @@ -79,7 +76,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.CreateDefaultLocation]: FALSE, /* Autofill */ - [FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE, [FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE, [FeatureFlag.NotificationRefresh]: FALSE, [FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE, @@ -94,10 +90,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, /* Vault */ - [FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE, - [FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, - [FeatureFlag.EndUserNotifications]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.RemoveCardItemTypePolicy]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, @@ -116,6 +109,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.AllowTrialLengthZero]: FALSE, [FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, + [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/common/src/key-management/crypto/index.ts b/libs/common/src/key-management/crypto/index.ts new file mode 100644 index 00000000000..8c0dc5a0732 --- /dev/null +++ b/libs/common/src/key-management/crypto/index.ts @@ -0,0 +1,2 @@ +export { KeyGenerationService } from "./key-generation/key-generation.service"; +export { DefaultKeyGenerationService } from "./key-generation/default-key-generation.service"; diff --git a/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts new file mode 100644 index 00000000000..8e8d2de1ce4 --- /dev/null +++ b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts @@ -0,0 +1,94 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore + +// 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 { KdfConfig } from "@bitwarden/key-management"; +import { PureCrypto } from "@bitwarden/sdk-internal"; + +import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; +import { EncryptionType } from "../../../platform/enums"; +import { Utils } from "../../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../../types/csprng"; +import { CryptoFunctionService } from "../abstractions/crypto-function.service"; + +import { KeyGenerationService } from "./key-generation.service"; + +export class DefaultKeyGenerationService implements KeyGenerationService { + constructor(private cryptoFunctionService: CryptoFunctionService) {} + + async createKey(bitLength: 256 | 512): Promise { + const key = await this.cryptoFunctionService.aesGenerateKey(bitLength); + return new SymmetricCryptoKey(key); + } + + async createKeyWithPurpose( + bitLength: 128 | 192 | 256 | 512, + purpose: string, + salt?: string, + ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> { + if (salt == null) { + const bytes = await this.cryptoFunctionService.randomBytes(32); + salt = Utils.fromBufferToUtf8(bytes); + } + const material = await this.cryptoFunctionService.aesGenerateKey(bitLength); + const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); + return { salt, material, derivedKey: new SymmetricCryptoKey(key) }; + } + + async deriveKeyFromMaterial( + material: CsprngArray, + salt: string, + purpose: string, + ): Promise { + const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); + return new SymmetricCryptoKey(key); + } + + async deriveKeyFromPassword( + password: string | Uint8Array, + salt: string | Uint8Array, + kdfConfig: KdfConfig, + ): Promise { + if (typeof password === "string") { + password = new TextEncoder().encode(password); + } + if (typeof salt === "string") { + salt = new TextEncoder().encode(salt); + } + + await SdkLoadService.Ready; + return new SymmetricCryptoKey( + PureCrypto.derive_kdf_material(password, salt, kdfConfig.toSdkConfig()), + ); + } + + async stretchKey(key: SymmetricCryptoKey): Promise { + // The key to be stretched is actually usually the output of a KDF, and not actually meant for AesCbc256_B64 encryption, + // but has the same key length. Only 256-bit key materials should be stretched. + if (key.inner().type != EncryptionType.AesCbc256_B64) { + throw new Error("Key passed into stretchKey is not a 256-bit key."); + } + + const newKey = new Uint8Array(64); + // Master key and pin key are always 32 bytes + const encKey = await this.cryptoFunctionService.hkdfExpand( + key.inner().encryptionKey, + "enc", + 32, + "sha256", + ); + const macKey = await this.cryptoFunctionService.hkdfExpand( + key.inner().encryptionKey, + "mac", + 32, + "sha256", + ); + + newKey.set(new Uint8Array(encKey)); + newKey.set(new Uint8Array(macKey), 32); + + return new SymmetricCryptoKey(newKey); + } +} diff --git a/libs/common/src/platform/services/key-generation.service.spec.ts b/libs/common/src/key-management/crypto/key-generation/key-generation.service.spec.ts similarity index 88% rename from libs/common/src/platform/services/key-generation.service.spec.ts rename to libs/common/src/key-management/crypto/key-generation/key-generation.service.spec.ts index fb6c0a459b3..b8408cbb4cf 100644 --- a/libs/common/src/platform/services/key-generation.service.spec.ts +++ b/libs/common/src/key-management/crypto/key-generation/key-generation.service.spec.ts @@ -4,21 +4,21 @@ import { mock } from "jest-mock-extended"; // eslint-disable-next-line no-restricted-imports import { PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management"; -import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service"; -import { CsprngArray } from "../../types/csprng"; -import { SdkLoadService } from "../abstractions/sdk/sdk-load.service"; -import { EncryptionType } from "../enums"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; +import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.service"; +import { EncryptionType } from "../../../platform/enums"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../../types/csprng"; +import { CryptoFunctionService } from "../abstractions/crypto-function.service"; -import { KeyGenerationService } from "./key-generation.service"; +import { DefaultKeyGenerationService } from "./default-key-generation.service"; describe("KeyGenerationService", () => { - let sut: KeyGenerationService; + let sut: DefaultKeyGenerationService; const cryptoFunctionService = mock(); beforeEach(() => { - sut = new KeyGenerationService(cryptoFunctionService); + sut = new DefaultKeyGenerationService(cryptoFunctionService); }); describe("createKey", () => { diff --git a/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts new file mode 100644 index 00000000000..d6be436384e --- /dev/null +++ b/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts @@ -0,0 +1,90 @@ +// 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 { KdfConfig } from "@bitwarden/key-management"; + +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../../types/csprng"; + +/** + * @deprecated This is a low-level cryptographic service. New functionality should not be built + * on top of it, and instead should be built in the sdk. + */ +export abstract class KeyGenerationService { + /** + * Generates a key of the given length suitable for use in AES encryption + * + * @deprecated WARNING: DO NOT USE THIS FOR NEW CODE. Direct generation and handling of keys should only be done in the SDK, + * as memory safety cannot be ensured in a JS context. + * + * @param bitLength Length of key. + * 256 bits = 32 bytes + * 512 bits = 64 bytes + * @returns Generated key. + */ + abstract createKey(bitLength: 256 | 512): Promise; + /** + * Generates key material from CSPRNG and derives a 64 byte key from it. + * Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} + * for details. + * + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * New functionality should not be built on top of it, and instead should be built in the sdk. + * + * @param bitLength Length of key material. + * @param purpose Purpose for the key derivation function. + * Different purposes results in different keys, even with the same material. + * @param salt Optional. If not provided will be generated from CSPRNG. + * @returns An object containing the salt, key material, and derived key. + */ + abstract createKeyWithPurpose( + bitLength: 128 | 192 | 256 | 512, + purpose: string, + salt?: string, + ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>; + /** + * Derives a 64 byte key from key material. + * + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * New functionality should not be built on top of it, and instead should be built in the sdk. + * + * @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}. + * Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} for details. + * @param material key material. + * @param salt Salt for the key derivation function. + * @param purpose Purpose for the key derivation function. + * Different purposes results in different keys, even with the same material. + * @returns 64 byte derived key. + */ + abstract deriveKeyFromMaterial( + material: CsprngArray, + salt: string, + purpose: string, + ): Promise; + /** + * Derives a 32 byte key from a password using a key derivation function. + * + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * New functionality should not be built on top of it, and instead should be built in the sdk. + * + * @param password Password to derive the key from. + * @param salt Salt for the key derivation function. + * @param kdfConfig Configuration for the key derivation function. + * @returns 32 byte derived key. + */ + abstract deriveKeyFromPassword( + password: string | Uint8Array, + salt: string | Uint8Array, + kdfConfig: KdfConfig, + ): Promise; + + /** + * Derives a 64 byte key from a 32 byte key using a key derivation function. + * + * @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function. + * New functionality should not be built on top of it, and instead should be built in the sdk. + * + * @param key 32 byte key. + * @returns 64 byte derived key. + */ + abstract stretchKey(key: SymmetricCryptoKey): Promise; +} 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 47aa41275df..f39741f9faf 100644 --- a/libs/common/src/key-management/crypto/models/enc-string.ts +++ b/libs/common/src/key-management/crypto/models/enc-string.ts @@ -5,13 +5,12 @@ 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"; export const DECRYPT_ERROR = "[error: cannot decrypt]"; -export class EncString implements Encrypted { +export class EncString { encryptedString?: SdkEncString; encryptionType?: EncryptionType; decryptedValue?: string; diff --git a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts index 372b3282a72..58a2c680afa 100644 --- a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts +++ b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts @@ -20,7 +20,6 @@ import { import { AppIdService } from "../../../platform/abstractions/app-id.service"; import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; import { AbstractStorageService } from "../../../platform/abstractions/storage.service"; @@ -30,6 +29,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { DEVICE_TRUST_DISK_LOCAL, StateProvider, UserKeyDefinition } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { UserKey, DeviceKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncString } from "../../crypto/models/enc-string"; diff --git a/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts b/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts index 50a6b0efa21..7ed28518012 100644 --- a/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts +++ b/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts @@ -25,7 +25,6 @@ import { DeviceType } from "../../../enums"; import { AppIdService } from "../../../platform/abstractions/app-id.service"; import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; import { AbstractStorageService } from "../../../platform/abstractions/storage.service"; @@ -37,6 +36,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { CsprngArray } from "../../../types/csprng"; import { UserId } from "../../../types/guid"; import { DeviceKey, UserKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncString } from "../../crypto/models/enc-string"; diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts index 3db2a7ecd79..67961616034 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts @@ -18,9 +18,9 @@ import { TokenService } from "../../../auth/services/token.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { KeyGenerationService } from "../../../platform/services/key-generation.service"; import { OrganizationId, UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { EncString } from "../../crypto/models/enc-string"; import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service"; import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request"; diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.ts index 0c4f4090e61..a6207ab92e2 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.ts @@ -23,13 +23,13 @@ import { Organization } from "../../../admin-console/models/domain/organization" import { TokenService } from "../../../auth/abstractions/token.service"; import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; import { KeysRequest } from "../../../models/request/keys.request"; -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 { KEY_CONNECTOR_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { MasterKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service"; import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request"; 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 a09de9008d1..693a4fb39a6 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 @@ -13,13 +13,13 @@ import { 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"; 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, UserKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncString } from "../../crypto/models/enc-string"; 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 75e5032e004..6ca73a2ae14 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 @@ -11,7 +11,6 @@ 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"; import { StateService } from "../../../platform/abstractions/state.service"; import { EncryptionType } from "../../../platform/enums"; @@ -24,6 +23,7 @@ import { } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; +import { KeyGenerationService } from "../../crypto"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncryptedString, EncString } from "../../crypto/models/enc-string"; @@ -149,14 +149,18 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr if (userId == null) { throw new Error("User ID is required."); } - await this.stateProvider.getUser(userId, MASTER_KEY_HASH).update((_) => masterKeyHash); + await this.stateProvider.getUser(userId, MASTER_KEY_HASH).update((_) => masterKeyHash, { + shouldUpdate: (previousValue) => previousValue !== masterKeyHash, + }); } async clearMasterKeyHash(userId: UserId): Promise { if (userId == null) { throw new Error("User ID is required."); } - await this.stateProvider.getUser(userId, MASTER_KEY_HASH).update((_) => null); + await this.stateProvider.getUser(userId, MASTER_KEY_HASH).update((_) => null, { + shouldUpdate: (previousValue) => previousValue !== null, + }); } async setMasterKeyEncryptedUserKey(encryptedKey: EncString, userId: UserId): Promise { diff --git a/libs/common/src/key-management/pin/pin.service.implementation.ts b/libs/common/src/key-management/pin/pin.service.implementation.ts index f926f4a4af2..da46cd3bc76 100644 --- a/libs/common/src/key-management/pin/pin.service.implementation.ts +++ b/libs/common/src/key-management/pin/pin.service.implementation.ts @@ -9,11 +9,11 @@ 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 { KeyGenerationService } from "../crypto"; import { PinServiceAbstraction } from "./pin.service.abstraction"; diff --git a/libs/common/src/key-management/pin/pin.service.spec.ts b/libs/common/src/key-management/pin/pin.service.spec.ts index 3d7dbaa4718..b014c26c7dc 100644 --- a/libs/common/src/key-management/pin/pin.service.spec.ts +++ b/libs/common/src/key-management/pin/pin.service.spec.ts @@ -4,12 +4,12 @@ import { mock } from "jest-mock-extended"; 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 { KeyGenerationService } from "../crypto"; import { CryptoFunctionService } from "../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../crypto/abstractions/encrypt.service"; import { EncString } from "../crypto/models/enc-string"; diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts index 9963e7d24f8..26d263d7e72 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts @@ -12,15 +12,16 @@ import { LogoutReason } 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 { BiometricsService } from "@bitwarden/key-management"; +import { StateService } from "@bitwarden/state"; import { FakeAccountService, mockAccountServiceWith } from "../../../../spec"; import { AccountInfo } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; +import { TokenService } from "../../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { LogService } from "../../../platform/abstractions/log.service"; import { MessagingService } from "../../../platform/abstractions/messaging.service"; import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; -import { StateService } from "../../../platform/abstractions/state.service"; import { Utils } from "../../../platform/misc/utils"; import { TaskSchedulerService } from "../../../platform/scheduling"; import { StateEventRunnerService } from "../../../platform/state"; @@ -45,6 +46,7 @@ describe("VaultTimeoutService", () => { let messagingService: MockProxy; let searchService: MockProxy; let stateService: MockProxy; + let tokenService: MockProxy; let authService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let stateEventRunnerService: MockProxy; @@ -71,6 +73,7 @@ describe("VaultTimeoutService", () => { messagingService = mock(); searchService = mock(); stateService = mock(); + tokenService = mock(); authService = mock(); vaultTimeoutSettingsService = mock(); stateEventRunnerService = mock(); @@ -99,6 +102,7 @@ describe("VaultTimeoutService", () => { messagingService, searchService, stateService, + tokenService, authService, vaultTimeoutSettingsService, stateEventRunnerService, @@ -141,9 +145,8 @@ describe("VaultTimeoutService", () => { authService.getAuthStatus.mockImplementation((userId) => { return Promise.resolve(accounts[userId]?.authStatus); }); - stateService.getIsAuthenticated.mockImplementation((options) => { - // Just like actual state service, if no userId is given fallback to active userId - return Promise.resolve(accounts[options.userId ?? globalSetups?.userId]?.isAuthenticated); + tokenService.hasAccessToken$.mockImplementation((userId) => { + return of(accounts[userId]?.isAuthenticated ?? false); }); vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation((userId) => { @@ -201,7 +204,7 @@ describe("VaultTimeoutService", () => { const expectUserToHaveLocked = (userId: string) => { // This does NOT assert all the things that the lock process does - expect(stateService.getIsAuthenticated).toHaveBeenCalledWith({ userId: userId }); + expect(tokenService.hasAccessToken$).toHaveBeenCalledWith(userId); expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId); expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId }); expect(masterPasswordService.mock.clearMasterKey).toHaveBeenCalledWith(userId); 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 6d71bad0b0a..98f6f76fbe7 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 @@ -14,6 +14,7 @@ import { BiometricsService } from "@bitwarden/key-management"; import { AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; +import { TokenService } from "../../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { LogService } from "../../../platform/abstractions/log.service"; import { MessagingService } from "../../../platform/abstractions/messaging.service"; @@ -43,6 +44,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { private messagingService: MessagingService, private searchService: SearchService, private stateService: StateService, + private tokenService: TokenService, private authService: AuthService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private stateEventRunnerService: StateEventRunnerService, @@ -108,7 +110,10 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { async lock(userId?: UserId): Promise { await this.biometricService.setShouldAutopromptNow(false); - const authed = await this.stateService.getIsAuthenticated({ userId: userId }); + const lockingUserId = + userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)))); + + const authed = await firstValueFrom(this.tokenService.hasAccessToken$(lockingUserId)); if (!authed) { return; } @@ -121,12 +126,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { await this.logOut(userId); } - const currentUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - - const lockingUserId = userId ?? currentUserId; - // HACK: Start listening for the transition of the locking user from something to the locked state. // This is very much a hack to ensure that the authentication status to retrievable right after // it does its work. Particularly the `lockedCallback` and `"locked"` message. Instead diff --git a/libs/common/src/models/export/collection-with-id.export.ts b/libs/common/src/models/export/collection-with-id.export.ts index c973472e0bb..9a372fbdfa9 100644 --- a/libs/common/src/models/export/collection-with-id.export.ts +++ b/libs/common/src/models/export/collection-with-id.export.ts @@ -3,20 +3,18 @@ // 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 { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common"; - -import { CollectionId } from "../../types/guid"; +import { CollectionId } from "@bitwarden/common/types/guid"; import { CollectionExport } from "./collection.export"; export class CollectionWithIdExport extends CollectionExport { id: CollectionId; - static toView(req: CollectionWithIdExport, view = new CollectionView()) { - view.id = req.id; - return super.toView(req, view); + static toView(req: CollectionWithIdExport) { + return super.toView(req, req.id); } - static toDomain(req: CollectionWithIdExport, domain = new CollectionDomain()) { + static toDomain(req: CollectionWithIdExport, domain: CollectionDomain) { domain.id = req.id; return super.toDomain(req, domain); } diff --git a/libs/common/src/models/export/collection.export.ts b/libs/common/src/models/export/collection.export.ts index b141346d03f..631b31d8b7b 100644 --- a/libs/common/src/models/export/collection.export.ts +++ b/libs/common/src/models/export/collection.export.ts @@ -5,7 +5,7 @@ import { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common"; import { EncString } from "../../key-management/crypto/models/enc-string"; -import { emptyGuid, OrganizationId } from "../../types/guid"; +import { CollectionId, emptyGuid, OrganizationId } from "../../types/guid"; import { safeGetString } from "./utils"; @@ -18,16 +18,17 @@ export class CollectionExport { return req; } - static toView(req: CollectionExport, view = new CollectionView()) { - view.name = req.name; + static toView(req: CollectionExport, id: CollectionId) { + const view = new CollectionView({ + name: req.name, + organizationId: req.organizationId, + id, + }); view.externalId = req.externalId; - if (view.organizationId == null) { - view.organizationId = req.organizationId; - } return view; } - static toDomain(req: CollectionExport, domain = new CollectionDomain()) { + static toDomain(req: CollectionExport, domain: CollectionDomain) { domain.name = req.name != null ? new EncString(req.name) : null; domain.externalId = req.externalId; if (domain.organizationId == null) { diff --git a/libs/common/src/platform/abstractions/key-generation.service.ts b/libs/common/src/platform/abstractions/key-generation.service.ts index 91c630ed638..8a230fb5b86 100644 --- a/libs/common/src/platform/abstractions/key-generation.service.ts +++ b/libs/common/src/platform/abstractions/key-generation.service.ts @@ -1,66 +1,2 @@ -// 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 { KdfConfig } from "@bitwarden/key-management"; - -import { CsprngArray } from "../../types/csprng"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; - -export abstract class KeyGenerationService { - /** - * Generates a key of the given length suitable for use in AES encryption - * @param bitLength Length of key. - * 256 bits = 32 bytes - * 512 bits = 64 bytes - * @returns Generated key. - */ - abstract createKey(bitLength: 256 | 512): Promise; - /** - * Generates key material from CSPRNG and derives a 64 byte key from it. - * Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} - * for details. - * @param bitLength Length of key material. - * @param purpose Purpose for the key derivation function. - * Different purposes results in different keys, even with the same material. - * @param salt Optional. If not provided will be generated from CSPRNG. - * @returns An object containing the salt, key material, and derived key. - */ - abstract createKeyWithPurpose( - bitLength: 128 | 192 | 256 | 512, - purpose: string, - salt?: string, - ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>; - /** - * Derives a 64 byte key from key material. - * @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}. - * Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} for details. - * @param material key material. - * @param salt Salt for the key derivation function. - * @param purpose Purpose for the key derivation function. - * Different purposes results in different keys, even with the same material. - * @returns 64 byte derived key. - */ - abstract deriveKeyFromMaterial( - material: CsprngArray, - salt: string, - purpose: string, - ): Promise; - /** - * Derives a 32 byte key from a password using a key derivation function. - * @param password Password to derive the key from. - * @param salt Salt for the key derivation function. - * @param kdfConfig Configuration for the key derivation function. - * @returns 32 byte derived key. - */ - abstract deriveKeyFromPassword( - password: string | Uint8Array, - salt: string | Uint8Array, - kdfConfig: KdfConfig, - ): Promise; - - /** - * Derives a 64 byte key from a 32 byte key using a key derivation function. - * @param key 32 byte key. - * @returns 64 byte derived key. - */ - abstract stretchKey(key: SymmetricCryptoKey): Promise; -} +/** Temporary re-export. This should not be used for new imports */ +export { KeyGenerationService } from "../../key-management/crypto/key-generation/key-generation.service"; diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 4c1c000284e..612b801d535 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -1,63 +1 @@ -import { BiometricKey } from "../../auth/types/biometric-key"; -import { Account } from "../models/domain/account"; -import { StorageOptions } from "../models/domain/storage-options"; - -/** - * Options for customizing the initiation behavior. - */ -export type InitOptions = { - /** - * Whether or not to run state migrations as part of the init process. Defaults to true. - * - * If false, the init method will instead wait for migrations to complete before doing its - * other init operations. Make sure migrations have either already completed, or will complete - * before calling {@link StateService.init} with `runMigrations: false`. - */ - runMigrations?: boolean; -}; - -export abstract class StateService { - abstract addAccount(account: T): Promise; - abstract clean(options?: StorageOptions): Promise; - abstract init(initOptions?: InitOptions): Promise; - - /** - * Gets the user's auto key - */ - abstract getUserKeyAutoUnlock(options?: StorageOptions): Promise; - /** - * Sets the user's auto key - */ - abstract setUserKeyAutoUnlock(value: string | null, options?: StorageOptions): Promise; - /** - * Gets the user's biometric key - */ - abstract getUserKeyBiometric(options?: StorageOptions): Promise; - /** - * Checks if the user has a biometric key available - */ - abstract hasUserKeyBiometric(options?: StorageOptions): Promise; - /** - * Sets the user's biometric key - */ - abstract setUserKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise; - /** - * @deprecated For backwards compatible purposes only, use DesktopAutofillSettingsService - */ - abstract setEnableDuckDuckGoBrowserIntegration( - value: boolean, - options?: StorageOptions, - ): Promise; - abstract getDuckDuckGoSharedKey(options?: StorageOptions): Promise; - abstract setDuckDuckGoSharedKey(value: string, options?: StorageOptions): Promise; - - /** - * @deprecated Use `TokenService.hasAccessToken$()` or `AuthService.authStatusFor$` instead. - */ - abstract getIsAuthenticated(options?: StorageOptions): Promise; - - /** - * @deprecated Use `AccountService.activeAccount$` instead. - */ - abstract getUserId(options?: StorageOptions): Promise; -} +export { StateService } from "@bitwarden/state"; diff --git a/libs/common/src/platform/factories/account-factory.ts b/libs/common/src/platform/factories/account-factory.ts deleted file mode 100644 index 1fe5aee369c..00000000000 --- a/libs/common/src/platform/factories/account-factory.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Account } from "../models/domain/account"; - -export class AccountFactory { - private accountConstructor: new (init: Partial) => T; - - constructor(accountConstructor: new (init: Partial) => T) { - this.accountConstructor = accountConstructor; - } - - create(args: Partial) { - return new this.accountConstructor(args); - } -} diff --git a/libs/common/src/platform/factories/global-state-factory.ts b/libs/common/src/platform/factories/global-state-factory.ts deleted file mode 100644 index b52b022fd18..00000000000 --- a/libs/common/src/platform/factories/global-state-factory.ts +++ /dev/null @@ -1,15 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { GlobalState } from "../models/domain/global-state"; - -export class GlobalStateFactory { - private globalStateConstructor: new (init: Partial) => T; - - constructor(globalStateConstructor: new (init: Partial) => T) { - this.globalStateConstructor = globalStateConstructor; - } - - create(args?: Partial) { - return new this.globalStateConstructor(args); - } -} diff --git a/libs/common/src/platform/factories/state-factory.ts b/libs/common/src/platform/factories/state-factory.ts deleted file mode 100644 index fcdd3220c2b..00000000000 --- a/libs/common/src/platform/factories/state-factory.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Account } from "../models/domain/account"; -import { GlobalState } from "../models/domain/global-state"; - -import { AccountFactory } from "./account-factory"; -import { GlobalStateFactory } from "./global-state-factory"; - -export class StateFactory< - TGlobal extends GlobalState = GlobalState, - TAccount extends Account = Account, -> { - private globalStateFactory: GlobalStateFactory; - private accountFactory: AccountFactory; - - constructor( - globalStateConstructor: new (init: Partial) => TGlobal, - accountConstructor: new (init: Partial) => TAccount, - ) { - this.globalStateFactory = new GlobalStateFactory(globalStateConstructor); - this.accountFactory = new AccountFactory(accountConstructor); - } - - createGlobal(args: Partial): TGlobal { - return this.globalStateFactory.create(args); - } - - createAccount(args: Partial): TAccount { - return this.accountFactory.create(args); - } -} diff --git a/libs/common/src/platform/interfaces/encrypted.ts b/libs/common/src/platform/interfaces/encrypted.ts deleted file mode 100644 index 6f9d3a191df..00000000000 --- a/libs/common/src/platform/interfaces/encrypted.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { EncryptionType } from "../enums"; - -export interface Encrypted { - encryptionType?: EncryptionType; - dataBytes: Uint8Array; - macBytes: Uint8Array; - ivBytes: Uint8Array; -} diff --git a/libs/common/src/platform/misc/rxjs-operators.ts b/libs/common/src/platform/misc/rxjs-operators.ts index 423bcbb790f..b3c4423c36f 100644 --- a/libs/common/src/platform/misc/rxjs-operators.ts +++ b/libs/common/src/platform/misc/rxjs-operators.ts @@ -13,8 +13,8 @@ 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.filter((id) => id != null)); +export const getByIds = (ids: TId[]) => { + const idSet = new Set(ids); return map((objects) => { return objects.filter((o) => o.id && idSet.has(o.id)); }); diff --git a/libs/common/src/platform/models/domain/account-keys.spec.ts b/libs/common/src/platform/models/domain/account-keys.spec.ts deleted file mode 100644 index 6bdb08edd51..00000000000 --- a/libs/common/src/platform/models/domain/account-keys.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { makeStaticByteArray } from "../../../../spec"; -import { Utils } from "../../misc/utils"; - -import { AccountKeys, EncryptionPair } from "./account"; - -describe("AccountKeys", () => { - describe("toJSON", () => { - it("should serialize itself", () => { - const keys = new AccountKeys(); - const buffer = makeStaticByteArray(64); - keys.publicKey = buffer; - - const bufferSpy = jest.spyOn(Utils, "fromBufferToByteString"); - keys.toJSON(); - expect(bufferSpy).toHaveBeenCalledWith(buffer); - }); - - it("should serialize public key as a string", () => { - const keys = new AccountKeys(); - keys.publicKey = Utils.fromByteStringToArray("hello"); - const json = JSON.stringify(keys); - expect(json).toContain('"publicKey":"hello"'); - }); - }); - - describe("fromJSON", () => { - it("should deserialize public key to a buffer", () => { - const keys = AccountKeys.fromJSON({ - publicKey: "hello", - }); - expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello")); - }); - - it("should deserialize privateKey", () => { - const spy = jest.spyOn(EncryptionPair, "fromJSON"); - AccountKeys.fromJSON({ - privateKey: { encrypted: "encrypted", decrypted: "decrypted" }, - } as any); - expect(spy).toHaveBeenCalled(); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/account-profile.spec.ts b/libs/common/src/platform/models/domain/account-profile.spec.ts deleted file mode 100644 index 7c6deda34eb..00000000000 --- a/libs/common/src/platform/models/domain/account-profile.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AccountProfile } from "./account"; - -describe("AccountProfile", () => { - describe("fromJSON", () => { - it("should deserialize to an instance of itself", () => { - expect(AccountProfile.fromJSON({})).toBeInstanceOf(AccountProfile); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/account.spec.ts b/libs/common/src/platform/models/domain/account.spec.ts deleted file mode 100644 index 307fde62f93..00000000000 --- a/libs/common/src/platform/models/domain/account.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Account, AccountKeys, AccountProfile } from "./account"; - -describe("Account", () => { - describe("fromJSON", () => { - it("should deserialize to an instance of itself", () => { - expect(Account.fromJSON({})).toBeInstanceOf(Account); - }); - - it("should call all the sub-fromJSONs", () => { - const keysSpy = jest.spyOn(AccountKeys, "fromJSON"); - const profileSpy = jest.spyOn(AccountProfile, "fromJSON"); - - Account.fromJSON({}); - - expect(keysSpy).toHaveBeenCalled(); - expect(profileSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts deleted file mode 100644 index b9d10f47e97..00000000000 --- a/libs/common/src/platform/models/domain/account.ts +++ /dev/null @@ -1,136 +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 { DeepJsonify } from "../../../types/deep-jsonify"; -import { Utils } from "../../misc/utils"; - -import { SymmetricCryptoKey } from "./symmetric-crypto-key"; - -export class EncryptionPair { - encrypted?: TEncrypted; - decrypted?: TDecrypted; - - toJSON() { - return { - encrypted: this.encrypted, - decrypted: - this.decrypted instanceof ArrayBuffer - ? Utils.fromBufferToByteString(this.decrypted) - : this.decrypted, - }; - } - - static fromJSON( - obj: { encrypted?: Jsonify; decrypted?: string | Jsonify }, - decryptedFromJson?: (decObj: Jsonify | string) => TDecrypted, - encryptedFromJson?: (encObj: Jsonify) => TEncrypted, - ) { - if (obj == null) { - return null; - } - - const pair = new EncryptionPair(); - if (obj?.encrypted != null) { - pair.encrypted = encryptedFromJson - ? encryptedFromJson(obj.encrypted) - : (obj.encrypted as TEncrypted); - } - if (obj?.decrypted != null) { - pair.decrypted = decryptedFromJson - ? decryptedFromJson(obj.decrypted) - : (obj.decrypted as TDecrypted); - } - return pair; - } -} - -export class AccountKeys { - publicKey?: Uint8Array; - - /** @deprecated July 2023, left for migration purposes*/ - cryptoSymmetricKey?: EncryptionPair = new EncryptionPair< - string, - SymmetricCryptoKey - >(); - - toJSON() { - // If you pass undefined into fromBufferToByteString, you will get an empty string back - // which will cause all sorts of headaches down the line when you try to getPublicKey - // and expect a Uint8Array and get an empty string instead. - return Utils.merge(this, { - publicKey: this.publicKey ? Utils.fromBufferToByteString(this.publicKey) : undefined, - }); - } - - static fromJSON(obj: DeepJsonify): AccountKeys { - if (obj == null) { - return null; - } - return Object.assign(new AccountKeys(), obj, { - cryptoSymmetricKey: EncryptionPair.fromJSON( - obj?.cryptoSymmetricKey, - SymmetricCryptoKey.fromJSON, - ), - publicKey: Utils.fromByteStringToArray(obj?.publicKey), - }); - } - - static initRecordEncryptionPairsFromJSON(obj: any) { - return EncryptionPair.fromJSON(obj, (decObj: any) => { - if (obj == null) { - return null; - } - - const record: Record = {}; - for (const id in decObj) { - record[id] = SymmetricCryptoKey.fromJSON(decObj[id]); - } - return record; - }); - } -} - -export class AccountProfile { - name?: string; - email?: string; - emailVerified?: boolean; - userId?: string; - - static fromJSON(obj: Jsonify): AccountProfile { - if (obj == null) { - return null; - } - - return Object.assign(new AccountProfile(), obj); - } -} - -export class Account { - keys?: AccountKeys = new AccountKeys(); - profile?: AccountProfile = new AccountProfile(); - - constructor(init: Partial) { - Object.assign(this, { - keys: { - ...new AccountKeys(), - ...init?.keys, - }, - profile: { - ...new AccountProfile(), - ...init?.profile, - }, - }); - } - - static fromJSON(json: Jsonify): Account { - if (json == null) { - return null; - } - - return Object.assign(new Account({}), json, { - keys: AccountKeys.fromJSON(json?.keys), - profile: AccountProfile.fromJSON(json?.profile), - }); - } -} diff --git a/libs/common/src/platform/models/domain/domain-base.ts b/libs/common/src/platform/models/domain/domain-base.ts index b999a5e5d15..bab9f0f8ac7 100644 --- a/libs/common/src/platform/models/domain/domain-base.ts +++ b/libs/common/src/platform/models/domain/domain-base.ts @@ -13,7 +13,7 @@ export type DecryptedObject< > = Record & Omit; // extracts shared keys from the domain and view types -export type EncryptableKeys = (keyof D & +type EncryptableKeys = (keyof D & ConditionalKeys) & (keyof V & ConditionalKeys); diff --git a/libs/common/src/platform/models/domain/enc-array-buffer.ts b/libs/common/src/platform/models/domain/enc-array-buffer.ts index 55ecb3dfb16..e4af0215d7a 100644 --- a/libs/common/src/platform/models/domain/enc-array-buffer.ts +++ b/libs/common/src/platform/models/domain/enc-array-buffer.ts @@ -2,14 +2,13 @@ // @ts-strict-ignore import { Utils } from "../../../platform/misc/utils"; import { EncryptionType } from "../../enums"; -import { Encrypted } from "../../interfaces/encrypted"; const ENC_TYPE_LENGTH = 1; const IV_LENGTH = 16; const MAC_LENGTH = 32; const MIN_DATA_LENGTH = 1; -export class EncArrayBuffer implements Encrypted { +export class EncArrayBuffer { readonly encryptionType: EncryptionType = null; readonly dataBytes: Uint8Array = null; readonly ivBytes: Uint8Array = null; diff --git a/libs/common/src/platform/models/domain/encrypted-object.ts b/libs/common/src/platform/models/domain/encrypted-object.ts deleted file mode 100644 index 3caa7ae68d1..00000000000 --- a/libs/common/src/platform/models/domain/encrypted-object.ts +++ /dev/null @@ -1,8 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore - -export class EncryptedObject { - iv: Uint8Array; - data: Uint8Array; - mac: Uint8Array; -} diff --git a/libs/common/src/platform/models/domain/encryption-pair.spec.ts b/libs/common/src/platform/models/domain/encryption-pair.spec.ts deleted file mode 100644 index 1418c125ed6..00000000000 --- a/libs/common/src/platform/models/domain/encryption-pair.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Utils } from "../../misc/utils"; - -import { EncryptionPair } from "./account"; - -describe("EncryptionPair", () => { - describe("toJSON", () => { - it("should populate decryptedSerialized for buffer arrays", () => { - const pair = new EncryptionPair(); - pair.decrypted = Utils.fromByteStringToArray("hello").buffer; - const json = pair.toJSON(); - expect(json.decrypted).toEqual("hello"); - }); - - it("should populate decryptedSerialized for TypesArrays", () => { - const pair = new EncryptionPair(); - pair.decrypted = Utils.fromByteStringToArray("hello"); - const json = pair.toJSON(); - expect(json.decrypted).toEqual(new Uint8Array([104, 101, 108, 108, 111])); - }); - - it("should serialize encrypted and decrypted", () => { - const pair = new EncryptionPair(); - pair.encrypted = "hello"; - pair.decrypted = "world"; - const json = pair.toJSON(); - expect(json.encrypted).toEqual("hello"); - expect(json.decrypted).toEqual("world"); - }); - }); - - describe("fromJSON", () => { - it("should deserialize encrypted and decrypted", () => { - const pair = EncryptionPair.fromJSON({ - encrypted: "hello", - decrypted: "world", - }); - expect(pair.encrypted).toEqual("hello"); - expect(pair.decrypted).toEqual("world"); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/state.spec.ts b/libs/common/src/platform/models/domain/state.spec.ts deleted file mode 100644 index 55d17bded3f..00000000000 --- a/libs/common/src/platform/models/domain/state.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Account } from "./account"; -import { State } from "./state"; - -describe("state", () => { - describe("fromJSON", () => { - it("should deserialize to an instance of itself", () => { - expect(State.fromJSON({}, () => new Account({}))).toBeInstanceOf(State); - }); - - it("should always assign an object to accounts", () => { - const state = State.fromJSON({}, () => new Account({})); - expect(state.accounts).not.toBeNull(); - expect(state.accounts).toEqual({}); - }); - - it("should build an account map", () => { - const accountsSpy = jest.spyOn(Account, "fromJSON"); - const state = State.fromJSON( - { - accounts: { - userId: {}, - }, - }, - Account.fromJSON, - ); - - expect(state.accounts["userId"]).toBeInstanceOf(Account); - expect(accountsSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/libs/common/src/platform/models/domain/state.ts b/libs/common/src/platform/models/domain/state.ts deleted file mode 100644 index d9f5849a3ca..00000000000 --- a/libs/common/src/platform/models/domain/state.ts +++ /dev/null @@ -1,46 +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 { Account } from "./account"; -import { GlobalState } from "./global-state"; - -export class State< - TGlobalState extends GlobalState = GlobalState, - TAccount extends Account = Account, -> { - accounts: { [userId: string]: TAccount } = {}; - globals: TGlobalState; - - constructor(globals: TGlobalState) { - this.globals = globals; - } - - // TODO, make Jsonify work. It currently doesn't because Globals doesn't implement Jsonify. - static fromJSON( - obj: any, - accountDeserializer: (json: Jsonify) => TAccount, - ): State { - if (obj == null) { - return null; - } - - return Object.assign(new State(null), obj, { - accounts: State.buildAccountMapFromJSON(obj?.accounts, accountDeserializer), - }); - } - - private static buildAccountMapFromJSON( - jsonAccounts: { [userId: string]: Jsonify }, - accountDeserializer: (json: Jsonify) => TAccount, - ) { - if (!jsonAccounts) { - return {}; - } - const accounts: { [userId: string]: TAccount } = {}; - for (const userId in jsonAccounts) { - accounts[userId] = accountDeserializer(jsonAccounts[userId]); - } - return accounts; - } -} diff --git a/libs/common/src/platform/services/key-generation.service.ts b/libs/common/src/platform/services/key-generation.service.ts index d25be087b06..55d1f96e7df 100644 --- a/libs/common/src/platform/services/key-generation.service.ts +++ b/libs/common/src/platform/services/key-generation.service.ts @@ -1,92 +1,2 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -// 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 { KdfConfig } from "@bitwarden/key-management"; -import { PureCrypto } from "@bitwarden/sdk-internal"; - -import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service"; -import { CsprngArray } from "../../types/csprng"; -import { KeyGenerationService as KeyGenerationServiceAbstraction } from "../abstractions/key-generation.service"; -import { SdkLoadService } from "../abstractions/sdk/sdk-load.service"; -import { EncryptionType } from "../enums"; -import { Utils } from "../misc/utils"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; - -export class KeyGenerationService implements KeyGenerationServiceAbstraction { - constructor(private cryptoFunctionService: CryptoFunctionService) {} - - async createKey(bitLength: 256 | 512): Promise { - const key = await this.cryptoFunctionService.aesGenerateKey(bitLength); - return new SymmetricCryptoKey(key); - } - - async createKeyWithPurpose( - bitLength: 128 | 192 | 256 | 512, - purpose: string, - salt?: string, - ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> { - if (salt == null) { - const bytes = await this.cryptoFunctionService.randomBytes(32); - salt = Utils.fromBufferToUtf8(bytes); - } - const material = await this.cryptoFunctionService.aesGenerateKey(bitLength); - const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); - return { salt, material, derivedKey: new SymmetricCryptoKey(key) }; - } - - async deriveKeyFromMaterial( - material: CsprngArray, - salt: string, - purpose: string, - ): Promise { - const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); - return new SymmetricCryptoKey(key); - } - - async deriveKeyFromPassword( - password: string | Uint8Array, - salt: string | Uint8Array, - kdfConfig: KdfConfig, - ): Promise { - if (typeof password === "string") { - password = new TextEncoder().encode(password); - } - if (typeof salt === "string") { - salt = new TextEncoder().encode(salt); - } - - await SdkLoadService.Ready; - return new SymmetricCryptoKey( - PureCrypto.derive_kdf_material(password, salt, kdfConfig.toSdkConfig()), - ); - } - - async stretchKey(key: SymmetricCryptoKey): Promise { - // The key to be stretched is actually usually the output of a KDF, and not actually meant for AesCbc256_B64 encryption, - // but has the same key length. Only 256-bit key materials should be stretched. - if (key.inner().type != EncryptionType.AesCbc256_B64) { - throw new Error("Key passed into stretchKey is not a 256-bit key."); - } - - const newKey = new Uint8Array(64); - // Master key and pin key are always 32 bytes - const encKey = await this.cryptoFunctionService.hkdfExpand( - key.inner().encryptionKey, - "enc", - 32, - "sha256", - ); - const macKey = await this.cryptoFunctionService.hkdfExpand( - key.inner().encryptionKey, - "mac", - 32, - "sha256", - ); - - newKey.set(new Uint8Array(encKey)); - newKey.set(new Uint8Array(macKey), 32); - - return new SymmetricCryptoKey(newKey); - } -} +/** Temporary re-export. This should not be used for new imports */ +export { DefaultKeyGenerationService as KeyGenerationService } from "../../key-management/crypto/key-generation/default-key-generation.service"; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts deleted file mode 100644 index 284c8a7f2dc..00000000000 --- a/libs/common/src/platform/services/state.service.ts +++ /dev/null @@ -1,659 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { firstValueFrom, map } from "rxjs"; -import { Jsonify, JsonValue } from "type-fest"; - -import { AccountService } from "../../auth/abstractions/account.service"; -import { TokenService } from "../../auth/abstractions/token.service"; -import { BiometricKey } from "../../auth/types/biometric-key"; -import { UserId } from "../../types/guid"; -import { EnvironmentService } from "../abstractions/environment.service"; -import { LogService } from "../abstractions/log.service"; -import { - InitOptions, - StateService as StateServiceAbstraction, -} from "../abstractions/state.service"; -import { AbstractStorageService } from "../abstractions/storage.service"; -import { HtmlStorageLocation, StorageLocation } from "../enums"; -import { StateFactory } from "../factories/state-factory"; -import { Account } from "../models/domain/account"; -import { GlobalState } from "../models/domain/global-state"; -import { State } from "../models/domain/state"; -import { StorageOptions } from "../models/domain/storage-options"; - -import { MigrationRunner } from "./migration-runner"; - -const keys = { - state: "state", - stateVersion: "stateVersion", - global: "global", - tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication -}; - -const partialKeys = { - userAutoKey: "_user_auto", - userBiometricKey: "_user_biometric", - - autoKey: "_masterkey_auto", - masterKey: "_masterkey", -}; - -const DDG_SHARED_KEY = "DuckDuckGoSharedKey"; - -export class StateService< - TGlobalState extends GlobalState = GlobalState, - TAccount extends Account = Account, -> implements StateServiceAbstraction -{ - private hasBeenInited = false; - protected isRecoveredSession = false; - - // default account serializer, must be overridden by child class - protected accountDeserializer = Account.fromJSON as (json: Jsonify) => TAccount; - - constructor( - protected storageService: AbstractStorageService, - protected secureStorageService: AbstractStorageService, - protected memoryStorageService: AbstractStorageService, - protected logService: LogService, - protected stateFactory: StateFactory, - protected accountService: AccountService, - protected environmentService: EnvironmentService, - protected tokenService: TokenService, - private migrationRunner: MigrationRunner, - ) {} - - async init(initOptions: InitOptions = {}): Promise { - // Deconstruct and apply defaults - const { runMigrations = true } = initOptions; - if (this.hasBeenInited) { - return; - } - - if (runMigrations) { - await this.migrationRunner.run(); - } else { - // It may have been requested to not run the migrations but we should defensively not - // continue this method until migrations have a chance to be completed elsewhere. - await this.migrationRunner.waitForCompletion(); - } - - await this.state().then(async (state) => { - if (state == null) { - await this.setState(new State(this.createGlobals())); - } else { - this.isRecoveredSession = true; - } - }); - await this.initAccountState(); - - this.hasBeenInited = true; - } - - async initAccountState() { - if (this.isRecoveredSession) { - return; - } - - // Get all likely authenticated accounts - const authenticatedAccounts = await firstValueFrom( - this.accountService.accounts$.pipe(map((accounts) => Object.keys(accounts))), - ); - - await this.updateState(async (state) => { - for (const i in authenticatedAccounts) { - state = await this.syncAccountFromDisk(authenticatedAccounts[i]); - } - - return state; - }); - } - - async syncAccountFromDisk(userId: string): Promise> { - if (userId == null) { - return; - } - const diskAccount = await this.getAccountFromDisk({ userId: userId }); - const state = await this.updateState(async (state) => { - if (state.accounts == null) { - state.accounts = {}; - } - state.accounts[userId] = this.createAccount(); - - if (diskAccount == null) { - // Return early because we can't set the diskAccount.profile - // if diskAccount itself is null - return state; - } - - state.accounts[userId].profile = diskAccount.profile; - return state; - }); - - return state; - } - - async addAccount(account: TAccount) { - await this.updateState(async (state) => { - state.accounts[account.profile.userId] = account; - return state; - }); - await this.scaffoldNewAccountStorage(account); - } - - async clean(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); - await this.deAuthenticateAccount(options.userId); - - await this.removeAccountFromDisk(options?.userId); - await this.removeAccountFromMemory(options?.userId); - } - - /** - * user key when using the "never" option of vault timeout - */ - async getUserKeyAutoUnlock(options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "auto" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return null; - } - return await this.secureStorageService.get( - `${options.userId}${partialKeys.userAutoKey}`, - options, - ); - } - - /** - * user key when using the "never" option of vault timeout - */ - async setUserKeyAutoUnlock(value: string | null, options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "auto" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return; - } - await this.saveSecureStorageKey(partialKeys.userAutoKey, value, options); - } - - /** - * User's encrypted symmetric key when using biometrics - */ - async getUserKeyBiometric(options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "biometric" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return null; - } - return await this.secureStorageService.get( - `${options.userId}${partialKeys.userBiometricKey}`, - options, - ); - } - - async hasUserKeyBiometric(options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "biometric" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return false; - } - return await this.secureStorageService.has( - `${options.userId}${partialKeys.userBiometricKey}`, - options, - ); - } - - async setUserKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "biometric" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return; - } - await this.saveSecureStorageKey(partialKeys.userBiometricKey, value, options); - } - - async getDuckDuckGoSharedKey(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); - if (options?.userId == null) { - return null; - } - return await this.secureStorageService.get(DDG_SHARED_KEY, options); - } - - async setDuckDuckGoSharedKey(value: string, options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); - if (options?.userId == null) { - return; - } - value == null - ? await this.secureStorageService.remove(DDG_SHARED_KEY, options) - : await this.secureStorageService.save(DDG_SHARED_KEY, value, options); - } - - async setEnableDuckDuckGoBrowserIntegration( - value: boolean, - options?: StorageOptions, - ): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.enableDuckDuckGoBrowserIntegration = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - - /** - * @deprecated Use UserKey instead - */ - async getEncryptedCryptoSymmetricKey(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.keys.cryptoSymmetricKey.encrypted; - } - - async getIsAuthenticated(options?: StorageOptions): Promise { - return ( - (await this.tokenService.getAccessToken(options?.userId as UserId)) != null && - (await this.getUserId(options)) != null - ); - } - - async getUserId(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.profile?.userId; - } - - protected async getGlobals(options: StorageOptions): Promise { - let globals: TGlobalState; - if (this.useMemory(options.storageLocation)) { - globals = await this.getGlobalsFromMemory(); - } - - if (this.useDisk && globals == null) { - globals = await this.getGlobalsFromDisk(options); - } - - if (globals == null) { - globals = this.createGlobals(); - } - - return globals; - } - - protected async saveGlobals(globals: TGlobalState, options: StorageOptions) { - return this.useMemory(options.storageLocation) - ? this.saveGlobalsToMemory(globals) - : await this.saveGlobalsToDisk(globals, options); - } - - protected async getGlobalsFromMemory(): Promise { - return (await this.state()).globals; - } - - protected async getGlobalsFromDisk(options: StorageOptions): Promise { - return await this.storageService.get(keys.global, options); - } - - protected async saveGlobalsToMemory(globals: TGlobalState): Promise { - await this.updateState(async (state) => { - state.globals = globals; - return state; - }); - } - - protected async saveGlobalsToDisk(globals: TGlobalState, options: StorageOptions): Promise { - if (options.useSecureStorage) { - await this.secureStorageService.save(keys.global, globals, options); - } else { - await this.storageService.save(keys.global, globals, options); - } - } - - protected async getAccount(options: StorageOptions): Promise { - try { - let account: TAccount; - if (this.useMemory(options.storageLocation)) { - account = await this.getAccountFromMemory(options); - } - - if (this.useDisk(options.storageLocation) && account == null) { - account = await this.getAccountFromDisk(options); - } - - return account; - } catch (e) { - this.logService.error(e); - } - } - - protected async getAccountFromMemory(options: StorageOptions): Promise { - const userId = - options.userId ?? - (await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - )); - - return await this.state().then(async (state) => { - if (state.accounts == null) { - return null; - } - return state.accounts[userId]; - }); - } - - protected async getAccountFromDisk(options: StorageOptions): Promise { - const userId = - options.userId ?? - (await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - )); - - if (userId == null) { - return null; - } - - const account = options?.useSecureStorage - ? ((await this.secureStorageService.get(options.userId, options)) ?? - (await this.storageService.get( - options.userId, - this.reconcileOptions(options, { htmlStorageLocation: HtmlStorageLocation.Local }), - ))) - : await this.storageService.get(options.userId, options); - return account; - } - - protected useMemory(storageLocation: StorageLocation) { - return storageLocation === StorageLocation.Memory || storageLocation === StorageLocation.Both; - } - - protected useDisk(storageLocation: StorageLocation) { - return storageLocation === StorageLocation.Disk || storageLocation === StorageLocation.Both; - } - - protected async saveAccount( - account: TAccount, - options: StorageOptions = { - storageLocation: StorageLocation.Both, - useSecureStorage: false, - }, - ) { - return this.useMemory(options.storageLocation) - ? await this.saveAccountToMemory(account) - : await this.saveAccountToDisk(account, options); - } - - protected async saveAccountToDisk(account: TAccount, options: StorageOptions): Promise { - const storageLocation = options.useSecureStorage - ? this.secureStorageService - : this.storageService; - - await storageLocation.save(`${options.userId}`, account, options); - } - - protected async saveAccountToMemory(account: TAccount): Promise { - if ((await this.getAccountFromMemory({ userId: account.profile.userId })) !== null) { - await this.updateState((state) => { - return new Promise((resolve) => { - state.accounts[account.profile.userId] = account; - resolve(state); - }); - }); - } - } - - protected async scaffoldNewAccountStorage(account: TAccount): Promise { - // We don't want to manipulate the referenced in memory account - const deepClone = JSON.parse(JSON.stringify(account)); - await this.scaffoldNewAccountLocalStorage(deepClone); - await this.scaffoldNewAccountSessionStorage(deepClone); - await this.scaffoldNewAccountMemoryStorage(deepClone); - } - - // TODO: There is a tech debt item for splitting up these methods - only Web uses multiple storage locations in its storageService. - // For now these methods exist with some redundancy to facilitate this special web requirement. - protected async scaffoldNewAccountLocalStorage(account: TAccount): Promise { - await this.saveAccount( - account, - this.reconcileOptions( - { userId: account.profile.userId }, - await this.defaultOnDiskLocalOptions(), - ), - ); - } - - protected async scaffoldNewAccountMemoryStorage(account: TAccount): Promise { - await this.storageService.save( - account.profile.userId, - account, - await this.defaultOnDiskMemoryOptions(), - ); - await this.saveAccount( - account, - this.reconcileOptions( - { userId: account.profile.userId }, - await this.defaultOnDiskMemoryOptions(), - ), - ); - } - - protected async scaffoldNewAccountSessionStorage(account: TAccount): Promise { - await this.storageService.save( - account.profile.userId, - account, - await this.defaultOnDiskMemoryOptions(), - ); - await this.saveAccount( - account, - this.reconcileOptions({ userId: account.profile.userId }, await this.defaultOnDiskOptions()), - ); - } - - protected reconcileOptions( - requestedOptions: StorageOptions, - defaultOptions: StorageOptions, - ): StorageOptions { - if (requestedOptions == null) { - return defaultOptions; - } - requestedOptions.userId = requestedOptions?.userId ?? defaultOptions.userId; - requestedOptions.storageLocation = - requestedOptions?.storageLocation ?? defaultOptions.storageLocation; - requestedOptions.useSecureStorage = - requestedOptions?.useSecureStorage ?? defaultOptions.useSecureStorage; - requestedOptions.htmlStorageLocation = - requestedOptions?.htmlStorageLocation ?? defaultOptions.htmlStorageLocation; - requestedOptions.keySuffix = requestedOptions?.keySuffix ?? defaultOptions.keySuffix; - return requestedOptions; - } - - protected async defaultInMemoryOptions(): Promise { - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - return { - storageLocation: StorageLocation.Memory, - userId, - }; - } - - protected async defaultOnDiskOptions(): Promise { - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - return { - storageLocation: StorageLocation.Disk, - htmlStorageLocation: HtmlStorageLocation.Session, - userId, - useSecureStorage: false, - }; - } - - protected async defaultOnDiskLocalOptions(): Promise { - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - return { - storageLocation: StorageLocation.Disk, - htmlStorageLocation: HtmlStorageLocation.Local, - userId, - useSecureStorage: false, - }; - } - - protected async defaultOnDiskMemoryOptions(): Promise { - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - return { - storageLocation: StorageLocation.Disk, - htmlStorageLocation: HtmlStorageLocation.Memory, - userId, - useSecureStorage: false, - }; - } - - protected async defaultSecureStorageOptions(): Promise { - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - return { - storageLocation: StorageLocation.Disk, - useSecureStorage: true, - userId, - }; - } - - protected async getActiveUserIdFromStorage(): Promise { - return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); - } - - protected async removeAccountFromLocalStorage(userId: string = null): Promise { - userId ??= await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - const storedAccount = await this.getAccount( - this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()), - ); - await this.saveAccount( - this.resetAccount(storedAccount), - this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()), - ); - } - - protected async removeAccountFromSessionStorage(userId: string = null): Promise { - userId ??= await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - const storedAccount = await this.getAccount( - this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()), - ); - await this.saveAccount( - this.resetAccount(storedAccount), - this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()), - ); - } - - protected async removeAccountFromSecureStorage(userId: string = null): Promise { - userId ??= await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - await this.setUserKeyAutoUnlock(null, { userId: userId }); - await this.setUserKeyBiometric(null, { userId: userId }); - } - - protected async removeAccountFromMemory(userId: string = null): Promise { - userId ??= await firstValueFrom( - this.accountService.activeAccount$.pipe(map((account) => account?.id)), - ); - - await this.updateState(async (state) => { - delete state.accounts[userId]; - return state; - }); - } - - // settings persist even on reset, and are not affected by this method - protected resetAccount(account: TAccount) { - // All settings have been moved to StateProviders - return this.createAccount(); - } - - protected createAccount(init: Partial = null): TAccount { - return this.stateFactory.createAccount(init); - } - - protected createGlobals(init: Partial = null): TGlobalState { - return this.stateFactory.createGlobal(init); - } - - protected async deAuthenticateAccount(userId: string): Promise { - // We must have a manual call to clear tokens as we can't leverage state provider to clean - // up our data as we have secure storage in the mix. - await this.tokenService.clearTokens(userId as UserId); - } - - protected async removeAccountFromDisk(userId: string) { - await this.removeAccountFromSessionStorage(userId); - await this.removeAccountFromLocalStorage(userId); - await this.removeAccountFromSecureStorage(userId); - } - - protected async saveSecureStorageKey( - key: string, - value: T | null, - options?: StorageOptions, - ) { - return value == null - ? await this.secureStorageService.remove(`${options.userId}${key}`, options) - : await this.secureStorageService.save(`${options.userId}${key}`, value, options); - } - - protected async state(): Promise> { - let state = await this.memoryStorageService.get>(keys.state); - if (this.memoryStorageService.valuesRequireDeserialization) { - state = State.fromJSON(state, this.accountDeserializer); - } - return state; - } - - private async setState( - state: State, - ): Promise> { - await this.memoryStorageService.save(keys.state, state); - return state; - } - - protected async updateState( - stateUpdater: (state: State) => Promise>, - ): Promise> { - return await this.state().then(async (state) => { - const updatedState = await stateUpdater(state); - if (updatedState == null) { - throw new Error("Attempted to update state to null value"); - } - - return await this.setState(updatedState); - }); - } -} diff --git a/libs/common/src/platform/sync/core-sync.service.ts b/libs/common/src/platform/sync/core-sync.service.ts index 40419a343da..45a127c599a 100644 --- a/libs/common/src/platform/sync/core-sync.service.ts +++ b/libs/common/src/platform/sync/core-sync.service.ts @@ -9,6 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "../../abstractions/api.service"; import { AccountService } from "../../auth/abstractions/account.service"; import { AuthService } from "../../auth/abstractions/auth.service"; +import { TokenService } from "../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { SyncCipherNotification, @@ -26,7 +27,6 @@ import { SyncService } from "../../vault/abstractions/sync/sync.service.abstract import { CipherData } from "../../vault/models/data/cipher.data"; import { FolderData } from "../../vault/models/data/folder.data"; import { LogService } from "../abstractions/log.service"; -import { StateService } from "../abstractions/state.service"; import { MessageSender } from "../messaging"; import { StateProvider, SYNC_DISK, UserKeyDefinition } from "../state"; @@ -44,7 +44,7 @@ export abstract class CoreSyncService implements SyncService { syncInProgress = false; constructor( - protected readonly stateService: StateService, + readonly tokenService: TokenService, protected readonly folderService: InternalFolderService, protected readonly folderApiService: FolderApiServiceAbstraction, protected readonly messageSender: MessageSender, @@ -256,7 +256,13 @@ export abstract class CoreSyncService implements SyncService { async syncDeleteSend(notification: SyncSendNotification): Promise { this.syncStarted(); - if (await this.stateService.getIsAuthenticated()) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + if ( + activeUserId != null && + (await firstValueFrom(this.tokenService.hasAccessToken$(activeUserId))) + ) { await this.sendService.delete(notification.id); this.messageSender.send("syncedDeletedSend", { sendId: notification.id }); // TODO: Update syncCompleted userId when send service allows modification of non-active users diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index fc6b9481bd5..8929e74c635 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -36,7 +36,6 @@ import { CipherService } from "../../vault/abstractions/cipher.service"; import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction"; import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; import { LogService } from "../abstractions/log.service"; -import { StateService } from "../abstractions/state.service"; import { MessageSender } from "../messaging"; import { StateProvider } from "../state"; @@ -57,7 +56,6 @@ describe("DefaultSyncService", () => { let sendService: MockProxy; let logService: MockProxy; let keyConnectorService: MockProxy; - let stateService: MockProxy; let providerService: MockProxy; let folderApiService: MockProxy; let organizationService: MockProxy; @@ -86,7 +84,6 @@ describe("DefaultSyncService", () => { sendService = mock(); logService = mock(); keyConnectorService = mock(); - stateService = mock(); providerService = mock(); folderApiService = mock(); organizationService = mock(); @@ -113,7 +110,6 @@ describe("DefaultSyncService", () => { sendService, logService, keyConnectorService, - stateService, providerService, folderApiService, organizationService, diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 99e87383657..9ef7b432d9c 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -53,7 +53,6 @@ import { FolderData } from "../../vault/models/data/folder.data"; import { CipherResponse } from "../../vault/models/response/cipher.response"; import { FolderResponse } from "../../vault/models/response/folder.response"; import { LogService } from "../abstractions/log.service"; -import { StateService } from "../abstractions/state.service"; import { MessageSender } from "../messaging"; import { StateProvider } from "../state"; @@ -87,7 +86,6 @@ export class DefaultSyncService extends CoreSyncService { sendService: InternalSendService, logService: LogService, private keyConnectorService: KeyConnectorService, - stateService: StateService, private providerService: ProviderService, folderApiService: FolderApiServiceAbstraction, private organizationService: InternalOrganizationServiceAbstraction, @@ -96,12 +94,12 @@ export class DefaultSyncService extends CoreSyncService { private avatarService: AvatarService, private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise, private billingAccountProfileStateService: BillingAccountProfileStateService, - private tokenService: TokenService, + tokenService: TokenService, authService: AuthService, stateProvider: StateProvider, ) { super( - stateService, + tokenService, folderService, folderApiService, messageSender, diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 6edc298235d..8b080089c3c 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -12,11 +12,11 @@ import { awaitAsync, mockAccountServiceWith, } from "../../../../spec"; +import { KeyGenerationService } from "../../../key-management/crypto"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import { EnvironmentService } from "../../../platform/abstractions/environment.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { Utils } from "../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../platform/services/container.service"; diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 6e2b4391c96..2664b0d4351 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -6,10 +6,10 @@ import { Observable, concatMap, distinctUntilChanged, firstValueFrom, map } from // eslint-disable-next-line no-restricted-imports import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management"; +import { KeyGenerationService } from "../../../key-management/crypto"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; diff --git a/libs/common/src/tools/state/buffered-state.ts b/libs/common/src/tools/state/buffered-state.ts index b10ee6c7b85..c53390e4af7 100644 --- a/libs/common/src/tools/state/buffered-state.ts +++ b/libs/common/src/tools/state/buffered-state.ts @@ -45,12 +45,14 @@ export class BufferedState implements SingleUserState map((dependency) => [key.shouldOverwrite(dependency), dependency] as const), ); const overwrite$ = combineLatest([hasValue$, overwriteDependency$]).pipe( - concatMap(async ([hasValue, [shouldOverwrite, dependency]]) => { - if (hasValue && shouldOverwrite) { - await this.overwriteOutput(dependency); - } - return [false, null] as const; - }), + concatMap( + async ([hasValue, [shouldOverwrite, dependency]]): Promise => { + if (hasValue && shouldOverwrite) { + await this.overwriteOutput(dependency); + } + return [false, null] as const; + }, + ), ); // drive overwrites only when there's a subscription; @@ -71,7 +73,7 @@ export class BufferedState implements SingleUserState private async overwriteOutput(dependency: Dependency) { // take the latest value from the buffer let buffered: Input; - await this.bufferedState.update((state) => { + await this.bufferedState.update((state): Input | null => { buffered = state ?? null; return null; }); diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index be72d618dee..2088f50d1cc 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -20,7 +20,6 @@ import { EncString } from "../../key-management/crypto/models/enc-string"; import { UriMatchStrategy } from "../../models/domain/domain-service"; import { ConfigService } from "../../platform/abstractions/config/config.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; -import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; @@ -94,7 +93,6 @@ let accountService: FakeAccountService; describe("Cipher Service", () => { const keyService = mock(); - const stateService = mock(); const autofillSettingsService = mock(); const domainSettingsService = mock(); const apiService = mock(); @@ -127,7 +125,6 @@ describe("Cipher Service", () => { apiService, i18nService, searchService, - stateService, autofillSettingsService, encryptService, cipherFileUploadService, @@ -470,8 +467,6 @@ describe("Cipher Service", () => { searchService.indexedEntityId$.mockReturnValue(of(null)); - stateService.getUserId.mockResolvedValue(mockUserId); - const keys = { userKey: originalUserKey } as CipherDecryptionKeys; keyService.cipherDecryptionKeys$.mockReturnValue(of(keys)); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index ba986dcc763..2f225d4dfc5 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -31,7 +31,6 @@ import { ListResponse } from "../../models/response/list.response"; import { View } from "../../models/view/view"; import { ConfigService } from "../../platform/abstractions/config/config.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; -import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; import Domain from "../../platform/models/domain/domain-base"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; @@ -110,7 +109,6 @@ export class CipherService implements CipherServiceAbstraction { private apiService: ApiService, private i18nService: I18nService, private searchService: SearchService, - private stateService: StateService, private autofillSettingsService: AutofillSettingsServiceAbstraction, private encryptService: EncryptService, private cipherFileUploadService: CipherFileUploadService, @@ -672,13 +670,14 @@ export class CipherService implements CipherServiceAbstraction { } async getManyFromApiForOrganization(organizationId: string): Promise { - const response = await this.apiService.send( + const r = await this.apiService.send( "GET", "/ciphers/organization-details/assigned?organizationId=" + organizationId, null, true, true, ); + const response = new ListResponse(r, CipherResponse); return this.decryptOrganizationCiphersResponse(response, organizationId); } diff --git a/libs/common/src/vault/services/restricted-item-types.service.spec.ts b/libs/common/src/vault/services/restricted-item-types.service.spec.ts index 9b549665184..f412de08046 100644 --- a/libs/common/src/vault/services/restricted-item-types.service.spec.ts +++ b/libs/common/src/vault/services/restricted-item-types.service.spec.ts @@ -67,6 +67,13 @@ describe("RestrictedItemTypesService", () => { expect(result).toEqual([]); }); + it("emits empty array if no account is active", async () => { + accountService.activeAccount$ = of(null); + + const result = await firstValueFrom(service.restricted$); + expect(result).toEqual([]); + }); + it("emits empty array if no organizations exist", async () => { organizationService.organizations$.mockReturnValue(of([])); policyService.policiesByType$.mockReturnValue(of([])); diff --git a/libs/common/src/vault/services/restricted-item-types.service.ts b/libs/common/src/vault/services/restricted-item-types.service.ts index 8ccc94d365c..0e4c9b87716 100644 --- a/libs/common/src/vault/services/restricted-item-types.service.ts +++ b/libs/common/src/vault/services/restricted-item-types.service.ts @@ -5,7 +5,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -32,39 +32,43 @@ export class RestrictedItemTypesService { return of([]); } return this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => - combineLatest([ + getOptionalUserId, + switchMap((userId) => { + if (userId == null) { + return of([]); // No user logged in, no restrictions + } + return combineLatest([ this.organizationService.organizations$(userId), this.policyService.policiesByType$(PolicyType.RestrictedItemTypes, userId), - ]), - ), - map(([orgs, enabledPolicies]) => { - // Helper to extract restricted types, defaulting to [Card] - const restrictedTypes = (p: (typeof enabledPolicies)[number]) => - (p.data as CipherType[]) ?? [CipherType.Card]; + ]).pipe( + map(([orgs, enabledPolicies]) => { + // Helper to extract restricted types, defaulting to [Card] + const restrictedTypes = (p: (typeof enabledPolicies)[number]) => + (p.data as CipherType[]) ?? [CipherType.Card]; - // Union across all enabled policies - const allRestrictedTypes = Array.from( - new Set(enabledPolicies.flatMap(restrictedTypes)), + // Union across all enabled policies + const allRestrictedTypes = Array.from( + new Set(enabledPolicies.flatMap(restrictedTypes)), + ); + + return allRestrictedTypes.map((cipherType) => { + // Determine which orgs allow viewing this type + const allowViewOrgIds = orgs + .filter((org) => { + const orgPolicy = enabledPolicies.find((p) => p.organizationId === org.id); + // no policy for this org => allows everything + if (!orgPolicy) { + return true; + } + // if this type not in their restricted list => they allow it + return !restrictedTypes(orgPolicy).includes(cipherType); + }) + .map((org) => org.id); + + return { cipherType, allowViewOrgIds }; + }); + }), ); - - return allRestrictedTypes.map((cipherType) => { - // Determine which orgs allow viewing this type - const allowViewOrgIds = orgs - .filter((org) => { - const orgPolicy = enabledPolicies.find((p) => p.organizationId === org.id); - // no policy for this org => allows everything - if (!orgPolicy) { - return true; - } - // if this type not in their restricted list => they allow it - return !restrictedTypes(orgPolicy).includes(cipherType); - }) - .map((org) => org.id); - - return { cipherType, allowViewOrgIds }; - }); }), ); }), diff --git a/libs/components/src/a11y/a11y-title.directive.ts b/libs/components/src/a11y/a11y-title.directive.ts index 80486ab9bcf..8bcff2cff4e 100644 --- a/libs/components/src/a11y/a11y-title.directive.ts +++ b/libs/components/src/a11y/a11y-title.directive.ts @@ -1,39 +1,24 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core"; +import { Directive, effect, ElementRef, input, Renderer2 } from "@angular/core"; @Directive({ selector: "[appA11yTitle]", }) -export class A11yTitleDirective implements OnInit { - // TODO: Skipped for signal migration because: - // Accessor inputs cannot be migrated as they are too complex. - @Input() set appA11yTitle(title: string) { - this.title = title; - this.setAttributes(); - } - - private title: string; - private originalTitle: string | null; - private originalAriaLabel: string | null; +export class A11yTitleDirective { + title = input.required({ alias: "appA11yTitle" }); constructor( private el: ElementRef, private renderer: Renderer2, - ) {} - - ngOnInit() { - this.originalTitle = this.el.nativeElement.getAttribute("title"); - this.originalAriaLabel = this.el.nativeElement.getAttribute("aria-label"); - this.setAttributes(); - } - - private setAttributes() { - if (this.originalTitle === null) { - this.renderer.setAttribute(this.el.nativeElement, "title", this.title); - } - if (this.originalAriaLabel === null) { - this.renderer.setAttribute(this.el.nativeElement, "aria-label", this.title); - } + ) { + const originalTitle = this.el.nativeElement.getAttribute("title"); + const originalAriaLabel = this.el.nativeElement.getAttribute("aria-label"); + effect(() => { + if (originalTitle === null) { + this.renderer.setAttribute(this.el.nativeElement, "title", this.title()); + } + if (originalAriaLabel === null) { + this.renderer.setAttribute(this.el.nativeElement, "aria-label", this.title()); + } + }); } } 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 4b570df9814..33b90f7eb8a 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -1,9 +1,7 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { ChangeDetectorRef, Component, OnInit, inject, DestroyRef } from "@angular/core"; +import { ChangeDetectorRef, Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; -import { filter, switchMap, tap } from "rxjs"; +import { Subject, filter, of, switchMap, tap } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -53,13 +51,15 @@ export interface AnonLayoutWrapperData { imports: [AnonLayoutComponent, RouterModule], }) export class AnonLayoutWrapperComponent implements OnInit { - protected pageTitle: string; - protected pageSubtitle: string; - protected pageIcon: Icon; - protected showReadonlyHostname: boolean; - protected maxWidth: AnonLayoutMaxWidth; - protected hideCardWrapper: boolean; - protected hideIcon: boolean = false; + private destroy$ = new Subject(); + + protected pageTitle?: string | null; + protected pageSubtitle?: string | null; + protected pageIcon?: Icon | null; + protected showReadonlyHostname?: boolean | null; + protected maxWidth?: AnonLayoutMaxWidth | null; + protected hideCardWrapper?: boolean | null; + protected hideIcon?: boolean | null; constructor( private router: Router, @@ -85,7 +85,7 @@ export class AnonLayoutWrapperComponent implements OnInit { filter((event) => event instanceof NavigationEnd), // reset page data on page changes tap(() => this.resetPageData()), - switchMap(() => this.route.firstChild?.data || null), + switchMap(() => this.route.firstChild?.data || of(null)), takeUntilDestroyed(this.destroyRef), ) .subscribe((firstChildRouteData: Data | null) => { @@ -93,7 +93,7 @@ export class AnonLayoutWrapperComponent implements OnInit { }); } - private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData: Data | null) { + private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData?: Data | null) { if (!firstChildRouteData) { return; } diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index 355f3aef6eb..8b002cae7f0 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, @@ -56,8 +54,8 @@ export class AnonLayoutComponent implements OnInit, OnChanges { protected logo = BitwardenLogo; protected year: string; protected clientType: ClientType; - protected hostname: string; - protected version: string; + protected hostname?: string; + protected version?: string; protected hideYearAndVersion = false; diff --git a/libs/components/src/async-actions/bit-action.directive.ts b/libs/components/src/async-actions/bit-action.directive.ts index 2de8a16dd31..c89ba932583 100644 --- a/libs/components/src/async-actions/bit-action.directive.ts +++ b/libs/components/src/async-actions/bit-action.directive.ts @@ -1,6 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, HostListener, model, Optional, inject, DestroyRef } from "@angular/core"; +import { DestroyRef, Directive, HostListener, inject, model, Optional } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { BehaviorSubject, finalize, tap } from "rxjs"; @@ -38,7 +36,7 @@ export class BitActionDirective { disabled = false; - readonly handler = model(undefined, { alias: "bitAction" }); + readonly handler = model.required({ alias: "bitAction" }); private readonly destroyRef = inject(DestroyRef); diff --git a/libs/components/src/async-actions/bit-submit.directive.ts b/libs/components/src/async-actions/bit-submit.directive.ts index e7911196fc3..2d662493cd3 100644 --- a/libs/components/src/async-actions/bit-submit.directive.ts +++ b/libs/components/src/async-actions/bit-submit.directive.ts @@ -1,6 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, OnInit, Optional, input, inject, DestroyRef } from "@angular/core"; +import { DestroyRef, Directive, OnInit, Optional, inject, input } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormGroupDirective } from "@angular/forms"; import { BehaviorSubject, catchError, filter, of, switchMap } from "rxjs"; @@ -22,7 +20,7 @@ export class BitSubmitDirective implements OnInit { private _loading$ = new BehaviorSubject(false); private _disabled$ = new BehaviorSubject(false); - readonly handler = input(undefined, { alias: "bitSubmit" }); + readonly handler = input.required({ alias: "bitSubmit" }); readonly allowDisabledFormSubmit = input(false); @@ -63,7 +61,7 @@ export class BitSubmitDirective implements OnInit { ngOnInit(): void { this.formGroupDirective.statusChanges - .pipe(takeUntilDestroyed(this.destroyRef)) + ?.pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((c) => { if (this.allowDisabledFormSubmit()) { this._disabled$.next(false); diff --git a/libs/components/src/async-actions/form-button.directive.ts b/libs/components/src/async-actions/form-button.directive.ts index dc8c095fd18..a1d28f627d5 100644 --- a/libs/components/src/async-actions/form-button.directive.ts +++ b/libs/components/src/async-actions/form-button.directive.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Directive, Optional, input } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index 7c41d4e59a0..59a9492f8c8 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { NgClass } from "@angular/common"; import { Component, OnChanges, input } from "@angular/core"; import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; @@ -41,7 +39,7 @@ export class AvatarComponent implements OnChanges { private svgFontSize = 20; private svgFontWeight = 300; private svgSize = 48; - src: SafeResourceUrl; + src?: SafeResourceUrl; constructor(public sanitizer: DomSanitizer) {} @@ -50,14 +48,20 @@ export class AvatarComponent implements OnChanges { } get classList() { - return ["tw-rounded-full"] + return ["tw-rounded-full", "tw-inline"] .concat(SizeClasses[this.size()] ?? []) .concat(this.border() ? ["tw-border", "tw-border-solid", "tw-border-secondary-600"] : []); } private generate() { - let chars: string = null; - const upperCaseText = this.text()?.toUpperCase() ?? ""; + const color = this.color(); + const text = this.text(); + const id = this.id(); + if (!text && !color && !id) { + throw new Error("Must supply `text`, `color`, or `id` input."); + } + let chars: string | null = null; + const upperCaseText = text?.toUpperCase() ?? ""; chars = this.getFirstLetters(upperCaseText, this.svgCharCount); @@ -66,18 +70,17 @@ export class AvatarComponent implements OnChanges { } // If the chars contain an emoji, only show it. - if (chars.match(Utils.regexpEmojiPresentation)) { - chars = chars.match(Utils.regexpEmojiPresentation)[0]; + const emojiMatch = chars.match(Utils.regexpEmojiPresentation); + if (emojiMatch) { + chars = emojiMatch[0]; } let svg: HTMLElement; - let hexColor = this.color(); - - const id = this.id(); - if (!Utils.isNullOrWhitespace(this.color())) { + let hexColor = color ?? ""; + if (!Utils.isNullOrWhitespace(hexColor)) { svg = this.createSvgElement(this.svgSize, hexColor); - } else if (!Utils.isNullOrWhitespace(id)) { - hexColor = Utils.stringToColor(id.toString()); + } else if (!Utils.isNullOrWhitespace(id ?? "")) { + hexColor = Utils.stringToColor(id!.toString()); svg = this.createSvgElement(this.svgSize, hexColor); } else { hexColor = Utils.stringToColor(upperCaseText); @@ -95,7 +98,7 @@ export class AvatarComponent implements OnChanges { ); } - private getFirstLetters(data: string, count: number): string { + private getFirstLetters(data: string, count: number): string | null { const parts = data.split(" "); if (parts.length > 1) { let text = ""; diff --git a/libs/components/src/breadcrumbs/breadcrumb.component.ts b/libs/components/src/breadcrumbs/breadcrumb.component.ts index 54678f3e4ee..783cb2655f7 100644 --- a/libs/components/src/breadcrumbs/breadcrumb.component.ts +++ b/libs/components/src/breadcrumbs/breadcrumb.component.ts @@ -1,7 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore - -import { Component, EventEmitter, Output, TemplateRef, ViewChild, input } from "@angular/core"; +import { Component, EventEmitter, Output, TemplateRef, input, viewChild } from "@angular/core"; import { QueryParamsHandling } from "@angular/router"; @Component({ @@ -20,7 +17,7 @@ export class BreadcrumbComponent { @Output() click = new EventEmitter(); - @ViewChild(TemplateRef, { static: true }) content: TemplateRef; + readonly content = viewChild(TemplateRef); onClick(args: unknown) { this.click.next(args); diff --git a/libs/components/src/breadcrumbs/breadcrumbs.component.html b/libs/components/src/breadcrumbs/breadcrumbs.component.html index 820b100afd3..d062e82548e 100644 --- a/libs/components/src/breadcrumbs/breadcrumbs.component.html +++ b/libs/components/src/breadcrumbs/breadcrumbs.component.html @@ -8,7 +8,7 @@ [queryParams]="breadcrumb.queryParams()" [queryParamsHandling]="breadcrumb.queryParamsHandling()" > - + } @else { } @if (!last) { @@ -46,11 +46,11 @@ [queryParams]="breadcrumb.queryParams()" [queryParamsHandling]="breadcrumb.queryParamsHandling()" > - + } @else { } } @@ -66,7 +66,7 @@ [queryParams]="breadcrumb.queryParams()" [queryParamsHandling]="breadcrumb.queryParamsHandling()" > - + } @else { } @if (!last) { diff --git a/libs/components/src/callout/callout.component.html b/libs/components/src/callout/callout.component.html index b990e57a767..b98679766d5 100644 --- a/libs/components/src/callout/callout.component.html +++ b/libs/components/src/callout/callout.component.html @@ -1,6 +1,6 @@
@@ -109,11 +109,11 @@
} -@switch (input.hasError) { +@switch (input().hasError) { @case (false) { } @case (true) { - + } } diff --git a/libs/components/src/form-field/form-field.component.ts b/libs/components/src/form-field/form-field.component.ts index 660fcd5c8d6..26038caa466 100644 --- a/libs/components/src/form-field/form-field.component.ts +++ b/libs/components/src/form-field/form-field.component.ts @@ -1,18 +1,16 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { AfterContentChecked, booleanAttribute, Component, - ContentChild, ElementRef, HostBinding, HostListener, - ViewChild, signal, input, Input, + contentChild, + viewChild, } from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -30,14 +28,14 @@ import { BitFormFieldControl } from "./form-field-control"; imports: [CommonModule, BitErrorComponent, I18nPipe], }) export class BitFormFieldComponent implements AfterContentChecked { - @ContentChild(BitFormFieldControl) input: BitFormFieldControl; - @ContentChild(BitHintComponent) hint: BitHintComponent; - @ContentChild(BitLabel) label: BitLabel; + readonly input = contentChild.required(BitFormFieldControl); + readonly hint = contentChild(BitHintComponent); + readonly label = contentChild(BitLabel); - @ViewChild("prefixContainer") prefixContainer: ElementRef; - @ViewChild("suffixContainer") suffixContainer: ElementRef; + readonly prefixContainer = viewChild>("prefixContainer"); + readonly suffixContainer = viewChild>("suffixContainer"); - @ViewChild(BitErrorComponent) error: BitErrorComponent; + readonly error = viewChild(BitErrorComponent); readonly disableMargin = input(false, { transform: booleanAttribute }); @@ -54,7 +52,7 @@ export class BitFormFieldComponent implements AfterContentChecked { const shouldFocusBorderAppear = this.defaultContentIsFocused(); const groupClasses = [ - this.input.hasError + this.input().hasError ? "group-hover/bit-form-field:tw-border-danger-700" : "group-hover/bit-form-field:tw-border-primary-600", // the next 2 selectors override the above hover selectors when the input (or text area) is non-interactive (i.e. readonly, disabled) @@ -68,7 +66,7 @@ export class BitFormFieldComponent implements AfterContentChecked { : "", ]; - const baseInputBorderClasses = inputBorderClasses(this.input.hasError); + const baseInputBorderClasses = inputBorderClasses(this.input().hasError); const borderClasses = baseInputBorderClasses.concat(groupClasses); @@ -100,19 +98,21 @@ export class BitFormFieldComponent implements AfterContentChecked { } protected get readOnly(): boolean { - return this.input.readOnly; + return !!this.input().readOnly; } ngAfterContentChecked(): void { - if (this.error) { - this.input.ariaDescribedBy = this.error.id; - } else if (this.hint) { - this.input.ariaDescribedBy = this.hint.id; + const error = this.error(); + const hint = this.hint(); + if (error) { + this.input().ariaDescribedBy = error.id; + } else if (hint) { + this.input().ariaDescribedBy = hint.id; } else { - this.input.ariaDescribedBy = undefined; + this.input().ariaDescribedBy = undefined; } - this.prefixHasChildren.set(this.prefixContainer?.nativeElement.childElementCount > 0); - this.suffixHasChildren.set(this.suffixContainer?.nativeElement.childElementCount > 0); + this.prefixHasChildren.set((this.prefixContainer()?.nativeElement.childElementCount ?? 0) > 0); + this.suffixHasChildren.set((this.suffixContainer()?.nativeElement.childElementCount ?? 0) > 0); } } diff --git a/libs/components/src/form-field/form-field.stories.ts b/libs/components/src/form-field/form-field.stories.ts index 9fe8b057d4e..e070765ec8a 100644 --- a/libs/components/src/form-field/form-field.stories.ts +++ b/libs/components/src/form-field/form-field.stories.ts @@ -1,7 +1,4 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { TextFieldModule } from "@angular/cdk/text-field"; -import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core"; import { AbstractControl, UntypedFormBuilder, @@ -15,6 +12,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { A11yTitleDirective } from "../a11y/a11y-title.directive"; import { AsyncActionsModule } from "../async-actions"; import { BadgeModule } from "../badge"; import { ButtonModule } from "../button"; @@ -31,41 +29,6 @@ import { I18nMockService } from "../utils/i18n-mock.service"; import { BitFormFieldComponent } from "./form-field.component"; import { FormFieldModule } from "./form-field.module"; -// TOOD: This solves a circular dependency between components and angular. -@Directive({ - selector: "[appA11yTitle]", -}) -export class A11yTitleDirective implements OnInit { - @Input() set appA11yTitle(title: string) { - this.title = title; - this.setAttributes(); - } - - private title: string; - private originalTitle: string | null; - private originalAriaLabel: string | null; - - constructor( - private el: ElementRef, - private renderer: Renderer2, - ) {} - - ngOnInit() { - this.originalTitle = this.el.nativeElement.getAttribute("title"); - this.originalAriaLabel = this.el.nativeElement.getAttribute("aria-label"); - this.setAttributes(); - } - - private setAttributes() { - if (this.originalTitle === null) { - this.renderer.setAttribute(this.el.nativeElement, "title", this.title); - } - if (this.originalAriaLabel === null) { - this.renderer.setAttribute(this.el.nativeElement, "aria-label", this.title); - } - } -} - export default { title: "Component Library/Form/Field", component: BitFormFieldComponent, diff --git a/libs/components/src/form-field/password-input-toggle.directive.ts b/libs/components/src/form-field/password-input-toggle.directive.ts index e0fa4165cf8..251878f5cef 100644 --- a/libs/components/src/form-field/password-input-toggle.directive.ts +++ b/libs/components/src/form-field/password-input-toggle.directive.ts @@ -57,17 +57,19 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan } ngAfterContentInit(): void { - if (this.formField.input?.type) { - this.toggled.set(this.formField.input.type() !== "password"); + const input = this.formField.input(); + if (input?.type) { + this.toggled.set(input.type() !== "password"); } this.button.icon.set(this.icon); } private update() { this.button.icon.set(this.icon); - if (this.formField.input?.type != null) { - this.formField.input.type.set(this.toggled() ? "text" : "password"); - this.formField?.input?.spellcheck?.set(this.toggled() ? false : undefined); + const input = this.formField.input(); + if (input?.type != null) { + input.type.set(this.toggled() ? "text" : "password"); + input?.spellcheck?.set(this.toggled() ? false : undefined); } } } diff --git a/libs/components/src/form-field/password-input-toggle.spec.ts b/libs/components/src/form-field/password-input-toggle.spec.ts index 6b4c621f65c..95110f2bd93 100644 --- a/libs/components/src/form-field/password-input-toggle.spec.ts +++ b/libs/components/src/form-field/password-input-toggle.spec.ts @@ -55,7 +55,7 @@ describe("PasswordInputToggle", () => { button = buttonEl.componentInstance; const formFieldEl = fixture.debugElement.query(By.directive(BitFormFieldComponent)); const formField: BitFormFieldComponent = formFieldEl.componentInstance; - input = formField.input; + input = formField.input(); }); describe("initial state", () => { diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index db1f1f40192..5f5de1df3ef 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { NgClass } from "@angular/common"; import { Component, computed, ElementRef, HostBinding, input, model } from "@angular/core"; import { toObservable, toSignal } from "@angular/core/rxjs-interop"; @@ -90,7 +88,7 @@ const sizes: Record = { }, }) export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { - readonly icon = model(undefined, { alias: "bitIconButton" }); + readonly icon = model.required({ alias: "bitIconButton" }); readonly buttonType = input("main"); diff --git a/libs/components/src/input/autofocus.directive.ts b/libs/components/src/input/autofocus.directive.ts index 4a5ce2806a3..a4791a51f01 100644 --- a/libs/components/src/input/autofocus.directive.ts +++ b/libs/components/src/input/autofocus.directive.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { AfterContentChecked, booleanAttribute, @@ -73,11 +71,13 @@ export class AutofocusDirective implements AfterContentChecked { private focus() { const el = this.getElement(); - el.focus(); - this.focused = el === document.activeElement; + if (el) { + el.focus(); + this.focused = el === document.activeElement; + } } - private getElement() { + private getElement(): HTMLElement | undefined { if (this.focusableElement) { return this.focusableElement.getFocusTarget(); } diff --git a/libs/components/src/input/input.directive.ts b/libs/components/src/input/input.directive.ts index 58d9fd6769b..465736f7baa 100644 --- a/libs/components/src/input/input.directive.ts +++ b/libs/components/src/input/input.directive.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Directive, ElementRef, @@ -45,6 +43,7 @@ export class BitInputDirective implements BitFormFieldControl { "tw-block", "tw-w-full", "tw-h-full", + "tw-px-1", "tw-text-main", "tw-placeholder-text-muted", "tw-bg-background", @@ -62,7 +61,7 @@ export class BitInputDirective implements BitFormFieldControl { readonly id = input(`bit-input-${nextId++}`); - @HostBinding("attr.aria-describedby") ariaDescribedBy: string; + @HostBinding("attr.aria-describedby") ariaDescribedBy?: string; @HostBinding("attr.aria-invalid") get ariaInvalid() { return this.hasError ? true : undefined; @@ -82,7 +81,7 @@ export class BitInputDirective implements BitFormFieldControl { set required(value: any) { this._required = value != null && value !== false; } - private _required: boolean; + private _required?: boolean; readonly hasPrefix = input(false); readonly hasSuffix = input(false); @@ -100,19 +99,20 @@ export class BitInputDirective implements BitFormFieldControl { get hasError() { if (this.showErrorsWhenDisabled()) { - return ( + return !!( (this.ngControl?.status === "INVALID" || this.ngControl?.status === "DISABLED") && this.ngControl?.touched && this.ngControl?.errors != null ); } else { - return this.ngControl?.status === "INVALID" && this.ngControl?.touched; + return !!(this.ngControl?.status === "INVALID" && this.ngControl?.touched); } } get error(): [string, any] { - const key = Object.keys(this.ngControl.errors)[0]; - return [key, this.ngControl.errors[key]]; + const errors = this.ngControl.errors ?? {}; + const key = Object.keys(errors)[0]; + return [key, errors[key]]; } constructor( diff --git a/libs/components/src/item/item-content.component.ts b/libs/components/src/item/item-content.component.ts index 281f741e5d2..1f4ac73c77e 100644 --- a/libs/components/src/item/item-content.component.ts +++ b/libs/components/src/item/item-content.component.ts @@ -1,6 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore - import { NgClass } from "@angular/common"; import { AfterContentChecked, @@ -8,8 +5,8 @@ import { Component, ElementRef, signal, - ViewChild, input, + viewChild, } from "@angular/core"; import { TypographyModule } from "../typography"; @@ -30,7 +27,7 @@ import { TypographyModule } from "../typography"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ItemContentComponent implements AfterContentChecked { - @ViewChild("endSlot") endSlot: ElementRef; + readonly endSlot = viewChild>("endSlot"); protected endSlotHasChildren = signal(false); @@ -42,6 +39,6 @@ export class ItemContentComponent implements AfterContentChecked { readonly truncate = input(true); ngAfterContentChecked(): void { - this.endSlotHasChildren.set(this.endSlot?.nativeElement.childElementCount > 0); + this.endSlotHasChildren.set((this.endSlot()?.nativeElement.childElementCount ?? 0) > 0); } } diff --git a/libs/components/src/menu/menu-trigger-for.directive.ts b/libs/components/src/menu/menu-trigger-for.directive.ts index 94353299c5d..2eab12995ea 100644 --- a/libs/components/src/menu/menu-trigger-for.directive.ts +++ b/libs/components/src/menu/menu-trigger-for.directive.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { hasModifierKey } from "@angular/cdk/keycodes"; import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; import { TemplatePortal } from "@angular/cdk/portal"; @@ -31,9 +29,9 @@ export class MenuTriggerForDirective implements OnDestroy { readonly role = input("button"); - readonly menu = input(undefined, { alias: "bitMenuTriggerFor" }); + readonly menu = input.required({ alias: "bitMenuTriggerFor" }); - private overlayRef: OverlayRef; + private overlayRef: OverlayRef | null = null; private defaultMenuConfig: OverlayConfig = { panelClass: "bit-menu-panel", hasBackdrop: true, @@ -52,8 +50,8 @@ export class MenuTriggerForDirective implements OnDestroy { .withFlexibleDimensions(false) .withPush(true), }; - private closedEventsSub: Subscription; - private keyDownEventsSub: Subscription; + private closedEventsSub: Subscription | null = null; + private keyDownEventsSub: Subscription | null = null; constructor( private elementRef: ElementRef, @@ -78,28 +76,30 @@ export class MenuTriggerForDirective implements OnDestroy { this.isOpen = true; this.overlayRef = this.overlay.create(this.defaultMenuConfig); - const templatePortal = new TemplatePortal(menu.templateRef, this.viewContainerRef); + const templatePortal = new TemplatePortal(menu.templateRef(), this.viewContainerRef); this.overlayRef.attach(templatePortal); - this.closedEventsSub = this.getClosedEvents().subscribe((event: KeyboardEvent | undefined) => { - // Closing the menu is handled in this.destroyMenu, so we want to prevent the escape key - // from doing its normal default action, which would otherwise cause a parent component - // (like a dialog) or extension window to close - if (event?.key === "Escape" && !hasModifierKey(event)) { - event.preventDefault(); - } + this.closedEventsSub = + this.getClosedEvents()?.subscribe((event: KeyboardEvent | undefined) => { + // Closing the menu is handled in this.destroyMenu, so we want to prevent the escape key + // from doing its normal default action, which would otherwise cause a parent component + // (like a dialog) or extension window to close + if (event?.key === "Escape" && !hasModifierKey(event)) { + event.preventDefault(); + } + + if (event?.key && ["Tab", "Escape"].includes(event.key)) { + // Required to ensure tab order resumes correctly + this.elementRef.nativeElement.focus(); + } + this.destroyMenu(); + }) ?? null; - if (["Tab", "Escape"].includes(event?.key)) { - // Required to ensure tab order resumes correctly - this.elementRef.nativeElement.focus(); - } - this.destroyMenu(); - }); if (menu.keyManager) { menu.keyManager.setFirstItemActive(); this.keyDownEventsSub = this.overlayRef .keydownEvents() - .subscribe((event: KeyboardEvent) => this.menu().keyManager.onKeydown(event)); + .subscribe((event: KeyboardEvent) => this.menu().keyManager?.onKeydown(event)); } } @@ -113,7 +113,10 @@ export class MenuTriggerForDirective implements OnDestroy { this.menu().closed.emit(); } - private getClosedEvents(): Observable { + private getClosedEvents(): Observable | null { + if (!this.overlayRef) { + return null; + } const detachments = this.overlayRef.detachments(); const escKey = this.overlayRef.keydownEvents().pipe( filter((event: KeyboardEvent) => { diff --git a/libs/components/src/menu/menu.component.ts b/libs/components/src/menu/menu.component.ts index 0a76d59a09c..3cc4de9f90f 100644 --- a/libs/components/src/menu/menu.component.ts +++ b/libs/components/src/menu/menu.component.ts @@ -1,16 +1,13 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { FocusKeyManager, CdkTrapFocus } from "@angular/cdk/a11y"; import { Component, Output, TemplateRef, - ViewChild, EventEmitter, - ContentChildren, - QueryList, AfterContentInit, input, + viewChild, + contentChildren, } from "@angular/core"; import { MenuItemDirective } from "./menu-item.directive"; @@ -22,10 +19,9 @@ import { MenuItemDirective } from "./menu-item.directive"; imports: [CdkTrapFocus], }) export class MenuComponent implements AfterContentInit { - @ViewChild(TemplateRef) templateRef: TemplateRef; + readonly templateRef = viewChild.required(TemplateRef); @Output() closed = new EventEmitter(); - @ContentChildren(MenuItemDirective, { descendants: true }) - menuItems: QueryList; + readonly menuItems = contentChildren(MenuItemDirective, { descendants: true }); keyManager?: FocusKeyManager; readonly ariaRole = input<"menu" | "dialog">("menu"); @@ -34,9 +30,9 @@ export class MenuComponent implements AfterContentInit { ngAfterContentInit() { if (this.ariaRole() === "menu") { - this.keyManager = new FocusKeyManager(this.menuItems) + this.keyManager = new FocusKeyManager(this.menuItems()) .withWrap() - .skipPredicate((item) => item.disabled); + .skipPredicate((item) => !!item.disabled); } } } diff --git a/libs/components/src/menu/menu.stories.ts b/libs/components/src/menu/menu.stories.ts index 7c4894cbb2f..b29613061b8 100644 --- a/libs/components/src/menu/menu.stories.ts +++ b/libs/components/src/menu/menu.stories.ts @@ -46,7 +46,7 @@ export const OpenMenu: Story = {
- +
`, diff --git a/libs/components/src/multi-select/multi-select.component.ts b/libs/components/src/multi-select/multi-select.component.ts index af1fd8bab42..3e72318ba35 100644 --- a/libs/components/src/multi-select/multi-select.component.ts +++ b/libs/components/src/multi-select/multi-select.component.ts @@ -1,12 +1,9 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { hasModifierKey } from "@angular/cdk/keycodes"; import { Component, Input, OnInit, Output, - ViewChild, EventEmitter, HostBinding, Optional, @@ -14,6 +11,7 @@ import { input, model, booleanAttribute, + viewChild, } from "@angular/core"; import { ControlValueAccessor, @@ -48,10 +46,10 @@ let nextId = 0; * This component has been implemented to only support Multi-select list events */ export class MultiSelectComponent implements OnInit, BitFormFieldControl, ControlValueAccessor { - @ViewChild(NgSelectComponent) select: NgSelectComponent; + readonly select = viewChild.required(NgSelectComponent); // Parent component should only pass selectable items (complete list - selected items = baseItems) - readonly baseItems = model(); + readonly baseItems = model.required(); // Defaults to native ng-select behavior - set to "true" to clear selected items on dropdown close readonly removeSelectedItems = input(false); readonly placeholder = model(); @@ -61,10 +59,10 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro @Input({ transform: booleanAttribute }) disabled?: boolean; // Internal tracking of selected items - protected selectedItems: SelectItemView[]; + protected selectedItems: SelectItemView[] | null = null; // Default values for our implementation - loadingText: string; + loadingText?: string; protected searchInputId = `search-input-${nextId++}`; @@ -95,13 +93,14 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro /** Function for customizing keyboard navigation */ /** Needs to be arrow function to retain `this` scope. */ keyDown = (event: KeyboardEvent) => { - if (!this.select.isOpen && event.key === "Enter" && !hasModifierKey(event)) { + const select = this.select(); + if (!select.isOpen && event.key === "Enter" && !hasModifierKey(event)) { return false; } - if (this.select.isOpen && event.key === "Escape" && !hasModifierKey(event)) { + if (select.isOpen && event.key === "Escape" && !hasModifierKey(event)) { this.selectedItems = []; - this.select.close(); + select.close(); event.stopPropagation(); return false; } @@ -183,11 +182,11 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro get ariaDescribedBy() { return this._ariaDescribedBy; } - set ariaDescribedBy(value: string) { + set ariaDescribedBy(value: string | undefined) { this._ariaDescribedBy = value; - this.select?.searchInput.nativeElement.setAttribute("aria-describedby", value); + this.select()?.searchInput.nativeElement.setAttribute("aria-describedby", value ?? ""); } - private _ariaDescribedBy: string; + private _ariaDescribedBy?: string; /**Implemented as part of BitFormFieldControl */ get labelForId() { @@ -208,16 +207,17 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro set required(value: any) { this._required = value != null && value !== false; } - private _required: boolean; + private _required?: boolean; /**Implemented as part of BitFormFieldControl */ get hasError() { - return this.ngControl?.status === "INVALID" && this.ngControl?.touched; + return !!(this.ngControl?.status === "INVALID" && this.ngControl?.touched); } /**Implemented as part of BitFormFieldControl */ get error(): [string, any] { - const key = Object.keys(this.ngControl?.errors)[0]; - return [key, this.ngControl?.errors[key]]; + const errors = this.ngControl?.errors ?? {}; + const key = Object.keys(errors)[0]; + return [key, errors[key]]; } } diff --git a/libs/components/src/navigation/nav-base.component.ts b/libs/components/src/navigation/nav-base.component.ts index 0fb73740273..1ca40545cbb 100644 --- a/libs/components/src/navigation/nav-base.component.ts +++ b/libs/components/src/navigation/nav-base.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Directive, EventEmitter, Output, input } from "@angular/core"; import { RouterLink, RouterLinkActive } from "@angular/router"; diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index f27dd5ba10e..e294f3cebe2 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -1,5 +1,5 @@ -@if (!hideIfEmpty() || nestedNavComponents.length > 0) { +@if (!hideIfEmpty() || nestedNavComponents().length > 0) { ; + readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: true }); /** When the side nav is open, the parent nav item should not show active styles when open. */ protected get parentHideActiveStyles(): boolean { diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts index 74b44266074..52d67446f2f 100644 --- a/libs/components/src/navigation/nav-logo.component.ts +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -1,6 +1,3 @@ -// 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 } from "@angular/core"; import { RouterLinkActive, RouterLink } from "@angular/router"; diff --git a/libs/components/src/navigation/side-nav.component.ts b/libs/components/src/navigation/side-nav.component.ts index 75365b738af..fe428c7011f 100644 --- a/libs/components/src/navigation/side-nav.component.ts +++ b/libs/components/src/navigation/side-nav.component.ts @@ -1,8 +1,6 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; -import { Component, ElementRef, ViewChild, input } from "@angular/core"; +import { Component, ElementRef, input, viewChild } from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -21,15 +19,14 @@ export type SideNavVariant = "primary" | "secondary"; export class SideNavComponent { readonly variant = input("primary"); - @ViewChild("toggleButton", { read: ElementRef, static: true }) - private toggleButton: ElementRef; + private readonly toggleButton = viewChild("toggleButton", { read: ElementRef }); constructor(protected sideNavService: SideNavService) {} protected handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { this.sideNavService.setClose(); - this.toggleButton?.nativeElement.focus(); + this.toggleButton()?.nativeElement.focus(); return false; } diff --git a/libs/components/src/popover/default-positions.ts b/libs/components/src/popover/default-positions.ts index ae08c38a288..cf6adcb1f95 100644 --- a/libs/components/src/popover/default-positions.ts +++ b/libs/components/src/popover/default-positions.ts @@ -1,6 +1,6 @@ import { ConnectedPosition } from "@angular/cdk/overlay"; -const ORIGIN_OFFSET_PX = 6; +const ORIGIN_OFFSET_PX = 14; const OVERLAY_OFFSET_PX = 24; export type PositionIdentifier = diff --git a/libs/components/src/popover/popover-trigger-for.directive.ts b/libs/components/src/popover/popover-trigger-for.directive.ts index d21ea9d7ed6..cb114f1fbc3 100644 --- a/libs/components/src/popover/popover-trigger-for.directive.ts +++ b/libs/components/src/popover/popover-trigger-for.directive.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; import { TemplatePortal } from "@angular/cdk/portal"; import { @@ -27,12 +25,12 @@ import { PopoverComponent } from "./popover.component"; export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { readonly popoverOpen = model(false); - readonly popover = input(undefined, { alias: "bitPopoverTriggerFor" }); + readonly popover = input.required({ alias: "bitPopoverTriggerFor" }); readonly position = input(); - private overlayRef: OverlayRef; - private closedEventsSub: Subscription; + private overlayRef: OverlayRef | null = null; + private closedEventsSub: Subscription | null = null; get positions() { if (!this.position()) { @@ -82,7 +80,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { this.popoverOpen.set(true); this.overlayRef = this.overlay.create(this.defaultPopoverConfig); - const templatePortal = new TemplatePortal(this.popover().templateRef, this.viewContainerRef); + const templatePortal = new TemplatePortal(this.popover().templateRef(), this.viewContainerRef); this.overlayRef.attach(templatePortal); this.closedEventsSub = this.getClosedEvents().subscribe(() => { @@ -91,6 +89,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private getClosedEvents(): Observable { + if (!this.overlayRef) { + throw new Error("Overlay reference is not available"); + } + const detachments = this.overlayRef.detachments(); const escKey = this.overlayRef .keydownEvents() @@ -102,7 +104,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private destroyPopover() { - if (this.overlayRef == null || !this.popoverOpen()) { + if (!this.overlayRef || !this.popoverOpen()) { return; } @@ -112,7 +114,9 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { private disposeAll() { this.closedEventsSub?.unsubscribe(); + this.closedEventsSub = null; this.overlayRef?.dispose(); + this.overlayRef = null; } ngAfterViewInit() { diff --git a/libs/components/src/popover/popover.component.css b/libs/components/src/popover/popover.component.css index 548d1bea368..6077c051001 100644 --- a/libs/components/src/popover/popover.component.css +++ b/libs/components/src/popover/popover.component.css @@ -1,13 +1,13 @@ .bit-popover-arrow { - @apply tw-absolute tw-z-10 tw-h-4 tw-w-4 tw-rotate-45 tw-border-solid tw-bg-background; + @apply tw-absolute tw-z-10 tw-size-4 tw-rotate-45 tw-bg-primary-100 tw-shadow-lg; } .bit-popover-right .bit-popover-arrow { - @apply tw-left-1 -tw-translate-x-1/2 tw-rounded-bl-sm tw-border-b tw-border-l tw-border-b-secondary-300 tw-border-l-secondary-300; + @apply tw-left-1 -tw-translate-x-3/4 tw-rounded-bl-sm; } .bit-popover-left .bit-popover-arrow { - @apply tw-right-1 tw-translate-x-1/2 tw-rounded-tr-sm tw-border-r tw-border-t tw-border-r-secondary-300 tw-border-t-secondary-300; + @apply tw-right-1 tw-translate-x-3/4 tw-rounded-tr-sm; } .bit-popover-right-start .bit-popover-arrow, @@ -26,11 +26,11 @@ } .bit-popover-below .bit-popover-arrow { - @apply tw-top-1 -tw-translate-y-1/2 tw-rounded-tl-sm tw-border-l tw-border-t tw-border-l-secondary-300 tw-border-t-secondary-300; + @apply tw-top-1 -tw-translate-y-3/4 tw-rounded-tl-sm; } .bit-popover-above .bit-popover-arrow { - @apply tw-bottom-1 tw-translate-y-1/2 tw-rounded-br-sm tw-border-b tw-border-r tw-border-b-secondary-300 tw-border-r-secondary-300; + @apply tw-bottom-1 tw-translate-y-3/4 tw-rounded-br-sm; } .bit-popover-below-start .bit-popover-arrow, diff --git a/libs/components/src/popover/popover.component.html b/libs/components/src/popover/popover.component.html index 03b6eaf77e3..328da284732 100644 --- a/libs/components/src/popover/popover.component.html +++ b/libs/components/src/popover/popover.component.html @@ -1,8 +1,15 @@ -