diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c1fb0b4794d..255ddc08c80 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -162,6 +162,7 @@ apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-autofill-desktop- libs/components @bitwarden/team-ui-foundation libs/assets @bitwarden/team-ui-foundation libs/ui @bitwarden/team-ui-foundation +libs/angular/src/scss @bitwarden/team-ui-foundation apps/browser/src/platform/popup/layout @bitwarden/team-ui-foundation apps/browser/src/popup/app-routing.animations.ts @bitwarden/team-ui-foundation apps/browser/src/popup/components/extension-anon-layout-wrapper @bitwarden/team-ui-foundation @@ -204,10 +205,11 @@ apps/web/src/locales/en/messages.json .github/workflows/release-desktop.yml @bitwarden/dept-bre .github/workflows/release-web.yml @bitwarden/dept-bre -## Docker files have shared ownership ## +## Docker-related files **/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre **/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre **/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre +**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre **/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre ## Overrides diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 13d151225c2..9349239a134 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -38,4 +38,5 @@ jobs: uses: nrwl/nx-set-shas@826660b82addbef3abff5fa871492ebad618c9e1 # v4.3.3 - name: Run Nx affected tasks + continue-on-error: true run: npx nx affected -t build lint test \ No newline at end of file diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index c6427b2e0d8..3af1a1a8e9d 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -11,6 +11,7 @@ jobs: check-files: name: Check files runs-on: ubuntu-22.04 + if: ${{ github.event.workflow_run.conclusion == 'success' }} permissions: actions: write contents: read diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 00000000000..14d86ad6230 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 00000000000..1886c87239a --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,67 @@ +# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) +# * For C, use cpp +# * For JavaScript, use typescript +# Special requirements: +# * csharp: Requires the presence of a .sln file in the project folder. +language: typescript + +# whether to use the project's gitignore file to ignore files +# Added on 2025-04-07 +ignore_all_files_in_gitignore: true +# list of additional paths to ignore +# same syntax as gitignore, so you can use * and ** +# Was previously called `ignored_dirs`, please update your config if you are using that. +# Added (renamed) on 2025-04-07 +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "clients" diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index b2d5563c545..397ea877cb5 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "تعديل" }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 66b7f948d92..639e6c87d36 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Axtarışı sıfırla" }, + "archive": { + "message": "Arxivlə" + }, + "unarchive": { + "message": "Arxivdən çıxart" + }, + "itemsInArchive": { + "message": "Arxivdəki elementlər" + }, + "noItemsInArchive": { + "message": "Arxivdə element yoxdur" + }, + "noItemsInArchiveDesc": { + "message": "Arxivlənmiş elementlər burada görünəcək, ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək." + }, + "itemSentToArchive": { + "message": "Element arxivə göndərildi" + }, + "itemRemovedFromArchive": { + "message": "Element arxivdən çıxarıldı" + }, + "archiveItem": { + "message": "Elementi arxivlə" + }, + "archiveItemConfirmDesc": { + "message": "Arxivlənmiş elementlər ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək. Bu elementi arxivləmək istədiyinizə əminsiniz?" + }, "edit": { "message": "Düzəliş et" }, diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 2da642ef6f2..44c82ef85b4 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Рэдагаваць" }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 3f77317f06e..a440690cee1 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Нулиране на търсенето" }, + "archive": { + "message": "Архивиране" + }, + "unarchive": { + "message": "Изваждане от архива" + }, + "itemsInArchive": { + "message": "Елементи в архива" + }, + "noItemsInArchive": { + "message": "Няма елементи в архива" + }, + "noItemsInArchiveDesc": { + "message": "Архивираните елементи ще се показват тук и ще бъдат изключени от общите резултати при търсене и от предложенията за автоматично попълване." + }, + "itemSentToArchive": { + "message": "Елементът е преместен в архива" + }, + "itemRemovedFromArchive": { + "message": "Елементът е изваден от архива" + }, + "archiveItem": { + "message": "Архивиране на елемента" + }, + "archiveItemConfirmDesc": { + "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" + }, "edit": { "message": "Редактиране" }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 40844b6e734..e7c4c36bce0 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "সম্পাদনা" }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index e3dae509a9d..d9003a749a6 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index a79d0be6327..2002dfc467f 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Restableix la cerca" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edita" }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 5427289fdf4..0638257d687 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Resetovat hledání" }, + "archive": { + "message": "Archivovat" + }, + "unarchive": { + "message": "Odebrat z archivu" + }, + "itemsInArchive": { + "message": "Položky v archivu" + }, + "noItemsInArchive": { + "message": "Žádné položky v archivu" + }, + "noItemsInArchiveDesc": { + "message": "Zde se zobrazí archivované položky a budou vyloučeny z obecných výsledků vyhledávání a návrhů automatického vyplňování." + }, + "itemSentToArchive": { + "message": "Položka byla přesunuta do archivu" + }, + "itemRemovedFromArchive": { + "message": "Položka byla odebrána z archivu" + }, + "archiveItem": { + "message": "Archivovat položku" + }, + "archiveItemConfirmDesc": { + "message": "Archivované položky jsou vyloučeny z obecných výsledků vyhledávání a z návrhů automatického vyplňování. Jste si jisti, že chcete tuto položku archivovat?" + }, "edit": { "message": "Upravit" }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 6f670cec95f..8756a138e81 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Golygu" }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index e778685b566..a78ff26fb0f 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Redigér" }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 96dca6c3acb..f04ca5b11be 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Suche zurücksetzen" }, + "archive": { + "message": "Archivieren" + }, + "unarchive": { + "message": "Archivierung aufheben" + }, + "itemsInArchive": { + "message": "Einträge im Archiv" + }, + "noItemsInArchive": { + "message": "Kein Eintrag im Archiv" + }, + "noItemsInArchiveDesc": { + "message": "Archivierte Einträge erscheinen hier und werden von allgemeinen Suchergebnissen und Autofill Vorschlägen ausgeschlossen." + }, + "itemSentToArchive": { + "message": "Eintrag an das Archiv gesendet" + }, + "itemRemovedFromArchive": { + "message": "Eintrag aus dem Archiv entfernt" + }, + "archiveItem": { + "message": "Eintrag archivieren" + }, + "archiveItemConfirmDesc": { + "message": "Archivierte Einträge sind von allgemeinen Suchergebnissen und Autofill Vorschlägen ausgeschlossen. Sind Sie sicher, dass Sie diesen Eintrag archivieren möchten?" + }, "edit": { "message": "Bearbeiten" }, @@ -5512,16 +5539,16 @@ "message": "Willkommen in deinem Tresor!" }, "phishingPageTitle": { - "message": "Phishing website" + "message": "Phishing Webseite" }, "phishingPageCloseTab": { - "message": "Close tab" + "message": "Tab schließen" }, "phishingPageContinue": { - "message": "Continue" + "message": "Weiter" }, "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "message": "Warum sehen Sie das?" }, "hasItemsVaultNudgeBodyOne": { "message": "Einträge für die aktuelle Seite automatisch ausfüllen" diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index ce98d454084..cce3e0ea39f 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Επαναφορά αναζήτησης" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Επεξεργασία" }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index aab0cc92092..43bb17c297f 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 54ad3612f78..59c4966a48c 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 266a74b034a..d3c6e3556a0 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Restablecer búsqueda" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Editar" }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 7b9d096be82..5508a1cee72 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Muuda" }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 1ccd8f1849c..93242263dc0 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Editatu" }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 97b92fa8c29..129f2ee383a 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "ویرایش" }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 36c16254a67..5de1d9fe7e4 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Nollaa haku" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Muokkaa" }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 8a2c9912e5d..600abfb2d4e 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "I-edit" }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 3323995736b..765ebff53c5 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Réinitialiser la recherche" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Modifier" }, diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 947baabdc2c..c2573ea6bfa 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Editar" }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 6cdb3898962..38fe3618610 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "אפס חיפוש" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "ערוך" }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 215b5f88445..1575543aef3 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "खोज रीसेट करें" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "संपादन करें" }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index a37cc0cf368..4f67de34071 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Ponovno postavljanje pretraživanja" }, + "archive": { + "message": "Arhiviraj" + }, + "unarchive": { + "message": "Poništi arhiviranje" + }, + "itemsInArchive": { + "message": "Stavke u arhivi" + }, + "noItemsInArchive": { + "message": "Nema stavki u arhivi" + }, + "noItemsInArchiveDesc": { + "message": "Arhivirane stavke biti će prikazane ovdje i biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune." + }, + "itemSentToArchive": { + "message": "Stavka poslana u arhivu" + }, + "itemRemovedFromArchive": { + "message": "Stavka maknute iz arhive" + }, + "archiveItem": { + "message": "Arhiviraj stavku" + }, + "archiveItemConfirmDesc": { + "message": "Arhivirane stavke biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune. Sigurno želiš arhivirati?" + }, "edit": { "message": "Uredi" }, @@ -5512,16 +5539,16 @@ "message": "Dobrodošli u svoj trezor!" }, "phishingPageTitle": { - "message": "Phishing website" + "message": "Phishing web stranica" }, "phishingPageCloseTab": { - "message": "Close tab" + "message": "Zatvori karticu" }, "phishingPageContinue": { - "message": "Continue" + "message": "Nastavi" }, "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "message": "Zašto ovo vidiš?" }, "hasItemsVaultNudgeBodyOne": { "message": "Auto-ispuni stavke za trenutnu stranicu" diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 367a9fd1f9f..864580a64b0 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -6,11 +6,11 @@ "message": "Bitwarden logó" }, "extName": { - "message": "Bitwarden Password Manager", + "message": "Bitwarden Jelszókezelő", "description": "Extension name, MUST be less than 40 characters (Safari restriction)" }, "extDesc": { - "message": "At home, at work, or on the go, Bitwarden easily secures all your passwords, passkeys, and sensitive information", + "message": "Legyen otthon, munkában, vagy úton, a Bitwarden könnyen biztosítja jelszavát, kulcsait, és kényes információit", "description": "Extension description, MUST be less than 112 characters (Safari restriction)" }, "loginOrCreateNewAccount": { @@ -550,6 +550,33 @@ "resetSearch": { "message": "Keresés visszaállítása" }, + "archive": { + "message": "Archívum" + }, + "unarchive": { + "message": "Visszavétel archívumból" + }, + "itemsInArchive": { + "message": "Archívum elemek száma" + }, + "noItemsInArchive": { + "message": "Nincs elem az archívumban." + }, + "noItemsInArchiveDesc": { + "message": "Az archivált elemek itt jelennek meg és kizárásra kerülnek az általános keresési eredményekből és az automatikus kitöltési javaslatokból." + }, + "itemSentToArchive": { + "message": "Archívumba küldött elemek száma" + }, + "itemRemovedFromArchive": { + "message": "Az elem kikerült a kedvencekből." + }, + "archiveItem": { + "message": "Elem archiválása" + }, + "archiveItemConfirmDesc": { + "message": "Az archivált elemek ki vannak zárva az általános keresési eredményekből és az automatikus kitöltési javaslatokból. Biztosan archiválni szeretnénk ezt az elemet?" + }, "edit": { "message": "Szerkesztés" }, @@ -866,13 +893,13 @@ "message": "Kijelentkezett" }, "loggedOutDesc": { - "message": "You have been logged out of your account." + "message": "Kijelentkezett fiókjából." }, "loginExpired": { "message": "Bejelentkezési munkamenete lejárt." }, "logIn": { - "message": "Log in" + "message": "Bejelentkezés" }, "logInToBitwarden": { "message": "Bejelentkezés a Bitwardenbe" @@ -896,16 +923,16 @@ "message": "Kövessük az alábbi lépéseket a biztonsági kulccsal bejelentkezés befejezéséhez." }, "restartRegistration": { - "message": "Restart registration" + "message": "Regisztráció újraindítása" }, "expiredLink": { - "message": "Expired link" + "message": "Lejárt hivatkozás" }, "pleaseRestartRegistrationOrTryLoggingIn": { - "message": "Please restart registration or try logging in." + "message": "Kérem kezdje újra a regisztrációt, vagy próbáljon meg bejelentkezni." }, "youMayAlreadyHaveAnAccount": { - "message": "You may already have an account" + "message": "Lehetséges, hogy már rendelkezik fiókkal" }, "logOutConfirmation": { "message": "Biztos benne, hogy ki szeretnél jelentkezni?" @@ -1176,7 +1203,7 @@ "description": "Error message shown when the system fails to save login details." }, "saveFailureDetails": { - "message": "Oh no! We couldn't save this. Try entering the details manually.", + "message": "Ja ne! Nem tudtuk elmenteni. Próbálja meg beírni a kézzel.", "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { @@ -1606,7 +1633,7 @@ "description": "Represents the message for allowing the user to enable the autofill overlay" }, "autofillSuggestionsSectionTitle": { - "message": "Autofill suggestions" + "message": "Automatikus kitöltés javaslatok" }, "autofillSpotlightTitle": { "message": "Az automatikus kitöltési javaslatok könnyű megtalálása" diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index c14294534df..b38b6f05628 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Atur ulang pencarian" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index ed699b61c91..df4411ee42b 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Svuota ricerca" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Modifica" }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 7137e7e1a90..5305a265781 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "編集" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index c916c0d958e..b759d674cca 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "ჩასწორება" }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 0cdb46d102e..78a49021a0c 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 8e9d04688b1..1311a97df68 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "ಎಡಿಟ್" }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index f17371f28ba..06611be0282 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "편집" }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index f0f78f1de0f..464fa5aae92 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Keisti" }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 00708a95e41..99edb486d9d 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Atiestatīt meklēšanu" }, + "archive": { + "message": "Arhivēt" + }, + "unarchive": { + "message": "Atcelt arhivēšanu" + }, + "itemsInArchive": { + "message": "Vienumi arhīvā" + }, + "noItemsInArchive": { + "message": "Arhīvā nav vienumu" + }, + "noItemsInArchiveDesc": { + "message": "Šeit parādīsies arhivētie vienumi, un tie netiks iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos." + }, + "itemSentToArchive": { + "message": "Vienums ievietots arhīvā" + }, + "itemRemovedFromArchive": { + "message": "Vienums izņemts no arhīva" + }, + "archiveItem": { + "message": "Arhivēt vienumu" + }, + "archiveItemConfirmDesc": { + "message": "Arhivētie vienumi netiek iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos. Vai tiešām ahrivēt šo vienumu?" + }, "edit": { "message": "Labot" }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 8554d74db8c..efe18c96a59 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "തിരുത്തുക" }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 5dddf1f2bde..16ac31ff599 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 0cdb46d102e..78a49021a0c 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index c77bce78939..a23fd7fe4c1 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Rediger" }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 0cdb46d102e..78a49021a0c 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 379ed54e490..2562b7a1d4c 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Zoekopdracht resetten" }, + "archive": { + "message": "Archiveren" + }, + "unarchive": { + "message": "Dearchiveren" + }, + "itemsInArchive": { + "message": "Items in archief" + }, + "noItemsInArchive": { + "message": "Geen items in archief" + }, + "noItemsInArchiveDesc": { + "message": "Gearchiveerde items verschijnen hier en worden uitgesloten van algemene zoekresultaten en automatisch invulsuggesties." + }, + "itemSentToArchive": { + "message": "Item naar archief verzonden" + }, + "itemRemovedFromArchive": { + "message": "Item verwijderd uit archief" + }, + "archiveItem": { + "message": "Item archiveren" + }, + "archiveItemConfirmDesc": { + "message": "Gearchiveerde items worden uitgesloten van algemene zoekresultaten en automatische invulsuggesties. Weet je zeker dat je dit item wilt archiveren?" + }, "edit": { "message": "Bewerken" }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 0cdb46d102e..78a49021a0c 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 0cdb46d102e..78a49021a0c 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index b0f2b7017b7..f24e790c9ad 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Zresetuj wyszukiwanie" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Usuń z archiwum" + }, + "itemsInArchive": { + "message": "Elementy w archiwum" + }, + "noItemsInArchive": { + "message": "Brak elementów w archiwum" + }, + "noItemsInArchiveDesc": { + "message": "Zarchiwizowane elementy pojawią się tutaj i zostaną wykluczone z wyników wyszukiwania i sugestii autouzupełniania." + }, + "itemSentToArchive": { + "message": "Element został przeniesiony do archiwum" + }, + "itemRemovedFromArchive": { + "message": "Element został usunięty z archiwum" + }, + "archiveItem": { + "message": "Archiwizuj element" + }, + "archiveItemConfirmDesc": { + "message": "Zarchiwizowane elementy są wykluczone z wyników wyszukiwania i sugestii autouzupełniania. Czy na pewno chcesz archiwizować element?" + }, "edit": { "message": "Edytuj" }, diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 5fd9d1673b6..2d7dd1e42a4 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Editar" }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 1a8ea3bfb3c..acc5b5332f9 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Repor pesquisa" }, + "archive": { + "message": "Arquivar" + }, + "unarchive": { + "message": "Desarquivar" + }, + "itemsInArchive": { + "message": "Itens no arquivo" + }, + "noItemsInArchive": { + "message": "Nenhum item no arquivo" + }, + "noItemsInArchiveDesc": { + "message": "Os itens arquivados aparecerão aqui e serão excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático." + }, + "itemSentToArchive": { + "message": "Item movido para o arquivo" + }, + "itemRemovedFromArchive": { + "message": "Item removido do arquivo" + }, + "archiveItem": { + "message": "Arquivar item" + }, + "archiveItemConfirmDesc": { + "message": "Os itens arquivados são excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático. Tem a certeza de que pretende arquivar este item?" + }, "edit": { "message": "Editar" }, @@ -1235,10 +1262,10 @@ "message": "Tema" }, "themeDesc": { - "message": "Altere o tema de cores da aplicação." + "message": "Altere o tema da aplicação." }, "themeDescAlt": { - "message": "Altere o tema de cores da aplicação. Aplica-se a todas as contas com sessão iniciada." + "message": "Altere o tema da aplicação. Aplica-se a todas as contas com sessão iniciada." }, "dark": { "message": "Escuro", diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index e57fe4dd19c..d184460e293 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Editare" }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 485665524d5..17133350e3f 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Сбросить поиск" }, + "archive": { + "message": "Архив" + }, + "unarchive": { + "message": "Разархивировать" + }, + "itemsInArchive": { + "message": "Элементы в архиве" + }, + "noItemsInArchive": { + "message": "В архиве нет элементов" + }, + "noItemsInArchiveDesc": { + "message": "Архивированные элементы появятся здесь и будут исключены из общих результатов поиска и предложений автозаполнения." + }, + "itemSentToArchive": { + "message": "Элемент отправлен в архив" + }, + "itemRemovedFromArchive": { + "message": "Элемент удален из архива" + }, + "archiveItem": { + "message": "Архивировать элемент" + }, + "archiveItemConfirmDesc": { + "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" + }, "edit": { "message": "Изменить" }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 9e6f27bab83..2fd8f53e148 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "සංස්කරණය" }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 7d410f67db3..d0e143cce4a 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Resetovať vyhľadávanie" }, + "archive": { + "message": "Archivovať" + }, + "unarchive": { + "message": "Zrušiť archiváciu" + }, + "itemsInArchive": { + "message": "Položky v archíve" + }, + "noItemsInArchive": { + "message": "Žiadne položky v archíve" + }, + "noItemsInArchiveDesc": { + "message": "Tu sa zobrazia archivované položky, ktoré budú vylúčené zo všeobecného vyhľadávania a z návrhov automatického vypĺňania." + }, + "itemSentToArchive": { + "message": "Položka bola archivovaná" + }, + "itemRemovedFromArchive": { + "message": "Položka bola odobraná z archívu" + }, + "archiveItem": { + "message": "Archivovať položku" + }, + "archiveItemConfirmDesc": { + "message": "Archivované položky sú vylúčené zo všeobecného vyhľadávania a z návrhov automatického vypĺňania. Naozaj chcete archivovať túto položku?" + }, "edit": { "message": "Upraviť" }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index d742e0a4b2d..81b1a6bb52c 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Uredi" }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 5de4d4da336..269ebd41bdd 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Ресетовати претрагу" }, + "archive": { + "message": "Архива" + }, + "unarchive": { + "message": "Врати из архиве" + }, + "itemsInArchive": { + "message": "Ставке у архиви" + }, + "noItemsInArchive": { + "message": "Нема ставка у архиви" + }, + "noItemsInArchiveDesc": { + "message": "Архивиране ставке ће се овде појавити и бити искључени из општих резултата претраге и сугестија о ауто-пуњењу." + }, + "itemSentToArchive": { + "message": "Ставка је послата у архиву" + }, + "itemRemovedFromArchive": { + "message": "Ставка је уклоњена из архиве" + }, + "archiveItem": { + "message": "Архивирај ставку" + }, + "archiveItemConfirmDesc": { + "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" + }, "edit": { "message": "Уреди" }, @@ -1755,7 +1782,7 @@ "message": "Ако кликнете изван искачућег прозора да бисте проверили имејл за верификациони код, овај прозор ће се затворити. Да ли желите да отворите овај прозор у новом прозору да се не би затворио?" }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Прикажи иконе веб локација и преузмите линкове промене лозинке" }, "cardholderName": { "message": "Име Власника Картице" @@ -1920,79 +1947,79 @@ "message": "Белешка" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Ново пријављивање", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Нова картица", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Нови идентитет", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Нова белешка", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Нов SSH кљич", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Нови текст Send", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Нова датотека Send", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Уреди пријаву", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Уреди картицу", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Уреди идентитет", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Уреди белешку", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Уреди SSH кључ", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Уреди текст Send", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Уреди датотеку Send", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Преглед пријаве", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Преглед картице", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "View Identity", + "message": "Преглед идентитета", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Преглед белешке", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Преглед SSH кључа", "description": "Header for view SSH key item type" }, "passwordHistory": { @@ -5512,16 +5539,16 @@ "message": "Добродошли у ваш сеф!" }, "phishingPageTitle": { - "message": "Phishing website" + "message": "Пронађен злонамеран сајт" }, "phishingPageCloseTab": { - "message": "Close tab" + "message": "Затвори језичак" }, "phishingPageContinue": { - "message": "Continue" + "message": "Настави" }, "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "message": "Зашто видите ово?" }, "hasItemsVaultNudgeBodyOne": { "message": "Ауто-пуњење предмета за тренутну страницу" @@ -5599,10 +5626,10 @@ "description": "Aria label for the body content of the generator nudge" }, "aboutThisSetting": { - "message": "About this setting" + "message": "О овом подешавању" }, "permitCipherDetailsDescription": { - "message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service." + "message": "Bitwarden ће користити сачуване URI-јеве за пријаву да би одредио коју икону или URL за промену лозинке треба користити како би побољшао ваше искуство. Никакви подаци нису сакупљени нити сачувани приликом коришћења ове услуге." }, "noPermissionsViewPage": { "message": "Немате дозволе за преглед ове странице. Покушајте да се пријавите са другим налогом." diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 91c8919c810..8b0263bf15a 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Nollställ sökning" }, + "archive": { + "message": "Arkivera" + }, + "unarchive": { + "message": "Packa upp" + }, + "itemsInArchive": { + "message": "Objekt i arkiv" + }, + "noItemsInArchive": { + "message": "Inga objekt i arkivet" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Arkivera objekt" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Redigera" }, diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index 5660467e3ff..8d2199db6ca 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "தேடலை மீட்டமை" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "திருத்து" }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 0cdb46d102e..78a49021a0c 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Edit" }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 93f064c6801..61f97564f6a 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Reset search" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "แก้ไข" }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index ce3d485a937..0b65ae7d476 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Aramayı sıfırla" }, + "archive": { + "message": "Arşivle" + }, + "unarchive": { + "message": "Arşivden çıkar" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "Düzenle" }, diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 24f7f71b9b2..f088e610051 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Скинути пошук" }, + "archive": { + "message": "Архівувати" + }, + "unarchive": { + "message": "Видобути" + }, + "itemsInArchive": { + "message": "Записи в архіві" + }, + "noItemsInArchive": { + "message": "Немає записів у архіві" + }, + "noItemsInArchiveDesc": { + "message": "Архівовані записи з'являтимуться тут і будуть виключені з результатів звичайного пошуку та пропозицій автозаповнення." + }, + "itemSentToArchive": { + "message": "Запис переміщено до архіву" + }, + "itemRemovedFromArchive": { + "message": "Запис вилучено з архіву" + }, + "archiveItem": { + "message": "Архівувати запис" + }, + "archiveItemConfirmDesc": { + "message": "Архівовані записи виключаються з результатів звичайного пошуку та пропозицій автозаповнення. Ви дійсно хочете архівувати цей запис?" + }, "edit": { "message": "Змінити" }, @@ -557,7 +584,7 @@ "message": "Переглянути" }, "viewLogin": { - "message": "View login" + "message": "Переглянути запис" }, "noItemsInList": { "message": "Немає записів." @@ -1755,7 +1782,7 @@ "message": "Натискання поза межами спливаючого вікна для перевірки коду підтвердження в пошті спричинить його закриття. Хочете відкрити його в новому вікні, щоб воно не закрилося?" }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Показувати піктограми вебсайтів та отримувати адреси для зміни паролів" }, "cardholderName": { "message": "Ім'я власника картки" @@ -1920,79 +1947,79 @@ "message": "Нотатка" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Новий запис", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Нова картка", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Нове посвідчення", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Нова нотатка", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Новий ключ SSH", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Нове текстове відправлення", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Нове файлове відправлення", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Редагувати запис", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Редагувати картку", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Редагувати посвідчення", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Редагувати нотатку", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Редагувати ключ SSH", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Редагувати текстове відправлення", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Редагувати файлове відправлення", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Перегляд запису", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Перегляд картки", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "View Identity", + "message": "Перегляд посвідчення", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Перегляд нотатки", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Перегляд ключа SSH", "description": "Header for view SSH key item type" }, "passwordHistory": { @@ -3234,7 +3261,7 @@ "message": "Адреса е-пошти Catch-all" }, "catchallEmailDesc": { - "message": "Використовуйте свою скриньку вхідних Catch-All власного домену." + "message": "Використовуйте можливості Catch-All власного домену." }, "random": { "message": "Випадково" @@ -5458,10 +5485,10 @@ "message": "Змінити ризикований пароль" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Цей запис ризикований, і не має адреси вебсайту. Додайте адресу вебсайту і змініть пароль для вдосконалення безпеки." }, "missingWebsite": { - "message": "Missing website" + "message": "Немає вебсайту" }, "settingsVaultOptions": { "message": "Параметри сховища" @@ -5512,16 +5539,16 @@ "message": "Вітаємо у вашому сховищі!" }, "phishingPageTitle": { - "message": "Phishing website" + "message": "Шахрайський вебсайт" }, "phishingPageCloseTab": { - "message": "Close tab" + "message": "Закрити вкладку" }, "phishingPageContinue": { - "message": "Continue" + "message": "Продовжити" }, "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "message": "Чому ви це бачите?" }, "hasItemsVaultNudgeBodyOne": { "message": "Автозаповнення записів для поточної сторінки" @@ -5599,10 +5626,10 @@ "description": "Aria label for the body content of the generator nudge" }, "aboutThisSetting": { - "message": "About this setting" + "message": "Про ці налаштування" }, "permitCipherDetailsDescription": { - "message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service." + "message": "Bitwarden використовуватиме збережені URI-адреси записів для визначення піктограм вебсайтів або URL-адрес для зміни паролів, щоб вдосконалити вашу роботу. Під час використання цієї послуги ваша інформація не збирається і не зберігається." }, "noPermissionsViewPage": { "message": "У вас немає дозволу переглядати цю сторінку. Спробуйте ввійти з іншим обліковим записом." @@ -5625,6 +5652,6 @@ "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "confirmKeyConnectorDomain": { - "message": "Confirm Key Connector domain" + "message": "Підтвердити домен Key Connector" } } diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 57d404438ef..06920433037 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "Đặt lại tìm kiếm" }, + "archive": { + "message": "Lưu trữ" + }, + "unarchive": { + "message": "Hủy lưu trữ" + }, + "itemsInArchive": { + "message": "Các mục trong kho lưu trữ" + }, + "noItemsInArchive": { + "message": "Không có mục nào trong kho lưu trữ" + }, + "noItemsInArchiveDesc": { + "message": "Các mục đã lưu trữ sẽ hiển thị ở đây và sẽ bị loại khỏi kết quả tìm kiếm và gợi ý tự động điền." + }, + "itemSentToArchive": { + "message": "Mục đã được gửi đến kho lưu trữ" + }, + "itemRemovedFromArchive": { + "message": "Mục đã được gỡ khỏi kho lưu trữ" + }, + "archiveItem": { + "message": "Lưu trữ mục" + }, + "archiveItemConfirmDesc": { + "message": "Các mục đã lưu trữ sẽ bị loại khỏi kết quả tìm kiếm chung và gợi ý tự động điền. Bạn có chắc chắn muốn lưu trữ mục này không?" + }, "edit": { "message": "Sửa" }, @@ -1755,7 +1782,7 @@ "message": "Nhấp bên ngoài popup để xem mã xác thực trong email của bạn sẽ làm cho popup này đóng lại. Bạn có muốn mở popup này trong một cửa sổ mới để nó không bị đóng?" }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Hiển thị biểu tượng trang web và truy xuất các URL đổi mật khẩu" }, "cardholderName": { "message": "Tên chủ thẻ" @@ -1920,79 +1947,79 @@ "message": "Ghi chú" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Đăng nhập mới", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Thẻ mới", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Danh tính mới", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Ghi chú mới", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Khóa SSH mới", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Send văn bản mới", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Send tập tin mới", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Chỉnh sửa đăng nhập", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Chỉnh sửa thẻ", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Chỉnh sửa danh tính", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Chỉnh sửa ghi chú", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Sửa khóa SSH", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Sửa Send văn bản", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Sửa Send tập tin", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Xem đăng nhập", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Xem thẻ", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "View Identity", + "message": "Xem danh tính", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Xem ghi chú", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Xem khóa SSH", "description": "Header for view SSH key item type" }, "passwordHistory": { @@ -5512,16 +5539,16 @@ "message": "Chào mừng đến với kho lưu trữ của bạn!" }, "phishingPageTitle": { - "message": "Phishing website" + "message": "Trang web lừa đảo" }, "phishingPageCloseTab": { - "message": "Close tab" + "message": "Đóng tab" }, "phishingPageContinue": { - "message": "Continue" + "message": "Tiếp tục" }, "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "message": "Tại sao bạn thấy điều này?" }, "hasItemsVaultNudgeBodyOne": { "message": "Tự động điền các mục cho trang hiện tại" @@ -5599,10 +5626,10 @@ "description": "Aria label for the body content of the generator nudge" }, "aboutThisSetting": { - "message": "About this setting" + "message": "Về cài đặt này" }, "permitCipherDetailsDescription": { - "message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service." + "message": "Bitwarden sẽ sử dụng URI đăng nhập đã lưu để xác định biểu tượng hoặc URL đổi mật khẩu nào cần dùng nhằm cải thiện trải nghiệm của bạn. Không có thông tin nào được thu thập hay lưu lại khi bạn sử dụng dịch vụ này." }, "noPermissionsViewPage": { "message": "Bạn không có quyền truy cập vào trang này. Hãy thử đăng nhập bằng tài khoản khác." diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 9e8ce80a29b..eb3cf1aa901 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "重置搜索" }, + "archive": { + "message": "归档" + }, + "unarchive": { + "message": "取消归档" + }, + "itemsInArchive": { + "message": "归档中的项目" + }, + "noItemsInArchive": { + "message": "归档中没有项目" + }, + "noItemsInArchiveDesc": { + "message": "已归档的项目将显示在此处,并将被排除在一般搜索结果和自动填充建议之外。" + }, + "itemSentToArchive": { + "message": "项目已归档" + }, + "itemRemovedFromArchive": { + "message": "项目已取消归档" + }, + "archiveItem": { + "message": "归档项目" + }, + "archiveItemConfirmDesc": { + "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" + }, "edit": { "message": "编辑" }, @@ -814,7 +841,7 @@ "message": "您可以关闭此窗口" }, "masterPassSent": { - "message": "我们已经为您发送了包含主密码提示的电子邮件。" + "message": "我们已经向您发送了一封包含主密码提示的电子邮件。" }, "verificationCodeRequired": { "message": "必须填写验证码。" @@ -1612,10 +1639,10 @@ "message": "轻松找到自动填充建议" }, "autofillSpotlightDesc": { - "message": "关闭浏览器的自动填充设置,以免与 Bitwarden 产生冲突。" + "message": "停用您浏览器的自动填充设置,以免与 Bitwarden 产生冲突。" }, "turnOffBrowserAutofill": { - "message": "关闭 $BROWSER$ 的自动填充", + "message": "停用 $BROWSER$ 自动填充", "placeholders": { "browser": { "content": "$1", @@ -1624,7 +1651,7 @@ } }, "turnOffAutofill": { - "message": "关闭自动填充" + "message": "停用自动填充" }, "showInlineMenuLabel": { "message": "在表单字段中显示自动填充建议" @@ -1755,7 +1782,7 @@ "message": "如果您点击弹窗外的区域以检查您的验证码电子邮件,将导致弹窗关闭。您想在新窗口中打开此弹窗,以便它不会关闭吗?" }, "showIconsChangePasswordUrls": { - "message": "显示网站图标并检索更改密码的 URL" + "message": "显示网站图标并获取更改密码的 URL" }, "cardholderName": { "message": "持卡人姓名" @@ -4376,7 +4403,7 @@ "message": "仅此一次" }, "alwaysForThisSite": { - "message": "总是为此站点" + "message": "始终适用于此站点" }, "domainAddedToExcludedDomains": { "message": "$DOMAIN$ 已添加到排除域名列表。", @@ -5521,7 +5548,7 @@ "message": "继续" }, "phishingPageLearnWhy": { - "message": "您为什么会看到这个?" + "message": "为什么您会看到这个?" }, "hasItemsVaultNudgeBodyOne": { "message": "为当前页面自动填充项目" diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index b528695ccc4..f5801fb2c7d 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -550,6 +550,33 @@ "resetSearch": { "message": "重設搜尋" }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, "edit": { "message": "編輯" }, @@ -1049,10 +1076,10 @@ "message": "於分頁頁面顯示身分以便於自動填入。" }, "clickToAutofillOnVault": { - "message": "在密碼庫檢視中點擊項目來自動填入" + "message": "在密碼庫檢視中點選項目來自動填入" }, "clickToAutofill": { - "message": "Click items in autofill suggestion to fill" + "message": "點選自動填入建議中的項目進行填入" }, "clearClipboard": { "message": "清除剪貼簿", @@ -1562,16 +1589,16 @@ "message": "輸入寄送到您電子郵件信箱的驗證碼。" }, "selfHostedEnvironment": { - "message": "自我裝載環境" + "message": "自行部署環境" }, "selfHostedBaseUrlHint": { - "message": "指定您自建的 Bitwarden 伺服器的網域 URL。例如:https://bitwarden.company.com" + "message": "指定您自架的 Bitwarden 伺服器的網域 URL。例如:https://bitwarden.company.com" }, "selfHostedCustomEnvHeader": { "message": "適用於進階設定。您可以單獨指定各個服務的網域 URL。" }, "selfHostedEnvFormInvalid": { - "message": "您必須新增伺服器網域 URL 或至少一個自定義環境。" + "message": "您必須新增伺服器網域 URL 或至少一個自訂環境。" }, "customEnvironment": { "message": "自訂環境" @@ -1580,7 +1607,7 @@ "message": "伺服器 URL" }, "selfHostBaseUrl": { - "message": "自建伺服器 URL", + "message": "自架伺服器 URL", "description": "Label for field requesting a self-hosted integration service URL" }, "apiUrl": { @@ -5134,7 +5161,7 @@ } }, "showQuickCopyActions": { - "message": "在密碼庫中顯示快速複製" + "message": "在密碼庫中顯示快速複製圖示" }, "systemDefault": { "message": "系統預設值" diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index 3de1cc81a69..44900acc065 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -102,7 +102,7 @@ - +

{{ "manageDevices" | i18n }}

diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts index 63666440a76..2335c5c2e69 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts @@ -1,6 +1,7 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { ActivatedRoute } from "@angular/router"; import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; @@ -19,7 +20,6 @@ import { VaultTimeoutStringType, VaultTimeoutAction, } from "@bitwarden/common/key-management/vault-timeout"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -67,6 +67,7 @@ describe("AccountSecurityComponent", () => { providers: [ { provide: AccountService, useValue: accountService }, { provide: AccountSecurityComponent, useValue: mock() }, + { provide: ActivatedRoute, useValue: mock() }, { provide: BiometricsService, useValue: mock() }, { provide: BiometricStateService, useValue: biometricStateService }, { provide: DialogService, useValue: dialogService }, @@ -88,7 +89,6 @@ describe("AccountSecurityComponent", () => { { provide: LogService, useValue: mock() }, { provide: OrganizationService, useValue: mock() }, { provide: CollectionService, useValue: mock() }, - { provide: ConfigService, useValue: mock() }, { provide: ValidationService, useValue: validationService }, ], }) diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 72a389ecf71..0c9b4634569 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -31,7 +31,6 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeout, @@ -41,7 +40,6 @@ import { VaultTimeoutSettingsService, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -115,7 +113,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { biometricUnavailabilityReason: string; showChangeMasterPass = true; pinEnabled$: Observable = of(true); - extensionLoginApprovalFlagEnabled = false; form = this.formBuilder.group({ vaultTimeout: [null as VaultTimeout | null], @@ -157,7 +154,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { private biometricsService: BiometricsService, private vaultNudgesService: NudgesService, private validationService: ValidationService, - private configService: ConfigService, private logService: LogService, ) {} @@ -239,10 +235,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { }; this.form.patchValue(initialValues, { emitEvent: false }); - this.extensionLoginApprovalFlagEnabled = await this.configService.getFeatureFlag( - FeatureFlag.PM14938_BrowserExtensionLoginApproval, - ); - timer(0, 1000) .pipe( switchMap(async () => { diff --git a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts index 71452ec975a..a70ffe25310 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts @@ -19,7 +19,7 @@ export type LoginSecurityTaskInfo = { export type WebsiteOriginsWithFields = Map>; -export type ActiveFormSubmissionRequests = Set; +export type ActiveFormSubmissionRequests = Set; export type ModifyLoginCipherFormData = { uri: string; diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 75f2659c9df..6067d563db2 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -48,6 +48,7 @@ export type FocusedFieldData = { frameId?: number; accountCreationFieldType?: string; showPasskeys?: boolean; + focusedFieldForm?: string; }; export type InlineMenuElementPosition = { diff --git a/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts b/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts index 373354b4c54..82a907a9e43 100644 --- a/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts +++ b/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts @@ -110,11 +110,11 @@ describe("AutoSubmitLoginBackground", () => { }); describe("when the AutomaticAppLogIn policy is valid and active", () => { - let webRequestDetails: chrome.webRequest.WebRequestBodyDetails; + let webRequestDetails: chrome.webRequest.WebRequestDetails; describe("starting the auto-submit login workflow", () => { beforeEach(async () => { - webRequestDetails = mock({ + webRequestDetails = mock({ initiator: validIpdUrl1, url: validAutoSubmitUrl, type: "main_frame", @@ -196,7 +196,7 @@ describe("AutoSubmitLoginBackground", () => { describe("cancelling an active auto-submit login workflow", () => { beforeEach(async () => { - webRequestDetails = mock({ + webRequestDetails = mock({ initiator: validIpdUrl1, url: validAutoSubmitUrl, type: "main_frame", @@ -280,7 +280,7 @@ describe("AutoSubmitLoginBackground", () => { }); describe("requests that occur within a sub-frame", () => { - const webRequestDetails = mock({ + const webRequestDetails = mock({ url: validAutoSubmitUrl, frameId: 1, }); @@ -324,7 +324,7 @@ describe("AutoSubmitLoginBackground", () => { it("updates the most recent idp host when a tab is activated", async () => { jest.spyOn(BrowserApi, "getTab").mockResolvedValue(newTab); - triggerTabOnActivatedEvent(mock({ tabId: newTabId })); + triggerTabOnActivatedEvent(mock({ tabId: newTabId })); await flushPromises(); expect(autoSubmitLoginBackground["mostRecentIdpHost"]).toStrictEqual({ @@ -336,7 +336,7 @@ describe("AutoSubmitLoginBackground", () => { it("updates the most recent id host when a tab is updated", () => { triggerTabOnUpdatedEvent( newTabId, - mock({ url: validIpdUrl1 }), + mock({ url: validIpdUrl1 }), newTab, ); @@ -389,7 +389,7 @@ describe("AutoSubmitLoginBackground", () => { tabId: newTabId, }; - triggerTabOnRemovedEvent(newTabId, mock()); + triggerTabOnRemovedEvent(newTabId, mock()); expect(autoSubmitLoginBackground["currentAutoSubmitHostData"]).toStrictEqual({}); }); @@ -403,14 +403,14 @@ describe("AutoSubmitLoginBackground", () => { tabId: tabId, }; triggerWebRequestOnBeforeRedirectEvent( - mock({ + mock({ url: validIpdUrl1, redirectUrl: validIpdUrl2, frameId: 0, }), ); triggerWebRequestOnBeforeRedirectEvent( - mock({ + mock({ url: validIpdUrl2, redirectUrl: validAutoSubmitUrl, frameId: 0, @@ -418,7 +418,7 @@ describe("AutoSubmitLoginBackground", () => { ); triggerWebRequestOnBeforeRequestEvent( - mock({ + mock({ tabId: tabId, url: `https://${validAutoSubmitHost}`, initiator: null, diff --git a/apps/browser/src/autofill/background/auto-submit-login.background.ts b/apps/browser/src/autofill/background/auto-submit-login.background.ts index dfdfa0f4d67..f593fab2516 100644 --- a/apps/browser/src/autofill/background/auto-submit-login.background.ts +++ b/apps/browser/src/autofill/background/auto-submit-login.background.ts @@ -161,7 +161,9 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * * @param details - The details of the request. */ - private handleOnBeforeRequest = (details: chrome.webRequest.WebRequestBodyDetails) => { + private handleOnBeforeRequest = ( + details: chrome.webRequest.OnBeforeRequestDetails, + ): undefined => { const requestInitiator = this.getRequestInitiator(details); const isValidInitiator = this.isValidInitiator(requestInitiator); @@ -191,7 +193,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param isValidInitiator - A flag indicating if the initiator of the request is valid. */ private postRequestEncounteredAfterSubmission = ( - details: chrome.webRequest.WebRequestBodyDetails, + details: chrome.webRequest.OnBeforeRequestDetails, isValidInitiator: boolean, ) => { return details.method === "POST" && this.validAutoSubmitHosts.size > 0 && isValidInitiator; @@ -205,7 +207,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param isValidInitiator - A flag indicating if the initiator of the request is valid. */ private requestRedirectsToInvalidHost = ( - details: chrome.webRequest.WebRequestBodyDetails, + details: chrome.webRequest.OnBeforeRequestDetails, isValidInitiator: boolean, ) => { return ( @@ -221,7 +223,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * * @param details - The details of the request. */ - private setupAutoSubmitFlow = (details: chrome.webRequest.WebRequestBodyDetails) => { + private setupAutoSubmitFlow = (details: chrome.webRequest.OnBeforeRequestDetails) => { if (this.isRequestInMainFrame(details)) { this.currentAutoSubmitHostData = { url: details.url, @@ -288,7 +290,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param details - The details of the request. */ private handleWebRequestOnBeforeRedirect = ( - details: chrome.webRequest.WebRedirectionResponseDetails, + details: chrome.webRequest.OnBeforeRedirectDetails, ) => { if (this.isRequestInMainFrame(details) && this.urlContainsAutoSubmitHash(details.redirectUrl)) { this.validAutoSubmitHosts.add(this.getUrlHost(details.redirectUrl)); @@ -354,7 +356,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr */ private disableAutoSubmitFlow = async ( requestInitiator: string, - details: chrome.webRequest.WebRequestBodyDetails, + details: chrome.webRequest.OnBeforeRequestDetails, ) => { if (this.isValidAutoSubmitHost(requestInitiator)) { this.removeUrlFromAutoSubmitHosts(requestInitiator); @@ -390,7 +392,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param initiator - The initiator of the request. */ private shouldRouteTriggerAutoSubmit = ( - details: chrome.webRequest.ResourceRequest, + details: chrome.webRequest.OnBeforeRequestDetails, initiator: string, ) => { if (this.isRequestInMainFrame(details)) { @@ -449,7 +451,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * * @param details - The details of the request. */ - private getRequestInitiator = (details: chrome.webRequest.ResourceRequest) => { + private getRequestInitiator = (details: chrome.webRequest.OnBeforeRequestDetails) => { if (!this.isSafariBrowser) { return details.initiator || (details as browser.webRequest._OnBeforeRequestDetails).originUrl; } @@ -470,7 +472,12 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * * @param details - The details of the request. */ - private isRequestInMainFrame = (details: chrome.webRequest.ResourceRequest) => { + private isRequestInMainFrame = ( + details: SetPartial< + chrome.webRequest.WebRequestDetails, + "documentId" | "documentLifecycle" | "frameType" + >, + ) => { if (this.isSafariBrowser) { return details.frameId === 0; } @@ -545,7 +552,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * * @param activeInfo - The active tab information. */ - private handleSafariTabOnActivated = async (activeInfo: chrome.tabs.TabActiveInfo) => { + private handleSafariTabOnActivated = async (activeInfo: chrome.tabs.OnActivatedInfo) => { if (activeInfo.tabId < 0) { return; } @@ -562,7 +569,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param tabId - The tab ID associated with the URL. * @param changeInfo - The change information of the tab. */ - private handleSafariTabOnUpdated = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => { + private handleSafariTabOnUpdated = (tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo) => { if (changeInfo) { this.setMostRecentIdpHost(changeInfo.url, tabId); } diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts index cf317de4fd2..c596a1ba774 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts @@ -385,7 +385,7 @@ describe("OverlayNotificationsBackground", () => { it("ignores requests that are not part of an active form submission", async () => { triggerWebRequestOnCompletedEvent( - mock({ + mock({ url: sender.url, tabId: sender.tab.id, requestId: "123345", @@ -409,7 +409,7 @@ describe("OverlayNotificationsBackground", () => { await flushPromises(); triggerWebRequestOnCompletedEvent( - mock({ + mock({ url: sender.url, tabId: sender.tab.id, requestId, @@ -438,7 +438,7 @@ describe("OverlayNotificationsBackground", () => { await flushPromises(); triggerWebRequestOnCompletedEvent( - mock({ + mock({ url: sender.url, tabId: sender.tab.id, statusCode: 404, @@ -492,7 +492,7 @@ describe("OverlayNotificationsBackground", () => { ); }); triggerWebRequestOnCompletedEvent( - mock({ + mock({ url: sender.url, tabId: sender.tab.id, requestId, @@ -541,7 +541,7 @@ describe("OverlayNotificationsBackground", () => { }); triggerWebRequestOnCompletedEvent( - mock({ + mock({ url: sender.url, tabId: sender.tab.id, requestId, @@ -643,7 +643,7 @@ describe("OverlayNotificationsBackground", () => { }); it("clears all associated data with a removed tab", () => { - triggerTabOnRemovedEvent(sender.tab.id, mock()); + triggerTabOnRemovedEvent(sender.tab.id, mock()); expect(overlayNotificationsBackground["websiteOriginsWithFields"].size).toBe(0); }); @@ -652,7 +652,7 @@ describe("OverlayNotificationsBackground", () => { it("skips clearing the website origins if the changeInfo does not contain a `loading` status", () => { triggerTabOnUpdatedEvent( sender.tab.id, - mock({ status: "complete" }), + mock({ status: "complete" }), mock({ status: "complete" }), ); @@ -662,7 +662,7 @@ describe("OverlayNotificationsBackground", () => { it("skips clearing the website origins if the changeInfo does not contain a url", () => { triggerTabOnUpdatedEvent( sender.tab.id, - mock({ status: "loading", url: "" }), + mock({ status: "loading", url: "" }), mock({ status: "loading" }), ); @@ -672,7 +672,7 @@ describe("OverlayNotificationsBackground", () => { it("skips clearing the website origins if the tab does not contain known website origins", () => { triggerTabOnUpdatedEvent( 199, - mock({ status: "loading", url: "https://example.com" }), + mock({ status: "loading", url: "https://example.com" }), mock({ status: "loading", id: 199 }), ); @@ -682,7 +682,7 @@ describe("OverlayNotificationsBackground", () => { it("skips clearing the website origins if the changeInfo's url is present as part of the know website origin match patterns", () => { triggerTabOnUpdatedEvent( sender.tab.id, - mock({ + mock({ status: "loading", url: "https://subdomain.example.com", }), @@ -695,7 +695,7 @@ describe("OverlayNotificationsBackground", () => { it("clears all associated data with a tab that is entering a `loading` state", () => { triggerTabOnUpdatedEvent( sender.tab.id, - mock({ status: "loading" }), + mock({ status: "loading" }), mock({ status: "loading" }), ); diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index e7126a57e9f..4657dfb6d1f 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -228,7 +228,9 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * * @param details - The details of the web request */ - private handleOnBeforeRequestEvent = (details: chrome.webRequest.WebRequestDetails) => { + private handleOnBeforeRequestEvent = ( + details: chrome.webRequest.OnBeforeRequestDetails, + ): undefined => { if (this.isPostSubmissionFormRedirection(details)) { this.setupNotificationInitTrigger( details.tabId, @@ -275,7 +277,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * * @param details - The details of the web request */ - private isPostSubmissionFormRedirection = (details: chrome.webRequest.WebRequestDetails) => { + private isPostSubmissionFormRedirection = (details: chrome.webRequest.OnBeforeRequestDetails) => { return ( details.method?.toUpperCase() === "GET" && this.activeFormSubmissionRequests.has(details.requestId) && @@ -289,7 +291,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * * @param details - The details of the web request */ - private isValidFormSubmissionRequest = (details: chrome.webRequest.WebRequestDetails) => { + private isValidFormSubmissionRequest = (details: chrome.webRequest.OnBeforeRequestDetails) => { return ( !this.requestHostIsInvalid(details) && this.formSubmissionRequestMethods.has(details.method?.toUpperCase()) @@ -325,7 +327,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * * @param details - The details of the web response */ - private handleOnCompletedRequestEvent = async (details: chrome.webRequest.WebResponseDetails) => { + private handleOnCompletedRequestEvent = async (details: chrome.webRequest.OnCompletedDetails) => { if ( this.requestHostIsInvalid(details) || !this.activeFormSubmissionRequests.has(details.requestId) @@ -382,8 +384,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * @param modifyLoginData - The modified login form data */ private delayNotificationInitUntilTabIsComplete = async ( - tabId: chrome.webRequest.ResourceRequest["tabId"], - requestId: chrome.webRequest.ResourceRequest["requestId"], + tabId: chrome.webRequest.WebRequestDetails["tabId"], + requestId: chrome.webRequest.WebRequestDetails["requestId"], modifyLoginData: ModifyLoginCipherFormData, ) => { const handleWebNavigationOnCompleted = async () => { @@ -403,7 +405,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * @param tab - The tab details */ private processNotifications = async ( - requestId: chrome.webRequest.ResourceRequest["requestId"], + requestId: chrome.webRequest.WebRequestDetails["requestId"], modifyLoginData: ModifyLoginCipherFormData, tab: chrome.tabs.Tab, config: { skippable: NotificationType[] } = { skippable: [] }, @@ -477,7 +479,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * @param tab - The tab details */ private clearCompletedWebRequest = ( - requestId: chrome.webRequest.ResourceRequest["requestId"], + requestId: chrome.webRequest.WebRequestDetails["requestId"], tabId: chrome.tabs.Tab["id"], ) => { this.activeFormSubmissionRequests.delete(requestId); @@ -492,7 +494,12 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * * @param details - The details of the web request */ - private requestHostIsInvalid = (details: chrome.webRequest.ResourceRequest) => { + private requestHostIsInvalid = ( + details: SetPartial< + chrome.webRequest.WebRequestDetails, + "documentId" | "documentLifecycle" | "frameType" + >, + ) => { return !details.url?.startsWith("http") || details.tabId < 0; }; @@ -553,7 +560,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg * @param tabId - The id of the tab that was updated * @param changeInfo - The change info of the tab */ - private handleTabUpdated = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => { + private handleTabUpdated = (tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo) => { if (changeInfo.status !== "loading" || !changeInfo.url) { return; } diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 696454b4248..47a5e8fec4c 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -3371,7 +3371,7 @@ describe("OverlayBackground", () => { }); await flushPromises(); triggerWebRequestOnCompletedEvent( - mock({ + mock({ statusCode: 401, }), ); @@ -3391,7 +3391,7 @@ describe("OverlayBackground", () => { }); await flushPromises(); triggerWebRequestOnCompletedEvent( - mock({ + mock({ statusCode: 200, }), ); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index a2eb6eb7e90..35585d58863 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1175,6 +1175,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { pageDetails, fillNewPassword: true, allowTotpAutofill: true, + focusedFieldForm: this.focusedFieldData?.focusedFieldForm, }); if (totpCode) { @@ -1237,7 +1238,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param details - The web request details */ private handlePasskeyAuthenticationOnCompleted = ( - details: chrome.webRequest.WebResponseCacheDetails, + details: chrome.webRequest.OnCompletedDetails, ) => { chrome.webRequest.onCompleted.removeListener(this.handlePasskeyAuthenticationOnCompleted); @@ -1859,6 +1860,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { pageDetails, fillNewPassword: true, allowTotpAutofill: false, + focusedFieldForm: this.focusedFieldData?.focusedFieldForm, }); globalThis.setTimeout(async () => { diff --git a/apps/browser/src/autofill/background/tabs.background.ts b/apps/browser/src/autofill/background/tabs.background.ts index cd2c1595d69..b76997c0ae9 100644 --- a/apps/browser/src/autofill/background/tabs.background.ts +++ b/apps/browser/src/autofill/background/tabs.background.ts @@ -81,7 +81,7 @@ export default class TabsBackground { */ private handleTabOnUpdated = async ( tabId: number, - changeInfo: chrome.tabs.TabChangeInfo, + changeInfo: chrome.tabs.OnUpdatedInfo, tab: chrome.tabs.Tab, ) => { if (this.focusedWindowId > 0 && tab.windowId !== this.focusedWindowId) { diff --git a/apps/browser/src/autofill/background/web-request.background.ts b/apps/browser/src/autofill/background/web-request.background.ts index 22e10a3dd0a..5c02f2df34d 100644 --- a/apps/browser/src/autofill/background/web-request.background.ts +++ b/apps/browser/src/autofill/background/web-request.background.ts @@ -26,7 +26,10 @@ export default class WebRequestBackground { startListening() { this.webRequest.onAuthRequired.addListener( - async (details, callback) => { + (async ( + details: chrome.webRequest.OnAuthRequiredDetails, + callback: (response: chrome.webRequest.BlockingResponse) => void, + ) => { if (!details.url || this.pendingAuthRequests.has(details.requestId)) { if (callback) { callback(null); @@ -42,7 +45,7 @@ export default class WebRequestBackground { } else { await this.resolveAuthCredentials(details.url, callback, callback); } - }, + }) as any, { urls: ["http://*/*", "https://*/*"] }, [this.isFirefox ? "blocking" : "asyncBlocking"], ); @@ -50,16 +53,17 @@ export default class WebRequestBackground { this.webRequest.onCompleted.addListener((details) => this.completeAuthRequest(details), { urls: ["http://*/*"], }); - this.webRequest.onErrorOccurred.addListener( - (details: any) => this.completeAuthRequest(details), - { - urls: ["http://*/*"], - }, - ); + this.webRequest.onErrorOccurred.addListener((details) => this.completeAuthRequest(details), { + urls: ["http://*/*"], + }); } - // eslint-disable-next-line - private async resolveAuthCredentials(domain: string, success: Function, error: Function) { + private async resolveAuthCredentials( + domain: string, + success: (response: chrome.webRequest.BlockingResponse) => void, + // eslint-disable-next-line + error: Function, + ) { const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getOptionalUserId), ); @@ -97,7 +101,7 @@ export default class WebRequestBackground { } } - private completeAuthRequest(details: chrome.webRequest.WebResponseCacheDetails) { + private completeAuthRequest(details: chrome.webRequest.WebRequestDetails) { this.pendingAuthRequests.delete(details.requestId); } } diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.ts b/apps/browser/src/autofill/fido2/background/fido2.background.ts index 788c98ca85b..22ee4a1822d 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.ts @@ -218,7 +218,10 @@ export class Fido2Background implements Fido2BackgroundInterface { tabId: tab.id, injectDetails: { frame: "all_frames", ...this.sharedInjectionDetails }, mv2Details: { file: await this.getFido2PageScriptAppendFileName() }, - mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" }, + mv3Details: { + file: Fido2ContentScript.PageScript, + world: chrome.scripting.ExecutionWorld.MAIN, + }, }); void this.scriptInjectorService.inject({ diff --git a/apps/browser/src/autofill/fido2/content/fido2-content-script.spec.ts b/apps/browser/src/autofill/fido2/content/fido2-content-script.spec.ts index af7344beb66..f50e541f677 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-content-script.spec.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-content-script.spec.ts @@ -75,7 +75,7 @@ describe("Fido2 Content Script", () => { data: mock(), }); const mockResult = { credentialId: "mock" } as CreateCredentialResult; - jest.spyOn(chrome.runtime, "sendMessage").mockResolvedValue(mockResult); + (jest.spyOn(chrome.runtime, "sendMessage") as jest.Mock).mockResolvedValue(mockResult); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -167,7 +167,9 @@ describe("Fido2 Content Script", () => { data: mock(), }); const abortController = new AbortController(); - jest.spyOn(chrome.runtime, "sendMessage").mockResolvedValue({ error: errorMessage }); + (jest.spyOn(chrome.runtime, "sendMessage") as jest.Mock).mockResolvedValue({ + error: errorMessage, + }); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/apps/browser/src/autofill/services/abstractions/autofill.service.ts b/apps/browser/src/autofill/services/abstractions/autofill.service.ts index 5eb8ee99eb5..09e22e278be 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill.service.ts @@ -30,6 +30,7 @@ export interface AutoFillOptions { allowUntrustedIframe?: boolean; allowTotpAutofill?: boolean; autoSubmitLogin?: boolean; + focusedFieldForm?: string; } export interface FormData { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 07c97a5a344..656516d1119 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -974,6 +974,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ inlineMenuFillType: autofillFieldData?.inlineMenuFillType, showPasskeys: !!autofillFieldData?.showPasskeys, accountCreationFieldType: autofillFieldData?.accountCreationFieldType, + focusedFieldForm: autofillFieldData?.form, }; const allFields = this.formFieldElements; diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 512690929cc..0e238d14d23 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -434,7 +434,15 @@ export default class AutofillService implements AutofillServiceInterface { return; } - const fillScript = await this.generateFillScript(pd.details, { + // If we have a focused form, filter the page details to only include fields from that form + const details = options.focusedFieldForm + ? { + ...pd.details, + fields: pd.details.fields.filter((f) => f.form === options.focusedFieldForm), + } + : pd.details; + + const fillScript = await this.generateFillScript(details, { skipUsernameOnlyFill: options.skipUsernameOnlyFill || false, onlyEmptyFields: options.onlyEmptyFields || false, fillNewPassword: options.fillNewPassword || false, @@ -964,7 +972,7 @@ export default class AutofillService implements AutofillServiceInterface { fillScript.autosubmit = Array.from(formElementsSet); } - if (options.allowTotpAutofill) { + if (options.allowTotpAutofill && login?.totp) { await Promise.all( totps.map(async (t, i) => { if (Object.prototype.hasOwnProperty.call(filledFields, t.opid)) { @@ -972,10 +980,10 @@ export default class AutofillService implements AutofillServiceInterface { } filledFields[t.opid] = t; - const totpResponse = await firstValueFrom( - this.totpService.getCode$(options.cipher.login.totp), - ); + + const totpResponse = await firstValueFrom(this.totpService.getCode$(login.totp)); let totpValue = totpResponse.code; + if (totpValue.length == totps.length) { totpValue = totpValue.charAt(i); } diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index e36d6811ecb..9edcdbb3a95 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -153,7 +153,9 @@ describe("InsertAutofillContentService", () => { it("returns early if the script is filling within a sand boxed iframe", async () => { Object.defineProperty(globalThis, "frameElement", { - value: { hasAttribute: jest.fn(() => true) }, + value: { + getAttribute: jest.fn(() => ""), + }, writable: true, }); jest.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill"); diff --git a/apps/browser/src/autofill/spec/testing-utils.ts b/apps/browser/src/autofill/spec/testing-utils.ts index 1a3f3a52234..0082f022fb6 100644 --- a/apps/browser/src/autofill/spec/testing-utils.ts +++ b/apps/browser/src/autofill/spec/testing-utils.ts @@ -80,7 +80,7 @@ export function triggerWindowOnFocusedChangedEvent(windowId: number) { ); } -export function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.TabActiveInfo) { +export function triggerTabOnActivatedEvent(activeInfo: chrome.tabs.OnActivatedInfo) { (chrome.tabs.onActivated.addListener as unknown as jest.SpyInstance).mock.calls.forEach( (call) => { const callback = call[0]; @@ -98,7 +98,7 @@ export function triggerTabOnReplacedEvent(addedTabId: number, removedTabId: numb export function triggerTabOnUpdatedEvent( tabId: number, - changeInfo: chrome.tabs.TabChangeInfo, + changeInfo: chrome.tabs.OnUpdatedInfo, tab: chrome.tabs.Tab, ) { (chrome.tabs.onUpdated.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { @@ -107,7 +107,7 @@ export function triggerTabOnUpdatedEvent( }); } -export function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.TabRemoveInfo) { +export function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.OnRemovedInfo) { (chrome.tabs.onRemoved.addListener as unknown as jest.SpyInstance).mock.calls.forEach((call) => { const callback = call[0]; callback(tabId, removeInfo); @@ -165,7 +165,7 @@ export function triggerWebRequestOnBeforeRedirectEvent( }); } -export function triggerWebRequestOnCompletedEvent(details: chrome.webRequest.WebResponseDetails) { +export function triggerWebRequestOnCompletedEvent(details: chrome.webRequest.OnCompletedDetails) { (chrome.webRequest.onCompleted.addListener as unknown as jest.SpyInstance).mock.calls.forEach( (call) => { const callback = call[0]; diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 0e102dcfd99..a3d61c7f0b2 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -499,11 +499,24 @@ export function isInvalidResponseStatusCode(statusCode: number) { * Determines if the current context is within a sandboxed iframe. */ export function currentlyInSandboxedIframe(): boolean { - return ( - String(self.origin).toLowerCase() === "null" || - globalThis.frameElement?.hasAttribute("sandbox") || - globalThis.location.hostname === "" - ); + if (String(self.origin).toLowerCase() === "null" || globalThis.location.hostname === "") { + return true; + } + + const sandbox = globalThis.frameElement?.getAttribute?.("sandbox"); + + // No frameElement or sandbox attribute means not sandboxed + if (sandbox === null || sandbox === undefined) { + return false; + } + + // An empty string means fully sandboxed + if (sandbox === "") { + return true; + } + + const tokens = new Set(sandbox.toLowerCase().split(" ")); + return !["allow-scripts", "allow-same-origin"].every((token) => tokens.has(token)); } /** diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts index 81a869917a6..2de4b48a9c0 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -46,7 +46,7 @@ export default class IdleBackground { if (this.idle.onStateChanged) { this.idle.onStateChanged.addListener( - async (newState: chrome.idle.IdleState | browser.idle.IdleState) => { + async (newState: `${chrome.idle.IdleState}` | browser.idle.IdleState) => { if (newState === "locked") { // Need to check if any of the current users have their timeout set to `onLocked` const allUsers = await firstValueFrom(this.accountService.accounts$); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index dd7cf083a01..1497ac96dba 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -230,7 +230,7 @@ export class PhishingDetectionService { */ private static async _processNavigation( tabId: number, - changeInfo: chrome.tabs.TabChangeInfo, + changeInfo: chrome.tabs.OnUpdatedInfo, tab: chrome.tabs.Tab, ): Promise { if (changeInfo.status !== "complete" || !tab.url) { @@ -253,7 +253,7 @@ export class PhishingDetectionService { private static _handleNavigationEvent( tabId: number, - changeInfo: chrome.tabs.TabChangeInfo, + changeInfo: chrome.tabs.OnUpdatedInfo, tab: chrome.tabs.Tab, ): boolean { this._navigationEventsSubject.next({ tabId, changeInfo, tab }); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.types.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.types.ts index 86fe61909c4..21793616241 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.types.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.types.ts @@ -30,6 +30,6 @@ export type CaughtPhishingDomain = { export type PhishingDetectionNavigationEvent = { tabId: number; - changeInfo: chrome.tabs.TabChangeInfo; + changeInfo: chrome.tabs.OnUpdatedInfo; tab: chrome.tabs.Tab; }; diff --git a/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts b/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts index 0b0dd21824b..a8e7b7eeb37 100644 --- a/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts +++ b/apps/browser/src/platform/browser/browser-api.register-content-scripts-polyfill.ts @@ -141,8 +141,24 @@ function buildRegisterContentScriptsPolyfill() { return [possibleArray]; } - function arrayOrUndefined(value?: number) { - return value === undefined ? undefined : [value]; + function createTarget( + tabId: number, + frameId: number | undefined, + allFrames: boolean, + ): chrome.scripting.InjectionTarget { + if (frameId === undefined) { + return { + tabId, + frameIds: undefined, + allFrames: allFrames, + }; + } else { + return { + tabId, + frameIds: [frameId], + allFrames: undefined, + }; + } } async function insertCSS( @@ -170,15 +186,17 @@ function buildRegisterContentScriptsPolyfill() { } if (gotScripting) { - return chrome.scripting.insertCSS({ - target: { - tabId, - frameIds: arrayOrUndefined(frameId), - allFrames: frameId === undefined ? allFrames : undefined, - }, - files: "file" in content ? [content.file] : undefined, - css: "code" in content ? content.code : undefined, - }); + if ("file" in content) { + return chrome.scripting.insertCSS({ + target: createTarget(tabId, frameId, allFrames), + files: [content.file], + }); + } else { + return chrome.scripting.insertCSS({ + target: createTarget(tabId, frameId, allFrames), + css: content.code, + }); + } } return chromeProxy.tabs.insertCSS(tabId, { @@ -226,11 +244,7 @@ function buildRegisterContentScriptsPolyfill() { if (gotScripting) { assertNoCode(normalizedFiles); const injection = chrome.scripting.executeScript({ - target: { - tabId, - frameIds: arrayOrUndefined(frameId), - allFrames: frameId === undefined ? allFrames : undefined, - }, + target: createTarget(tabId, frameId, allFrames), files: normalizedFiles.map(({ file }: { file: string }) => file), }); @@ -397,7 +411,7 @@ function buildRegisterContentScriptsPolyfill() { }; const tabListener = async ( tabId: number, - { status }: chrome.tabs.TabChangeInfo, + { status }: chrome.tabs.OnUpdatedInfo, { url }: chrome.tabs.Tab, ) => { if (status === "loading" && url) { diff --git a/apps/browser/src/platform/browser/browser-api.spec.ts b/apps/browser/src/platform/browser/browser-api.spec.ts index 49d3e8e1cec..f7561b2b50b 100644 --- a/apps/browser/src/platform/browser/browser-api.spec.ts +++ b/apps/browser/src/platform/browser/browser-api.spec.ts @@ -375,7 +375,7 @@ describe("BrowserApi", () => { describe("executeScriptInTab", () => { it("calls to the extension api to execute a script within the give tabId", async () => { const tabId = 1; - const injectDetails = mock(); + const injectDetails = mock(); jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2); (chrome.tabs.executeScript as jest.Mock).mockImplementation( (tabId, injectDetails, callback) => callback(executeScriptResult), @@ -393,7 +393,7 @@ describe("BrowserApi", () => { it("calls the manifest v3 scripting API if the extension manifest is for v3", async () => { const tabId = 1; - const injectDetails = mock({ + const injectDetails = mock({ file: "file.js", allFrames: true, runAt: "document_start", @@ -419,7 +419,7 @@ describe("BrowserApi", () => { it("injects the script into a specified frameId when the extension is built for manifest v3", async () => { const tabId = 1; const frameId = 2; - const injectDetails = mock({ + const injectDetails = mock({ file: "file.js", allFrames: true, runAt: "document_start", @@ -443,7 +443,7 @@ describe("BrowserApi", () => { it("injects the script into the MAIN world context when injecting a script for manifest v3", async () => { const tabId = 1; - const injectDetails = mock({ + const injectDetails = mock({ file: null, allFrames: true, runAt: "document_start", diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 339fd71b071..8a3dbafc5ce 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -685,29 +685,27 @@ export class BrowserApi { */ static executeScriptInTab( tabId: number, - details: chrome.tabs.InjectDetails, + details: chrome.extensionTypes.InjectDetails, scriptingApiDetails?: { world: chrome.scripting.ExecutionWorld; }, ): Promise { if (BrowserApi.isManifestVersion(3)) { - const target: chrome.scripting.InjectionTarget = { - tabId, - }; + let target: chrome.scripting.InjectionTarget; if (typeof details.frameId === "number") { - target.frameIds = [details.frameId]; - } - - if (!target.frameIds?.length && details.allFrames) { - target.allFrames = details.allFrames; + target = { tabId, frameIds: [details.frameId] }; + } else if (details.allFrames) { + target = { tabId, allFrames: true }; + } else { + target = { tabId }; } return chrome.scripting.executeScript({ target, files: details.file ? [details.file] : null, injectImmediately: details.runAt === "document_start", - world: scriptingApiDetails?.world || "ISOLATED", + world: scriptingApiDetails?.world || chrome.scripting.ExecutionWorld.ISOLATED, }); } diff --git a/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts b/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts index ac8a01375fa..c1d0aa235fb 100644 --- a/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts +++ b/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts @@ -52,7 +52,7 @@ describe("ChromeStorageApiService", () => { }); afterEach(() => { - chrome.runtime.lastError = undefined; + (chrome.runtime.lastError as any) = undefined; }); it("uses `objToStore` to prepare a value for set", async () => { @@ -80,7 +80,7 @@ describe("ChromeStorageApiService", () => { it("translates chrome.runtime.lastError to promise rejection", async () => { setMock.mockImplementation((data, callback) => { - chrome.runtime.lastError = new Error("Test Error"); + (chrome.runtime.lastError as any) = new Error("Test Error"); callback(); }); @@ -101,7 +101,7 @@ describe("ChromeStorageApiService", () => { }); afterEach(() => { - chrome.runtime.lastError = undefined; + (chrome.runtime.lastError as any) = undefined; }); it("returns a stored value when it is serialized", async () => { @@ -132,9 +132,9 @@ describe("ChromeStorageApiService", () => { it("translates chrome.runtime.lastError to promise rejection", async () => { getMock.mockImplementation((key, callback) => { - chrome.runtime.lastError = new Error("Test Error"); + (chrome.runtime.lastError as any) = new Error("Test Error"); callback(); - chrome.runtime.lastError = undefined; + (chrome.runtime.lastError as any) = undefined; }); await expect(async () => await service.get("test")).rejects.toThrow("Test Error"); 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 21f6debc02f..e4abea1d719 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 @@ -41,7 +41,10 @@ describe("ScriptInjectorService", () => { const mv2SpecificFile = "content/autofill-init-mv2.js"; const mv2Details = { file: mv2SpecificFile }; const mv3SpecificFile = "content/autofill-init-mv3.js"; - const mv3Details: Mv3ScriptInjectionDetails = { file: mv3SpecificFile, world: "MAIN" }; + const mv3Details: Mv3ScriptInjectionDetails = { + file: mv3SpecificFile, + world: chrome.scripting.ExecutionWorld.MAIN, + }; const sharedInjectDetails: CommonScriptInjectionDetails = { runAt: "document_start", }; diff --git a/apps/browser/src/platform/services/browser-script-injector.service.ts b/apps/browser/src/platform/services/browser-script-injector.service.ts index a617f2215c0..575e9ccf70b 100644 --- a/apps/browser/src/platform/services/browser-script-injector.service.ts +++ b/apps/browser/src/platform/services/browser-script-injector.service.ts @@ -63,7 +63,7 @@ export class BrowserScriptInjectorService extends ScriptInjectorService { if (BrowserApi.isManifestVersion(3)) { try { await BrowserApi.executeScriptInTab(tabId, injectionDetails, { - world: mv3Details?.world ?? "ISOLATED", + world: mv3Details?.world ?? chrome.scripting.ExecutionWorld.ISOLATED, }); } catch (error) { // Swallow errors for host permissions, since this is believed to be a Manifest V3 Chrome bug @@ -112,9 +112,9 @@ export class BrowserScriptInjectorService extends ScriptInjectorService { private buildInjectionDetails( injectDetails: CommonScriptInjectionDetails, file: string, - ): chrome.tabs.InjectDetails { + ): chrome.extensionTypes.InjectDetails { const { frame, runAt } = injectDetails; - const injectionDetails: chrome.tabs.InjectDetails = { file }; + const injectionDetails: chrome.extensionTypes.InjectDetails = { file }; if (runAt) { injectionDetails.runAt = runAt; diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts index 81e1008eea8..61e56f08e16 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts @@ -167,7 +167,7 @@ describe("Browser Utils Service", () => { it("returns false if special error is sent", async () => { chrome.runtime.sendMessage = jest.fn().mockImplementation((message, callback) => { - chrome.runtime.lastError = new Error( + (chrome.runtime.lastError as any) = new Error( "Could not establish connection. Receiving end does not exist.", ); callback(undefined); @@ -177,7 +177,7 @@ describe("Browser Utils Service", () => { expect(isViewOpen).toBe(false); - chrome.runtime.lastError = null; + (chrome.runtime.lastError as any) = null; }); }); diff --git a/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts b/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts index 0eb4739ea92..b835c711853 100644 --- a/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts +++ b/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts @@ -46,7 +46,7 @@ export class BrowserSystemNotificationService implements SystemNotificationsServ return new Promise((resolve) => { const deviceType: DeviceType = this.platformUtilsService.getDevice(); - const options: chrome.notifications.NotificationOptions = { + const options: chrome.notifications.NotificationCreateOptions = { iconUrl: chrome.runtime.getURL("images/icon128.png"), message: createInfo.body, type: "basic", @@ -70,6 +70,7 @@ export class BrowserSystemNotificationService implements SystemNotificationsServ } async clear(clearInfo: SystemNotificationClearInfo): Promise { + // eslint-disable-next-line @typescript-eslint/no-floating-promises chrome.notifications.clear(clearInfo.id); } diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 501ef0ba8ff..b69d7b73672 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -14,7 +14,6 @@ import { } from "@bitwarden/angular/auth/guards"; import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password"; import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component"; -import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { DevicesIcon, RegistrationUserAddIcon, @@ -40,7 +39,6 @@ import { TwoFactorAuthComponent, TwoFactorAuthGuard, } from "@bitwarden/auth/angular"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; @@ -262,7 +260,7 @@ const routes: Routes = [ { path: "device-management", component: ExtensionDeviceManagementComponent, - canActivate: [canAccessFeature(FeatureFlag.PM14938_BrowserExtensionLoginApproval), authGuard], + canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, { diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 39f82622b68..998531488d3 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -42,9 +42,7 @@ 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 { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -82,7 +80,6 @@ export class AppComponent implements OnInit, OnDestroy { private activeUserId: UserId; private routerAnimations = false; private processingPendingAuth = false; - private extensionLoginApprovalFeatureFlag = false; private destroy$ = new Subject(); @@ -118,7 +115,6 @@ export class AppComponent implements OnInit, OnDestroy { private authRequestService: AuthRequestServiceAbstraction, private pendingAuthRequestsState: PendingAuthRequestsStateService, private authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction, - private readonly configService: ConfigService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -127,10 +123,6 @@ export class AppComponent implements OnInit, OnDestroy { } async ngOnInit() { - this.extensionLoginApprovalFeatureFlag = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval), - ); - initPopupClosedListener(); this.compactModeService.init(); @@ -140,24 +132,22 @@ export class AppComponent implements OnInit, OnDestroy { this.activeUserId = account?.id; }); - if (this.extensionLoginApprovalFeatureFlag) { - // Trigger processing auth requests when the active user is in an unlocked state. Runs once when - // the popup is open. - this.accountService.activeAccount$ - .pipe( - map((a) => a?.id), // Extract active userId - distinctUntilChanged(), // Only when userId actually changes - filter((userId) => userId != null), // Require a valid userId - switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user - filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked - tap(() => { - // Trigger processing when switching users while popup is open - void this.authRequestAnsweringService.processPendingAuthRequests(); - }), - takeUntil(this.destroy$), - ) - .subscribe(); - } + // Trigger processing auth requests when the active user is in an unlocked state. Runs once when + // the popup is open. + this.accountService.activeAccount$ + .pipe( + map((a) => a?.id), // Extract active userId + distinctUntilChanged(), // Only when userId actually changes + filter((userId) => userId != null), // Require a valid userId + switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user + filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked + tap(() => { + // Trigger processing when switching users while popup is open + void this.authRequestAnsweringService.processPendingAuthRequests(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); this.authService.activeAccountStatus$ .pipe( @@ -169,24 +159,22 @@ export class AppComponent implements OnInit, OnDestroy { ) .subscribe(); - if (this.extensionLoginApprovalFeatureFlag) { - // When the popup is already open and the active account transitions to Unlocked, - // process any pending auth requests for the active user. The above subscription does not handle - // this case. - this.authService.activeAccountStatus$ - .pipe( - startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission - pairwise(), // Compare previous and current statuses - filter( - ([prev, curr]) => - prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial) - ), - takeUntil(this.destroy$), - ) - .subscribe(() => { - void this.authRequestAnsweringService.processPendingAuthRequests(); - }); - } + // When the popup is already open and the active account transitions to Unlocked, + // process any pending auth requests for the active user. The above subscription does not handle + // this case. + this.authService.activeAccountStatus$ + .pipe( + startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission + pairwise(), // Compare previous and current statuses + filter( + ([prev, curr]) => + prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial) + ), + takeUntil(this.destroy$), + ) + .subscribe(() => { + void this.authRequestAnsweringService.processPendingAuthRequests(); + }); this.ngZone.runOutsideAngular(() => { window.onmousedown = () => this.recordActivity(); @@ -241,10 +229,7 @@ export class AppComponent implements OnInit, OnDestroy { } await this.router.navigate(["lock"]); - } else if ( - msg.command === "openLoginApproval" && - this.extensionLoginApprovalFeatureFlag - ) { + } else if (msg.command === "openLoginApproval") { if (this.processingPendingAuth) { return; } diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index 4c394317d14..d389fd8d783 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -5,12 +5,9 @@ [showBackButton]="showBackButton" [pageTitle]="''" > - +
+ +
diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index 3b84eac2217..c1694d80668 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -5,7 +5,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, switchMap, takeUntil, tap } from "rxjs"; -import { ExtensionBitwardenLogo, Icon } from "@bitwarden/assets/svg"; +import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { IconModule, @@ -62,7 +62,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { protected hideCardWrapper: boolean = false; protected theme: string; - protected logo = ExtensionBitwardenLogo; + protected logo = BitwardenLogo; constructor( private router: Router, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index d87c9417c85..ef4dd0be090 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -119,10 +119,12 @@ import { SystemNotificationsService } from "@bitwarden/common/platform/system-no import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/system-notifications/unsupported-system-notifications.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; +import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { @@ -145,8 +147,6 @@ import { DefaultSshImportPromptService, PasswordRepromptService, SshImportPromptService, - CipherArchiveService, - DefaultCipherArchiveService, } from "@bitwarden/vault"; import { AccountSwitcherService } from "../../auth/popup/account-switching/services/account-switcher.service"; @@ -708,14 +708,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: CipherArchiveService, useClass: DefaultCipherArchiveService, - deps: [ - CipherService, - ApiService, - DialogService, - PasswordRepromptService, - BillingAccountProfileStateService, - ConfigService, - ], + deps: [CipherService, ApiService, BillingAccountProfileStateService, ConfigService], }), ]; diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 6979f519f2d..324ea2ffcdf 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -13,6 +13,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; @@ -28,7 +29,7 @@ import { MenuModule, ToastService, } from "@bitwarden/components"; -import { CipherArchiveService, PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService } from "@bitwarden/vault"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 6499719b64f..513e159f7aa 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -14,6 +14,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncService } from "@bitwarden/common/platform/sync"; import { mockAccountServiceWith, ObservableTracker } from "@bitwarden/common/spec"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; @@ -26,7 +27,6 @@ import { RestrictedItemTypesService, } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -import { CipherArchiveService } from "@bitwarden/vault"; import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service"; import { BrowserApi } from "../../../platform/browser/browser-api"; diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 3e4b793737e..fa56b45c080 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -26,6 +26,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; @@ -35,7 +36,6 @@ import { CipherViewLike, CipherViewLikeUtils, } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -import { CipherArchiveService } from "@bitwarden/vault"; import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator"; import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service"; diff --git a/apps/browser/src/vault/popup/settings/archive.component.ts b/apps/browser/src/vault/popup/settings/archive.component.ts index c3e078a9274..d685beb0287 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.ts +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -9,6 +9,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { @@ -22,7 +23,11 @@ import { ToastService, TypographyModule, } from "@bitwarden/components"; -import { CanDeleteCipherDirective, CipherArchiveService } from "@bitwarden/vault"; +import { + CanDeleteCipherDirective, + DecryptionFailureDialogComponent, + PasswordRepromptService, +} from "@bitwarden/vault"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; @@ -56,6 +61,7 @@ export class ArchiveComponent { private toastService = inject(ToastService); private i18nService = inject(I18nService); private cipherArchiveService = inject(CipherArchiveService); + private passwordRepromptService = inject(PasswordRepromptService); private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); @@ -69,7 +75,7 @@ export class ArchiveComponent { ); async view(cipher: CipherView) { - if (!(await this.cipherArchiveService.canInteract(cipher))) { + if (!(await this.canInteract(cipher))) { return; } @@ -79,7 +85,7 @@ export class ArchiveComponent { } async edit(cipher: CipherView) { - if (!(await this.cipherArchiveService.canInteract(cipher))) { + if (!(await this.canInteract(cipher))) { return; } @@ -89,7 +95,7 @@ export class ArchiveComponent { } async delete(cipher: CipherView) { - if (!(await this.cipherArchiveService.canInteract(cipher))) { + if (!(await this.canInteract(cipher))) { return; } const confirmed = await this.dialogService.openSimpleDialog({ @@ -118,7 +124,7 @@ export class ArchiveComponent { } async unarchive(cipher: CipherView) { - if (!(await this.cipherArchiveService.canInteract(cipher))) { + if (!(await this.canInteract(cipher))) { return; } const activeUserId = await firstValueFrom(this.userId$); @@ -132,7 +138,7 @@ export class ArchiveComponent { } async clone(cipher: CipherView) { - if (!(await this.cipherArchiveService.canInteract(cipher))) { + if (!(await this.canInteract(cipher))) { return; } @@ -156,4 +162,21 @@ export class ArchiveComponent { }, }); } + + /** + * Check if the user is able to interact with the cipher + * (password re-prompt / decryption failure checks). + * @param cipher + * @private + */ + private canInteract(cipher: CipherView) { + if (cipher.decryptionFailure) { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: [cipher.id as CipherId], + }); + return false; + } + + return this.passwordRepromptService.passwordRepromptCheck(cipher); + } } diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts index 4e8a49b2591..92cbf951ead 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts @@ -9,9 +9,9 @@ import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { BadgeComponent, ItemModule, ToastOptions, ToastService } from "@bitwarden/components"; -import { CipherArchiveService } from "@bitwarden/vault"; import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; diff --git a/apps/browser/store/locales/uk/copy.resx b/apps/browser/store/locales/uk/copy.resx index 6f994cd0bf1..0bc1e7afce4 100644 --- a/apps/browser/store/locales/uk/copy.resx +++ b/apps/browser/store/locales/uk/copy.resx @@ -130,7 +130,7 @@ Убезпечте своє цифрове життя та захистіться від витоків даних, генеруючи та зберігаючи унікальні надійні паролі для кожного облікового запису. Зберігайте все в наскрізно зашифрованому сховищі паролів, доступ до якого маєте тільки ви. ДОСТУП ДО ДАНИХ БУДЬ-ДЕ, БУДЬ-КОЛИ, НА БУДЬ-ЯКОМУ ПРИСТРОЇ -Легко керуйте, зберігайте, захищайте та діліться необмеженою кількістю паролів на необмеженій кількості пристроїв. +Легко зберігайте необмежену кількість паролів у безпеці на необмеженій кількості пристроїв, а також керуйте й діліться ними. КОЖЕН ПОВИНЕН МАТИ ІНСТРУМЕНТИ ДЛЯ БЕЗПЕКИ В ІНТЕРНЕТІ Використовуйте Bitwarden безплатно без реклами або продажу даних. Bitwarden вважає, що кожен повинен мати можливість залишатися в безпеці в Інтернеті. Завдяки тарифним планам Преміум можна отримати доступ до розширених можливостей. @@ -144,7 +144,7 @@ Інші причини для вибору Bitwarden: Шифрування світового класу -Паролі захищаються розширеним наскрізним шифруванням (AES-256, сіллю хешування і PBKDF2 SHA-256), тому ваші дані завжди зберігаються приватно і в безпеці. +Паролі захищаються за допомогою досконалого наскрізного шифрування (AES-256, хешуванням із сіллю і PBKDF2 SHA-256), тому ваші дані завжди зберігаються приватно і в безпеці. Сторонні аудити Bitwarden регулярно проводить комплексні аудити безпеки із залученням третіх сторін – відомих компаній у сфері безпеки. Під час цих щорічних аудитів проводиться оцінка програмного коду і тестування на проникнення через IP-адреси Bitwarden, сервери та вебпрограми. @@ -156,13 +156,13 @@ Bitwarden регулярно проводить комплексні аудит Передавайте дані безпосередньо іншим користувачам, зберігаючи наскрізне шифрування та обмежуючи їх викриття. Вбудований генератор -Створюйте довгі, складні та чіткі паролі, а також унікальні імена користувачів для кожного сайту, який ви відвідуєте. Користуйтеся інтеграцією з провайдерами псевдонімів електронної пошти для забезпечення додаткової приватності. +Створюйте довгі, складні та особливі паролі, а також унікальні імена користувачів для кожного сайту, який ви відвідуєте. Користуйтеся інтеграцією з провайдерами псевдонімів електронної пошти для забезпечення додаткової приватності. Переклад різними мовами Bitwarden перекладено понад 60 мовами завдяки зусиллям нашої світової спільноти на Crowdin. Програми для різних платформ -Захищайте та діліться конфіденційними даними в межах свого сховища Bitwarden з будь-якого браузера, мобільного пристрою або комп'ютерної ОС, а також інших можливостей. +Захищайте конфіденційні дані та діліться ними в межах свого сховища Bitwarden з будь-якого браузера, мобільного пристрою або комп'ютерної ОС, а також користуйтеся іншими можливостями. Bitwarden захищає не лише паролі Комплексні рішення для керування наскрізно зашифрованими обліковими даними від Bitwarden дають змогу організаціям захищати все, включно з секретами розробників та ключами доступу. Відвідайте Bitwarden.com, щоб дізнатися більше про Менеджер секретів Bitwarden і Bitwarden Passwordless.dev! diff --git a/apps/browser/store/locales/zh_TW/copy.resx b/apps/browser/store/locales/zh_TW/copy.resx index ad3f12ae6f0..bfabc13f280 100644 --- a/apps/browser/store/locales/zh_TW/copy.resx +++ b/apps/browser/store/locales/zh_TW/copy.resx @@ -121,55 +121,55 @@ Bitwarden 密碼管理工具 - 無論在家、在辦公或在途中,Bitwarden 都能輕易的保護你的密碼、登入金鑰和敏感資訊。 + 無論在家中、工作中抑或旅途中,Bitwarden 都能輕鬆地保護您的密碼、登入金鑰和敏感資訊。 - Recognized as the best password manager by PCMag, WIRED, The Verge, CNET, G2, and more! + 被 PCMag、WIRED、The Verge、CNET、G2 等認可為最佳的密碼管理工具! -SECURE YOUR DIGITAL LIFE -Secure your digital life and protect against data breaches by generating and saving unique, strong passwords for every account. Maintain everything in an end-to-end encrypted password vault that only you can access. +保護您的數位生活 +透過為每個帳戶產生並保存唯一的強密碼,保護您的數位生活並防止資料外洩。將所有內容儲存在只有您可以存取的端對端加密密碼庫中。 -ACCESS YOUR DATA, ANYWHERE, ANYTIME, ON ANY DEVICE -Easily manage, store, secure, and share unlimited passwords across unlimited devices without restrictions. +隨時隨地在任何裝置上存取您的資料 +不受限制地跨裝置輕鬆管理、儲存、保護和分享無限多的密碼。 -EVERYONE SHOULD HAVE THE TOOLS TO STAY SAFE ONLINE -Utilize Bitwarden for free with no ads or selling data. Bitwarden believes everyone should have the ability to stay safe online. Premium plans offer access to advanced features. +每個人都應該擁有保持上網安全的工具 +免費使用 Bitwarden,沒有廣告或銷售資料。Bitwarden 認為每個人都應該有能力確保上網安全。進階版計劃提供對進階功能的存取。 -EMPOWER YOUR TEAMS WITH BITWARDEN -Plans for Teams and Enterprise come with professional business features. Some examples include SSO integration, self-hosting, directory integration and SCIM provisioning, global policies, API access, event logs, and more. +透過 BITWARDEN 強化您的團隊 +團隊和企業計劃具有專業的商業功能。包括 SSO 整合、自架服務、目錄整合和 SCIM 配置、全域原則、API 存取、事件記錄等。 -Use Bitwarden to secure your workforce and share sensitive information with colleagues. +使用 Bitwarden 來保護您的員工並與同事分享敏感資訊。 -More reasons to choose Bitwarden: +選擇 Bitwarden 的其他理由: -World-Class Encryption -Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashing, and PBKDF2 SHA-256) so your data stays secure and private. +世界級的加密 +密碼受到進階端對端加密(AES-256 位元加密、加鹽雜湊和 PBKDF2 SHA-256)的保護,因此您的資料保持安全和私密。 -3rd-party Audits -Bitwarden regularly conducts comprehensive third-party security audits with notable security firms. These annual audits include source code assessments and penetration testing across Bitwarden IPs, servers, and web applications. +第三方稽核 +Bitwarden 定期與知名資安公司進行全面的第三方安全稽核。這些年度稽核包括 Bitwarden IP、伺服器和 Web 應用程式的原始程式碼評估和滲透測試。 -Advanced 2FA -Secure your login with a third-party authenticator, emailed codes, or FIDO2 WebAuthn credentials such as a hardware security key or passkey. +進階的雙重要素驗證 +使用第三方驗證器、透過電子郵件傳送的代碼或 FIDO2 WebAuthn 憑證(例如硬體安全金鑰或密碼)來保護您的登入。 -Bitwarden Send -Transmit data directly to others while maintaining end-to-end encrypted security and limiting exposure. +Bitwarden 傳送 +直接將資料傳輸給其他人,同時保持端對端加密安全性並限制暴露。 -Built-in Generator -Create long, complex, and distinct passwords and unique usernames for every site you visit. Integrate with email alias providers for additional privacy. +內建產生器 +為您造訪的每個網站建立長、複雜且獨特的密碼和唯一的使用者名稱。與電子郵件別名提供者整合以進一步保護隱私。 -Global Translations -Bitwarden translations exist for more than 60 languages, translated by the global community though Crowdin. +全球化翻譯 +Bitwarden 有 60 多種語言翻譯,由全球社群透過 Crowdin 翻譯。 -Cross-Platform Applications -Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more. +跨平台應用程式 +透過任何瀏覽器、行動裝置或桌面作業系統等,保護和共用 Bitwarden 密碼庫中的敏感資料。 -Bitwarden secures more than just passwords -End-to-end encrypted credential management solutions from Bitwarden empower organizations to secure everything, including developer secrets and passkey experiences. Visit Bitwarden.com to learn more about Bitwarden Secrets Manager and Bitwarden Passwordless.dev! +Bitwarden 保護的不僅是密碼 +Bitwarden 的端對端加密憑證管理解決方案可讓組織保護一切,包括開發人員機密和金鑰體驗。造訪 Bitwarden.com 以了解更多有關 Bitwarden 機密管理員和 Bitwarden Passwordless.dev 的資訊! - 無論在家、在辦公或在途中,Bitwarden 都能輕易的保護你的密碼、登入金鑰和敏感資訊。 + 無論在家中、工作中抑或旅途中,Bitwarden 都能輕鬆地保護您的密碼、登入金鑰和敏感資訊。 在多部裝置上同步和存取密碼庫 diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index eb635e646e1..08b7287984c 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -79,6 +79,7 @@ const scripting = { executeScript: jest.fn(), registerContentScripts: jest.fn(), unregisterContentScripts: jest.fn(), + ExecutionWorld: { ISOLATED: "ISOLATED", MAIN: "MAIN" }, }; const windows = { diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index 92674aa3dcd..f4216196ead 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import * as inquirer from "inquirer"; import { firstValueFrom, map, switchMap } from "rxjs"; import { UpdateCollectionRequest } from "@bitwarden/admin-console/common"; @@ -9,6 +10,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CipherExport } from "@bitwarden/common/models/export/cipher.export"; import { CollectionExport } from "@bitwarden/common/models/export/collection.export"; @@ -40,6 +42,7 @@ export class EditCommand { private accountService: AccountService, private cliRestrictedItemTypesService: CliRestrictedItemTypesService, private policyService: PolicyService, + private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} async run( @@ -92,6 +95,10 @@ export class EditCommand { private async editCipher(id: string, req: CipherExport) { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipher = await this.cipherService.get(id, activeUserId); + const hasPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId), + ); + if (cipher == null) { return Response.notFound(); } @@ -102,6 +109,17 @@ export class EditCommand { } cipherView = CipherExport.toView(req, cipherView); + // When a user is editing an archived cipher and does not have premium, automatically unarchive it + if (cipherView.isArchived && !hasPremium) { + const acceptedPrompt = await this.promptForArchiveEdit(); + + if (!acceptedPrompt) { + return Response.error("Edit cancelled."); + } + + cipherView.archivedDate = null; + } + const isCipherRestricted = await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView); if (isCipherRestricted) { @@ -240,6 +258,38 @@ export class EditCommand { return Response.error(e); } } + + /** Prompt the user to accept movement of their cipher back to the their vault. */ + private async promptForArchiveEdit(): Promise { + // When running in serve or no interaction mode, automatically accept the prompt + if (process.env.BW_SERVE === "true" || process.env.BW_NOINTERACTION === "true") { + CliUtils.writeLn( + "Archive is only available with a Premium subscription, which has ended. Your edit was saved and the item was moved back to your vault.", + ); + return true; + } + + const answer: inquirer.Answers = await inquirer.createPromptModule({ + output: process.stderr, + })({ + type: "list", + name: "confirm", + message: + "When you edit and save details for an archived item without a Premium subscription, it'll be moved from your archive back to your vault.", + choices: [ + { + name: "Move now", + value: "confirmed", + }, + { + name: "Cancel", + value: "cancel", + }, + ], + }); + + return answer.confirm === "confirmed"; + } } class Options { diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index d8b4cfcfd10..49527f6bf78 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -16,6 +16,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EventType } from "@bitwarden/common/enums"; import { ListResponse as ApiListResponse } from "@bitwarden/common/models/response/list.response"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; @@ -45,6 +46,7 @@ export class ListCommand { private accountService: AccountService, private keyService: KeyService, private cliRestrictedItemTypesService: CliRestrictedItemTypesService, + private cipherArchiveService: CipherArchiveService, ) {} async run(object: string, cmdOptions: Record): Promise { @@ -71,8 +73,13 @@ export class ListCommand { let ciphers: CipherView[]; const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const userCanArchive = await firstValueFrom( + this.cipherArchiveService.userCanArchive$(activeUserId), + ); options.trash = options.trash || false; + options.archived = userCanArchive && options.archived; + if (options.url != null && options.url.trim() !== "") { ciphers = await this.cipherService.getAllDecryptedForUrl(options.url, activeUserId); } else { @@ -85,9 +92,12 @@ export class ListCommand { options.organizationId != null ) { ciphers = ciphers.filter((c) => { - if (options.trash !== c.isDeleted) { + const matchesStateOptions = this.matchesStateOptions(c, options); + + if (!matchesStateOptions) { return false; } + if (options.folderId != null) { if (options.folderId === "notnull" && c.folderId != null) { return true; @@ -131,11 +141,16 @@ export class ListCommand { return false; }); } else if (options.search == null || options.search.trim() === "") { - ciphers = ciphers.filter((c) => options.trash === c.isDeleted); + ciphers = ciphers.filter((c) => this.matchesStateOptions(c, options)); } if (options.search != null && options.search.trim() !== "") { - ciphers = this.searchService.searchCiphersBasic(ciphers, options.search, options.trash); + ciphers = this.searchService.searchCiphersBasic( + ciphers, + options.search, + options.trash, + options.archived, + ); } ciphers = await this.cliRestrictedItemTypesService.filterRestrictedCiphers(ciphers); @@ -287,6 +302,17 @@ export class ListCommand { const res = new ListResponse(organizations.map((o) => new OrganizationResponse(o))); return Response.success(res); } + + /** + * Checks if the cipher passes either the trash or the archive options. + * @returns true if the cipher passes *any* of the filters + */ + private matchesStateOptions(c: CipherView, options: Options): boolean { + const passesTrashFilter = options.trash && c.isDeleted; + const passesArchivedFilter = options.archived && c.isArchived; + + return passesTrashFilter || passesArchivedFilter; + } } class Options { @@ -296,6 +322,7 @@ class Options { search: string; url: string; trash: boolean; + archived: boolean; constructor(passedOptions: Record) { this.organizationId = passedOptions?.organizationid || passedOptions?.organizationId; @@ -304,5 +331,6 @@ class Options { this.search = passedOptions?.search; this.url = passedOptions?.url; this.trash = CliUtils.convertBooleanOption(passedOptions?.trash); + this.archived = CliUtils.convertBooleanOption(passedOptions?.archived); } } diff --git a/apps/cli/src/commands/restore.command.ts b/apps/cli/src/commands/restore.command.ts index 0b30193ffd4..d8cefdfce5d 100644 --- a/apps/cli/src/commands/restore.command.ts +++ b/apps/cli/src/commands/restore.command.ts @@ -2,8 +2,14 @@ import { firstValueFrom } from "rxjs"; 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 { CipherId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { UserId } from "@bitwarden/user-core"; import { Response } from "../models/response"; @@ -12,6 +18,8 @@ export class RestoreCommand { private cipherService: CipherService, private accountService: AccountService, private cipherAuthorizationService: CipherAuthorizationService, + private cipherArchiveService: CipherArchiveService, + private configService: ConfigService, ) {} async run(object: string, id: string): Promise { @@ -30,10 +38,23 @@ export class RestoreCommand { private async restoreCipher(id: string) { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipher = await this.cipherService.get(id, activeUserId); + const isArchivedVaultEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive), + ); if (cipher == null) { return Response.notFound(); } + + if (cipher.archivedDate && isArchivedVaultEnabled) { + return this.restoreArchivedCipher(cipher, activeUserId); + } else { + return this.restoreDeletedCipher(cipher, activeUserId); + } + } + + /** Restores a cipher from the trash. */ + private async restoreDeletedCipher(cipher: Cipher, userId: UserId) { if (cipher.deletedDate == null) { return Response.badRequest("Cipher is not in trash."); } @@ -47,7 +68,17 @@ export class RestoreCommand { } try { - await this.cipherService.restoreWithServer(id, activeUserId); + await this.cipherService.restoreWithServer(cipher.id, userId); + return Response.success(); + } catch (e) { + return Response.error(e); + } + } + + /** Restore a cipher from the archive vault */ + private async restoreArchivedCipher(cipher: Cipher, userId: UserId) { + try { + await this.cipherArchiveService.unarchiveWithServer(cipher.id as CipherId, userId); return Response.success(); } catch (e) { return Response.error(e); diff --git a/apps/cli/src/commands/serve.command.ts b/apps/cli/src/commands/serve.command.ts index c0ec37d3c9c..5bf19333f35 100644 --- a/apps/cli/src/commands/serve.command.ts +++ b/apps/cli/src/commands/serve.command.ts @@ -51,7 +51,7 @@ export class ServeCommand { .use(koaBodyParser()) .use(koaJson({ pretty: false, param: "pretty" })); - this.serveConfigurator.configureRouter(router); + await this.serveConfigurator.configureRouter(router); server.use(router.routes()).use(router.allowedMethods()); diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index 6ae2776eae7..3c80d12af2f 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -5,6 +5,8 @@ import * as koaRouter from "@koa/router"; import * as koa from "koa"; import { firstValueFrom, map } from "rxjs"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import { ConfirmCommand } from "./admin-console/commands/confirm.command"; import { ShareCommand } from "./admin-console/commands/share.command"; import { LockCommand } from "./auth/commands/lock.command"; @@ -26,6 +28,7 @@ import { SendListCommand, SendRemovePasswordCommand, } from "./tools/send"; +import { ArchiveCommand } from "./vault/archive.command"; import { CreateCommand } from "./vault/create.command"; import { DeleteCommand } from "./vault/delete.command"; import { SyncCommand } from "./vault/sync.command"; @@ -40,6 +43,7 @@ export class OssServeConfigurator { private statusCommand: StatusCommand; private syncCommand: SyncCommand; private deleteCommand: DeleteCommand; + private archiveCommand: ArchiveCommand; private confirmCommand: ConfirmCommand; private restoreCommand: RestoreCommand; private lockCommand: LockCommand; @@ -81,6 +85,7 @@ export class OssServeConfigurator { this.serviceContainer.accountService, this.serviceContainer.keyService, this.serviceContainer.cliRestrictedItemTypesService, + this.serviceContainer.cipherArchiveService, ); this.createCommand = new CreateCommand( this.serviceContainer.cipherService, @@ -104,6 +109,7 @@ export class OssServeConfigurator { this.serviceContainer.accountService, this.serviceContainer.cliRestrictedItemTypesService, this.serviceContainer.policyService, + this.serviceContainer.billingAccountProfileStateService, ); this.generateCommand = new GenerateCommand( this.serviceContainer.passwordGenerationService, @@ -127,6 +133,13 @@ export class OssServeConfigurator { this.serviceContainer.accountService, this.serviceContainer.cliRestrictedItemTypesService, ); + this.archiveCommand = new ArchiveCommand( + this.serviceContainer.cipherService, + this.serviceContainer.accountService, + this.serviceContainer.configService, + this.serviceContainer.cipherArchiveService, + this.serviceContainer.billingAccountProfileStateService, + ); this.confirmCommand = new ConfirmCommand( this.serviceContainer.apiService, this.serviceContainer.keyService, @@ -140,6 +153,8 @@ export class OssServeConfigurator { this.serviceContainer.cipherService, this.serviceContainer.accountService, this.serviceContainer.cipherAuthorizationService, + this.serviceContainer.cipherArchiveService, + this.serviceContainer.configService, ); this.shareCommand = new ShareCommand( this.serviceContainer.cipherService, @@ -199,7 +214,7 @@ export class OssServeConfigurator { ); } - configureRouter(router: koaRouter) { + async configureRouter(router: koaRouter) { router.get("/generate", async (ctx, next) => { const response = await this.generateCommand.run(ctx.request.query); this.processResponse(ctx.response, response); @@ -401,6 +416,23 @@ export class OssServeConfigurator { this.processResponse(ctx.response, response); await next(); }); + + const isArchivedEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.PM19148_InnovationArchive, + ); + + if (isArchivedEnabled) { + router.post("/archive/:object/:id", async (ctx, next) => { + if (await this.errorIfLocked(ctx.response)) { + await next(); + return; + } + let response: Response = null; + response = await this.archiveCommand.run(ctx.params.object, ctx.params.id); + this.processResponse(ctx.response, response); + await next(); + }); + } } protected processResponse(res: koa.Response, commandResponse: Response) { diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 4d541739aab..8f202bc0845 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -5,6 +5,7 @@ import { program, Command, OptionValues } from "commander"; import { firstValueFrom, of, switchMap } from "rxjs"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockCommand } from "./auth/commands/lock.command"; import { LoginCommand } from "./auth/commands/login.command"; @@ -26,6 +27,10 @@ const writeLn = CliUtils.writeLn; export class Program extends BaseProgram { async register() { + const isArchivedEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.PM19148_InnovationArchive, + ); + program .option("--pretty", "Format output. JSON is tabbed with two spaces.") .option("--raw", "Return raw output instead of a descriptive message.") @@ -94,6 +99,9 @@ export class Program extends BaseProgram { " bw edit folder c7c7b60b-9c61-40f2-8ccd-36c49595ed72 eyJuYW1lIjoiTXkgRm9sZGVyMiJ9Cg==", ); writeLn(" bw delete item 99ee88d2-6046-4ea7-92c2-acac464b1412"); + if (isArchivedEnabled) { + writeLn(" bw archive item 99ee88d2-6046-4ea7-92c2-acac464b1412"); + } writeLn(" bw generate -lusn --length 18"); writeLn(" bw config server https://bitwarden.example.com"); writeLn(" bw send -f ./file.ext"); diff --git a/apps/cli/src/register-oss-programs.ts b/apps/cli/src/register-oss-programs.ts index 1fc1f0119d2..71d7aaa0d52 100644 --- a/apps/cli/src/register-oss-programs.ts +++ b/apps/cli/src/register-oss-programs.ts @@ -15,7 +15,7 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) { await program.register(); const vaultProgram = new VaultProgram(serviceContainer); - vaultProgram.register(); + await vaultProgram.register(); const sendProgram = new SendProgram(serviceContainer); sendProgram.register(); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 7b148b2a3d5..8fb48fbc1ee 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -125,6 +125,7 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { @@ -132,6 +133,7 @@ import { DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; +import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; @@ -303,6 +305,7 @@ export class ServiceContainer { cipherEncryptionService: CipherEncryptionService; restrictedItemTypesService: RestrictedItemTypesService; cliRestrictedItemTypesService: CliRestrictedItemTypesService; + cipherArchiveService: CipherArchiveService; constructor() { let p = null; @@ -730,6 +733,13 @@ export class ServiceContainer { this.messagingService, ); + this.cipherArchiveService = new DefaultCipherArchiveService( + this.cipherService, + this.apiService, + this.billingAccountProfileStateService, + this.configService, + ); + this.folderService = new FolderService( this.keyService, this.encryptService, diff --git a/apps/cli/src/vault.program.ts b/apps/cli/src/vault.program.ts index 5b35f6b0499..21f87feab00 100644 --- a/apps/cli/src/vault.program.ts +++ b/apps/cli/src/vault.program.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { program, Command } from "commander"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + import { ConfirmCommand } from "./admin-console/commands/confirm.command"; import { ShareCommand } from "./admin-console/commands/share.command"; import { BaseProgram } from "./base-program"; @@ -13,25 +15,34 @@ import { Response } from "./models/response"; import { ExportCommand } from "./tools/export.command"; import { ImportCommand } from "./tools/import.command"; import { CliUtils } from "./utils"; +import { ArchiveCommand } from "./vault/archive.command"; import { CreateCommand } from "./vault/create.command"; import { DeleteCommand } from "./vault/delete.command"; const writeLn = CliUtils.writeLn; export class VaultProgram extends BaseProgram { - register() { + async register() { + const isArchivedEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.PM19148_InnovationArchive, + ); + program - .addCommand(this.listCommand()) + .addCommand(this.listCommand(isArchivedEnabled)) .addCommand(this.getCommand()) .addCommand(this.createCommand()) .addCommand(this.editCommand()) .addCommand(this.deleteCommand()) - .addCommand(this.restoreCommand()) + .addCommand(this.restoreCommand(isArchivedEnabled)) .addCommand(this.shareCommand("move", false)) .addCommand(this.confirmCommand()) .addCommand(this.importCommand()) .addCommand(this.exportCommand()) .addCommand(this.shareCommand("share", true)); + + if (isArchivedEnabled) { + program.addCommand(this.archiveCommand()); + } } private validateObject(requestedObject: string, validObjects: string[]): boolean { @@ -42,7 +53,7 @@ export class VaultProgram extends BaseProgram { Response.badRequest( 'Unknown object "' + requestedObject + - '". Allowed objects are ' + + '". Allowed objects are: ' + validObjects.join(", ") + ".", ), @@ -51,7 +62,7 @@ export class VaultProgram extends BaseProgram { return success; } - private listCommand(): Command { + private listCommand(isArchivedEnabled: boolean): Command { const listObjects = [ "items", "folders", @@ -61,7 +72,7 @@ export class VaultProgram extends BaseProgram { "organizations", ]; - return new Command("list") + const command = new Command("list") .argument("", "Valid objects are: " + listObjects.join(", ")) .description("List an array of objects from the vault.") .option("--search ", "Perform a search on the listed objects.") @@ -94,6 +105,9 @@ export class VaultProgram extends BaseProgram { " bw list items --folderid 60556c31-e649-4b5d-8daf-fc1c391a1bf2 --organizationid notnull", ); writeLn(" bw list items --trash"); + if (isArchivedEnabled) { + writeLn(" bw list items --archived"); + } writeLn(" bw list folders --search email"); writeLn(" bw list org-members --organizationid 60556c31-e649-4b5d-8daf-fc1c391a1bf2"); writeLn("", true); @@ -116,11 +130,18 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.accountService, this.serviceContainer.keyService, this.serviceContainer.cliRestrictedItemTypesService, + this.serviceContainer.cipherArchiveService, ); const response = await command.run(object, cmd); this.processResponse(response); }); + + if (isArchivedEnabled) { + command.option("--archived", "Filter items that are archived."); + } + + return command; } private getCommand(): Command { @@ -286,6 +307,7 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.accountService, this.serviceContainer.cliRestrictedItemTypesService, this.serviceContainer.policyService, + this.serviceContainer.billingAccountProfileStateService, ); const response = await command.run(object, id, encodedJson, cmd); this.processResponse(response); @@ -336,12 +358,41 @@ export class VaultProgram extends BaseProgram { }); } - private restoreCommand(): Command { + private archiveCommand(): Command { + const archiveObjects = ["item"]; + return new Command("archive") + .argument("", "Valid objects are: " + archiveObjects.join(", ")) + .argument("", "Object's globally unique `id`.") + .description("Archive an object from the vault.") + .on("--help", () => { + writeLn("\n Examples:"); + writeLn(""); + writeLn(" bw archive item 7063feab-4b10-472e-b64c-785e2b870b92"); + writeLn("", true); + }) + .action(async (object, id) => { + if (!this.validateObject(object, archiveObjects)) { + return; + } + + await this.exitIfLocked(); + const command = new ArchiveCommand( + this.serviceContainer.cipherService, + this.serviceContainer.accountService, + this.serviceContainer.configService, + this.serviceContainer.cipherArchiveService, + this.serviceContainer.billingAccountProfileStateService, + ); + const response = await command.run(object, id); + this.processResponse(response); + }); + } + + private restoreCommand(isArchivedEnabled: boolean): Command { const restoreObjects = ["item"]; - return new Command("restore") + const command = new Command("restore") .argument("", "Valid objects are: " + restoreObjects.join(", ")) .argument("", "Object's globally unique `id`.") - .description("Restores an object from the trash.") .on("--help", () => { writeLn("\n Examples:"); writeLn(""); @@ -358,10 +409,20 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.cipherService, this.serviceContainer.accountService, this.serviceContainer.cipherAuthorizationService, + this.serviceContainer.cipherArchiveService, + this.serviceContainer.configService, ); const response = await command.run(object, id); this.processResponse(response); }); + + if (isArchivedEnabled) { + command.description("Restores an object from the trash or archive."); + } else { + command.description("Restores an object from the trash."); + } + + return command; } private shareCommand(commandName: string, deprecated: boolean): Command { diff --git a/apps/cli/src/vault/archive.command.ts b/apps/cli/src/vault/archive.command.ts new file mode 100644 index 00000000000..5ced2282c6d --- /dev/null +++ b/apps/cli/src/vault/archive.command.ts @@ -0,0 +1,109 @@ +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherViewLikeUtils } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { UserId } from "@bitwarden/user-core"; + +import { Response } from "../models/response"; + +export class ArchiveCommand { + constructor( + private cipherService: CipherService, + private accountService: AccountService, + private configService: ConfigService, + private cipherArchiveService: CipherArchiveService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + ) {} + + async run(object: string, id: string): Promise { + const featureFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM19148_InnovationArchive, + ); + + if (!featureFlagEnabled) { + return Response.notFound(); + } + + if (id != null) { + id = id.toLowerCase(); + } + + const normalizedObject = object.toLowerCase(); + + if (normalizedObject === "item") { + return this.archiveCipher(id); + } + + return Response.badRequest("Unknown object."); + } + + private async archiveCipher(cipherId: string) { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const cipher = await this.cipherService.get(cipherId, activeUserId); + + if (cipher == null) { + return Response.notFound(); + } + + const cipherView = await this.cipherService.decrypt(cipher, activeUserId); + + const { canArchive, errorMessage } = await this.userCanArchiveCipher(cipherView, activeUserId); + + if (!canArchive) { + return Response.error(errorMessage); + } + + try { + await this.cipherArchiveService.archiveWithServer(cipherView.id as CipherId, activeUserId); + return Response.success(); + } catch (e) { + return Response.error(e); + } + } + + /** + * Determines if the user can archive the given cipher. + * When the user cannot archive the cipher, an appropriate error message is provided. + */ + private async userCanArchiveCipher( + cipher: CipherView, + userId: UserId, + ): Promise< + { canArchive: true; errorMessage?: never } | { canArchive: false; errorMessage: string } + > { + const hasPremiumFromAnySource = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId), + ); + + switch (true) { + case !hasPremiumFromAnySource: { + return { + canArchive: false, + errorMessage: "Premium status is required to use this feature.", + }; + } + case CipherViewLikeUtils.isArchived(cipher): { + return { canArchive: false, errorMessage: "Item is already archived." }; + } + case CipherViewLikeUtils.isDeleted(cipher): { + return { + canArchive: false, + errorMessage: "Item is in the trash, the item must be restored before archiving.", + }; + } + case cipher.organizationId != null: { + return { canArchive: false, errorMessage: "Cannot archive items in an organization." }; + } + default: + return { canArchive: true }; + } + } +} diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 55f474f2992..9020e08362e 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -342,6 +342,7 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" name = "autotype" version = "0.0.0" dependencies = [ + "anyhow", "tracing", "windows 0.61.1", "windows-core 0.61.0", @@ -2897,9 +2898,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b369d18893388b345804dc0007963c99b7d665ae71d275812d828c6f089640" +checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" dependencies = [ "bitflags", "core-foundation", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index b8f582f43de..0a637b12de9 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -49,7 +49,7 @@ rsa = "=0.9.6" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" secmem-proc = "=0.3.7" -security-framework = "=3.4.0" +security-framework = "=3.5.0" security-framework-sys = "=2.15.0" serde = "=1.0.209" serde_json = "=1.0.127" diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml index ceccd0c890a..3d1e74254ce 100644 --- a/apps/desktop/desktop_native/autotype/Cargo.toml +++ b/apps/desktop/desktop_native/autotype/Cargo.toml @@ -5,6 +5,9 @@ license.workspace = true edition.workspace = true publish.workspace = true +[dependencies] +anyhow = { workspace = true } + [target.'cfg(windows)'.dependencies] tracing.workspace = true windows = { workspace = true, features = [ diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs index 6d7b9f9db85..92996996434 100644 --- a/apps/desktop/desktop_native/autotype/src/lib.rs +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -1,3 +1,5 @@ +use anyhow::Result; + #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(target_os = "windows", path = "windows.rs")] @@ -5,18 +7,26 @@ mod windowing; /// Gets the title bar string for the foreground window. /// -/// TODO: The error handling will be improved in a future PR: PM-23615 -#[allow(clippy::result_unit_err)] -pub fn get_foreground_window_title() -> std::result::Result { +/// # Errors +/// +/// This function returns an `anyhow::Error` if there is any +/// issue obtaining the window title. Detailed reasons will +/// vary based on platform implementation. +pub fn get_foreground_window_title() -> Result { windowing::get_foreground_window_title() } /// Attempts to type the input text wherever the user's cursor is. /// -/// `input` must be an array of utf-16 encoded characters to insert. +/// # Arguments /// -/// TODO: The error handling will be improved in a future PR: PM-23615 -#[allow(clippy::result_unit_err)] -pub fn type_input(input: Vec) -> std::result::Result<(), ()> { - windowing::type_input(input) +/// * `input` must be an array of utf-16 encoded characters to insert. +/// +/// # Errors +/// +/// This function returns an `anyhow::Error` if there is any +/// issue obtaining the window title. Detailed reasons will +/// vary based on platform implementation. +pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { + windowing::type_input(input, keyboard_shortcut) } diff --git a/apps/desktop/desktop_native/autotype/src/linux.rs b/apps/desktop/desktop_native/autotype/src/linux.rs index d53d7af0bd9..9fda0ed9e33 100644 --- a/apps/desktop/desktop_native/autotype/src/linux.rs +++ b/apps/desktop/desktop_native/autotype/src/linux.rs @@ -1,7 +1,7 @@ -pub fn get_foreground_window_title() -> std::result::Result { +pub fn get_foreground_window_title() -> anyhow::Result { todo!("Bitwarden does not yet support Linux autotype"); } -pub fn type_input(_input: Vec) -> std::result::Result<(), ()> { +pub fn type_input(_input: Vec, _keyboard_shortcut: Vec) -> anyhow::Result<()> { todo!("Bitwarden does not yet support Linux autotype"); } diff --git a/apps/desktop/desktop_native/autotype/src/macos.rs b/apps/desktop/desktop_native/autotype/src/macos.rs index 7ab9f5441b7..c6681a3291e 100644 --- a/apps/desktop/desktop_native/autotype/src/macos.rs +++ b/apps/desktop/desktop_native/autotype/src/macos.rs @@ -1,7 +1,7 @@ -pub fn get_foreground_window_title() -> std::result::Result { - todo!("Bitwarden does not yet support Mac OS autotype"); +pub fn get_foreground_window_title() -> anyhow::Result { + todo!("Bitwarden does not yet support macOS autotype"); } -pub fn type_input(_input: Vec) -> std::result::Result<(), ()> { - todo!("Bitwarden does not yet support Mac OS autotype"); +pub fn type_input(_input: Vec, _keyboard_shortcut: Vec) -> anyhow::Result<()> { + todo!("Bitwarden does not yet support macOS autotype"); } diff --git a/apps/desktop/desktop_native/autotype/src/windows.rs b/apps/desktop/desktop_native/autotype/src/windows.rs index f1f9bee7f60..1e125ef8e21 100644 --- a/apps/desktop/desktop_native/autotype/src/windows.rs +++ b/apps/desktop/desktop_native/autotype/src/windows.rs @@ -1,49 +1,156 @@ -use std::ffi::OsString; -use std::os::windows::ffi::OsStringExt; +use std::{ffi::OsString, os::windows::ffi::OsStringExt}; -use tracing::debug; -use windows::Win32::Foundation::{GetLastError, HWND}; -use windows::Win32::UI::Input::KeyboardAndMouse::{ - SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, - VIRTUAL_KEY, -}; -use windows::Win32::UI::WindowsAndMessaging::{ - GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, +use anyhow::{anyhow, Result}; +use tracing::{debug, error, warn}; +use windows::Win32::{ + Foundation::{GetLastError, SetLastError, HWND, WIN32_ERROR}, + UI::{ + Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, + KEYEVENTF_UNICODE, VIRTUAL_KEY, + }, + WindowsAndMessaging::{GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW}, + }, }; +const WIN32_SUCCESS: WIN32_ERROR = WIN32_ERROR(0); + +fn clear_last_error() { + debug!("Clearing last error with SetLastError."); + unsafe { + SetLastError(WIN32_ERROR(0)); + } +} + +fn get_last_error() -> WIN32_ERROR { + let last_err = unsafe { GetLastError() }; + debug!("GetLastError(): {}", last_err.to_hresult().message()); + last_err +} + +// The handle should be validated before any unsafe calls referencing it. +fn validate_window_handle(handle: &HWND) -> Result<()> { + if handle.is_invalid() { + error!("Window handle is invalid."); + return Err(anyhow!("Window handle is invalid.")); + } + Ok(()) +} + +// ---------- Window title -------------- + /// Gets the title bar string for the foreground window. -pub fn get_foreground_window_title() -> std::result::Result { - let Ok(window_handle) = get_foreground_window() else { - return Err(()); - }; - let Ok(Some(window_title)) = get_window_title(window_handle) else { - return Err(()); - }; +pub fn get_foreground_window_title() -> Result { + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow + let window_handle = unsafe { GetForegroundWindow() }; - Ok(window_title) + debug!("GetForegroundWindow() called."); + + validate_window_handle(&window_handle)?; + + get_window_title(&window_handle) } +/// Gets the length of the window title bar text. +/// +/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw +fn get_window_title_length(window_handle: &HWND) -> Result { + // GetWindowTextLengthW does not itself clear the last error so we must do it ourselves. + clear_last_error(); + + validate_window_handle(window_handle)?; + + let length = unsafe { GetWindowTextLengthW(*window_handle) }; + + let length = usize::try_from(length)?; + + debug!(length, "window text length retrieved from handle."); + + if length == 0 { + // attempt to retreive win32 error + let last_err = get_last_error(); + if last_err != WIN32_SUCCESS { + let last_err = last_err.to_hresult().message(); + error!(last_err, "Error getting window text length."); + return Err(anyhow!("Error getting window text length: {last_err}")); + } + } + + Ok(length) +} + +/// Gets the window title bar title. +/// +/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw +fn get_window_title(window_handle: &HWND) -> Result { + let expected_window_title_length = get_window_title_length(window_handle)?; + + // This isn't considered an error by the windows API, but in practice it means we can't + // match against the title so we'll stop here. + // The upstream will make a contains comparison on what we return, so an empty string + // will not result on a match. + if expected_window_title_length == 0 { + warn!("Window title length is zero."); + return Ok(String::from("")); + } + + let mut buffer: Vec = vec![0; expected_window_title_length + 1]; // add extra space for the null character + + validate_window_handle(window_handle)?; + + let actual_window_title_length = unsafe { GetWindowTextW(*window_handle, &mut buffer) }; + + debug!(actual_window_title_length, "window title retrieved."); + + if actual_window_title_length == 0 { + // attempt to retreive win32 error + let last_err = get_last_error(); + if last_err != WIN32_SUCCESS { + let last_err = last_err.to_hresult().message(); + error!(last_err, "Error retrieving window title."); + return Err(anyhow!("Error retrieving window title. {last_err}")); + } + // in practice, we should not get to the below code, since we asserted the len > 0 + // above. but it is an extra protection in case the windows API didn't set an error. + warn!(expected_window_title_length, "No window title retrieved."); + } + + let window_title = OsString::from_wide(&buffer); + + Ok(window_title.to_string_lossy().into_owned()) +} + +// ---------- Type Input -------------- + /// Attempts to type the input text wherever the user's cursor is. /// -/// `input` must be an array of utf-16 encoded characters to insert. +/// `input` must be a vector of utf-16 encoded characters to insert. +/// `keyboard_shortcut` must be a vector of Strings, where valid shortcut keys: Control, Alt, Super, Shift, letters a - Z /// /// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -pub fn type_input(input: Vec) -> Result<(), ()> { - const TAB_KEY: u16 = 9; - let mut keyboard_inputs: Vec = Vec::new(); +pub fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { + const TAB_KEY: u8 = 9; - // Release hotkeys - keyboard_inputs.push(build_virtual_key_input(InputKeyPress::Up, 0x11)); // ctrl - keyboard_inputs.push(build_virtual_key_input(InputKeyPress::Up, 0x10)); // shift - keyboard_inputs.push(build_unicode_input(InputKeyPress::Up, 42)); // b + // the length of this vec is always shortcut keys to release + (2x length of input chars) + let mut keyboard_inputs: Vec = + Vec::with_capacity(keyboard_shortcut.len() + (input.len() * 2)); + debug!(?keyboard_shortcut, "Converting keyboard shortcut to input."); + + // Add key "up" inputs for the shortcut + for key in keyboard_shortcut { + keyboard_inputs.push(convert_shortcut_key_to_up_input(key)?); + } + + // Add key "down" and "up" inputs for the input + // (currently in this form: {username}/t{password}) for i in input { - let next_down_input = if i == TAB_KEY { + let next_down_input = if i == TAB_KEY.into() { build_virtual_key_input(InputKeyPress::Down, i as u8) } else { build_unicode_input(InputKeyPress::Down, i) }; - let next_up_input = if i == TAB_KEY { + let next_up_input = if i == TAB_KEY.into() { build_virtual_key_input(InputKeyPress::Up, i as u8) } else { build_unicode_input(InputKeyPress::Up, i) @@ -56,59 +163,65 @@ pub fn type_input(input: Vec) -> Result<(), ()> { send_input(keyboard_inputs) } -/// Gets the foreground window handle. +/// Converts a valid shortcut key to an "up" keyboard input. /// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getforegroundwindow -fn get_foreground_window() -> Result { - let foreground_window_handle = unsafe { GetForegroundWindow() }; +/// `input` must be a valid shortcut key: Control, Alt, Super, Shift, letters [a-z][A-Z] +fn convert_shortcut_key_to_up_input(key: String) -> Result { + const SHIFT_KEY: u8 = 0x10; + const SHIFT_KEY_STR: &str = "Shift"; + const CONTROL_KEY: u8 = 0x11; + const CONTROL_KEY_STR: &str = "Control"; + const ALT_KEY: u8 = 0x12; + const ALT_KEY_STR: &str = "Alt"; + const LEFT_WINDOWS_KEY: u8 = 0x5B; + const LEFT_WINDOWS_KEY_STR: &str = "Super"; - if foreground_window_handle.is_invalid() { - return Err(()); - } - - Ok(foreground_window_handle) + Ok(match key.as_str() { + SHIFT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, SHIFT_KEY), + CONTROL_KEY_STR => build_virtual_key_input(InputKeyPress::Up, CONTROL_KEY), + ALT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, ALT_KEY), + LEFT_WINDOWS_KEY_STR => build_virtual_key_input(InputKeyPress::Up, LEFT_WINDOWS_KEY), + _ => build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?), + }) } -/// Gets the length of the window title bar text. +/// Given a letter that is a String, get the utf16 encoded +/// decimal version of the letter as long as it meets the +/// [a-z][A-Z] restriction. /// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextlengthw -fn get_window_title_length(window_handle: HWND) -> Result { - if window_handle.is_invalid() { - return Err(()); +/// Because we only accept [a-z][A-Z], the decimal u16 +/// cast of the letter is safe because the unicode code point +/// of these characters fits in a u16. +fn get_alphabetic_hotkey(letter: String) -> Result { + if letter.len() != 1 { + error!( + len = letter.len(), + "Final keyboard shortcut key should be a single character." + ); + return Err(anyhow!( + "Final keyboard shortcut key should be a single character: {letter}" + )); } - match usize::try_from(unsafe { GetWindowTextLengthW(window_handle) }) { - Ok(length) => Ok(length), - Err(_) => Err(()), + let c = letter.chars().next().expect("letter is size 1"); + + // is_ascii_alphabetic() checks for: + // U+0041 `A` ..= U+005A `Z`, or U+0061 `a` ..= U+007A `z` + if !c.is_ascii_alphabetic() { + error!(letter = %c, "Letter is not ASCII Alphabetic ([a-z][A-Z])."); + return Err(anyhow!( + "Letter is not ASCII Alphabetic ([a-z][A-Z]): '{letter}'", + )); } + + let c = c as u16; + + debug!(c, letter, "Got alphabetic hotkey."); + + Ok(c) } -/// Gets the window title bar title. -/// -/// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowtextw -fn get_window_title(window_handle: HWND) -> Result, ()> { - if window_handle.is_invalid() { - return Err(()); - } - - let window_title_length = get_window_title_length(window_handle)?; - if window_title_length == 0 { - return Ok(None); - } - - let mut buffer: Vec = vec![0; window_title_length + 1]; // add extra space for the null character - - let window_title_length = unsafe { GetWindowTextW(window_handle, &mut buffer) }; - if window_title_length == 0 { - return Ok(None); - } - - let window_title = OsString::from_wide(&buffer); - - Ok(Some(window_title.to_string_lossy().into_owned())) -} - -/// Used in build_input() to specify if an input key is being pressed (down) or released (up). +/// An input key can be either pressed (down), or released (up). enum InputKeyPress { Down, Up, @@ -184,17 +297,57 @@ fn build_virtual_key_input(key_press: InputKeyPress, virtual_key: u8) -> INPUT { /// Attempts to type the provided input wherever the user's cursor is. /// /// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput -fn send_input(inputs: Vec) -> Result<(), ()> { +fn send_input(inputs: Vec) -> Result<()> { let insert_count = unsafe { SendInput(&inputs, std::mem::size_of::() as i32) }; - let e = unsafe { GetLastError().to_hresult().message() }; - debug!("type_input() called, GetLastError() is: {:?}", e); + debug!("SendInput() called."); if insert_count == 0 { - return Err(()); // input was blocked by another thread + let last_err = get_last_error().to_hresult().message(); + error!(GetLastError = %last_err, "SendInput sent 0 inputs. Input was blocked by another thread."); + + return Err(anyhow!("SendInput sent 0 inputs. Input was blocked by another thread. GetLastError: {last_err}")); } else if insert_count != inputs.len() as u32 { - return Err(()); // input insertion not completed + let last_err = get_last_error().to_hresult().message(); + error!(sent = %insert_count, expected = inputs.len(), GetLastError = %last_err, + "SendInput sent does not match expected." + ); + return Err(anyhow!( + "SendInput does not match expected. sent: {insert_count}, expected: {}", + inputs.len() + )); } + debug!(insert_count, "Autotype sent input."); + Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_alphabetic_hot_key_happy() { + for c in ('a'..='z').chain('A'..='Z') { + let letter = c.to_string(); + println!("{}", letter); + let converted = get_alphabetic_hotkey(letter).unwrap(); + assert_eq!(converted, c as u16); + } + } + + #[test] + #[should_panic = "Final keyboard shortcut key should be a single character: foo"] + fn get_alphabetic_hot_key_fail_not_single_char() { + let letter = String::from("foo"); + get_alphabetic_hotkey(letter).unwrap(); + } + + #[test] + #[should_panic = "Letter is not ASCII Alphabetic ([a-z][A-Z]): '}'"] + fn get_alphabetic_hot_key_fail_not_alphabetic() { + let letter = String::from("}"); + get_alphabetic_hotkey(letter).unwrap(); + } +} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index d038ba2277f..3440a0114ae 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -11,6 +11,7 @@ use bitwarden_russh::{ session_bind::SessionBindResult, ssh_agent::{self, SshKey}, }; +use tracing::{error, info}; #[cfg_attr(target_os = "windows", path = "windows.rs")] #[cfg_attr(target_os = "macos", path = "unix.rs")] @@ -86,7 +87,7 @@ impl ssh_agent::Agent info: &peerinfo::models::PeerInfo, ) -> bool { if !self.is_running() { - println!("[BitwardenDesktopAgent] Agent is not running, but tried to call confirm"); + error!("Agent is not running, but tried to call confirm"); return false; } @@ -94,7 +95,7 @@ impl ssh_agent::Agent let request_data = match request_parser::parse_request(data) { Ok(data) => data, Err(e) => { - println!("[SSH Agent] Error while parsing request: {e}"); + error!(error = %e, "Error while parsing request"); return false; } }; @@ -105,12 +106,12 @@ impl ssh_agent::Agent _ => None, }; - println!( - "[SSH Agent] Confirming request from application: {}, is_forwarding: {}, namespace: {}, host_key: {}", + info!( + is_forwarding = %info.is_forwarding(), + namespace = ?namespace.as_ref(), + host_key = %STANDARD.encode(info.host_key()), + "Confirming request from application: {}", info.process_name(), - info.is_forwarding(), - namespace.clone().unwrap_or_default(), - STANDARD.encode(info.host_key()) ); let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe(); @@ -172,7 +173,7 @@ impl ssh_agent::Agent connection_info.set_host_key(session_bind_info.host_key.clone()); } SessionBindResult::SignatureFailure => { - println!("[BitwardenDesktopAgent] Session bind failure: Signature failure"); + error!("Session bind failure: Signature failure"); } } } @@ -181,7 +182,7 @@ impl ssh_agent::Agent impl BitwardenDesktopAgent { pub fn stop(&self) { if !self.is_running() { - println!("[BitwardenDesktopAgent] Tried to stop agent while it is not running"); + error!("Tried to stop agent while it is not running"); return; } @@ -227,7 +228,7 @@ impl BitwardenDesktopAgent { ); } Err(e) => { - eprintln!("[SSH Agent Native Module] Error while parsing key: {e}"); + error!(error=%e, "Error while parsing key"); } } } @@ -265,7 +266,7 @@ impl BitwardenDesktopAgent { fn get_request_id(&self) -> u32 { if !self.is_running() { - println!("[BitwardenDesktopAgent] Agent is not running, but tried to get request id"); + error!("Agent is not running, but tried to get request id"); return 0; } diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs b/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs index fccd7ca5ed6..cb10e873a33 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs @@ -14,6 +14,7 @@ use tokio::{ select, }; use tokio_util::sync::CancellationToken; +use tracing::{error, info}; use windows::Win32::{Foundation::HANDLE, System::Pipes::GetNamedPipeClientProcessId}; use crate::ssh_agent::peerinfo::{self, models::PeerInfo}; @@ -31,42 +32,38 @@ impl NamedPipeServerStream { pub fn new(cancellation_token: CancellationToken, is_running: Arc) -> Self { let (tx, rx) = tokio::sync::mpsc::channel(16); tokio::spawn(async move { - println!( - "[SSH Agent Native Module] Creating named pipe server on {}", - PIPE_NAME - ); + info!("Creating named pipe server on {}", PIPE_NAME); let mut listener = match ServerOptions::new().create(PIPE_NAME) { Ok(pipe) => pipe, - Err(err) => { - println!("[SSH Agent Native Module] Encountered an error creating the first pipe. The system's openssh service must likely be disabled"); - println!("[SSH Agent Natvie Module] error: {}", err); + Err(e) => { + error!(error = %e, "Encountered an error creating the first pipe. The system's openssh service must likely be disabled"); cancellation_token.cancel(); is_running.store(false, Ordering::Relaxed); return; } }; loop { - println!("[SSH Agent Native Module] Waiting for connection"); + info!("Waiting for connection"); select! { _ = cancellation_token.cancelled() => { - println!("[SSH Agent Native Module] Cancellation token triggered, stopping named pipe server"); + info!("[SSH Agent Native Module] Cancellation token triggered, stopping named pipe server"); break; } _ = listener.connect() => { - println!("[SSH Agent Native Module] Incoming connection"); + info!("[SSH Agent Native Module] Incoming connection"); let handle = HANDLE(listener.as_raw_handle()); let mut pid = 0; unsafe { if let Err(e) = GetNamedPipeClientProcessId(handle, &mut pid) { - println!("Error getting named pipe client process id {}", e); + error!(error = %e, pid, "Faile to get named pipe client process id"); continue } }; let peer_info = peerinfo::gather::get_peer_info(pid); let peer_info = match peer_info { - Err(err) => { - println!("Failed getting process info for pid {} {}", pid, err); + Err(e) => { + error!(error = %e, pid = %pid, "Failed getting process info"); continue }, Ok(info) => info, @@ -76,8 +73,8 @@ impl NamedPipeServerStream { listener = match ServerOptions::new().create(PIPE_NAME) { Ok(pipe) => pipe, - Err(err) => { - println!("[SSH Agent Native Module] Encountered an error creating a new pipe {}", err); + Err(e) => { + error!(error = %e, "Encountered an error creating a new pipe"); cancellation_token.cancel(); is_running.store(false, Ordering::Relaxed); return; diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs index 53142d4c476..813ebd61cc1 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs @@ -12,6 +12,7 @@ use bitwarden_russh::ssh_agent; use homedir::my_home; use tokio::{net::UnixListener, sync::Mutex}; use tokio_util::sync::CancellationToken; +use tracing::{error, info}; use crate::ssh_agent::peercred_unix_listener_stream::PeercredUnixListenerStream; @@ -36,14 +37,12 @@ impl BitwardenDesktopAgent { let ssh_path = match std::env::var("BITWARDEN_SSH_AUTH_SOCK") { Ok(path) => path, Err(_) => { - println!("[SSH Agent Native Module] BITWARDEN_SSH_AUTH_SOCK not set, using default path"); + info!("BITWARDEN_SSH_AUTH_SOCK not set, using default path"); let ssh_agent_directory = match my_home() { Ok(Some(home)) => home, _ => { - println!( - "[SSH Agent Native Module] Could not determine home directory" - ); + info!("Could not determine home directory"); return; } }; @@ -65,10 +64,10 @@ impl BitwardenDesktopAgent { } }; - println!("[SSH Agent Native Module] Starting SSH Agent server on {ssh_path:?}"); + info!(socket = %ssh_path, "Starting SSH Agent server"); let sockname = std::path::Path::new(&ssh_path); if let Err(e) = std::fs::remove_file(sockname) { - println!("[SSH Agent Native Module] Could not remove existing socket file: {e}"); + error!(error = %e, socket = %ssh_path, "Could not remove existing socket file"); if e.kind() != std::io::ErrorKind::NotFound { return; } @@ -79,7 +78,7 @@ impl BitwardenDesktopAgent { // Only the current user should be able to access the socket if let Err(e) = fs::set_permissions(sockname, fs::Permissions::from_mode(0o600)) { - println!("[SSH Agent Native Module] Could not set socket permissions: {e}"); + error!(error = %e, socket = ?sockname, "Could not set socket permissions"); return; } @@ -100,10 +99,10 @@ impl BitwardenDesktopAgent { cloned_agent_state .is_running .store(false, std::sync::atomic::Ordering::Relaxed); - println!("[SSH Agent Native Module] SSH Agent server exited"); + info!("SSH Agent server exited"); } Err(e) => { - eprintln!("[SSH Agent Native Module] Error while starting agent server: {e}"); + error!(error = %e, socket = %ssh_path, "Unable to start start agent server"); } } }); diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 2212c03f4f8..030bf4c964d 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -234,5 +234,5 @@ export declare namespace chromium_importer { } export declare namespace autotype { export function getForegroundWindowTitle(): string - export function typeInput(input: Array): void + export function typeInput(input: Array, keyboardShortcut: Array): void } diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 4731166852b..327c7c1c8e5 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -1044,8 +1044,11 @@ pub mod autotype { } #[napi] - pub fn type_input(input: Vec) -> napi::Result<(), napi::Status> { - autotype::type_input(input).map_err(|_| { + pub fn type_input( + input: Vec, + keyboard_shortcut: Vec, + ) -> napi::Result<(), napi::Status> { + autotype::type_input(input, keyboard_shortcut).map_err(|_| { napi::Error::from_reason("Autotype Error: failed to type input".to_string()) }) } diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 18f7f67abc2..a0380a8b5ce 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -340,7 +340,10 @@ (change)="saveEnableAutotype()" />
- {{ "enableAutotypeTransitionKey" | i18n }} + {{ "enableAutotypeShortcutPreview" | i18n }} +
+ {{ form.value.autotypeShortcut }} +
@@ -348,7 +351,13 @@ {{ "important" | i18n }} {{ "enableAutotypeDescriptionTransitionKey" | i18n }} - {{ "editShortcut" | i18n }} + {{ "editShortcut" | i18n }} +
diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index 082801cba0a..a791fd7b9a4 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -183,6 +183,7 @@ describe("SettingsComponent", () => { policyService.policiesByType$.mockReturnValue(of([null])); desktopAutotypeService.resolvedAutotypeEnabled$ = of(false); desktopAutotypeService.autotypeEnabledUserSetting$ = of(false); + desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Shift", "B"]); billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); configService.getFeatureFlag$.mockReturnValue(of(true)); }); diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 0ec77419d02..030027913bc 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -58,6 +58,7 @@ import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/ import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; import { SetPinComponent } from "../../auth/components/set-pin.component"; +import { AutotypeShortcutComponent } from "../../autofill/components/autotype-shortcut.component"; import { SshAgentPromptType } from "../../autofill/models/ssh-agent-setting"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype.service"; @@ -111,6 +112,7 @@ export class SettingsComponent implements OnInit, OnDestroy { requireEnableTray = false; showDuckDuckGoIntegrationOption = false; showEnableAutotype = false; + autotypeShortcut: string; showOpenAtLoginOption = false; isWindows: boolean; isLinux: boolean; @@ -173,6 +175,7 @@ export class SettingsComponent implements OnInit, OnDestroy { value: false, disabled: true, }), + autotypeShortcut: [null as string | null], theme: [null as Theme | null], locale: [null as string | null], }); @@ -397,6 +400,9 @@ export class SettingsComponent implements OnInit, OnDestroy { ), allowScreenshots: !(await firstValueFrom(this.desktopSettingsService.preventScreenshots$)), enableAutotype: await firstValueFrom(this.desktopAutotypeService.autotypeEnabledUserSetting$), + autotypeShortcut: this.getFormattedAutotypeShortcutText( + (await firstValueFrom(this.desktopAutotypeService.autotypeKeyboardShortcut$)) ?? [], + ), theme: await firstValueFrom(this.themeStateService.selectedTheme$), locale: await firstValueFrom(this.i18nService.userSetLocale$), }; @@ -897,6 +903,29 @@ export class SettingsComponent implements OnInit, OnDestroy { async saveEnableAutotype() { await this.desktopAutotypeService.setAutotypeEnabledState(this.form.value.enableAutotype); + const currentShortcut = await firstValueFrom( + this.desktopAutotypeService.autotypeKeyboardShortcut$, + ); + if (currentShortcut) { + this.form.controls.autotypeShortcut.setValue( + this.getFormattedAutotypeShortcutText(currentShortcut), + ); + } + } + + async saveAutotypeShortcut() { + const dialogRef = AutotypeShortcutComponent.open(this.dialogService); + + const newShortcutArray = await firstValueFrom(dialogRef.closed); + + if (!newShortcutArray) { + return; + } + + this.form.controls.autotypeShortcut.setValue( + this.getFormattedAutotypeShortcutText(newShortcutArray), + ); + await this.desktopAutotypeService.setAutotypeKeyboardShortcutState(newShortcutArray); } private async generateVaultTimeoutOptions(): Promise { @@ -944,4 +973,8 @@ export class SettingsComponent implements OnInit, OnDestroy { throw new Error("Unsupported platform"); } } + + getFormattedAutotypeShortcutText(shortcut: string[]) { + return shortcut ? shortcut.join("+").replace("Super", "Win") : null; + } } diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 1e7ef8e0000..1c2d3aa464d 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -77,6 +77,7 @@ import { KeyService, BiometricStateService } from "@bitwarden/key-management"; import { AddEditFolderDialogComponent, AddEditFolderDialogResult } from "@bitwarden/vault"; import { DeleteAccountComponent } from "../auth/delete-account.component"; +import { DesktopAutotypeDefaultSettingPolicy } from "../autofill/services/desktop-autotype-policy.service"; import { PremiumComponent } from "../billing/app/accounts/premium.component"; import { MenuAccount, MenuUpdateRequest } from "../main/menu/menu.updater"; @@ -177,6 +178,7 @@ export class AppComponent implements OnInit, OnDestroy { private readonly documentLangSetter: DocumentLangSetter, private restrictedItemTypesService: RestrictedItemTypesService, private readonly tokenService: TokenService, + private desktopAutotypeDefaultSettingPolicy: DesktopAutotypeDefaultSettingPolicy, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 094dea2e215..9f2bb1acc90 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -37,7 +37,10 @@ import { } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { + PolicyService as PolicyServiceAbstraction, + InternalPolicyService, +} from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService, AccountService as AccountServiceAbstraction, @@ -112,6 +115,7 @@ import { DesktopLoginComponentService } from "../../auth/login/desktop-login-com import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service"; +import { DesktopAutotypeDefaultSettingPolicy } from "../../autofill/services/desktop-autotype-policy.service"; import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype.service"; import { DesktopFido2UserInterfaceService } from "../../autofill/services/desktop-fido2-user-interface.service"; import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service"; @@ -464,8 +468,14 @@ const safeProviders: SafeProvider[] = [ GlobalStateProvider, PlatformUtilsServiceAbstraction, BillingAccountProfileStateService, + DesktopAutotypeDefaultSettingPolicy, ], }), + safeProvider({ + provide: DesktopAutotypeDefaultSettingPolicy, + useClass: DesktopAutotypeDefaultSettingPolicy, + deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService], + }), ]; @NgModule({ diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.html b/apps/desktop/src/autofill/components/autotype-shortcut.component.html new file mode 100644 index 00000000000..774c299e0b6 --- /dev/null +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.html @@ -0,0 +1,33 @@ +
+ +
+ {{ "typeShortcut" | i18n }} +
+
+

+ {{ "editAutotypeShortcutDescription" | i18n }} +

+ + {{ "typeShortcut" | i18n }} + + +
+ + + + +
+
diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts b/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts new file mode 100644 index 00000000000..90aa493c596 --- /dev/null +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts @@ -0,0 +1,281 @@ +import { AbstractControl, FormBuilder, ValidationErrors } from "@angular/forms"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { AutotypeShortcutComponent } from "./autotype-shortcut.component"; + +describe("AutotypeShortcutComponent", () => { + let component: AutotypeShortcutComponent; + let validator: (control: AbstractControl) => ValidationErrors | null; + let formBuilder: MockProxy; + let i18nService: MockProxy; + + beforeEach(() => { + formBuilder = mock(); + i18nService = mock(); + i18nService.t.mockReturnValue("Invalid shortcut"); + component = new AutotypeShortcutComponent(null as any, formBuilder, i18nService); + validator = component["shortcutCombinationValidator"](); + }); + + describe("shortcutCombinationValidator", () => { + const createControl = (value: string | null): AbstractControl => + ({ + value, + }) as AbstractControl; + + describe("valid shortcuts", () => { + it("should accept single modifier with letter", () => { + const validShortcuts = [ + "Control+A", + "Alt+B", + "Shift+C", + "Win+D", + "control+e", // case insensitive + "ALT+F", + "SHIFT+G", + "WIN+H", + ]; + + validShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toBeNull(); + }); + }); + + it("should accept two modifiers with letter", () => { + const validShortcuts = [ + "Control+Alt+A", + "Control+Shift+B", + "Control+Win+C", + "Alt+Shift+D", + "Alt+Win+E", + "Shift+Win+F", + ]; + + validShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toBeNull(); + }); + }); + + it("should accept modifiers in different orders", () => { + const validShortcuts = ["Alt+Control+A", "Shift+Control+B", "Win+Alt+C"]; + + validShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toBeNull(); + }); + }); + }); + + describe("invalid shortcuts", () => { + it("should reject shortcuts without modifiers", () => { + const invalidShortcuts = ["A", "B", "Z", "1", "9"]; + + invalidShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + + it("should reject shortcuts with invalid base keys", () => { + const invalidShortcuts = [ + "Control+1", + "Alt+2", + "Shift+3", + "Win+4", + "Control+!", + "Alt+@", + "Shift+#", + "Win+$", + "Control+Space", + "Alt+Enter", + "Shift+Tab", + "Win+Escape", + ]; + + invalidShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + + it("should reject shortcuts with only modifiers", () => { + const invalidShortcuts = [ + "Control", + "Alt", + "Shift", + "Win", + "Control+Alt", + "Control+Shift", + "Alt+Shift", + "Control+Alt+Shift", + ]; + + invalidShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + + it("should reject shortcuts with invalid modifier names", () => { + const invalidShortcuts = ["Ctrl+A", "Command+A", "Super+A", "Meta+A", "Cmd+A", "Invalid+A"]; + + invalidShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + + it("should reject shortcuts with multiple base keys", () => { + const invalidShortcuts = ["Control+A+B", "Alt+Ctrl+Shift"]; + + invalidShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + + it("should reject shortcuts with more than two modifiers", () => { + const invalidShortcuts = [ + "Control+Alt+Shift+A", + "Control+Alt+Win+B", + "Control+Shift+Win+C", + "Alt+Shift+Win+D", + "Control+Alt+Shift+Win+E", + ]; + + invalidShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + + it("should reject shortcuts with extra characters", () => { + const invalidShortcuts = [ + "Control+A+", + "+Control+A", + "Control++A", + "Control+A+Extra", + "Control A", + "Control-A", + "Control.A", + ]; + + invalidShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + + it("should reject empty or whitespace shortcuts", () => { + // Empty string is handled by required validator + const controlEmpty = createControl(""); + expect(validator(controlEmpty)).toBeNull(); + + // Whitespace strings are invalid shortcuts + const whitespaceShortcuts = [" ", " ", "\t", "\n"]; + + whitespaceShortcuts.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + }); + + describe("edge cases", () => { + it("should handle null and undefined values", () => { + const controlNull = createControl(null); + const controlUndefined = createControl(undefined as any); + + expect(validator(controlNull)).toBeNull(); + expect(validator(controlUndefined)).toBeNull(); + }); + + it("should handle non-string values", () => { + const controlNumber = createControl(123 as any); + const controlObject = createControl({} as any); + const controlArray = createControl([] as any); + + expect(validator(controlNumber)).toEqual({ + invalidShortcut: { message: "Invalid shortcut" }, + }); + expect(validator(controlObject)).toEqual({ + invalidShortcut: { message: "Invalid shortcut" }, + }); + // Empty array becomes empty string when converted to string, which is handled by required validator + expect(validator(controlArray)).toBeNull(); + }); + + it("should handle very long strings", () => { + const longString = "Control+Alt+Shift+Win+A".repeat(100); + const control = createControl(longString); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + + describe("modifier combinations", () => { + it("should accept all possible single modifier combinations", () => { + const modifiers = ["Control", "Alt", "Shift", "Win"]; + + modifiers.forEach((modifier) => { + const control = createControl(`${modifier}+A`); + const result = validator(control); + expect(result).toBeNull(); + }); + }); + + it("should accept all possible two-modifier combinations", () => { + const combinations = [ + "Control+Alt+A", + "Control+Shift+A", + "Control+Win+A", + "Alt+Shift+A", + "Alt+Win+A", + "Shift+Win+A", + ]; + + combinations.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toBeNull(); + }); + }); + + it("should reject all three-modifier combinations", () => { + const combinations = [ + "Control+Alt+Shift+A", + "Control+Alt+Win+A", + "Control+Shift+Win+A", + "Alt+Shift+Win+A", + ]; + + combinations.forEach((shortcut) => { + const control = createControl(shortcut); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + + it("should reject all four modifiers combination", () => { + const control = createControl("Control+Alt+Shift+Win+A"); + const result = validator(control); + expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); + }); + }); + }); +}); diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.ts b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts new file mode 100644 index 00000000000..5cf1d90cb79 --- /dev/null +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts @@ -0,0 +1,139 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { + FormBuilder, + ReactiveFormsModule, + Validators, + ValidatorFn, + AbstractControl, + ValidationErrors, +} from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + DialogRef, + DialogService, + FormFieldModule, + IconButtonModule, +} from "@bitwarden/components"; + +@Component({ + templateUrl: "autotype-shortcut.component.html", + imports: [ + DialogModule, + CommonModule, + JslibModule, + ButtonModule, + IconButtonModule, + ReactiveFormsModule, + AsyncActionsModule, + FormFieldModule, + ], +}) +export class AutotypeShortcutComponent { + constructor( + private dialogRef: DialogRef, + private formBuilder: FormBuilder, + private i18nService: I18nService, + ) {} + + private shortcutArray: string[] = []; + + setShortcutForm = this.formBuilder.group({ + shortcut: ["", [Validators.required, this.shortcutCombinationValidator()]], + requireMasterPasswordOnClientRestart: true, + }); + + submit = async () => { + const shortcutFormControl = this.setShortcutForm.controls.shortcut; + + if (Utils.isNullOrWhitespace(shortcutFormControl.value) || shortcutFormControl.invalid) { + return; + } + + this.dialogRef.close(this.shortcutArray); + }; + + static open(dialogService: DialogService) { + return dialogService.open(AutotypeShortcutComponent); + } + + onShortcutKeydown(event: KeyboardEvent): void { + event.preventDefault(); + + const shortcut = this.buildShortcutFromEvent(event); + + if (shortcut != null) { + this.setShortcutForm.controls.shortcut.setValue(shortcut); + this.setShortcutForm.controls.shortcut.markAsDirty(); + this.setShortcutForm.controls.shortcut.updateValueAndValidity(); + } + } + + private buildShortcutFromEvent(event: KeyboardEvent): string | null { + const hasCtrl = event.ctrlKey; + const hasAlt = event.altKey; + const hasShift = event.shiftKey; + const hasMeta = event.metaKey; // Windows key on Windows, Command on macOS + + // Require at least one modifier (Control, Alt, Shift, or Super) + if (!hasCtrl && !hasAlt && !hasShift && !hasMeta) { + return null; + } + + const key = event.key; + + // Ignore pure modifier keys themselves + if (key === "Control" || key === "Alt" || key === "Shift" || key === "Meta") { + return null; + } + + // Accept a single alphabetical letter as the base key + const isAlphabetical = typeof key === "string" && /^[a-z]$/i.test(key); + if (!isAlphabetical) { + return null; + } + + const parts: string[] = []; + if (hasCtrl) { + parts.push("Control"); + } + if (hasAlt) { + parts.push("Alt"); + } + if (hasShift) { + parts.push("Shift"); + } + if (hasMeta) { + parts.push("Super"); + } + parts.push(key.toUpperCase()); + + this.shortcutArray = parts; + + return parts.join("+").replace("Super", "Win"); + } + + private shortcutCombinationValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = (control.value ?? "").toString(); + if (value.length === 0) { + return null; // handled by required + } + + // Must include exactly 1-2 modifiers and end with a single letter + // Valid examples: Ctrl+A, Shift+Z, Ctrl+Shift+X, Alt+Shift+Q + // Allow modifiers in any order, but only 1-2 modifiers total + const pattern = + /^(?=.*\b(Control|Alt|Shift|Win)\b)(?:Control\+|Alt\+|Shift\+|Win\+){1,2}[A-Z]$/i; + return pattern.test(value) + ? null + : { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } }; + }; + } +} diff --git a/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts b/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts index 884a4fc1ce4..09f03d2ef8e 100644 --- a/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/main/main-desktop-autotype.service.ts @@ -5,21 +5,45 @@ import { LogService } from "@bitwarden/logging"; import { WindowMain } from "../../main/window.main"; import { stringIsNotUndefinedNullAndEmpty } from "../../utils"; +import { AutotypeKeyboardShortcut } from "../models/main-autotype-keyboard-shortcut"; export class MainDesktopAutotypeService { - keySequence: string = "CommandOrControl+Shift+B"; + autotypeKeyboardShortcut: AutotypeKeyboardShortcut; constructor( private logService: LogService, private windowMain: WindowMain, - ) {} + ) { + this.autotypeKeyboardShortcut = new AutotypeKeyboardShortcut(); + } init() { ipcMain.on("autofill.configureAutotype", (event, data) => { - if (data.enabled === true && !globalShortcut.isRegistered(this.keySequence)) { - this.enableAutotype(); - } else if (data.enabled === false && globalShortcut.isRegistered(this.keySequence)) { + if (data.enabled) { + const newKeyboardShortcut = new AutotypeKeyboardShortcut(); + const newKeyboardShortcutIsValid = newKeyboardShortcut.set(data.keyboardShortcut); + + if (newKeyboardShortcutIsValid) { + this.disableAutotype(); + this.autotypeKeyboardShortcut = newKeyboardShortcut; + this.enableAutotype(); + } else { + this.logService.error( + "Attempting to configure autotype but the shortcut given is invalid.", + ); + } + } else { this.disableAutotype(); + + // Deregister the incoming keyboard shortcut if needed + const setCorrectly = this.autotypeKeyboardShortcut.set(data.keyboardShortcut); + if ( + setCorrectly && + globalShortcut.isRegistered(this.autotypeKeyboardShortcut.getElectronFormat()) + ) { + globalShortcut.unregister(this.autotypeKeyboardShortcut.getElectronFormat()); + this.logService.info("Autotype disabled."); + } } }); @@ -30,34 +54,42 @@ export class MainDesktopAutotypeService { stringIsNotUndefinedNullAndEmpty(response.username) && stringIsNotUndefinedNullAndEmpty(response.password) ) { - this.doAutotype(response.username, response.password); + this.doAutotype( + response.username, + response.password, + this.autotypeKeyboardShortcut.getArrayFormat(), + ); } }); } disableAutotype() { - if (globalShortcut.isRegistered(this.keySequence)) { - globalShortcut.unregister(this.keySequence); + // Deregister the current keyboard shortcut if needed + const formattedKeyboardShortcut = this.autotypeKeyboardShortcut.getElectronFormat(); + if (globalShortcut.isRegistered(formattedKeyboardShortcut)) { + globalShortcut.unregister(formattedKeyboardShortcut); + this.logService.info("Autotype disabled."); } - - this.logService.info("Autotype disabled."); } private enableAutotype() { - const result = globalShortcut.register(this.keySequence, () => { - const windowTitle = autotype.getForegroundWindowTitle(); + const result = globalShortcut.register( + this.autotypeKeyboardShortcut.getElectronFormat(), + () => { + const windowTitle = autotype.getForegroundWindowTitle(); - this.windowMain.win.webContents.send("autofill.listenAutotypeRequest", { - windowTitle, - }); - }); + this.windowMain.win.webContents.send("autofill.listenAutotypeRequest", { + windowTitle, + }); + }, + ); result ? this.logService.info("Autotype enabled.") : this.logService.info("Enabling autotype failed."); } - private doAutotype(username: string, password: string) { + private doAutotype(username: string, password: string, keyboardShortcut: string[]) { const inputPattern = username + "\t" + password; const inputArray = new Array(inputPattern.length); @@ -65,6 +97,6 @@ export class MainDesktopAutotypeService { inputArray[i] = inputPattern.charCodeAt(i); } - autotype.typeInput(inputArray); + autotype.typeInput(inputArray, keyboardShortcut); } } diff --git a/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts b/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts new file mode 100644 index 00000000000..b26be92585e --- /dev/null +++ b/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts @@ -0,0 +1,98 @@ +import { defaultWindowsAutotypeKeyboardShortcut } from "../services/desktop-autotype.service"; + +/* + This class provides the following: + - A way to get and set an AutotypeKeyboardShortcut value within the main process + - A way to set an AutotypeKeyboardShortcut with validation + - A way to "get" the value in string array format or a single string format for electron + - Default shortcut support + + This is currently only supported for Windows operating systems. +*/ +export class AutotypeKeyboardShortcut { + private autotypeKeyboardShortcut: string[]; + + constructor() { + this.autotypeKeyboardShortcut = defaultWindowsAutotypeKeyboardShortcut; + } + + /* + Returns a boolean value indicating if the autotypeKeyboardShortcut + was valid and set or not. + */ + set(newAutotypeKeyboardShortcut: string[]) { + if (!this.#keyboardShortcutIsValid(newAutotypeKeyboardShortcut)) { + return false; + } + + this.autotypeKeyboardShortcut = newAutotypeKeyboardShortcut; + return true; + } + + /* + Returns the autotype keyboard shortcut as a string array. + */ + getArrayFormat() { + return this.autotypeKeyboardShortcut; + } + + /* + Returns the autotype keyboard shortcut as a single string, as + Electron expects. Please note this does not reorder the keys. + + See Electron keyboard shorcut docs for more info: + https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts + */ + getElectronFormat() { + return this.autotypeKeyboardShortcut.join("+"); + } + + /* + This private function validates the strArray input to make sure the array contains + valid, currently accepted shortcut keys for Windows. + + Valid windows shortcut keys: Control, Alt, Super, Shift, letters A - Z + Valid macOS shortcut keys: Control, Alt, Command, Shift, letters A - Z (not yet supported) + + See Electron keyboard shorcut docs for more info: + https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts + */ + #keyboardShortcutIsValid(strArray: string[]) { + const VALID_SHORTCUT_CONTROL_KEYS: string[] = ["Control", "Alt", "Super", "Shift"]; + const UNICODE_LOWER_BOUND = 65; // unicode 'A' + const UNICODE_UPPER_BOUND = 90; // unicode 'Z' + const MIN_LENGTH: number = 2; + const MAX_LENGTH: number = 3; + + // Ensure strArray is a string array of valid length + if ( + strArray === undefined || + strArray === null || + strArray.length < MIN_LENGTH || + strArray.length > MAX_LENGTH + ) { + return false; + } + + // Ensure strArray is all modifier keys, and that the last key is a letter + for (let i = 0; i < strArray.length; i++) { + if (i < strArray.length - 1) { + if (!VALID_SHORTCUT_CONTROL_KEYS.includes(strArray[i])) { + return false; + } + } else { + const unicodeValue: number = strArray[i].charCodeAt(0); + + if ( + Number.isNaN(unicodeValue) || + unicodeValue < UNICODE_LOWER_BOUND || + unicodeValue > UNICODE_UPPER_BOUND + ) { + return false; + } + } + } + + return true; + } +} diff --git a/apps/desktop/src/autofill/preload.ts b/apps/desktop/src/autofill/preload.ts index af238b17e80..fcb2f646743 100644 --- a/apps/desktop/src/autofill/preload.ts +++ b/apps/desktop/src/autofill/preload.ts @@ -127,8 +127,8 @@ export default { }, ); }, - configureAutotype: (enabled: boolean) => { - ipcRenderer.send("autofill.configureAutotype", { enabled }); + configureAutotype: (enabled: boolean, keyboardShortcut: string[]) => { + ipcRenderer.send("autofill.configureAutotype", { enabled, keyboardShortcut }); }, listenAutotypeRequest: ( fn: ( diff --git a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts new file mode 100644 index 00000000000..7fb30333e28 --- /dev/null +++ b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts @@ -0,0 +1,166 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom, take, timeout, TimeoutError } from "rxjs"; + +import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Account, UserId } from "@bitwarden/common/platform/models/domain/account"; + +import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service"; + +describe("DesktopAutotypeDefaultSettingPolicy", () => { + let service: DesktopAutotypeDefaultSettingPolicy; + let accountService: MockProxy; + let authService: MockProxy; + let policyService: MockProxy; + let configService: MockProxy; + + let mockAccountSubject: BehaviorSubject<{ id: UserId } | null>; + let mockFeatureFlagSubject: BehaviorSubject; + let mockAuthStatusSubject: BehaviorSubject; + let mockPolicyAppliesSubject: BehaviorSubject; + + const mockUserId = "user-123" as UserId; + + beforeEach(() => { + mockAccountSubject = new BehaviorSubject({ + id: mockUserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }); + mockFeatureFlagSubject = new BehaviorSubject(true); + mockAuthStatusSubject = new BehaviorSubject( + AuthenticationStatus.Unlocked, + ); + mockPolicyAppliesSubject = new BehaviorSubject(false); + + accountService = mock(); + authService = mock(); + policyService = mock(); + configService = mock(); + + accountService.activeAccount$ = mockAccountSubject.asObservable(); + configService.getFeatureFlag$ = jest + .fn() + .mockReturnValue(mockFeatureFlagSubject.asObservable()); + authService.authStatusFor$ = jest + .fn() + .mockImplementation((_: UserId) => mockAuthStatusSubject.asObservable()); + policyService.policyAppliesToUser$ = jest + .fn() + .mockReturnValue(mockPolicyAppliesSubject.asObservable()); + + TestBed.configureTestingModule({ + providers: [ + DesktopAutotypeDefaultSettingPolicy, + { provide: AccountService, useValue: accountService }, + { provide: AuthService, useValue: authService }, + { provide: InternalPolicyService, useValue: policyService }, + { provide: ConfigService, useValue: configService }, + ], + }); + + service = TestBed.inject(DesktopAutotypeDefaultSettingPolicy); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockAccountSubject.complete(); + mockFeatureFlagSubject.complete(); + mockAuthStatusSubject.complete(); + mockPolicyAppliesSubject.complete(); + }); + + describe("autotypeDefaultSetting$", () => { + it("should emit null when feature flag is disabled", async () => { + mockFeatureFlagSubject.next(false); + const result = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(result).toBeNull(); + }); + + it("should not emit when no active account", async () => { + mockAccountSubject.next(null); + await expect( + firstValueFrom(service.autotypeDefaultSetting$.pipe(timeout({ first: 30 }))), + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it("should emit null when user is not unlocked", async () => { + mockAuthStatusSubject.next(AuthenticationStatus.Locked); + const result = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(result).toBeNull(); + }); + + it("should emit null when no autotype policy exists", async () => { + mockPolicyAppliesSubject.next(false); + const policy = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(policy).toBeNull(); + }); + + it("should emit true when autotype policy is enabled", async () => { + mockPolicyAppliesSubject.next(true); + const policyStatus = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(policyStatus).toBe(true); + }); + + it("should emit false when autotype policy is disabled", async () => { + mockPolicyAppliesSubject.next(false); + const policyStatus = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(policyStatus).toBeNull(); + }); + + it("should emit null when autotype policy does not apply", async () => { + mockPolicyAppliesSubject.next(false); + const policy = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(policy).toBeNull(); + }); + + it("should react to authentication status changes", async () => { + // Expect one emission when unlocked + mockAuthStatusSubject.next(AuthenticationStatus.Unlocked); + const first = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(first).toBeNull(); + + // Expect null emission when locked + mockAuthStatusSubject.next(AuthenticationStatus.Locked); + const lockedResult = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(lockedResult).toBeNull(); + }); + + it("should react to account changes", async () => { + const newUserId = "user-456" as UserId; + + // First value for original user + const firstValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(firstValue).toBeNull(); + + // Change account and expect a new emission + mockAccountSubject.next({ + id: newUserId, + }); + const secondValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(secondValue).toBeNull(); + + // Verify the auth lookup was switched to the new user + expect(authService.authStatusFor$).toHaveBeenCalledWith(newUserId); + }); + + it("should react to policy changes", async () => { + mockPolicyAppliesSubject.next(false); + const nullValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(nullValue).toBeNull(); + + mockPolicyAppliesSubject.next(true); + const trueValue = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(trueValue).toBe(true); + + mockPolicyAppliesSubject.next(false); + const nullValueAgain = await firstValueFrom(service.autotypeDefaultSetting$.pipe(take(1))); + expect(nullValueAgain).toBeNull(); + }); + }); +}); diff --git a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts new file mode 100644 index 00000000000..887a30ef6f6 --- /dev/null +++ b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from "@angular/core"; +import { Observable, of } from "rxjs"; +import { distinctUntilChanged, filter, map, shareReplay, switchMap } from "rxjs/operators"; + +import { InternalPolicyService } 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 { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +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"; + +@Injectable({ providedIn: "root" }) +export class DesktopAutotypeDefaultSettingPolicy { + constructor( + private readonly accountService: AccountService, + private readonly authService: AuthService, + private readonly policyService: InternalPolicyService, + private readonly configService: ConfigService, + ) {} + + /** + * Emits the autotype policy enabled status when account is unlocked and WindowsDesktopAutotype is enabled. + * - true: autotype policy exists and is enabled + * - null: no autotype policy exists for the user's organization + */ + readonly autotypeDefaultSetting$: Observable = this.configService + .getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype) + .pipe( + switchMap((autotypeFeatureEnabled) => { + if (!autotypeFeatureEnabled) { + return of(null); + } + + return this.accountService.activeAccount$.pipe( + filter((account) => account != null), + getUserId, + distinctUntilChanged(), + switchMap((userId) => { + const isUnlocked$ = this.authService.authStatusFor$(userId).pipe( + map((status) => status === AuthenticationStatus.Unlocked), + distinctUntilChanged(), + ); + + const policy$ = this.policyService + .policyAppliesToUser$(PolicyType.AutotypeDefaultSetting, userId) + .pipe( + map((appliesToUser) => (appliesToUser ? true : null)), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + return isUnlocked$.pipe(switchMap((unlocked) => (unlocked ? policy$ : of(null)))); + }), + ); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); +} diff --git a/apps/desktop/src/autofill/services/desktop-autotype.service.ts b/apps/desktop/src/autofill/services/desktop-autotype.service.ts index 60e87aa2aa5..6f9289f3513 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype.service.ts @@ -1,6 +1,6 @@ import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -17,17 +17,38 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { UserId } from "@bitwarden/user-core"; -export const AUTOTYPE_ENABLED = new KeyDefinition( +import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service"; + +export const defaultWindowsAutotypeKeyboardShortcut: string[] = ["Control", "Shift", "B"]; + +export const AUTOTYPE_ENABLED = new KeyDefinition( AUTOTYPE_SETTINGS_DISK, "autotypeEnabled", { deserializer: (b) => b }, ); +/* + Valid windows shortcut keys: Control, Alt, Super, Shift, letters A - Z + Valid macOS shortcut keys: Control, Alt, Command, Shift, letters A - Z + + See Electron keyboard shorcut docs for more info: + https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts +*/ +export const AUTOTYPE_KEYBOARD_SHORTCUT = new KeyDefinition( + AUTOTYPE_SETTINGS_DISK, + "autotypeKeyboardShortcut", + { deserializer: (b) => b }, +); + export class DesktopAutotypeService { private readonly autotypeEnabledState = this.globalStateProvider.get(AUTOTYPE_ENABLED); + private readonly autotypeKeyboardShortcut = this.globalStateProvider.get( + AUTOTYPE_KEYBOARD_SHORTCUT, + ); autotypeEnabledUserSetting$: Observable = of(false); resolvedAutotypeEnabled$: Observable = of(false); + autotypeKeyboardShortcut$: Observable = of(defaultWindowsAutotypeKeyboardShortcut); constructor( private accountService: AccountService, @@ -37,6 +58,7 @@ export class DesktopAutotypeService { private globalStateProvider: GlobalStateProvider, private platformUtilsService: PlatformUtilsService, private billingAccountProfileStateService: BillingAccountProfileStateService, + private desktopAutotypePolicy: DesktopAutotypeDefaultSettingPolicy, ) { ipc.autofill.listenAutotypeRequest(async (windowTitle, callback) => { const possibleCiphers = await this.matchCiphersToWindowTitle(windowTitle); @@ -51,8 +73,34 @@ export class DesktopAutotypeService { async init() { this.autotypeEnabledUserSetting$ = this.autotypeEnabledState.state$; + this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$; + // Currently Autotype is only supported for Windows if (this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop) { + // If `autotypeDefaultPolicy` is `true` for a user's organization, and the + // user has never changed their local autotype setting (`autotypeEnabledState`), + // we set their local setting to `true` (once the local user setting is changed + // by this policy or the user themselves, the default policy should + // never change the user setting again). + combineLatest([ + this.autotypeEnabledState.state$, + this.desktopAutotypePolicy.autotypeDefaultSetting$, + ]) + .pipe( + map(async ([autotypeEnabledState, autotypeDefaultPolicy]) => { + if (autotypeDefaultPolicy === true && autotypeEnabledState === null) { + await this.setAutotypeEnabledState(true); + } + }), + ) + .subscribe(); + + // autotypeEnabledUserSetting$ publicly represents the value the + // user has set for autotyeEnabled in their local settings. + this.autotypeEnabledUserSetting$ = this.autotypeEnabledState.state$; + + // resolvedAutotypeEnabled$ represents the final determination if the Autotype + // feature should be on or off. this.resolvedAutotypeEnabled$ = combineLatest([ this.autotypeEnabledState.state$, this.configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype), @@ -61,9 +109,9 @@ export class DesktopAutotypeService { switchMap((userId) => this.authService.authStatusFor$(userId)), ), this.accountService.activeAccount$.pipe( - map((activeAccount) => activeAccount?.id), - switchMap((userId) => - this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId), + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), ), ), ]).pipe( @@ -76,9 +124,11 @@ export class DesktopAutotypeService { ), ); - this.resolvedAutotypeEnabled$.subscribe((enabled) => { - ipc.autofill.configureAutotype(enabled); - }); + combineLatest([this.resolvedAutotypeEnabled$, this.autotypeKeyboardShortcut$]).subscribe( + ([resolvedAutotypeEnabled, autotypeKeyboardShortcut]) => { + ipc.autofill.configureAutotype(resolvedAutotypeEnabled, autotypeKeyboardShortcut); + }, + ); } } @@ -88,6 +138,10 @@ export class DesktopAutotypeService { }); } + async setAutotypeKeyboardShortcutState(keyboardShortcut: string[]): Promise { + await this.autotypeKeyboardShortcut.update(() => keyboardShortcut); + } + async matchCiphersToWindowTitle(windowTitle: string): Promise { const URI_PREFIX = "apptitle://"; windowTitle = windowTitle.toLowerCase(); diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index b6869f4f048..e579c498ded 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index 37f338ff65e..4efec524886 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index 889cea55a2b..9618bafca3e 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Qısayola düzəliş et" + }, + "archive": { + "message": "Arxivlə" + }, + "unarchive": { + "message": "Arxivdən çıxart" + }, + "itemsInArchive": { + "message": "Arxivdəki elementlər" + }, + "noItemsInArchive": { + "message": "Arxivdə element yoxdur" + }, + "noItemsInArchiveDesc": { + "message": "Arxivlənmiş elementlər burada görünəcək, ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək." + }, + "itemSentToArchive": { + "message": "Element arxivə göndərildi" + }, + "itemRemovedFromArchive": { + "message": "Element arxivdən çıxarıldı" + }, + "archiveItem": { + "message": "Elementi arxivlə" + }, + "archiveItemConfirmDesc": { + "message": "Arxivlənmiş elementlər ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək. Bu elementi arxivləmək istədiyinizə əminsiniz?" } } diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index b1c44ddd730..eb5971c97af 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index 71faa0fbe74..e32363f0c55 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Редактиране на комбинацията" + }, + "archive": { + "message": "Архивиране" + }, + "unarchive": { + "message": "Изваждане от архива" + }, + "itemsInArchive": { + "message": "Елементи в архива" + }, + "noItemsInArchive": { + "message": "Няма елементи в архива" + }, + "noItemsInArchiveDesc": { + "message": "Архивираните елементи ще се показват тук и ще бъдат изключени от общите резултати при търсене и от предложенията за автоматично попълване." + }, + "itemSentToArchive": { + "message": "Елементът е преместен в архива" + }, + "itemRemovedFromArchive": { + "message": "Елементът е изваден от архива" + }, + "archiveItem": { + "message": "Архивиране на елемента" + }, + "archiveItemConfirmDesc": { + "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" } } diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index 583377ac4ca..60b925af2e3 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 56ac07e0f92..e6cdff50696 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index bafe54322b6..0defa7a878a 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -1303,7 +1303,7 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Mostra icones de llocs web i recupera les URL de canvi de contrasenya" }, "enableMinToTray": { "message": "Minimitza a icona en la safata" @@ -2416,16 +2416,16 @@ "message": "La vostra contrasenya mestra no compleix una o més de les polítiques de l'organització. Per accedir a la caixa forta, heu d'actualitzar-la ara. Si continueu, es tancarà la sessió actual i us demanarà que torneu a iniciar-la. Les sessions en altres dispositius poden continuar romanent actives fins a una hora." }, "changePasswordWarning": { - "message": "En canviar la teva contrasenya, cal iniciar la sessió amb la nova contrasenya. Les sessions actives en altres dispositius es tancaran en una hora." + "message": "En canviar la contrasenya, cal iniciar la sessió amb la nova contrasenya. Les sessions actives en altres dispositius es tancaran en una hora." }, "accountRecoveryUpdateMasterPasswordSubtitle": { - "message": "Canvia la contrasenya mestra per completar el recobrament del compte." + "message": "Canvia la contrasenya mestra per completar la recuperació del compte." }, "updateMasterPasswordSubtitle": { - "message": "La contrasenya mestra no s'ajusta als requisits de l'organització. Canvia't la contrasenya mestra per continuar." + "message": "La contrasenya mestra no s'ajusta als requisits de l'organització. Canvieu-la per continuar." }, "tdeDisabledMasterPasswordRequired": { - "message": "La teva organització ha desactivat l'encriptació de dispositius fiables. Fixa una contrasenya mestra per accedir a la teva caixa forta." + "message": "L'organització ha desactivat el xifratge de dispositius de confiança. Defineix una contrasenya mestra per accedir a la caixa forta." }, "tryAgain": { "message": "Torneu-ho a provar" @@ -2470,7 +2470,7 @@ "message": "Minuts" }, "vaultTimeoutPolicyInEffect1": { - "message": "$HOURS$ hora(es) i $MINUTES$ minut(s) màxim.", + "message": "$HOURS$ hores i $MINUTES$ minuts com a màxim.", "placeholders": { "hours": { "content": "$1", @@ -2565,7 +2565,7 @@ "message": "S'ha suprimit la contrasenya mestra." }, "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "Ja no cal contrasenya mestra per als membres de la següent organització. Confirma'n el domini a sota amb l'administrador de la teva organització." + "message": "Ja no cal contrasenya mestra per als membres de la següent organització. Confirmeu el domini següent amb l'administrador de l'organització." }, "organizationName": { "message": "Nom de l'organització" @@ -2634,7 +2634,7 @@ } }, "exportingIndividualVaultWithAttachmentsDescription": { - "message": "Només els objectes individuals de la caixa forta, inclosos adjunts associats amb $EMAIL$, seran exportats. Els objectes de la caixa forta de l'organització no hi seran inclosos", + "message": "Només s'exportaran els elements individuals de la caixa forta, inclosos els fitxers adjunts associats amb $EMAIL$. No s'hi inclouran els elements de la caixa forta de l'organització", "placeholders": { "email": { "content": "$1", @@ -2760,7 +2760,7 @@ "message": "Utilitzeu aquesta contrasenya" }, "useThisPassphrase": { - "message": "Empra aquesta frase de pas" + "message": "Utilitzeu aquesta frase de contrasenya" }, "useThisUsername": { "message": "Utilitzeu aquest nom d'usuari" @@ -2829,7 +2829,7 @@ } }, "forwaderInvalidToken": { - "message": "API token de $SERVICENAME$ invàlid", + "message": "Token d'API $SERVICENAME$ no vàlid", "description": "Displayed when the user's API token is empty or rejected by the forwarding service.", "placeholders": { "servicename": { @@ -3453,10 +3453,10 @@ "message": "Cal l'inici de sessió en dos passos de Duo al vostre compte. Seguiu els passos de sota per finalitzar l'inici de sessió." }, "followTheStepsBelowToFinishLoggingIn": { - "message": "Seguiu els passos de sota per finalitzar l'inici de sessió." + "message": "Seguiu els passos següents per finalitzar l'inici de sessió." }, "followTheStepsBelowToFinishLoggingInWithSecurityKey": { - "message": "Seguiu els passos de sota per finalitzar l'inici de sessió amb la clau de seguretat." + "message": "Seguiu els passos següents per finalitzar l'inici de sessió amb la clau de seguretat." }, "launchDuo": { "message": "Inicia Duo al navegador" @@ -3627,7 +3627,7 @@ "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "Més sobre la detecció de coincidències", + "message": "Més informació sobre la detecció de coincidències", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index 06b1ffa2aed..2c5ed437187 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Upravit zkratku" + }, + "archive": { + "message": "Archivovat" + }, + "unarchive": { + "message": "Odebrat z archivu" + }, + "itemsInArchive": { + "message": "Položky v archivu" + }, + "noItemsInArchive": { + "message": "Žádné položky v archivu" + }, + "noItemsInArchiveDesc": { + "message": "Zde se zobrazí archivované položky a budou vyloučeny z obecných výsledků vyhledávání a návrhů automatického vyplňování." + }, + "itemSentToArchive": { + "message": "Položka byla přesunuta do archivu" + }, + "itemRemovedFromArchive": { + "message": "Položka byla odebrána z archivu" + }, + "archiveItem": { + "message": "Archivovat položku" + }, + "archiveItemConfirmDesc": { + "message": "Archivované položky jsou vyloučeny z obecných výsledků vyhledávání a z návrhů automatického vyplňování. Jste si jisti, že chcete tuto položku archivovat?" } } diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index 0e0ae3d6411..9ff42bfa2c7 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index d1e3e3ece89..4a064a004cb 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index b2d8dada299..47b3bad34e8 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index 55298aed348..0c2ee1fab65 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index aa597a6bf97..08ec76af874 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4080,12 +4080,18 @@ "showLess": { "message": "Show less" }, - "enableAutotype": { - "message": "Enable Autotype" - }, "enableAutotypeDescription": { "message": "Bitwarden does not validate input locations, be sure you are in the right window and field before using the shortcut." }, + "typeShortcut": { + "message": "Type shortcut" + }, + "editAutotypeShortcutDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + }, + "invalidShortcut": { + "message": "Invalid shortcut" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." @@ -4099,8 +4105,8 @@ "confirm": { "message": "Confirm" }, - "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "enableAutotypeShortcutPreview": { + "message": "Enable autotype shortcut (Feature Preview)" }, "enableAutotypeDescriptionTransitionKey": { "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index 267116025d1..625d8804676 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index 44b5000bfde..41211c2e7d7 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index d3a2bb1b649..cebc2fa1432 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index 096639cd08f..346dc0d4221 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index 107c5245d1c..b8afeb2ed6a 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index f4138389253..7f719ec0a4b 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index 7239c9b75d5..fbbbdfd8c7f 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index aadf705cd13..ecde260d80e 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index 6d75cd274ad..5ad2661b46a 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 119f6638079..f85708edbe0 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -4081,7 +4081,7 @@ "message": "Afficher moins" }, "enableAutotype": { - "message": "Activer le type automatique" + "message": "Activer la Saisie Auto" }, "enableAutotypeDescription": { "message": "Bitwarden ne valide pas les emplacements d'entrée, assurez-vous d'être dans la bonne fenêtre et le bon champ avant d'utiliser le raccourci." @@ -4100,12 +4100,39 @@ "message": "Confirmer" }, "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "message": "Activer le raccourci de la Saisie Auto" }, "enableAutotypeDescriptionTransitionKey": { - "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." + "message": "Assurez-vous d'être dans le bon champ avant d'utiliser le raccourci pour éviter de remplir les données au mauvais endroit." }, "editShortcut": { "message": "Modifier le raccourci" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Désarchiver" + }, + "itemsInArchive": { + "message": "Éléments dans l'archive" + }, + "noItemsInArchive": { + "message": "Aucun élément dans l'archive" + }, + "noItemsInArchiveDesc": { + "message": "Les éléments archivés apparaîtront ici et seront exclus des résultats de recherche généraux et des suggestions de remplissage automatique." + }, + "itemSentToArchive": { + "message": "Élément envoyé à l'archive" + }, + "itemRemovedFromArchive": { + "message": "Élément retiré de l'archive" + }, + "archiveItem": { + "message": "Archiver l'élément" + }, + "archiveItemConfirmDesc": { + "message": "Les éléments archivés sont exclus des résultats de recherche généraux et des suggestions de remplissage automatique. Êtes-vous sûr de vouloir archiver cet élément ?" } } diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index 27394d39ece..5849d9d4cee 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index ea6528e3f86..5cbceb3ad76 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "ערוך קיצור דרך" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index 7cbf25e24d4..25ecbdf3840 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index b88444f948f..64f89a8b15f 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Uredi prečac" + }, + "archive": { + "message": "Arhiviraj" + }, + "unarchive": { + "message": "Poništi arhiviranje" + }, + "itemsInArchive": { + "message": "Stavke u arhivi" + }, + "noItemsInArchive": { + "message": "Nema stavki u arhivi" + }, + "noItemsInArchiveDesc": { + "message": "Arhivirane stavke biti će prikazane ovdje i biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune." + }, + "itemSentToArchive": { + "message": "Stavka poslana u arhivu" + }, + "itemRemovedFromArchive": { + "message": "Stavka maknute iz arhive" + }, + "archiveItem": { + "message": "Arhiviraj stavku" + }, + "archiveItemConfirmDesc": { + "message": "Arhivirane stavke biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune. Sigurno želiš arhivirati?" } } diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index 1bedc36c8d2..9f71448ce5a 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Parancsikon szerkesztése" + }, + "archive": { + "message": "Archívum" + }, + "unarchive": { + "message": "Visszavétel archívumból" + }, + "itemsInArchive": { + "message": "Archívum elemek száma" + }, + "noItemsInArchive": { + "message": "Nincs elem az archívumban." + }, + "noItemsInArchiveDesc": { + "message": "Az archivált elemek itt jelennek meg és kizárásra kerülnek az általános keresési eredményekből és az automatikus kitöltési javaslatokból." + }, + "itemSentToArchive": { + "message": "Archívumba küldött elemek száma" + }, + "itemRemovedFromArchive": { + "message": "Az elem kikerült az archívumból." + }, + "archiveItem": { + "message": "Elem archiválása" + }, + "archiveItemConfirmDesc": { + "message": "Az archivált elemek ki vannak zárva az általános keresési eredményekből és az automatikus kitöltési javaslatokból. Biztosan archiválni szeretnénk ezt az elemet?" } } diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index 23292b356bc..3f44fd8bf97 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index 72db169d7ca..780d09f3582 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index ed030ae71d5..a58543302fa 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index c33e414ac3f..0bb7e929979 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index 27394d39ece..5849d9d4cee 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index f09d2e67167..66a6e43d0cb 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index fdd0b055665..59423b8ad73 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index b377ab195b0..8159bc5e28b 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index 97f239b56cc..5e29f10190b 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Labot saīsni" + }, + "archive": { + "message": "Arhivēt" + }, + "unarchive": { + "message": "Atcelt arhivēšanu" + }, + "itemsInArchive": { + "message": "Vienumi arhīvā" + }, + "noItemsInArchive": { + "message": "Arhīvā nav vienumu" + }, + "noItemsInArchiveDesc": { + "message": "Šeit parādīsies arhivētie vienumi, un tie netiks iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos." + }, + "itemSentToArchive": { + "message": "Vienums ievietots arhīvā" + }, + "itemRemovedFromArchive": { + "message": "Vienums izņemts no arhīva" + }, + "archiveItem": { + "message": "Arhivēt vienumu" + }, + "archiveItemConfirmDesc": { + "message": "Arhivētie vienumi netiek iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos. Vai tiešām ahrivēt šo vienumu?" } } diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index 9e87e473af4..b023d0efab0 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index 2e191908bc9..863f3941a0f 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index 27394d39ece..5849d9d4cee 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index 4e9bab677a6..fae67d310f1 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index 03783474190..ec1c1bdb9b5 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index 916becbf94d..813fa967252 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index ff4a7a6b7bb..c726c003776 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Snelkoppeling bewerken" + }, + "archive": { + "message": "Archiveren" + }, + "unarchive": { + "message": "Dearchiveren" + }, + "itemsInArchive": { + "message": "Items in archief" + }, + "noItemsInArchive": { + "message": "Geen items in archief" + }, + "noItemsInArchiveDesc": { + "message": "Gearchiveerde items verschijnen hier en worden uitgesloten van algemene zoekresultaten en automatisch invulsuggesties." + }, + "itemSentToArchive": { + "message": "Item naar archief verzonden" + }, + "itemRemovedFromArchive": { + "message": "Item verwijderd uit archief" + }, + "archiveItem": { + "message": "Item archiveren" + }, + "archiveItemConfirmDesc": { + "message": "Gearchiveerde items worden uitgesloten van algemene zoekresultaten en automatische invulsuggesties. Weet je zeker dat je dit item wilt archiveren?" } } diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index f625cb54c9f..94c2196edfa 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index 4a6debac4bf..217439bec80 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index 37f6263064a..6caee67447f 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -1303,7 +1303,7 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Pokaż ikony stron internetowych i pobierz adresy URL do zmiany hasła" }, "enableMinToTray": { "message": "Minimalizuj do zasobnika systemowego" @@ -3586,10 +3586,10 @@ "message": "Kontynuuj logowanie przy użyciu danych firmowych." }, "importDirectlyFromBrowser": { - "message": "Import directly from browser" + "message": "Importuj bezpośrednio z przeglądarki" }, "browserProfile": { - "message": "Browser Profile" + "message": "Profil przeglądarki" }, "seeDetailedInstructions": { "message": "Zobacz szczegółowe instrukcje na naszej stronie pomocy pod adresem", @@ -3834,10 +3834,10 @@ "message": "Zmień zagrożone hasło" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Dane logowania są zagrożone i nie zawierają strony internetowej. Dodaj stronę internetową i zmień hasło." }, "missingWebsite": { - "message": "Missing website" + "message": "Brak strony internetowej" }, "cannotRemoveViewOnlyCollections": { "message": "Nie możesz usunąć następujących kolekcji z uprawnieniami tylko do odczytu: $COLLECTIONS$", @@ -3935,10 +3935,10 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "aboutThisSetting": { - "message": "About this setting" + "message": "O ustawieniu" }, "permitCipherDetailsDescription": { - "message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service." + "message": "Bitwarden użyje zapisanych adresów URL danych logowania, aby określić, która ikona lub adres URL zmiany hasła powinien zostać użyty w celu poprawy komfortu użytkowania. Usługa nie zapisuje żadnych danych." }, "assignToCollections": { "message": "Przypisz do kolekcji" @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edytuj skrót" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Usuń z archiwum" + }, + "itemsInArchive": { + "message": "Elementy w archiwum" + }, + "noItemsInArchive": { + "message": "Brak elementów w archiwum" + }, + "noItemsInArchiveDesc": { + "message": "Zarchiwizowane elementy pojawią się tutaj i zostaną wykluczone z wyników wyszukiwania i sugestii autouzupełniania." + }, + "itemSentToArchive": { + "message": "Element został przeniesiony do archiwum" + }, + "itemRemovedFromArchive": { + "message": "Element został usunięty z archiwum" + }, + "archiveItem": { + "message": "Archiwizuj element" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index b00cd3a8087..4b28ca4918a 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 8cef762328e..49573dcd647 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -1369,13 +1369,13 @@ "message": "Idioma" }, "languageDesc": { - "message": "Alterar o idioma utilizado pela aplicação. É necessário reiniciar." + "message": "Altere o idioma utilizado pela aplicação. É necessário reiniciar." }, "theme": { "message": "Tema" }, "themeDesc": { - "message": "Altere o tema de cores da aplicação." + "message": "Altere o tema da aplicação." }, "dark": { "message": "Escuro", @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Editar atalho" + }, + "archive": { + "message": "Arquivar" + }, + "unarchive": { + "message": "Desarquivar" + }, + "itemsInArchive": { + "message": "Itens no arquivo" + }, + "noItemsInArchive": { + "message": "Nenhum item no arquivo" + }, + "noItemsInArchiveDesc": { + "message": "Os itens arquivados aparecerão aqui e serão excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático." + }, + "itemSentToArchive": { + "message": "Item movido para o arquivo" + }, + "itemRemovedFromArchive": { + "message": "Item removido do arquivo" + }, + "archiveItem": { + "message": "Arquivar item" + }, + "archiveItemConfirmDesc": { + "message": "Os itens arquivados são excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático. Tem a certeza de que pretende arquivar este item?" } } diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index 0d2c02854bf..802afc3ef22 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index e2dfbdd3fcc..292564be5f3 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -3834,10 +3834,10 @@ "message": "Изменить пароль, подверженный риску" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Этот логин находится под угрозой и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." }, "missingWebsite": { - "message": "Missing website" + "message": "Отсутствует сайт" }, "cannotRemoveViewOnlyCollections": { "message": "Вы не можете удалить коллекции с правами только на просмотр: $COLLECTIONS$", @@ -4087,7 +4087,7 @@ "message": "Bitwarden не проверяет местоположение ввода, поэтому, прежде чем использовать ярлык, убедитесь, что вы находитесь в нужном окне и поле." }, "moreBreadcrumbs": { - "message": "More breadcrumbs", + "message": "Больше хлебных крошек", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "next": { @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Изменить ярлык" + }, + "archive": { + "message": "Архив" + }, + "unarchive": { + "message": "Разархивировать" + }, + "itemsInArchive": { + "message": "Элементы в архиве" + }, + "noItemsInArchive": { + "message": "В архиве нет элементов" + }, + "noItemsInArchiveDesc": { + "message": "Архивированные элементы появятся здесь и будут исключены из общих результатов поиска и предложений автозаполнения." + }, + "itemSentToArchive": { + "message": "Элемент отправлен в архив" + }, + "itemRemovedFromArchive": { + "message": "Элемент удален из архива" + }, + "archiveItem": { + "message": "Архивировать элемент" + }, + "archiveItemConfirmDesc": { + "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" } } diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index c283ca34fe1..8d4072e1da2 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index 6e123bb38a1..566b9b8210a 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Upraviť skratku" + }, + "archive": { + "message": "Archivovať" + }, + "unarchive": { + "message": "Zrušiť archiváciu" + }, + "itemsInArchive": { + "message": "Položky v archíve" + }, + "noItemsInArchive": { + "message": "Žiadne položky v archíve" + }, + "noItemsInArchiveDesc": { + "message": "Tu sa zobrazia archivované položky, ktoré budú vylúčené zo všeobecného vyhľadávania a z návrhov automatického vypĺňania." + }, + "itemSentToArchive": { + "message": "Položka bola archivovaná" + }, + "itemRemovedFromArchive": { + "message": "Položka bola odobraná z archívu" + }, + "archiveItem": { + "message": "Archivovať položku" + }, + "archiveItemConfirmDesc": { + "message": "Archivované položky sú vylúčené zo všeobecného vyhľadávania a z návrhov automatického vypĺňania. Naozaj chcete archivovať túto položku?" } } diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index 030167a6697..23a6df7ad95 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index a78483b9687..cd41aa9e4b9 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -1303,7 +1303,7 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Прикажи иконе веб локација и преузмите линкове промене лозинке" }, "enableMinToTray": { "message": "Минимизирај као иконицу у системској траци" @@ -3834,10 +3834,10 @@ "message": "Променити ризичну лозинку" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Ова пријава је ризична и недостаје веб локација. Додајте веб страницу и промените лозинку за јачу сигурност." }, "missingWebsite": { - "message": "Missing website" + "message": "Недостаје веб страница" }, "cannotRemoveViewOnlyCollections": { "message": "Не можете уклонити колекције са дозволама само за приказ: $COLLECTIONS$", @@ -3935,10 +3935,10 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "aboutThisSetting": { - "message": "About this setting" + "message": "О овом подешавању" }, "permitCipherDetailsDescription": { - "message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service." + "message": "Bitwarden ће користити сачуване URI-јеве за пријаву да би одредио коју икону или URL за промену лозинке треба користити како би побољшао ваше искуство. Никакви подаци нису сакупљени нити сачувани приликом коришћења ове услуге." }, "assignToCollections": { "message": "Додели колекцијама" @@ -4100,12 +4100,39 @@ "message": "Потврди" }, "enableAutotypeTransitionKey": { - "message": "Enable autotype shortcut" + "message": "Омогућава пречицу за аутоматски унос" }, "enableAutotypeDescriptionTransitionKey": { - "message": "Be sure you are in the correct field before using the shortcut to avoid filling data into the wrong place." + "message": "Будите сигурни да сте у исправном пољу пре употребе пречице да бисте избегли попуњавање података на погрешно место." }, "editShortcut": { - "message": "Edit shortcut" + "message": "Уреди пречицу" + }, + "archive": { + "message": "Архива" + }, + "unarchive": { + "message": "Врати из архиве" + }, + "itemsInArchive": { + "message": "Ставке у архиви" + }, + "noItemsInArchive": { + "message": "Нема ставка у архиви" + }, + "noItemsInArchiveDesc": { + "message": "Архивиране ставке ће се овде појавити и бити искључени из општих резултата претраге и сугестија о ауто-пуњењу." + }, + "itemSentToArchive": { + "message": "Ставка је послата у архиву" + }, + "itemRemovedFromArchive": { + "message": "Ставка је уклоњена из архиве" + }, + "archiveItem": { + "message": "Архивирај ставку" + }, + "archiveItemConfirmDesc": { + "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" } } diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 701daee522f..a867fc28753 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Redigera genväg" + }, + "archive": { + "message": "Arkivera" + }, + "unarchive": { + "message": "Packa upp" + }, + "itemsInArchive": { + "message": "Objekt i arkivet" + }, + "noItemsInArchive": { + "message": "Inga objekt i arkivet" + }, + "noItemsInArchiveDesc": { + "message": "Arkiverade objekt kommer att visas här och kommer att uteslutas från allmänna sökresultat och förslag för autofyll." + }, + "itemSentToArchive": { + "message": "Objekt skickat till arkiv" + }, + "itemRemovedFromArchive": { + "message": "Objekt borttaget från arkiv" + }, + "archiveItem": { + "message": "Arkivera objekt" + }, + "archiveItemConfirmDesc": { + "message": "Arkiverade objekt är uteslutna från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?" } } diff --git a/apps/desktop/src/locales/ta/messages.json b/apps/desktop/src/locales/ta/messages.json index 1364837b860..4874985a8fd 100644 --- a/apps/desktop/src/locales/ta/messages.json +++ b/apps/desktop/src/locales/ta/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "குறுக்குவழியைத் திருத்து" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index 27394d39ece..5849d9d4cee 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index df2bdbad7b2..fa619695fdb 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index bb7f1219fcc..c33570af387 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Kısayolu düzenle" + }, + "archive": { + "message": "Arşivle" + }, + "unarchive": { + "message": "Arşivden çıkar" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index 3adbb24e3c7..c5c86baacdf 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -2751,7 +2751,7 @@ "message": "Адреса е-пошти Catch-all" }, "catchallEmailDesc": { - "message": "Використовуйте свою скриньку вхідних Catch-All власного домену." + "message": "Використовуйте можливості Catch-All власного домену." }, "useThisEmail": { "message": "Використати цю е-пошту" @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index 363b4647519..a9ac6aa5bd7 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -1303,7 +1303,7 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Hiển thị biểu tượng trang web và truy xuất các URL đổi mật khẩu" }, "enableMinToTray": { "message": "Thu nhỏ vào khay hệ thống" @@ -3935,10 +3935,10 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "aboutThisSetting": { - "message": "About this setting" + "message": "Về cài đặt này" }, "permitCipherDetailsDescription": { - "message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service." + "message": "Bitwarden sẽ sử dụng URI đăng nhập đã lưu để xác định biểu tượng hoặc URL đổi mật khẩu nào cần dùng nhằm cải thiện trải nghiệm của bạn. Không có thông tin nào được thu thập hay lưu lại khi bạn sử dụng dịch vụ này." }, "assignToCollections": { "message": "Gán vào bộ sưu tập" @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Chỉnh sửa phím tắt" + }, + "archive": { + "message": "Lưu trữ" + }, + "unarchive": { + "message": "Hủy lưu trữ" + }, + "itemsInArchive": { + "message": "Các mục trong kho lưu trữ" + }, + "noItemsInArchive": { + "message": "Không có mục nào trong kho lưu trữ" + }, + "noItemsInArchiveDesc": { + "message": "Các mục đã lưu trữ sẽ hiển thị ở đây và sẽ bị loại khỏi kết quả tìm kiếm và gợi ý tự động điền." + }, + "itemSentToArchive": { + "message": "Mục đã được gửi đến kho lưu trữ" + }, + "itemRemovedFromArchive": { + "message": "Mục đã được gỡ khỏi kho lưu trữ" + }, + "archiveItem": { + "message": "Lưu trữ mục" + }, + "archiveItemConfirmDesc": { + "message": "Các mục đã lưu trữ sẽ bị loại khỏi kết quả tìm kiếm chung và gợi ý tự động điền. Bạn có chắc chắn muốn lưu trữ mục này không?" } } diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 7b5a4b87e3b..552afdca34c 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -897,7 +897,7 @@ "message": "您已登录!" }, "masterPassSent": { - "message": "我们已经为您发送了包含主密码提示的电子邮件。" + "message": "我们已经向您发送了一封包含主密码提示的电子邮件。" }, "unexpectedError": { "message": "发生意外错误。" @@ -1303,7 +1303,7 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "showIconsChangePasswordUrls": { - "message": "显示网站图标并检索更改密码的 URL" + "message": "显示网站图标并获取更改密码的 URL" }, "enableMinToTray": { "message": "最小化到托盘图标" @@ -1363,7 +1363,7 @@ "message": "确认隐藏到托盘" }, "confirmTrayDesc": { - "message": "关闭此设置也将关闭其他与托盘相关的设置。" + "message": "停用此设置也将停用其他与托盘相关的设置。" }, "language": { "message": "语言" @@ -4081,7 +4081,7 @@ "message": "显示更少" }, "enableAutotype": { - "message": "启用自动填写" + "message": "启用自动输入" }, "enableAutotypeDescription": { "message": "Bitwarden 不会验证输入位置,在使用快捷键之前,请确保您位于正确的窗口和字段中。" @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "编辑快捷键" + }, + "archive": { + "message": "归档" + }, + "unarchive": { + "message": "取消归档" + }, + "itemsInArchive": { + "message": "归档中的项目" + }, + "noItemsInArchive": { + "message": "归档中没有项目" + }, + "noItemsInArchiveDesc": { + "message": "已归档的项目将显示在此处,并将被排除在一般搜索结果和自动填充建议之外。" + }, + "itemSentToArchive": { + "message": "项目已归档" + }, + "itemRemovedFromArchive": { + "message": "项目已取消归档" + }, + "archiveItem": { + "message": "归档项目" + }, + "archiveItemConfirmDesc": { + "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" } } diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index fa14858d62e..d4e579f89c1 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -1021,16 +1021,16 @@ "message": "選取兩步驟登入方式" }, "selfHostedEnvironment": { - "message": "自我裝載環境" + "message": "自行部署環境" }, "selfHostedBaseUrlHint": { - "message": "指定您自建的 Bitwarden 伺服器的網域 URL。例如:https://bitwarden.company.com" + "message": "指定您自架的 Bitwarden 伺服器的網域 URL。例如:https://bitwarden.company.com" }, "selfHostedCustomEnvHeader": { "message": "適用於進階設定。您可以單獨指定各個服務的網域 URL。" }, "selfHostedEnvFormInvalid": { - "message": "您必須新增伺服器網域 URL 或至少一個自定義環境。" + "message": "您必須新增伺服器網域 URL 或至少一個自訂環境。" }, "customEnvironment": { "message": "自訂環境" @@ -1045,7 +1045,7 @@ "message": "驗證工作階段因時間過久已逾時。請重試登入。" }, "selfHostBaseUrl": { - "message": "自建伺服器 URL", + "message": "自架伺服器 URL", "description": "Label for field requesting a self-hosted integration service URL" }, "apiUrl": { @@ -4107,5 +4107,32 @@ }, "editShortcut": { "message": "Edit shortcut" + }, + "archive": { + "message": "Archive" + }, + "unarchive": { + "message": "Unarchive" + }, + "itemsInArchive": { + "message": "Items in archive" + }, + "noItemsInArchive": { + "message": "No items in archive" + }, + "noItemsInArchiveDesc": { + "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + }, + "itemSentToArchive": { + "message": "Item sent to archive" + }, + "itemRemovedFromArchive": { + "message": "Item removed from archive" + }, + "archiveItem": { + "message": "Archive item" + }, + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" } } diff --git a/apps/desktop/src/scss/misc.scss b/apps/desktop/src/scss/misc.scss index 3c3d4ff508c..b64bdd92120 100644 --- a/apps/desktop/src/scss/misc.scss +++ b/apps/desktop/src/scss/misc.scss @@ -360,6 +360,13 @@ form, } } +.settings-link { + @include themify($themes) { + color: themed("primaryColor"); + } + font-weight: bold; +} + app-root > #loading, .loading { display: flex; diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 5a6683ed904..3fdb14aa154 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -25,7 +25,6 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -319,26 +318,13 @@ export class VaultV2Component this.searchBarService.setEnabled(true); this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); - if ( - (await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval), - )) === true - ) { - const authRequests = await firstValueFrom( - this.authRequestService.getLatestPendingAuthRequest$(), - ); - if (authRequests != null) { - this.messagingService.send("openLoginApproval", { - notificationId: authRequests.id, - }); - } - } else { - const authRequest = await this.apiService.getLastAuthRequest(); - if (authRequest != null) { - this.messagingService.send("openLoginApproval", { - notificationId: authRequest.id, - }); - } + const authRequests = await firstValueFrom( + this.authRequestService.getLatestPendingAuthRequest$(), + ); + if (authRequests != null) { + this.messagingService.send("openLoginApproval", { + notificationId: authRequests.id, + }); } this.activeUserId = await firstValueFrom( diff --git a/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.html b/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.html deleted file mode 100644 index 326dc627e17..00000000000 --- a/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.html +++ /dev/null @@ -1,121 +0,0 @@ -@if (organization) { - - - - -} - - - -
-
- -
-
- - - {{ "all" | i18n }} - - - - {{ "addAccess" | i18n }} - - - - {{ trashCleanupWarning }} - - - - - - {{ "noItemsInList" | i18n }} - - - - - -
- - {{ "loading" | i18n }} -
-
-
diff --git a/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.ts deleted file mode 100644 index fce2827c073..00000000000 --- a/apps/web/src/app/admin-console/organizations/collections/deprecated_vault.component.ts +++ /dev/null @@ -1,1389 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; -import { ActivatedRoute, Params, Router } from "@angular/router"; -import { - BehaviorSubject, - combineLatest, - firstValueFrom, - lastValueFrom, - merge, - Observable, - Subject, -} from "rxjs"; -import { - concatMap, - debounceTime, - distinctUntilChanged, - filter, - first, - map, - shareReplay, - switchMap, - takeUntil, - tap, -} from "rxjs/operators"; - -import { - CollectionAdminService, - CollectionAdminView, - CollectionService, - CollectionView, - Unassigned, -} from "@bitwarden/admin-console/common"; -import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; -import { NoResults } from "@bitwarden/assets/svg"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { 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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; -import { EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; -import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; -import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; -import { - BannerModule, - DialogRef, - DialogService, - NoItemsModule, - ToastService, -} from "@bitwarden/components"; -import { - AttachmentDialogResult, - AttachmentsV2Component, - CipherFormConfig, - CipherFormConfigService, - CollectionAssignmentResult, - DecryptionFailureDialogComponent, - PasswordRepromptService, -} from "@bitwarden/vault"; -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 { SharedModule } from "../../../shared"; -import { AssignCollectionsWebComponent } from "../../../vault/components/assign-collections"; -import { - VaultItemDialogComponent, - VaultItemDialogMode, - VaultItemDialogResult, -} from "../../../vault/components/vault-item-dialog/vault-item-dialog.component"; -import { VaultItemEvent } from "../../../vault/components/vault-items/vault-item-event"; -import { VaultItemsModule } from "../../../vault/components/vault-items/vault-items.module"; -import { - BulkDeleteDialogResult, - openBulkDeleteDialog, -} from "../../../vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component"; -import { VaultFilterService } from "../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; -import { RoutedVaultFilterBridgeService } from "../../../vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; -import { RoutedVaultFilterService } from "../../../vault/individual-vault/vault-filter/services/routed-vault-filter.service"; -import { createFilterFunction } from "../../../vault/individual-vault/vault-filter/shared/models/filter-function"; -import { - All, - RoutedVaultFilterModel, -} from "../../../vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model"; -import { VaultFilter } from "../../../vault/individual-vault/vault-filter/shared/models/vault-filter.model"; -import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; -import { GroupApiService, GroupView } from "../core"; -import { openEntityEventsDialog } from "../manage/entity-events.component"; -import { - CollectionDialogAction, - CollectionDialogTabType, - openCollectionDialog, -} from "../shared/components/collection-dialog"; - -import { - BulkCollectionsDialogComponent, - BulkCollectionsDialogResult, -} from "./bulk-collections-dialog"; -import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component"; -import { getFlatCollectionTree, getNestedCollectionTree } from "./utils"; -import { VaultFilterModule } from "./vault-filter/vault-filter.module"; -import { VaultHeaderComponent } from "./vault-header/vault-header.component"; - -const BroadcasterSubscriptionId = "OrgVaultComponent"; -const SearchTextDebounceInterval = 200; - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -enum AddAccessStatusType { - All = 0, - AddAccess = 1, -} - -@Component({ - selector: "app-org-vault", - templateUrl: "deprecated_vault.component.html", - imports: [ - VaultHeaderComponent, - CollectionAccessRestrictedComponent, - VaultFilterModule, - VaultItemsModule, - SharedModule, - BannerModule, - NoItemsModule, - OrganizationFreeTrialWarningComponent, - OrganizationResellerRenewalWarningComponent, - ], - providers: [ - RoutedVaultFilterService, - RoutedVaultFilterBridgeService, - { provide: CipherFormConfigService, useClass: AdminConsoleCipherFormConfigService }, - ], -}) -export class VaultComponent implements OnInit, OnDestroy { - protected Unassigned = Unassigned; - - trashCleanupWarning: string = null; - activeFilter: VaultFilter = new VaultFilter(); - - protected showAddAccessToggle = false; - protected noItemIcon = NoResults; - protected performingInitialLoad = true; - protected refreshing = false; - protected processingEvent = false; - protected filter: RoutedVaultFilterModel = {}; - protected organization: Organization; - protected allCollections: CollectionAdminView[]; - protected allGroups: GroupView[]; - protected ciphers: CipherView[]; - protected collections: CollectionAdminView[]; - protected selectedCollection: TreeNode | undefined; - protected isEmpty: boolean; - protected showCollectionAccessRestricted: boolean; - protected currentSearchText$: Observable; - protected prevCipherId: string | null = null; - protected userId: UserId; - /** - * A list of collections that the user can assign items to and edit those items within. - * @protected - */ - protected editableCollections$: Observable; - protected allCollectionsWithoutUnassigned$: Observable; - - protected get hideVaultFilters(): boolean { - return this.organization?.isProviderUser && !this.organization?.isMember; - } - - private searchText$ = new Subject(); - private refresh$ = new BehaviorSubject(null); - private destroy$ = new Subject(); - protected addAccessStatus$ = new BehaviorSubject(0); - private vaultItemDialogRef?: DialogRef | undefined; - - @ViewChild("vaultItems", { static: false }) vaultItemsComponent: VaultItemsComponent; - - constructor( - private route: ActivatedRoute, - private organizationService: OrganizationService, - protected vaultFilterService: VaultFilterService, - private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, - private routedVaultFilterService: RoutedVaultFilterService, - private router: Router, - private changeDetectorRef: ChangeDetectorRef, - private syncService: SyncService, - private i18nService: I18nService, - private dialogService: DialogService, - private messagingService: MessagingService, - private broadcasterService: BroadcasterService, - private ngZone: NgZone, - private platformUtilsService: PlatformUtilsService, - private cipherService: CipherService, - private passwordRepromptService: PasswordRepromptService, - private collectionAdminService: CollectionAdminService, - private searchService: SearchService, - private searchPipe: SearchPipe, - private groupService: GroupApiService, - private logService: LogService, - private eventCollectionService: EventCollectionService, - private totpService: TotpService, - private apiService: ApiService, - private toastService: ToastService, - private configService: ConfigService, - private cipherFormConfigService: CipherFormConfigService, - protected billingApiService: BillingApiServiceAbstraction, - private accountService: AccountService, - private organizationWarningsService: OrganizationWarningsService, - private collectionService: CollectionService, - ) {} - - async ngOnInit() { - this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - - this.trashCleanupWarning = this.i18nService.t( - this.platformUtilsService.isSelfHost() - ? "trashCleanupWarningSelfHosted" - : "trashCleanupWarning", - ); - - const filter$ = this.routedVaultFilterService.filter$; - - // FIXME: The RoutedVaultFilterModel uses `organizationId: Unassigned` to represent the individual vault, - // but that is never used in Admin Console. This function narrows the type so it doesn't pollute our code here, - // but really we should change to using our own vault filter model that only represents valid states in AC. - const isOrganizationId = (value: OrganizationId | Unassigned): value is OrganizationId => - value !== Unassigned; - const organizationId$ = filter$.pipe( - map((filter) => filter.organizationId), - filter((filter) => filter !== undefined), - filter(isOrganizationId), - distinctUntilChanged(), - ); - - const organization$ = this.accountService.activeAccount$.pipe( - map((account) => account?.id), - switchMap((id) => - organizationId$.pipe( - switchMap((organizationId) => - this.organizationService - .organizations$(id) - .pipe(map((organizations) => organizations.find((org) => org.id === organizationId))), - ), - takeUntil(this.destroy$), - shareReplay({ refCount: false, bufferSize: 1 }), - ), - ), - ); - - const firstSetup$ = combineLatest([organization$, this.route.queryParams]).pipe( - first(), - switchMap(async ([organization]) => { - this.organization = organization; - - if (!organization.canEditAnyCollection) { - await this.syncService.fullSync(false); - } - - return undefined; - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.ngZone.run(async () => { - switch (message.command) { - case "syncCompleted": - if (message.successfully) { - this.refresh(); - this.changeDetectorRef.detectChanges(); - } - break; - } - }); - }); - - this.routedVaultFilterBridgeService.activeFilter$ - .pipe(takeUntil(this.destroy$)) - .subscribe((activeFilter) => { - this.activeFilter = activeFilter; - - // watch the active filters. Only show toggle when viewing the collections filter - if (!this.activeFilter.collectionId) { - this.showAddAccessToggle = false; - } - }); - - this.searchText$ - .pipe(debounceTime(SearchTextDebounceInterval), takeUntil(this.destroy$)) - .subscribe((searchText) => - this.router.navigate([], { - queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText }, - queryParamsHandling: "merge", - replaceUrl: true, - }), - ); - - this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search)); - - this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe( - switchMap(() => organizationId$), - switchMap((orgId) => - this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => this.collectionAdminService.collectionAdminViews$(orgId, userId)), - ), - ), - shareReplay({ refCount: false, bufferSize: 1 }), - ); - - this.editableCollections$ = this.allCollectionsWithoutUnassigned$.pipe( - map((collections) => { - // Users that can edit all ciphers can implicitly add to / edit within any collection - if (this.organization.canEditAllCiphers) { - return collections; - } - return collections.filter((c) => c.assigned); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const allCollections$ = combineLatest([ - organizationId$, - this.allCollectionsWithoutUnassigned$, - ]).pipe( - map(([organizationId, allCollections]) => { - // 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. - return allCollections.concat( - new CollectionAdminView({ - name: this.i18nService.t("unassigned"), - id: Unassigned as CollectionId, - organizationId, - }), - ); - }), - ); - - const allGroups$ = organizationId$.pipe( - switchMap((organizationId) => this.groupService.getAll(organizationId)), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const allCiphers$ = combineLatest([organization$, this.refresh$]).pipe( - switchMap(async ([organization]) => { - // If user swaps organization reset the addAccessToggle - if (!this.showAddAccessToggle || organization) { - this.addAccessToggle(0); - } - let ciphers; - - // Restricted providers (who are not members) do not have access org cipher endpoint below - // Return early to avoid 404 response - if (!organization.isMember && organization.isProviderUser) { - return []; - } - - // If the user can edit all ciphers for the organization then fetch them ALL. - if (organization.canEditAllCiphers) { - ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); - ciphers?.forEach((c) => (c.edit = true)); - } else { - // Otherwise, only fetch ciphers they have access to (includes unassigned for admins). - ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id); - } - - await this.searchService.indexCiphers(this.userId, ciphers, organization.id); - return ciphers; - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const allCipherMap$ = allCiphers$.pipe( - map((ciphers) => { - return Object.fromEntries(ciphers.map((c) => [c.id, c])); - }), - ); - - const nestedCollections$ = allCollections$.pipe( - map((collections) => getNestedCollectionTree(collections)), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const collections$ = combineLatest([ - nestedCollections$, - filter$, - this.currentSearchText$, - this.addAccessStatus$, - ]).pipe( - filter(([collections, filter]) => collections != undefined && filter != undefined), - concatMap(async ([collections, filter, searchText, addAccessStatus]) => { - if ( - filter.collectionId === Unassigned || - (filter.collectionId === undefined && filter.type !== undefined) - ) { - return []; - } - - this.showAddAccessToggle = false; - let searchableCollectionNodes: TreeNode[] = []; - if (filter.collectionId === undefined || filter.collectionId === All) { - searchableCollectionNodes = collections; - } else { - const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( - collections, - filter.collectionId, - ); - searchableCollectionNodes = selectedCollection?.children ?? []; - } - - let collectionsToReturn: CollectionAdminView[] = []; - - if (await this.searchService.isSearchable(this.userId, searchText)) { - // Flatten the tree for searching through all levels - const flatCollectionTree: CollectionAdminView[] = - getFlatCollectionTree(searchableCollectionNodes); - - collectionsToReturn = this.searchPipe.transform( - flatCollectionTree, - searchText, - (collection) => collection.name, - (collection) => collection.id, - ); - } else { - collectionsToReturn = searchableCollectionNodes.map( - (treeNode: TreeNode): CollectionAdminView => treeNode.node, - ); - } - - // Add access toggle is only shown if allowAdminAccessToAllCollectionItems is false and there are unmanaged collections the user can edit - this.showAddAccessToggle = - !this.organization.allowAdminAccessToAllCollectionItems && - this.organization.canEditUnmanagedCollections && - collectionsToReturn.some((c) => c.unmanaged); - - if (addAccessStatus === 1 && this.showAddAccessToggle) { - collectionsToReturn = collectionsToReturn.filter((c) => c.unmanaged); - } - return collectionsToReturn; - }), - takeUntil(this.destroy$), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const selectedCollection$ = combineLatest([nestedCollections$, filter$]).pipe( - filter(([collections, filter]) => collections != undefined && filter != undefined), - map(([collections, filter]) => { - if ( - filter.collectionId === undefined || - filter.collectionId === All || - filter.collectionId === Unassigned - ) { - return undefined; - } - - return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const showCollectionAccessRestricted$ = combineLatest([ - filter$, - selectedCollection$, - organization$, - ]).pipe( - map(([filter, collection, organization]) => { - return ( - (filter.collectionId === Unassigned && !organization.canEditUnassignedCiphers) || - (!organization.canEditAllCiphers && collection != undefined && !collection.node.assigned) - ); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - const ciphers$ = combineLatest([ - allCiphers$, - filter$, - this.currentSearchText$, - showCollectionAccessRestricted$, - ]).pipe( - filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), - concatMap(async ([ciphers, filter, searchText, showCollectionAccessRestricted]) => { - if (filter.collectionId === undefined && filter.type === undefined) { - return []; - } - - if (showCollectionAccessRestricted) { - // Do not show ciphers for restricted collections - // Ciphers belonging to multiple collections may still be present in $allCiphers and shouldn't be visible - return []; - } - - const filterFunction = createFilterFunction(filter); - - if (await this.searchService.isSearchable(this.userId, searchText)) { - return await this.searchService.searchCiphers( - this.userId, - searchText, - [filterFunction], - ciphers, - ); - } - - return ciphers.filter(filterFunction); - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); - - firstSetup$ - .pipe( - switchMap(() => combineLatest([this.route.queryParams, allCipherMap$])), - filter(() => this.vaultItemDialogRef == undefined), - switchMap(async ([qParams, allCiphersMap]) => { - const cipherId = getCipherIdFromParams(qParams); - - if (!cipherId) { - this.prevCipherId = null; - return; - } - - if (cipherId === this.prevCipherId) { - return; - } - - this.prevCipherId = cipherId; - - const cipher = allCiphersMap[cipherId]; - if (cipher) { - let action = qParams.action; - - if (action == "showFailedToDecrypt") { - DecryptionFailureDialogComponent.open(this.dialogService, { - cipherIds: [cipherId as CipherId], - }); - await this.router.navigate([], { - queryParams: { itemId: null, cipherId: null, action: null }, - queryParamsHandling: "merge", - replaceUrl: true, - }); - return; - } - - // Default to "view" - if (action == null) { - action = "view"; - } - - if (action === "view") { - await this.viewCipherById(cipher); - } else { - await this.editCipher(cipher, false); - } - } else { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("unknownCipher"), - }); - await this.router.navigate([], { - queryParams: { cipherId: null, itemId: null }, - queryParamsHandling: "merge", - }); - } - }), - takeUntil(this.destroy$), - ) - .subscribe(); - - firstSetup$ - .pipe( - switchMap(() => combineLatest([this.route.queryParams, organization$, allCiphers$])), - switchMap(async ([qParams, organization, allCiphers$]) => { - const cipherId = qParams.viewEvents; - if (!cipherId) { - return; - } - const cipher = allCiphers$.find((c) => c.id === cipherId); - if (organization.useEvents && cipher != undefined) { - await this.viewEvents(cipher); - } else { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("unknownCipher"), - }); - await this.router.navigate([], { - queryParams: { viewEvents: null }, - queryParamsHandling: "merge", - }); - } - }), - takeUntil(this.destroy$), - ) - .subscribe(); - - // Billing Warnings - organization$ - .pipe( - switchMap((organization) => - merge( - this.organizationWarningsService.showInactiveSubscriptionDialog$(organization), - this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), - ), - ), - takeUntil(this.destroy$), - ) - .subscribe(); - // End Billing Warnings - - firstSetup$ - .pipe( - switchMap(() => this.refresh$), - tap(() => (this.refreshing = true)), - switchMap(() => - combineLatest([ - organization$, - filter$, - allCollections$, - allGroups$, - ciphers$, - collections$, - selectedCollection$, - showCollectionAccessRestricted$, - ]), - ), - takeUntil(this.destroy$), - ) - .subscribe( - ([ - organization, - filter, - allCollections, - allGroups, - ciphers, - collections, - selectedCollection, - showCollectionAccessRestricted, - ]) => { - this.organization = organization; - this.filter = filter; - this.allCollections = allCollections; - this.allGroups = allGroups; - this.ciphers = ciphers; - this.collections = collections; - this.selectedCollection = selectedCollection; - this.showCollectionAccessRestricted = showCollectionAccessRestricted; - - this.isEmpty = collections?.length === 0 && ciphers?.length === 0; - - // This is a temporary fix to avoid double fetching collections. - // TODO: Remove when implementing new VVR menu - this.vaultFilterService.reloadCollections(allCollections); - - this.refreshing = false; - this.performingInitialLoad = false; - }, - ); - } - - async navigateToPaymentMethod() { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; - await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], { - state: { launchPaymentModalAutomatically: true }, - }); - } - - addAccessToggle(e: AddAccessStatusType) { - this.addAccessStatus$.next(e); - } - - get loading() { - return this.refreshing || this.processingEvent; - } - - ngOnDestroy() { - this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); - this.destroy$.next(); - this.destroy$.complete(); - } - - async onVaultItemsEvent(event: VaultItemEvent) { - this.processingEvent = true; - - try { - switch (event.type) { - case "viewAttachments": - await this.editCipherAttachments(event.item); - break; - case "clone": - await this.cloneCipher(event.item); - break; - case "restore": - if (event.items.length === 1) { - await this.restore(event.items[0]); - } else { - await this.bulkRestore(event.items); - } - break; - case "delete": { - const ciphers = event.items - .filter((i) => i.collection === undefined) - .map((i) => i.cipher); - const collections = event.items - .filter((i) => i.cipher === undefined) - .map((i) => i.collection); - if (ciphers.length === 1 && collections.length === 0) { - await this.deleteCipher(ciphers[0]); - } else if (ciphers.length === 0 && collections.length === 1) { - await this.deleteCollection(collections[0] as CollectionAdminView); - } else { - await this.bulkDelete(ciphers, collections, this.organization); - } - break; - } - case "copyField": - await this.copy(event.item, event.field); - break; - case "editCollection": - await this.editCollection( - event.item as CollectionAdminView, - CollectionDialogTabType.Info, - event.readonly, - ); - break; - case "viewCollectionAccess": - await this.editCollection( - event.item as CollectionAdminView, - CollectionDialogTabType.Access, - event.readonly, - ); - break; - case "bulkEditCollectionAccess": - await this.bulkEditCollectionAccess(event.items, this.organization); - break; - case "assignToCollections": - await this.bulkAssignToCollections(event.items); - break; - case "viewEvents": - await this.viewEvents(event.item); - break; - } - } finally { - this.processingEvent = false; - } - } - - filterSearchText(searchText: string) { - this.searchText$.next(searchText); - } - - async editCipherAttachments(cipher: CipherView) { - if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { - this.go({ cipherId: null, itemId: null }); - return; - } - - if (this.organization.maxStorageGb == null || this.organization.maxStorageGb === 0) { - this.messagingService.send("upgradeOrganization", { organizationId: cipher.organizationId }); - return; - } - - const dialogRef = AttachmentsV2Component.open(this.dialogService, { - cipherId: cipher.id as CipherId, - organizationId: cipher.organizationId as OrganizationId, - admin: true, - }); - - const result = await firstValueFrom(dialogRef.closed); - - if ( - result.action === AttachmentDialogResult.Removed || - result.action === AttachmentDialogResult.Uploaded - ) { - this.refresh(); - } - } - - /** Opens the Add/Edit Dialog */ - async addCipher(cipherType?: CipherType) { - const cipherFormConfig = await this.cipherFormConfigService.buildConfig( - "add", - null, - cipherType, - ); - - const collectionId: CollectionId | undefined = this.activeFilter.collectionId as CollectionId; - - cipherFormConfig.initialValues = { - organizationId: this.organization.id as OrganizationId, - collectionIds: collectionId ? [collectionId] : [], - }; - - await this.openVaultItemDialog("form", cipherFormConfig); - } - - /** - * Edit the given cipher or add a new cipher - * @param cipherView - When set, the cipher to be edited - * @param cloneCipher - `true` when the cipher should be cloned. - */ - async editCipher(cipher: CipherView | null, cloneCipher: boolean) { - if ( - cipher && - cipher.reprompt !== 0 && - !(await this.passwordRepromptService.showPasswordPrompt()) - ) { - // didn't pass password prompt, so don't open add / edit modal - this.go({ cipherId: null, itemId: null }); - return; - } - - const cipherFormConfig = await this.cipherFormConfigService.buildConfig( - cloneCipher ? "clone" : "edit", - cipher?.id as CipherId | null, - ); - - await this.openVaultItemDialog("form", cipherFormConfig, cipher); - } - - /** Opens the view dialog for the given cipher unless password reprompt fails */ - async viewCipherById(cipher: CipherView) { - if (!cipher) { - return; - } - - if ( - cipher && - cipher.reprompt !== 0 && - !(await this.passwordRepromptService.showPasswordPrompt()) - ) { - // Didn't pass password prompt, so don't open add / edit modal. - await this.go({ cipherId: null, itemId: null, action: null }); - return; - } - - const cipherFormConfig = await this.cipherFormConfigService.buildConfig( - "edit", - cipher.id as CipherId, - cipher.type, - ); - - await this.openVaultItemDialog( - "view", - cipherFormConfig, - cipher, - this.activeFilter.collectionId as CollectionId, - ); - } - - /** - * Open the combined view / edit dialog for a cipher. - */ - async openVaultItemDialog( - mode: VaultItemDialogMode, - formConfig: CipherFormConfig, - cipher?: CipherView, - activeCollectionId?: CollectionId, - ) { - const disableForm = cipher ? !cipher.edit && !this.organization.canEditAllCiphers : false; - // If the form is disabled, force the mode into `view` - const dialogMode = disableForm ? "view" : mode; - this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, { - mode: dialogMode, - formConfig, - disableForm, - activeCollectionId, - isAdminConsoleAction: true, - restore: this.restore, - }); - - const result = await lastValueFrom(this.vaultItemDialogRef.closed); - this.vaultItemDialogRef = undefined; - - // If the dialog was closed by deleting the cipher, refresh the vault. - if (result === VaultItemDialogResult.Deleted || result === VaultItemDialogResult.Saved) { - this.refresh(); - } - - // Clear the query params when the dialog closes - await this.go({ cipherId: null, itemId: null, action: null }); - } - - async cloneCipher(cipher: CipherView) { - if (cipher.login?.hasFido2Credentials) { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "passkeyNotCopied" }, - content: { key: "passkeyNotCopiedAlert" }, - type: "info", - }); - - if (!confirmed) { - return false; - } - } - - await this.editCipher(cipher, true); - } - - restore = async (c: CipherView): Promise => { - if (!c.isDeleted) { - return; - } - - if ( - !this.organization.permissions.editAnyCollection && - !c.edit && - !this.organization.allowAdminAccessToAllCollectionItems - ) { - this.showMissingPermissionsError(); - return; - } - - if (!(await this.repromptCipher([c]))) { - return; - } - - // Allow restore of an Unassigned Item - try { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const asAdmin = this.organization?.canEditAnyCollection || c.isUnassigned; - await this.cipherService.restoreWithServer(c.id, activeUserId, asAdmin); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("restoredItem"), - }); - this.refresh(); - } catch (e) { - this.logService.error(e); - } - }; - - async bulkRestore(ciphers: CipherView[]) { - if ( - !this.organization.permissions.editAnyCollection && - ciphers.some((c) => !c.edit && !this.organization.allowAdminAccessToAllCollectionItems) - ) { - this.showMissingPermissionsError(); - return; - } - - if (!(await this.repromptCipher(ciphers))) { - return; - } - - // assess if there are unassigned ciphers and/or editable ciphers selected in bulk for restore - const editAccessCiphers: string[] = []; - const unassignedCiphers: string[] = []; - - // If user has edit all Access no need to check for unassigned ciphers - if (this.organization.canEditAllCiphers) { - ciphers.map((cipher) => { - editAccessCiphers.push(cipher.id); - }); - } else { - ciphers.map((cipher) => { - if (cipher.collectionIds.length === 0) { - unassignedCiphers.push(cipher.id); - } else if (cipher.edit) { - editAccessCiphers.push(cipher.id); - } - }); - } - - if (unassignedCiphers.length === 0 && editAccessCiphers.length === 0) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("nothingSelected"), - }); - return; - } - - if (unassignedCiphers.length > 0 || editAccessCiphers.length > 0) { - await this.cipherService.restoreManyWithServer( - [...unassignedCiphers, ...editAccessCiphers], - this.userId, - this.organization.id, - ); - } - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("restoredItems"), - }); - this.refresh(); - } - - async deleteCipher(c: CipherView): Promise { - if (!c.edit && !this.organization.canEditAllCiphers) { - this.showMissingPermissionsError(); - return; - } - - if (!(await this.repromptCipher([c]))) { - return; - } - - const permanent = c.isDeleted; - - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: permanent ? "permanentlyDeleteItem" : "deleteItem" }, - content: { key: permanent ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - try { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.deleteCipherWithServer(c.id, activeUserId, permanent, c.isUnassigned); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem"), - }); - this.refresh(); - } catch (e) { - this.logService.error(e); - } - } - - async deleteCollection(collection: CollectionAdminView): Promise { - if (!collection.canDelete(this.organization)) { - this.showMissingPermissionsError(); - return; - } - const confirmed = await this.dialogService.openSimpleDialog({ - title: collection.name, - content: { key: "deleteCollectionConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return; - } - try { - await this.apiService.deleteCollection(this.organization?.id, collection.id); - await this.collectionService.delete([collection.id as CollectionId], this.userId); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("deletedCollectionId", collection.name), - }); - - // Clear the cipher cache to clear the deleted collection from the cipher state - await this.cipherService.clear(); - - // Navigate away if we deleted the collection we were viewing - if (this.selectedCollection?.node.id === collection.id) { - void this.router.navigate([], { - queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, - queryParamsHandling: "merge", - replaceUrl: true, - }); - } - - this.refresh(); - } catch (e) { - this.logService.error(e); - } - } - - async bulkDelete( - ciphers: CipherView[], - collections: CollectionView[], - organization: Organization, - ) { - if (!(await this.repromptCipher(ciphers))) { - return; - } - - // Allow bulk deleting of Unassigned Items - const unassignedCiphers: string[] = []; - const assignedCiphers: string[] = []; - - ciphers.map((c) => { - if (c.isUnassigned) { - unassignedCiphers.push(c.id); - } else { - assignedCiphers.push(c.id); - } - }); - - if (ciphers.length === 0 && collections.length === 0) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("nothingSelected"), - }); - return; - } - - const canDeleteCollections = - collections == null || collections.every((c) => c.canDelete(organization)); - const canDeleteCiphers = - ciphers == null || ciphers.every((c) => c.edit) || this.organization.canEditAllCiphers; - - if (!canDeleteCiphers || !canDeleteCollections) { - this.showMissingPermissionsError(); - return; - } - - const dialog = openBulkDeleteDialog(this.dialogService, { - data: { - permanent: this.filter.type === "trash", - cipherIds: assignedCiphers, - collections: collections, - organization, - unassignedCiphers, - }, - }); - - const result = await lastValueFrom(dialog.closed); - if (result === BulkDeleteDialogResult.Deleted) { - this.refresh(); - } - } - - async copy(cipher: CipherView, field: "username" | "password" | "totp") { - let aType; - let value; - let typeI18nKey; - - if (field === "username") { - aType = "Username"; - value = cipher.login.username; - typeI18nKey = "username"; - } else if (field === "password") { - aType = "Password"; - value = cipher.login.password; - typeI18nKey = "password"; - } else if (field === "totp") { - aType = "TOTP"; - const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); - value = totpResponse?.code; - typeI18nKey = "verificationCodeTotp"; - } else { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("unexpectedError"), - }); - return; - } - - if ( - this.passwordRepromptService.protectedFields().includes(aType) && - !(await this.repromptCipher([cipher])) - ) { - return; - } - - if (!cipher.viewPassword) { - return; - } - - this.platformUtilsService.copyToClipboard(value, { window: window }); - this.toastService.showToast({ - variant: "info", - title: null, - message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)), - }); - - if (field === "password") { - await this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id); - } else if (field === "totp") { - await this.eventCollectionService.collect( - EventType.Cipher_ClientCopiedHiddenField, - cipher.id, - ); - } - } - - async addCollection(): Promise { - const dialog = openCollectionDialog(this.dialogService, { - data: { - organizationId: this.organization?.id, - parentCollectionId: this.selectedCollection?.node.id, - limitNestedCollections: !this.organization.canEditAnyCollection, - isAdminConsoleActive: true, - }, - }); - - const result = await lastValueFrom(dialog.closed); - if ( - result.action === CollectionDialogAction.Saved || - result.action === CollectionDialogAction.Deleted - ) { - this.refresh(); - } - } - - async editCollection( - c: CollectionAdminView, - tab: CollectionDialogTabType, - readonly: boolean, - ): Promise { - const dialog = openCollectionDialog(this.dialogService, { - data: { - collectionId: c?.id, - organizationId: this.organization?.id, - initialTab: tab, - readonly: readonly, - isAddAccessCollection: c.unmanaged, - limitNestedCollections: !this.organization.canEditAnyCollection, - isAdminConsoleActive: true, - }, - }); - - const result = await lastValueFrom(dialog.closed); - if ( - result.action === CollectionDialogAction.Saved || - result.action === CollectionDialogAction.Deleted - ) { - this.refresh(); - - // If we deleted the selected collection, navigate up/away - if ( - result.action === CollectionDialogAction.Deleted && - this.selectedCollection?.node.id === c?.id - ) { - void this.router.navigate([], { - queryParams: { collectionId: this.selectedCollection.parent?.node.id ?? null }, - queryParamsHandling: "merge", - replaceUrl: true, - }); - } - } - } - - async bulkEditCollectionAccess( - collections: CollectionView[], - organization: Organization, - ): Promise { - if (collections.length === 0) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("noCollectionsSelected"), - }); - return; - } - - if (collections.some((c) => !c.canEdit(organization))) { - this.showMissingPermissionsError(); - return; - } - - const dialog = BulkCollectionsDialogComponent.open(this.dialogService, { - data: { - collections, - organizationId: this.organization?.id, - }, - }); - - const result = await lastValueFrom(dialog.closed); - if (result === BulkCollectionsDialogResult.Saved) { - this.refresh(); - } - } - - async bulkAssignToCollections(items: CipherView[]) { - if (items.length === 0) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("nothingSelected"), - }); - return; - } - - const availableCollections = await firstValueFrom(this.editableCollections$); - - const dialog = AssignCollectionsWebComponent.open(this.dialogService, { - data: { - ciphers: items, - organizationId: this.organization?.id as OrganizationId, - availableCollections, - activeCollection: this.activeFilter?.selectedCollectionNode?.node, - isSingleCipherAdmin: - items.length === 1 && (this.organization?.canEditAllCiphers || items[0].isUnassigned), - }, - }); - - const result = await lastValueFrom(dialog.closed); - if (result === CollectionAssignmentResult.Saved) { - this.refresh(); - } - } - - async viewEvents(cipher: CipherView) { - await openEntityEventsDialog(this.dialogService, { - data: { - name: cipher.name, - organizationId: this.organization.id, - entityId: cipher.id, - showUser: true, - entity: "cipher", - }, - }); - } - - protected deleteCipherWithServer( - id: string, - userId: UserId, - permanent: boolean, - isUnassigned: boolean, - ) { - const asAdmin = this.organization?.canEditAllCiphers || isUnassigned; - return permanent - ? this.cipherService.deleteWithServer(id, userId, asAdmin) - : this.cipherService.softDeleteWithServer(id, userId, asAdmin); - } - - protected async repromptCipher(ciphers: CipherView[]) { - const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); - - return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); - } - - private refresh() { - this.refresh$.next(); - this.vaultItemsComponent?.clearSelection(); - } - - private go(queryParams: any = null) { - if (queryParams == null) { - queryParams = { - type: this.activeFilter.cipherType, - collectionId: this.activeFilter.collectionId, - deleted: this.activeFilter.isDeleted || null, - }; - } - - void this.router.navigate([], { - relativeTo: this.route, - queryParams: queryParams, - queryParamsHandling: "merge", - replaceUrl: true, - }); - } - - protected readonly CollectionDialogTabType = CollectionDialogTabType; - - private showMissingPermissionsError() { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("missingPermissions"), - }); - } -} - -/** - * Allows backwards compatibility with - * old links that used the original `cipherId` param - */ -const getCipherIdFromParams = (params: Params): string => { - return params["itemId"] || params["cipherId"]; -}; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index 1ab76c74655..3341a428970 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -9,11 +9,11 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CipherArchiveService } from "@bitwarden/vault"; import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component"; import { VaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service"; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts b/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts index d529c4c31fe..7ad9f050d7b 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-routing.module.ts @@ -1,26 +1,19 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; -import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { canAccessVaultTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { organizationPermissionsGuard } from "../guards/org-permissions.guard"; -import { VaultComponent } from "./deprecated_vault.component"; -import { vNextVaultComponent } from "./vault.component"; +import { VaultComponent } from "./vault.component"; const routes: Routes = [ - ...featureFlaggedRoute({ - defaultComponent: VaultComponent, - flaggedComponent: vNextVaultComponent, - featureFlag: FeatureFlag.CollectionVaultRefactor, - routeOptions: { - data: { titleId: "vaults" }, - path: "", - canActivate: [organizationPermissionsGuard(canAccessVaultTab)], - }, - }), + { + data: { titleId: "vaults" }, + path: "", + canActivate: [organizationPermissionsGuard(canAccessVaultTab)], + component: VaultComponent, + }, ]; @NgModule({ 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 edd4ea3f9dc..3aab02b3b49 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 @@ -44,9 +44,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -162,7 +160,7 @@ enum AddAccessStatusType { { provide: CipherFormConfigService, useClass: AdminConsoleCipherFormConfigService }, ], }) -export class vNextVaultComponent implements OnInit, OnDestroy { +export class VaultComponent implements OnInit, OnDestroy { protected Unassigned = Unassigned; trashCleanupWarning: string = this.i18nService.t( @@ -239,7 +237,6 @@ export class vNextVaultComponent implements OnInit, OnDestroy { private totpService: TotpService, private apiService: ApiService, private toastService: ToastService, - private configService: ConfigService, private cipherFormConfigService: CipherFormConfigService, protected billingApiService: BillingApiServiceAbstraction, private accountService: AccountService, @@ -710,14 +707,13 @@ export class vNextVaultComponent implements OnInit, OnDestroy { } async navigateToPaymentMethod() { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; const organizationId = await firstValueFrom(this.organizationId$); - await this.router.navigate(["organizations", `${organizationId}`, "billing", route], { - state: { launchPaymentModalAutomatically: true }, - }); + await this.router.navigate( + ["organizations", `${organizationId}`, "billing", "payment-details"], + { + state: { launchPaymentModalAutomatically: true }, + }, + ); } addAccessToggle(e: AddAccessStatusType) { @@ -1132,7 +1128,7 @@ export class vNextVaultComponent implements OnInit, OnDestroy { const selectedCollection = await firstValueFrom(this.selectedCollection$); if (selectedCollection?.node.id === collection.id) { void this.router.navigate([], { - queryParams: { collectionId: selectedCollection.parent.node.id ?? null }, + queryParams: { collectionId: selectedCollection?.parent?.node.id ?? null }, queryParamsHandling: "merge", replaceUrl: true, }); diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.module.ts b/apps/web/src/app/admin-console/organizations/collections/vault.module.ts index 92dbc5d832c..1a093ff8352 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.module.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.module.ts @@ -6,10 +6,9 @@ import { ViewComponent } from "../../../vault/individual-vault/view.component"; import { CollectionDialogComponent } from "../shared/components/collection-dialog"; import { CollectionNameBadgeComponent } from "./collection-badge"; -import { VaultComponent } from "./deprecated_vault.component"; import { GroupBadgeModule } from "./group-badge/group-badge.module"; import { VaultRoutingModule } from "./vault-routing.module"; -import { vNextVaultComponent } from "./vault.component"; +import { VaultComponent } from "./vault.component"; @NgModule({ imports: [ @@ -20,7 +19,6 @@ import { vNextVaultComponent } from "./vault.component"; OrganizationBadgeModule, CollectionDialogComponent, VaultComponent, - vNextVaultComponent, ViewComponent, ], }) 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 10290d52f1e..e5af0faa164 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 @@ -71,10 +71,9 @@ > - @let paymentDetailsPageData = paymentDetailsPageData$ | async; 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 a9b61debf89..b9d44c125ad 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 @@ -23,9 +23,6 @@ import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/ 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 { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; import { BannerModule, IconModule } from "@bitwarden/components"; @@ -70,11 +67,6 @@ export class OrganizationLayoutComponent implements OnInit { protected showSponsoredFamiliesDropdown$: Observable; - protected paymentDetailsPageData$: Observable<{ - route: string; - textKey: string; - }>; - protected subscriber$: Observable; protected getTaxIdWarning$: () => Observable; @@ -82,12 +74,10 @@ export class OrganizationLayoutComponent implements OnInit { private route: ActivatedRoute, private organizationService: OrganizationService, private platformUtilsService: PlatformUtilsService, - private configService: ConfigService, private policyService: PolicyService, private providerService: ProviderService, private accountService: AccountService, private freeFamiliesPolicyService: FreeFamiliesPolicyService, - private organizationBillingService: OrganizationBillingServiceAbstraction, private organizationWarningsService: OrganizationWarningsService, ) {} @@ -141,16 +131,6 @@ export class OrganizationLayoutComponent implements OnInit { this.integrationPageEnabled$ = this.organization$.pipe(map((org) => org.canAccessIntegrations)); - this.paymentDetailsPageData$ = this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => - managePaymentDetailsOutsideCheckout - ? { route: "billing/payment-details", textKey: "paymentDetails" } - : { route: "billing/payment-method", textKey: "paymentMethod" }, - ), - ); - this.subscriber$ = this.organization$.pipe( map((organization) => ({ type: "organization", 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 5a1ae6cd98b..b31f1cbf358 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 @@ -975,12 +975,11 @@ export class MembersComponent extends BaseMembersComponent } async navigateToPaymentMethod(organization: Organization) { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + await this.router.navigate( + ["organizations", `${organization.id}`, "billing", "payment-details"], + { + state: { launchPaymentModalAutomatically: true }, + }, ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; - await this.router.navigate(["organizations", `${organization.id}`, "billing", route], { - state: { launchPaymentModalAutomatically: true }, - }); } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.html b/apps/web/src/app/admin-console/organizations/policies/policies.component.html index ea14986749f..8df73a50e14 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.html @@ -1,7 +1,6 @@ - @let organization = organization$ | async; @if (loading) { - @for (p of policies; track p.name) { - @if (p.display$(organization, configService) | async) { - - - - @if (policiesEnabledMap.get(p.type)) { - {{ "on" | i18n }} - } - {{ p.description | i18n }} - - - } + @for (p of policies$ | async; track p.type) { + + + + @if (policiesEnabledMap.get(p.type)) { + {{ "on" | i18n }} + } + {{ p.description | i18n }} + + } diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 45383133687..e2c51b77d45 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -2,8 +2,17 @@ // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom, lastValueFrom, Observable } from "rxjs"; -import { first, map } from "rxjs/operators"; +import { + combineLatest, + firstValueFrom, + lastValueFrom, + Observable, + of, + switchMap, + first, + map, + withLatestFrom, +} from "rxjs"; import { getOrganizationById, @@ -11,7 +20,6 @@ import { } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -39,8 +47,7 @@ import { POLICY_EDIT_REGISTER } from "./policy-register-token"; export class PoliciesComponent implements OnInit { loading = true; organizationId: string; - policies: readonly BasePolicyEditDefinition[]; - protected organization$: Observable; + policies$: Observable; private orgPolicies: PolicyResponse[]; protected policiesEnabledMap: Map = new Map(); @@ -63,28 +70,41 @@ export class PoliciesComponent implements OnInit { this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); - this.organization$ = this.organizationService + const organization$ = this.organizationService .organizations$(userId) .pipe(getOrganizationById(this.organizationId)); - this.policies = this.policyListService.getPolicies(); + this.policies$ = organization$.pipe( + withLatestFrom(of(this.policyListService.getPolicies())), + switchMap(([organization, policies]) => { + return combineLatest( + policies.map((policy) => + policy + .display$(organization, this.configService) + .pipe(map((shouldDisplay) => ({ policy, shouldDisplay }))), + ), + ); + }), + map((results) => + results.filter((result) => result.shouldDisplay).map((result) => result.policy), + ), + ); await this.load(); // Handle policies component launch from Event message - this.route.queryParams - .pipe(first()) + combineLatest([this.route.queryParams.pipe(first()), this.policies$]) /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - .subscribe(async (qParams) => { + .subscribe(async ([qParams, policies]) => { if (qParams.policyId != null) { const policyIdFromEvents: string = qParams.policyId; for (const orgPolicy of this.orgPolicies) { if (orgPolicy.id === policyIdFromEvents) { - for (let i = 0; i < this.policies.length; i++) { - if (this.policies[i].type === orgPolicy.type) { + for (let i = 0; i < policies.length; i++) { + if (policies[i].type === orgPolicy.type) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.edit(this.policies[i]); + this.edit(policies[i]); break; } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.html new file mode 100644 index 00000000000..f110e7d34cd --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.html @@ -0,0 +1,4 @@ + + + {{ "turnOn" | i18n }} + diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts new file mode 100644 index 00000000000..ce62a7ff5a3 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/autotype-policy.component.ts @@ -0,0 +1,25 @@ +import { Component } from "@angular/core"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { SharedModule } from "../../../../shared"; +import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component"; + +export class DesktopAutotypeDefaultSettingPolicy extends BasePolicyEditDefinition { + name = "desktopAutotypePolicy"; + description = "desktopAutotypePolicyDesc"; + type = PolicyType.AutotypeDefaultSetting; + component = DesktopAutotypeDefaultSettingPolicyComponent; + + display$(organization: Organization, configService: ConfigService) { + return configService.getFeatureFlag$(FeatureFlag.WindowsDesktopAutotype); + } +} +@Component({ + templateUrl: "autotype-policy.component.html", + imports: [SharedModule], +}) +export class DesktopAutotypeDefaultSettingPolicyComponent extends BasePolicyEditComponent {} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts index 13f29ab68f7..bb2c40b7a76 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts @@ -1,4 +1,5 @@ export { DisableSendPolicy } from "./disable-send.component"; +export { DesktopAutotypeDefaultSettingPolicy } from "./autotype-policy.component"; export { MasterPasswordPolicy } from "./master-password.component"; export { OrganizationDataOwnershipPolicy } from "./organization-data-ownership.component"; export { PasswordGeneratorPolicy } from "./password-generator.component"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts index 3a4ba9a710f..5e63ba1358a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts @@ -1,5 +1,6 @@ import { BasePolicyEditDefinition } from "./base-policy-edit.component"; import { + DesktopAutotypeDefaultSettingPolicy, DisableSendPolicy, MasterPasswordPolicy, OrganizationDataOwnershipPolicy, @@ -31,4 +32,5 @@ export const ossPolicyEditRegister: BasePolicyEditDefinition[] = [ new DisableSendPolicy(), new SendOptionsPolicy(), new RestrictedItemTypesPolicy(), + new DesktopAutotypeDefaultSettingPolicy(), ]; diff --git a/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts b/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts index 3073917e57b..02f870f094d 100644 --- a/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts +++ b/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts @@ -1,7 +1,16 @@ // FIXME: update to use a const object instead of a typescript enum // eslint-disable-next-line @bitwarden/platform/no-enums export enum WebauthnLoginCredentialPrfStatus { + /** + * Encrypted user key present, PRF function is supported. + */ Enabled = 0, + /** + * PRF function is supported. + */ Supported = 1, + /** + * PRF function is not supported. + */ Unsupported = 2, } diff --git a/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts index 85e7a7368e0..aba5940d752 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts @@ -40,6 +40,6 @@ export class WebauthnLoginCredentialResponse extends BaseResponse { } hasPrfKeyset(): boolean { - return this.encryptedUserKey != null && this.encryptedPublicKey != null; + return this.prfStatus === WebauthnLoginCredentialPrfStatus.Enabled; } } diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts index c2a9946ea38..74323773e66 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts @@ -10,15 +10,19 @@ import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/ab import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view"; import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { makeSymmetricCryptoKey } from "@bitwarden/common/spec"; +import { makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec"; import { PrfKey, UserKey } from "@bitwarden/common/types/key"; +import { UserId } from "@bitwarden/user-core"; +import { WebauthnLoginCredentialPrfStatus } from "../../enums/webauthn-login-credential-prf-status.enum"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view"; import { RotateableKeySetService } from "../rotateable-key-set.service"; import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request"; +import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response"; import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service"; import { WebauthnLoginAdminService } from "./webauthn-login-admin.service"; @@ -248,6 +252,79 @@ describe("WebauthnAdminService", () => { expect(rotateKeySetMock).not.toHaveBeenCalled(); }); }); + + describe("getRotatedData", () => { + const mockRotatedPublicKey = makeEncString("rotated_encryptedPublicKey"); + const mockRotatedUserKey = makeEncString("rotated_encryptedUserKey"); + const oldUserKey = makeSymmetricCryptoKey(64) as UserKey; + const newUserKey = makeSymmetricCryptoKey(64) as UserKey; + const userId = Utils.newGuid() as UserId; + + it("should only include credentials with PRF keysets", async () => { + const responseUnsupported = new WebauthnLoginCredentialResponse({ + id: "test-credential-id-1", + name: "Test Credential 1", + prfStatus: WebauthnLoginCredentialPrfStatus.Unsupported, + encryptedPublicKey: null, + encryptedUserKey: null, + }); + const responseSupported = new WebauthnLoginCredentialResponse({ + id: "test-credential-id-2", + name: "Test Credential 2", + prfStatus: WebauthnLoginCredentialPrfStatus.Supported, + encryptedPublicKey: null, + encryptedUserKey: null, + }); + const responseEnabled = new WebauthnLoginCredentialResponse({ + id: "test-credential-id-3", + name: "Test Credential 3", + prfStatus: WebauthnLoginCredentialPrfStatus.Enabled, + encryptedPublicKey: makeEncString("encryptedPublicKey").toJSON(), + encryptedUserKey: makeEncString("encryptedUserKey").toJSON(), + }); + + apiService.getCredentials.mockResolvedValue( + new ListResponse( + { + data: [responseUnsupported, responseSupported, responseEnabled], + }, + WebauthnLoginCredentialResponse, + ), + ); + + rotateableKeySetService.rotateKeySet.mockResolvedValue( + new RotateableKeySet(mockRotatedUserKey, mockRotatedPublicKey), + ); + + const result = await service.getRotatedData(oldUserKey, newUserKey, userId); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + id: "test-credential-id-3", + encryptedPublicKey: mockRotatedPublicKey, + encryptedUserKey: mockRotatedUserKey, + }), + ); + expect(rotateableKeySetService.rotateKeySet).toHaveBeenCalledTimes(1); + expect(rotateableKeySetService.rotateKeySet).toHaveBeenCalledWith( + responseEnabled.getRotateableKeyset(), + oldUserKey, + newUserKey, + ); + }); + + it("should error when getCredentials fails", async () => { + const expectedError = "API connection failed"; + apiService.getCredentials.mockRejectedValue(new Error(expectedError)); + + await expect(service.getRotatedData(oldUserKey, newUserKey, userId)).rejects.toThrow( + expectedError, + ); + + expect(rotateableKeySetService.rotateKeySet).not.toHaveBeenCalled(); + }); + }); }); function createCredentialCreateOptions(): CredentialCreateOptionsView { diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html deleted file mode 100644 index 0c1a4270662..00000000000 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html +++ /dev/null @@ -1,104 +0,0 @@ - - - {{ "loading" | i18n }} - -
-
-
-

{{ "billingPlanLabel" | i18n }}

-
- -
-
- -
-
-
-

{{ "paymentType" | i18n }}

- - - - @if (trialLength === 0) { - @let priceLabel = - subscriptionProduct === SubscriptionProduct.PasswordManager - ? "passwordManagerPlanPrice" - : "secretsManagerPlanPrice"; - -
-
- {{ priceLabel | i18n }}: {{ getPriceFor(formGroup.value.cadence) | currency: "USD $" }} -
- {{ "estimatedTax" | i18n }}: - @if (fetchingTaxAmount) { - - } @else { - {{ taxAmount | currency: "USD $" }} - } -
-
-
-

- {{ "total" | i18n }}: - @if (fetchingTaxAmount) { - - } @else { - {{ total | currency: "USD $" }}/{{ interval | i18n }} - } -

-
- } -
-
- - -
-
-
- - - - {{ "loading" | i18n }} - diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts deleted file mode 100644 index 431f8882505..00000000000 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts +++ /dev/null @@ -1,360 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { - Component, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, - ViewChild, -} from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { firstValueFrom, from, Subject, switchMap, takeUntil } from "rxjs"; - -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { - BillingInformation, - OrganizationBillingServiceAbstraction as OrganizationBillingService, - OrganizationInformation, - PaymentInformation, - PlanInformation, -} from "@bitwarden/common/billing/abstractions/organization-billing.service"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { - PaymentMethodType, - PlanType, - ProductTierType, - ProductType, -} from "@bitwarden/common/billing/enums"; -import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax"; -import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { ToastService } from "@bitwarden/components"; - -import { BillingSharedModule } from "../../shared"; -import { PaymentComponent } from "../../shared/payment/payment.component"; - -export type TrialOrganizationType = Exclude; - -export interface OrganizationInfo { - name: string; - email: string; - type: TrialOrganizationType | null; -} - -export interface OrganizationCreatedEvent { - organizationId: string; - planDescription: string; -} - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -enum SubscriptionCadence { - Annual, - Monthly, -} - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum SubscriptionProduct { - PasswordManager, - SecretsManager, -} - -@Component({ - selector: "app-trial-billing-step", - templateUrl: "trial-billing-step.component.html", - imports: [BillingSharedModule], -}) -export class TrialBillingStepComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxInfoComponent: ManageTaxInformationComponent; - @Input() organizationInfo: OrganizationInfo; - @Input() subscriptionProduct: SubscriptionProduct = SubscriptionProduct.PasswordManager; - @Input() trialLength: number; - @Output() steppedBack = new EventEmitter(); - @Output() organizationCreated = new EventEmitter(); - - loading = true; - fetchingTaxAmount = false; - - annualCadence = SubscriptionCadence.Annual; - monthlyCadence = SubscriptionCadence.Monthly; - - formGroup = this.formBuilder.group({ - cadence: [SubscriptionCadence.Annual, Validators.required], - }); - formPromise: Promise; - - applicablePlans: PlanResponse[]; - annualPlan?: PlanResponse; - monthlyPlan?: PlanResponse; - - taxAmount = 0; - - private destroy$ = new Subject(); - - protected readonly SubscriptionProduct = SubscriptionProduct; - - constructor( - private apiService: ApiService, - private i18nService: I18nService, - private formBuilder: FormBuilder, - private messagingService: MessagingService, - private organizationBillingService: OrganizationBillingService, - private toastService: ToastService, - private taxService: TaxServiceAbstraction, - private accountService: AccountService, - ) {} - - async ngOnInit(): Promise { - const plans = await this.apiService.getPlans(); - this.applicablePlans = plans.data.filter(this.isApplicable); - this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual); - this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly); - - if (this.trialLength === 0) { - this.formGroup.controls.cadence.valueChanges - .pipe( - switchMap((cadence) => from(this.previewTaxAmount(cadence))), - takeUntil(this.destroy$), - ) - .subscribe((taxAmount) => { - this.taxAmount = taxAmount; - }); - } - - this.loading = false; - } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - async submit(): Promise { - if (!this.taxInfoComponent.validate()) { - return; - } - - this.formPromise = this.createOrganization(); - - const organizationId = await this.formPromise; - const planDescription = this.getPlanDescription(); - - this.toastService.showToast({ - variant: "success", - title: this.i18nService.t("organizationCreated"), - message: this.i18nService.t("organizationReadyToGo"), - }); - - this.organizationCreated.emit({ - organizationId, - planDescription, - }); - - // TODO: No one actually listening to this? - this.messagingService.send("organizationCreated", { organizationId }); - } - - async onTaxInformationChanged() { - if (this.trialLength === 0) { - this.taxAmount = await this.previewTaxAmount(this.formGroup.value.cadence); - } - - this.paymentComponent.showBankAccount = - this.taxInfoComponent.getTaxInformation().country === "US"; - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - - protected getPriceFor(cadence: SubscriptionCadence): number { - const plan = this.findPlanFor(cadence); - return this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? plan.PasswordManager.basePrice === 0 - ? plan.PasswordManager.seatPrice - : plan.PasswordManager.basePrice - : plan.SecretsManager.basePrice === 0 - ? plan.SecretsManager.seatPrice - : plan.SecretsManager.basePrice; - } - - protected stepBack() { - this.steppedBack.emit(); - } - - private async createOrganization(): Promise { - const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - const planResponse = this.findPlanFor(this.formGroup.value.cadence); - - const { type, token } = await this.paymentComponent.tokenize(); - const paymentMethod: [string, PaymentMethodType] = [token, type]; - - const organization: OrganizationInformation = { - name: this.organizationInfo.name, - billingEmail: this.organizationInfo.email, - initiationPath: - this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? "Password Manager trial from marketing website" - : "Secrets Manager trial from marketing website", - }; - - const plan: PlanInformation = { - type: planResponse.type, - passwordManagerSeats: 1, - }; - - if (this.subscriptionProduct === SubscriptionProduct.SecretsManager) { - plan.subscribeToSecretsManager = true; - plan.isFromSecretsManagerTrial = true; - plan.secretsManagerSeats = 1; - } - - const payment: PaymentInformation = { - paymentMethod, - billing: this.getBillingInformationFromTaxInfoComponent(), - skipTrial: this.trialLength === 0, - }; - - const response = await this.organizationBillingService.purchaseSubscription( - { - organization, - plan, - payment, - }, - activeUserId, - ); - - return response.id; - } - - private productTypeToPlanTypeMap: { - [productType in TrialOrganizationType]: { - [cadence in SubscriptionCadence]?: PlanType; - }; - } = { - [ProductTierType.Enterprise]: { - [SubscriptionCadence.Annual]: PlanType.EnterpriseAnnually, - [SubscriptionCadence.Monthly]: PlanType.EnterpriseMonthly, - }, - [ProductTierType.Families]: { - [SubscriptionCadence.Annual]: PlanType.FamiliesAnnually, - // No monthly option for Families plan - }, - [ProductTierType.Teams]: { - [SubscriptionCadence.Annual]: PlanType.TeamsAnnually, - [SubscriptionCadence.Monthly]: PlanType.TeamsMonthly, - }, - [ProductTierType.TeamsStarter]: { - // No annual option for Teams Starter plan - [SubscriptionCadence.Monthly]: PlanType.TeamsStarter, - }, - }; - - private findPlanFor(cadence: SubscriptionCadence): PlanResponse | null { - const productType = this.organizationInfo.type; - const planType = this.productTypeToPlanTypeMap[productType]?.[cadence]; - return planType ? this.applicablePlans.find((plan) => plan.type === planType) : null; - } - - protected get showTaxIdField(): boolean { - switch (this.organizationInfo.type) { - case ProductTierType.Families: - return false; - default: - return true; - } - } - - private getBillingInformationFromTaxInfoComponent(): BillingInformation { - return { - postalCode: this.taxInfoComponent.getTaxInformation()?.postalCode, - country: this.taxInfoComponent.getTaxInformation()?.country, - taxId: this.taxInfoComponent.getTaxInformation()?.taxId, - addressLine1: this.taxInfoComponent.getTaxInformation()?.line1, - addressLine2: this.taxInfoComponent.getTaxInformation()?.line2, - city: this.taxInfoComponent.getTaxInformation()?.city, - state: this.taxInfoComponent.getTaxInformation()?.state, - }; - } - - private getPlanDescription(): string { - const plan = this.findPlanFor(this.formGroup.value.cadence); - const price = - this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? plan.PasswordManager.basePrice === 0 - ? plan.PasswordManager.seatPrice - : plan.PasswordManager.basePrice - : plan.SecretsManager.basePrice === 0 - ? plan.SecretsManager.seatPrice - : plan.SecretsManager.basePrice; - - switch (this.formGroup.value.cadence) { - case SubscriptionCadence.Annual: - return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`; - case SubscriptionCadence.Monthly: - return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`; - } - } - - private isApplicable(plan: PlanResponse): boolean { - const hasCorrectProductType = - plan.productTier === ProductTierType.Enterprise || - plan.productTier === ProductTierType.Families || - plan.productTier === ProductTierType.Teams || - plan.productTier === ProductTierType.TeamsStarter; - const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear; - return hasCorrectProductType && notDisabledOrLegacy; - } - - private previewTaxAmount = async (cadence: SubscriptionCadence): Promise => { - this.fetchingTaxAmount = true; - - if (!this.taxInfoComponent.validate()) { - this.fetchingTaxAmount = false; - return 0; - } - - const plan = this.findPlanFor(cadence); - - const productType = - this.subscriptionProduct === SubscriptionProduct.PasswordManager - ? ProductType.PasswordManager - : ProductType.SecretsManager; - - const taxInformation = this.taxInfoComponent.getTaxInformation(); - - const request: PreviewTaxAmountForOrganizationTrialRequest = { - planType: plan.type, - productType, - taxInformation: { - ...taxInformation, - }, - }; - - const response = await this.taxService.previewTaxAmountForOrganizationTrial(request); - this.fetchingTaxAmount = false; - return response; - }; - - get price() { - return this.getPriceFor(this.formGroup.value.cadence); - } - - get total() { - return this.price + this.taxAmount; - } - - get interval() { - return this.formGroup.value.cadence === SubscriptionCadence.Annual ? "year" : "month"; - } -} diff --git a/apps/web/src/app/billing/clients/index.ts b/apps/web/src/app/billing/clients/index.ts index ff962abcbf3..17f64248cfa 100644 --- a/apps/web/src/app/billing/clients/index.ts +++ b/apps/web/src/app/billing/clients/index.ts @@ -1,2 +1,3 @@ export * from "./organization-billing.client"; export * from "./subscriber-billing.client"; +export * from "./tax.client"; diff --git a/apps/web/src/app/billing/clients/subscriber-billing.client.ts b/apps/web/src/app/billing/clients/subscriber-billing.client.ts index 18ca215ef0c..107a8ccc728 100644 --- a/apps/web/src/app/billing/clients/subscriber-billing.client.ts +++ b/apps/web/src/app/billing/clients/subscriber-billing.client.ts @@ -82,6 +82,24 @@ export class SubscriberBillingClient { return data ? new MaskedPaymentMethodResponse(data).value : null; }; + restartSubscription = async ( + subscriber: BitwardenSubscriber, + paymentMethod: TokenizedPaymentMethod, + billingAddress: BillingAddress, + ): Promise => { + const path = `${this.getEndpoint(subscriber)}/subscription/restart`; + await this.apiService.send( + "POST", + path, + { + paymentMethod, + billingAddress, + }, + true, + false, + ); + }; + updateBillingAddress = async ( subscriber: BitwardenSubscriber, billingAddress: BillingAddress, diff --git a/apps/web/src/app/billing/clients/tax.client.ts b/apps/web/src/app/billing/clients/tax.client.ts new file mode 100644 index 00000000000..09debd5a210 --- /dev/null +++ b/apps/web/src/app/billing/clients/tax.client.ts @@ -0,0 +1,131 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; + +class TaxAmountResponse extends BaseResponse implements TaxAmounts { + tax: number; + total: number; + + constructor(response: any) { + super(response); + + this.tax = this.getResponseProperty("Tax"); + this.total = this.getResponseProperty("Total"); + } +} + +export type OrganizationSubscriptionPlan = { + tier: "families" | "teams" | "enterprise"; + cadence: "annually" | "monthly"; +}; + +export type OrganizationSubscriptionPurchase = OrganizationSubscriptionPlan & { + passwordManager: { + seats: number; + additionalStorage: number; + sponsored: boolean; + }; + secretsManager?: { + seats: number; + additionalServiceAccounts: number; + standalone: boolean; + }; +}; + +export type OrganizationSubscriptionUpdate = { + passwordManager?: { + seats?: number; + additionalStorage?: number; + }; + secretsManager?: { + seats?: number; + additionalServiceAccounts?: number; + }; +}; + +export interface TaxAmounts { + tax: number; + total: number; +} + +@Injectable() +export class TaxClient { + constructor(private apiService: ApiService) {} + + previewTaxForOrganizationSubscriptionPurchase = async ( + purchase: OrganizationSubscriptionPurchase, + billingAddress: BillingAddress, + ): Promise => { + const json = await this.apiService.send( + "POST", + "/billing/tax/organizations/subscriptions/purchase", + { + purchase, + billingAddress, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; + + previewTaxForOrganizationSubscriptionPlanChange = async ( + organizationId: string, + plan: { + tier: "families" | "teams" | "enterprise"; + cadence: "annually" | "monthly"; + }, + billingAddress: BillingAddress | null, + ): Promise => { + const json = await this.apiService.send( + "POST", + `/billing/tax/organizations/${organizationId}/subscription/plan-change`, + { + plan, + billingAddress, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; + + previewTaxForOrganizationSubscriptionUpdate = async ( + organizationId: string, + update: OrganizationSubscriptionUpdate, + ): Promise => { + const json = await this.apiService.send( + "POST", + `/billing/tax/organizations/${organizationId}/subscription/update`, + { + update, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; + + previewTaxForPremiumSubscriptionPurchase = async ( + additionalStorage: number, + billingAddress: BillingAddress, + ): Promise => { + const json = await this.apiService.send( + "POST", + `/billing/tax/premium/subscriptions/purchase`, + { + additionalStorage, + billingAddress, + }, + true, + true, + ); + + return new TaxAmountResponse(json); + }; +} diff --git a/apps/web/src/app/billing/index.ts b/apps/web/src/app/billing/index.ts index 217f1e05be9..a3047bbab6a 100644 --- a/apps/web/src/app/billing/index.ts +++ b/apps/web/src/app/billing/index.ts @@ -1,2 +1 @@ export { OrganizationPlansComponent } from "./organizations"; -export { TaxInfoComponent } from "./shared"; diff --git a/apps/web/src/app/billing/individual/index.ts b/apps/web/src/app/billing/individual/index.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index 87b342ed997..bb0ca60b677 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -3,8 +3,6 @@ import { RouterModule, Routes } from "@angular/router"; import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; -import { PaymentMethodComponent } from "../shared"; - import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { PremiumComponent } from "./premium/premium.component"; import { SubscriptionComponent } from "./subscription.component"; @@ -27,11 +25,6 @@ const routes: Routes = [ component: PremiumComponent, data: { titleId: "goPremium" }, }, - { - path: "payment-method", - component: PaymentMethodComponent, - data: { titleId: "paymentMethod" }, - }, { path: "payment-details", component: AccountPaymentDetailsComponent, diff --git a/apps/web/src/app/billing/individual/individual-billing.module.ts b/apps/web/src/app/billing/individual/individual-billing.module.ts index ad75da00c99..20f2a6cc143 100644 --- a/apps/web/src/app/billing/individual/individual-billing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing.module.ts @@ -1,5 +1,10 @@ import { NgModule } from "@angular/core"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, +} from "@bitwarden/web-vault/app/billing/payment/components"; + import { HeaderModule } from "../../layouts/header/header.module"; import { BillingSharedModule } from "../shared"; @@ -10,7 +15,13 @@ import { SubscriptionComponent } from "./subscription.component"; import { UserSubscriptionComponent } from "./user-subscription.component"; @NgModule({ - imports: [IndividualBillingRoutingModule, BillingSharedModule, HeaderModule], + imports: [ + IndividualBillingRoutingModule, + BillingSharedModule, + HeaderModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + ], declarations: [ SubscriptionComponent, BillingHistoryViewComponent, 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 9f46d9d3909..ca7902542de 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 @@ -1,22 +1,7 @@ import { Component } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { - BehaviorSubject, - EMPTY, - filter, - from, - map, - merge, - Observable, - shareReplay, - switchMap, - tap, -} from "rxjs"; -import { catchError } from "rxjs/operators"; +import { BehaviorSubject, filter, merge, Observable, shareReplay, switchMap, tap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; @@ -28,13 +13,6 @@ import { import { MaskedPaymentMethod } from "../../payment/types"; import { mapAccountToSubscriber, BitwardenSubscriber } from "../../types"; -class RedirectError { - constructor( - public path: string[], - public relativeTo: ActivatedRoute, - ) {} -} - type View = { account: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; @@ -56,23 +34,11 @@ export class AccountPaymentDetailsComponent { private viewState$ = new BehaviorSubject(null); private load$: Observable = this.accountService.activeAccount$.pipe( - switchMap((account) => - this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => { - if (!managePaymentDetailsOutsideCheckout) { - throw new RedirectError(["../payment-method"], this.activatedRoute); - } - return account; - }), - ), - ), mapAccountToSubscriber, switchMap(async (account) => { const [paymentMethod, credit] = await Promise.all([ - this.billingClient.getPaymentMethod(account), - this.billingClient.getCredit(account), + this.subscriberBillingClient.getPaymentMethod(account), + this.subscriberBillingClient.getCredit(account), ]); return { @@ -82,14 +48,6 @@ export class AccountPaymentDetailsComponent { }; }), shareReplay({ bufferSize: 1, refCount: false }), - 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( @@ -99,10 +57,7 @@ export class AccountPaymentDetailsComponent { constructor( private accountService: AccountService, - private activatedRoute: ActivatedRoute, - private billingClient: SubscriberBillingClient, - private configService: ConfigService, - private router: Router, + private subscriberBillingClient: SubscriberBillingClient, ) {} setPaymentMethod = (paymentMethod: MaskedPaymentMethod) => { diff --git a/apps/web/src/app/billing/individual/premium/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html index 3f0f97541df..52ebe7803df 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/premium.component.html @@ -70,7 +70,7 @@ (onLicenseFileUploaded)="onLicenseFileSelectedChanged()" /> -
+

{{ "addons" | i18n }}

@@ -93,15 +93,25 @@

{{ "summary" | i18n }}

{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
- {{ "additionalStorageGb" | i18n }}: {{ addOnFormGroup.value.additionalStorage || 0 }} GB × + {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × {{ storageGBPrice | currency: "$" }} = {{ additionalStorageCost | currency: "$" }}

{{ "paymentInformation" | i18n }}

- - +
+ + + + +
{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts index 974c22455ff..d5062e34881 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -9,36 +9,34 @@ import { debounceTime } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { PreviewIndividualInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-individual-invoice.request"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { ToastService } from "@bitwarden/components"; - -import { PaymentComponent } from "../../shared/payment/payment.component"; -import { TaxInfoComponent } from "../../shared/tax-info.component"; +import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { tokenizablePaymentMethodToLegacyEnum } from "@bitwarden/web-vault/app/billing/payment/types"; @Component({ templateUrl: "./premium.component.html", standalone: false, + providers: [TaxClient], }) export class PremiumComponent { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected hasPremiumFromAnyOrganization$: Observable; - protected addOnFormGroup = new FormGroup({ + protected formGroup = new FormGroup({ additionalStorage: new FormControl(0, [Validators.min(0), Validators.max(99)]), - }); - - protected licenseFormGroup = new FormGroup({ - file: new FormControl(null, [Validators.required]), + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), }); protected cloudWebVaultURL: string; @@ -53,16 +51,14 @@ export class PremiumComponent { private activatedRoute: ActivatedRoute, private apiService: ApiService, private billingAccountProfileStateService: BillingAccountProfileStateService, - private configService: ConfigService, private environmentService: EnvironmentService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private router: Router, private syncService: SyncService, private toastService: ToastService, - private tokenService: TokenService, - private taxService: TaxServiceAbstraction, private accountService: AccountService, + private taxClient: TaxClient, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); @@ -93,11 +89,13 @@ export class PremiumComponent { ) .subscribe(); - this.addOnFormGroup.controls.additionalStorage.valueChanges - .pipe(debounceTime(1000), takeUntilDestroyed()) - .subscribe(() => { - this.refreshSalesTax(); - }); + this.formGroup.valueChanges + .pipe( + debounceTime(1000), + switchMap(async () => await this.refreshSalesTax()), + takeUntilDestroyed(), + ) + .subscribe(); } finalizeUpgrade = async () => { @@ -117,53 +115,21 @@ export class PremiumComponent { navigateToSubscriptionPage = (): Promise => this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); - onLicenseFileSelected = (event: Event): void => { - const element = event.target as HTMLInputElement; - this.licenseFormGroup.value.file = element.files.length > 0 ? element.files[0] : null; - }; - - submitPremiumLicense = async (): Promise => { - this.licenseFormGroup.markAllAsTouched(); - - if (this.licenseFormGroup.invalid) { - return this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("selectFile"), - }); - } - - const emailVerified = await this.tokenService.getEmailVerified(); - if (!emailVerified) { - return this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("verifyEmailFirst"), - }); - } - - const formData = new FormData(); - formData.append("license", this.licenseFormGroup.value.file); - - await this.apiService.postAccountLicense(formData); - await this.finalizeUpgrade(); - await this.postFinalizeUpgrade(); - }; - submitPayment = async (): Promise => { - this.taxInfoComponent.taxFormGroup.markAllAsTouched(); - if (this.taxInfoComponent.taxFormGroup.invalid) { + if (this.formGroup.invalid) { return; } - const { type, token } = await this.paymentComponent.tokenize(); + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + + const legacyEnum = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); const formData = new FormData(); - formData.append("paymentMethodType", type.toString()); - formData.append("paymentToken", token); - formData.append("additionalStorageGb", this.addOnFormGroup.value.additionalStorage.toString()); - formData.append("country", this.taxInfoComponent.country); - formData.append("postalCode", this.taxInfoComponent.postalCode); + formData.append("paymentMethodType", legacyEnum.toString()); + formData.append("paymentToken", paymentMethod.token); + formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString()); + formData.append("country", this.formGroup.value.billingAddress.country); + formData.append("postalCode", this.formGroup.value.billingAddress.postalCode); await this.apiService.postPremium(formData); await this.finalizeUpgrade(); @@ -171,7 +137,7 @@ export class PremiumComponent { }; protected get additionalStorageCost(): number { - return this.storageGBPrice * this.addOnFormGroup.value.additionalStorage; + return this.storageGBPrice * this.formGroup.value.additionalStorage; } protected get premiumURL(): string { @@ -190,35 +156,18 @@ export class PremiumComponent { await this.postFinalizeUpgrade(); } - private refreshSalesTax(): void { - if (!this.taxInfoComponent.country || !this.taxInfoComponent.postalCode) { + private async refreshSalesTax(): Promise { + if (this.formGroup.invalid) { return; } - const request: PreviewIndividualInvoiceRequest = { - passwordManager: { - additionalStorage: this.addOnFormGroup.value.additionalStorage, - }, - taxInformation: { - postalCode: this.taxInfoComponent.postalCode, - country: this.taxInfoComponent.country, - }, - }; - this.taxService - .previewIndividualInvoice(request) - .then((invoice) => { - this.estimatedTax = invoice.taxAmount; - }) - .catch((error) => { - this.toastService.showToast({ - title: "", - variant: "error", - message: this.i18nService.t(error.message), - }); - }); - } + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); - protected onTaxInformationChanged(): void { - this.refreshSalesTax(); + const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase( + this.formGroup.value.additionalStorage, + billingAddress, + ); + + this.estimatedTax = taxAmounts.tax; } } diff --git a/apps/web/src/app/billing/individual/subscription.component.html b/apps/web/src/app/billing/individual/subscription.component.html index fa2eb0412a9..f9a46cf56ad 100644 --- a/apps/web/src/app/billing/individual/subscription.component.html +++ b/apps/web/src/app/billing/individual/subscription.component.html @@ -3,10 +3,7 @@ {{ "subscription" | i18n }} - @let paymentMethodPageData = paymentDetailsPageData$ | async; - {{ - paymentMethodPageData.textKey | i18n - }} + {{ "paymentDetails" | i18n }} {{ "billingHistory" | i18n }} diff --git a/apps/web/src/app/billing/individual/subscription.component.ts b/apps/web/src/app/billing/individual/subscription.component.ts index c6a20a9f6a3..2a08ec85127 100644 --- a/apps/web/src/app/billing/individual/subscription.component.ts +++ b/apps/web/src/app/billing/individual/subscription.component.ts @@ -1,12 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; -import { map, Observable, switchMap } from "rxjs"; +import { Observable, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Component({ @@ -15,32 +13,16 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl }) export class SubscriptionComponent implements OnInit { hasPremium$: Observable; - paymentDetailsPageData$: Observable<{ - route: string; - textKey: string; - }>; - selfHosted: boolean; constructor( private platformUtilsService: PlatformUtilsService, billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, - private configService: ConfigService, ) { this.hasPremium$ = accountService.activeAccount$.pipe( switchMap((account) => billingAccountProfileStateService.hasPremiumPersonally$(account.id)), ); - - this.paymentDetailsPageData$ = this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => - managePaymentDetailsOutsideCheckout - ? { route: "payment-details", textKey: "paymentDetails" } - : { route: "payment-method", textKey: "paymentMethod" }, - ), - ); } ngOnInit() { diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index f899b8eccb4..abd7bdb155a 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -328,24 +328,60 @@ *ngIf="formGroup.value.productTier !== productTypes.Free || isSubscriptionCanceled" >

{{ "paymentMethod" | i18n }}

-

- - {{ paymentSource?.description }} - - {{ "changePaymentMethod" | i18n }} - +

+ @switch (paymentMethod.type) { + @case ("bankAccount") { + + {{ paymentMethod.bankName }}, *{{ paymentMethod.last4 }} + @if (paymentMethod.hostedVerificationUrl) { + - {{ "unverified" | i18n }} + } + + {{ "changePaymentMethod" | i18n }} + + } + @case ("card") { +

+ @let cardBrandIcon = getCardBrandIcon(); + @if (cardBrandIcon !== null) { + + } @else { + + } + {{ paymentMethod.brand | titlecase }}, *{{ paymentMethod.last4 }}, + {{ paymentMethod.expiration }} + + {{ "changePaymentMethod" | i18n }} + +

+ } + @case ("payPal") { + + {{ paymentMethod.email }} + + {{ "changePaymentMethod" | i18n }} + + } + }

- - + + + +

diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index 6fc2dc57ba2..2b5c27e0f09 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -12,9 +12,9 @@ import { } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs"; +import { combineLatest, firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs"; +import { debounceTime } from "rxjs/operators"; -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { @@ -28,28 +28,8 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/ import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { - BillingApiServiceAbstraction, - BillingInformation, - OrganizationBillingServiceAbstraction as OrganizationBillingService, - OrganizationInformation, - PaymentInformation, - PlanInformation, -} from "@bitwarden/common/billing/abstractions"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { - PaymentMethodType, - PlanInterval, - PlanType, - ProductTierType, -} from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; -import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request"; -import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response"; +import { PlanInterval, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; -import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -57,6 +37,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { OrganizationId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { + CardComponent, DIALOG_DATA, DialogConfig, DialogRef, @@ -64,11 +45,25 @@ import { ToastService, } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { UserId } from "@bitwarden/user-core"; +import { + OrganizationSubscriptionPlan, + SubscriberBillingClient, + TaxClient, +} from "@bitwarden/web-vault/app/billing/clients"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + BillingAddress, + getCardBrandIcon, + MaskedPaymentMethod, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; import { BillingNotificationService } from "../services/billing-notification.service"; import { BillingSharedModule } from "../shared/billing-shared.module"; -import { PaymentComponent } from "../shared/payment/payment.component"; type ChangePlanDialogParams = { organizationId: string; @@ -111,11 +106,16 @@ interface OnSuccessArgs { @Component({ templateUrl: "./change-plan-dialog.component.html", - imports: [BillingSharedModule], + imports: [ + BillingSharedModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + CardComponent, + ], + providers: [SubscriberBillingClient, TaxClient], }) export class ChangePlanDialogComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent; + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent: EnterPaymentMethodComponent; @Input() acceptingSponsorship = false; @Input() organizationId: string; @@ -172,7 +172,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { clientOwnerEmail: ["", [Validators.email]], plan: [this.plan], productTier: [this.productTier], - // planInterval: [1], + }); + + billingFormGroup = this.formBuilder.group({ + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), }); planType: string; @@ -183,7 +187,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { secretsManagerPlans: PlanResponse[]; organization: Organization; sub: OrganizationSubscriptionResponse; - billing: BillingResponse; dialogHeaderName: string; currentPlanName: string; showPayment: boolean = false; @@ -191,15 +194,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { currentPlan: PlanResponse; isCardStateDisabled = false; focusedIndex: number | null = null; - accountCredit: number; - paymentSource?: PaymentSourceResponse; plans: ListResponse; isSubscriptionCanceled: boolean = false; secretsManagerTotal: number; - private destroy$ = new Subject(); + paymentMethod: MaskedPaymentMethod | null; + billingAddress: BillingAddress | null; - protected taxInformation: TaxInformation; + private destroy$ = new Subject(); constructor( @Inject(DIALOG_DATA) private dialogParams: ChangePlanDialogParams, @@ -215,11 +217,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private messagingService: MessagingService, private formBuilder: FormBuilder, private organizationApiService: OrganizationApiServiceAbstraction, - private billingApiService: BillingApiServiceAbstraction, - private taxService: TaxServiceAbstraction, private accountService: AccountService, - private organizationBillingService: OrganizationBillingService, private billingNotificationService: BillingNotificationService, + private subscriberBillingClient: SubscriberBillingClient, + private taxClient: TaxClient, ) {} async ngOnInit(): Promise { @@ -242,10 +243,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); if (this.sub?.subscription?.status !== "canceled") { try { - const { accountCredit, paymentSource } = - await this.billingApiService.getOrganizationPaymentMethod(this.organizationId); - this.accountCredit = accountCredit; - this.paymentSource = paymentSource; + const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization }; + const [paymentMethod, billingAddress] = await Promise.all([ + this.subscriberBillingClient.getPaymentMethod(subscriber), + this.subscriberBillingClient.getBillingAddress(subscriber), + ]); + + this.paymentMethod = paymentMethod; + this.billingAddress = billingAddress; } catch (error) { this.billingNotificationService.handleError(error); } @@ -307,15 +312,24 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ? 0 : (this.sub?.customerDiscount?.percentOff ?? 0); - this.setInitialPlanSelection(); - this.loading = false; - - const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); - this.taxInformation = TaxInformation.from(taxInfo); - + await this.setInitialPlanSelection(); if (!this.isSubscriptionCanceled) { - this.refreshSalesTax(); + await this.refreshSalesTax(); } + + combineLatest([ + this.billingFormGroup.controls.billingAddress.controls.country.valueChanges, + this.billingFormGroup.controls.billingAddress.controls.postalCode.valueChanges, + this.billingFormGroup.controls.billingAddress.controls.taxId.valueChanges, + ]) + .pipe( + debounceTime(1000), + switchMap(async () => await this.refreshSalesTax()), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.loading = false; } resolveHeaderName(subscription: OrganizationSubscriptionResponse): string { @@ -333,10 +347,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); } - setInitialPlanSelection() { + async setInitialPlanSelection() { this.focusedIndex = this.selectableProducts.length - 1; if (!this.isSubscriptionCanceled) { - this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); + await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); } } @@ -344,10 +358,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return this.selectableProducts.find((product) => product.productTier === productTier); } - isPaymentSourceEmpty() { - return this.paymentSource === null || this.paymentSource === undefined; - } - isSecretsManagerTrial(): boolean { return ( this.sub?.subscription?.items?.some((item) => @@ -356,13 +366,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ); } - planTypeChanged() { - this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); + async planTypeChanged() { + await this.selectPlan(this.getPlanByType(ProductTierType.Enterprise)); } - updateInterval(event: number) { + async updateInterval(event: number) { this.selectedInterval = event; - this.planTypeChanged(); + await this.planTypeChanged(); } protected getPlanIntervals() { @@ -460,7 +470,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } } - protected selectPlan(plan: PlanResponse) { + protected async selectPlan(plan: PlanResponse) { if ( this.selectedInterval === PlanInterval.Monthly && plan.productTier == ProductTierType.Families @@ -475,7 +485,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.formGroup.patchValue({ productTier: plan.productTier }); try { - this.refreshSalesTax(); + await this.refreshSalesTax(); } catch { this.estimatedTax = 0; } @@ -489,19 +499,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { get upgradeRequiresPaymentMethod() { const isFreeTier = this.organization?.productTierType === ProductTierType.Free; const shouldHideFree = !this.showFree; - const hasNoPaymentSource = !this.paymentSource; + const hasNoPaymentSource = !this.paymentMethod; return isFreeTier && shouldHideFree && hasNoPaymentSource; } - get selectedSecretsManagerPlan() { - let planResponse: PlanResponse; - if (this.secretsManagerPlans) { - return this.secretsManagerPlans.find((plan) => plan.type === this.selectedPlan.type); - } - return planResponse; - } - get selectedPlanInterval() { if (this.isSubscriptionCanceled) { return this.currentPlan.isAnnual ? "year" : "month"; @@ -591,8 +593,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return 0; } - const result = plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0); - return result; + return plan.PasswordManager.seatPrice * Math.abs(this.sub?.seats || 0); } secretsManagerSeatTotal(plan: PlanResponse, seats: number): number { @@ -746,39 +747,22 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.formGroup.controls.additionalSeats.setValue(1); } - changedCountry() { - this.paymentComponent.showBankAccount = this.taxInformation.country === "US"; - - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - - protected taxInformationChanged(event: TaxInformation): void { - this.taxInformation = event; - this.changedCountry(); - this.refreshSalesTax(); - } - submit = async () => { - if (this.taxComponent !== undefined && !this.taxComponent.validate()) { - this.taxComponent.markAllAsTouched(); + this.formGroup.markAllAsTouched(); + this.billingFormGroup.markAllAsTouched(); + if (this.formGroup.invalid || (this.billingFormGroup.invalid && !this.paymentMethod)) { return; } const doSubmit = async (): Promise => { - const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - let orgId: string = null; + let orgId: string; const sub = this.sub?.subscription; const isCanceled = sub?.status === "canceled"; const isCancelledDowngradedToFreeOrg = sub?.cancelled && this.organization.productTierType === ProductTierType.Free; if (isCanceled || isCancelledDowngradedToFreeOrg) { - await this.restartSubscription(activeUserId); + await this.restartSubscription(); orgId = this.organizationId; } else { orgId = await this.updateOrganization(); @@ -795,9 +779,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { await this.syncService.fullSync(true); if (!this.acceptingSponsorship && !this.isInTrialFlow) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]); + await this.router.navigate(["/organizations/" + orgId + "/billing/subscription"]); } if (this.isInTrialFlow) { @@ -818,46 +800,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.dialogRef.close(); }; - private async restartSubscription(activeUserId: UserId) { - const org = await this.organizationApiService.get(this.organizationId); - const organization: OrganizationInformation = { - name: org.name, - billingEmail: org.billingEmail, - }; - - const filteredPlan = this.plans.data - .filter((plan) => plan.productTier === this.selectedPlan.productTier && !plan.legacyYear) - .find((plan) => { - const isSameBillingCycle = plan.isAnnual === this.selectedPlan.isAnnual; - return isSameBillingCycle; - }); - - const plan: PlanInformation = { - type: filteredPlan.type, - passwordManagerSeats: org.seats, - }; - - if (org.useSecretsManager) { - plan.subscribeToSecretsManager = true; - plan.secretsManagerSeats = org.smSeats; - } - - const { type, token } = await this.paymentComponent.tokenize(); - const paymentMethod: [string, PaymentMethodType] = [token, type]; - - const payment: PaymentInformation = { + private async restartSubscription() { + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress); + await this.subscriberBillingClient.restartSubscription( + { type: "organization", data: this.organization }, paymentMethod, - billing: this.getBillingInformationFromTaxInfoComponent(), - }; - - await this.organizationBillingService.restartSubscription( - this.organization.id, - { - organization, - plan, - payment, - }, - activeUserId, + billingAddress, ); } @@ -875,25 +824,25 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { this.formGroup.controls.premiumAccessAddon.value; request.planType = this.selectedPlan.type; if (this.showPayment) { - request.billingAddressCountry = this.taxInformation.country; - request.billingAddressPostalCode = this.taxInformation.postalCode; + request.billingAddressCountry = this.billingFormGroup.controls.billingAddress.value.country; + request.billingAddressPostalCode = + this.billingFormGroup.controls.billingAddress.value.postalCode; } // Secrets Manager this.buildSecretsManagerRequest(request); - if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) { - const tokenizedPaymentSource = await this.paymentComponent.tokenize(); - const updatePaymentMethodRequest = new UpdatePaymentMethodRequest(); - updatePaymentMethodRequest.paymentSource = tokenizedPaymentSource; - updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From( - this.taxInformation, + if (this.upgradeRequiresPaymentMethod || this.showPayment || !this.paymentMethod) { + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + const billingAddress = getBillingAddressFromForm( + this.billingFormGroup.controls.billingAddress, ); - await this.billingApiService.updateOrganizationPaymentMethod( - this.organizationId, - updatePaymentMethodRequest, - ); + const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization }; + await Promise.all([ + this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null), + this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress), + ]); } // Backfill pub/priv key if necessary @@ -931,18 +880,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return text; } - private getBillingInformationFromTaxInfoComponent(): BillingInformation { - return { - country: this.taxInformation.country, - postalCode: this.taxInformation.postalCode, - taxId: this.taxInformation.taxId, - addressLine1: this.taxInformation.line1, - addressLine2: this.taxInformation.line2, - city: this.taxInformation.city, - state: this.taxInformation.state, - }; - } - private buildSecretsManagerRequest(request: OrganizationUpgradeRequest): void { request.useSecretsManager = this.organization.useSecretsManager; if (!this.organization.useSecretsManager) { @@ -1002,25 +939,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } calculateTotalAppliedDiscount(total: number) { - const discountedTotal = total * (this.discountPercentageFromSub / 100); - return discountedTotal; - } - - get paymentSourceClasses() { - if (this.paymentSource == null) { - return []; - } - switch (this.paymentSource.type) { - case PaymentMethodType.Card: - return ["bwi-credit-card"]; - case PaymentMethodType.BankAccount: - case PaymentMethodType.Check: - return ["bwi-billing"]; - case PaymentMethodType.PayPal: - return ["bwi-paypal text-primary"]; - default: - return []; - } + return total * (this.discountPercentageFromSub / 100); } resolvePlanName(productTier: ProductTierType) { @@ -1064,9 +983,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } } - onFocus(index: number) { + async onFocus(index: number) { this.focusedIndex = index; - this.selectPlan(this.selectableProducts[index]); + await this.selectPlan(this.selectableProducts[index]); } isCardDisabled(index: number): boolean { @@ -1078,58 +997,44 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return index; } - private refreshSalesTax(): void { - if ( - this.taxInformation === undefined || - !this.taxInformation.country || - !this.taxInformation.postalCode - ) { + private async refreshSalesTax(): Promise { + if (this.billingFormGroup.controls.billingAddress.invalid && !this.billingAddress) { return; } - const request: PreviewOrganizationInvoiceRequest = { - organizationId: this.organizationId, - passwordManager: { - additionalStorage: 0, - plan: this.selectedPlan?.type, - seats: this.sub.seats, - }, - taxInformation: { - postalCode: this.taxInformation.postalCode, - country: this.taxInformation.country, - taxId: this.taxInformation.taxId, - }, + const getPlanFromLegacyEnum = (planType: PlanType): OrganizationSubscriptionPlan => { + switch (planType) { + case PlanType.FamiliesAnnually: + return { tier: "families", cadence: "annually" }; + case PlanType.TeamsMonthly: + return { tier: "teams", cadence: "monthly" }; + case PlanType.TeamsAnnually: + return { tier: "teams", cadence: "annually" }; + case PlanType.EnterpriseMonthly: + return { tier: "enterprise", cadence: "monthly" }; + case PlanType.EnterpriseAnnually: + return { tier: "enterprise", cadence: "annually" }; + } }; - if (this.organization.useSecretsManager) { - request.secretsManager = { - seats: this.sub.smSeats, - additionalMachineAccounts: - this.sub.smServiceAccounts - this.sub.plan.SecretsManager.baseServiceAccount, - }; - } + const billingAddress = this.billingFormGroup.controls.billingAddress.valid + ? getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress) + : this.billingAddress; - this.taxService - .previewOrganizationInvoice(request) - .then((invoice) => { - this.estimatedTax = invoice.taxAmount; - }) - .catch((error) => { - const translatedMessage = this.i18nService.t(error.message); - this.toastService.showToast({ - title: "", - variant: "error", - message: - !translatedMessage || translatedMessage === "" ? error.message : translatedMessage, - }); - }); + const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange( + this.organizationId, + getPlanFromLegacyEnum(this.selectedPlan.type), + billingAddress, + ); + + this.estimatedTax = taxAmounts.tax; } protected canUpdatePaymentInformation(): boolean { return ( this.upgradeRequiresPaymentMethod || this.showPayment || - this.isPaymentSourceEmpty() || + !this.paymentMethod || this.isSubscriptionCanceled ); } @@ -1146,4 +1051,10 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { return this.i18nService.t("upgrade"); } } + + get supportsTaxId() { + return this.formGroup.value.productTier !== ProductTierType.Families; + } + + getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod); } diff --git a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts index 692791db855..5c8df483587 100644 --- a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts @@ -11,7 +11,6 @@ import { WebPlatformUtilsService } from "../../core/web-platform-utils.service"; import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component"; import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component"; -import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component"; const routes: Routes = [ { @@ -26,17 +25,6 @@ const routes: Routes = [ : OrganizationSubscriptionCloudComponent, data: { titleId: "subscription" }, }, - { - path: "payment-method", - component: OrganizationPaymentMethodComponent, - canActivate: [ - organizationPermissionsGuard((org) => org.canEditPaymentMethods), - organizationIsUnmanaged, - ], - data: { - titleId: "paymentMethod", - }, - }, { path: "payment-details", component: OrganizationPaymentDetailsComponent, diff --git a/apps/web/src/app/billing/organizations/organization-billing.module.ts b/apps/web/src/app/billing/organizations/organization-billing.module.ts index 707a854de02..90ba04c4fa4 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -17,7 +17,6 @@ import { OrganizationBillingRoutingModule } from "./organization-billing-routing import { OrganizationPlansComponent } from "./organization-plans.component"; import { OrganizationSubscriptionCloudComponent } from "./organization-subscription-cloud.component"; import { OrganizationSubscriptionSelfhostComponent } from "./organization-subscription-selfhost.component"; -import { OrganizationPaymentMethodComponent } from "./payment-method/organization-payment-method.component"; import { SecretsManagerAdjustSubscriptionComponent } from "./sm-adjust-subscription.component"; import { SecretsManagerSubscribeStandaloneComponent } from "./sm-subscribe-standalone.component"; import { SubscriptionHiddenComponent } from "./subscription-hidden.component"; @@ -45,7 +44,6 @@ import { SubscriptionStatusComponent } from "./subscription-status.component"; SecretsManagerSubscribeStandaloneComponent, SubscriptionHiddenComponent, SubscriptionStatusComponent, - OrganizationPaymentMethodComponent, ], }) export class OrganizationBillingModule {} diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 3b765927c3c..6234fc6e6e3 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -404,17 +404,16 @@

{{ paymentDesc }}

- - - + + } + + > +
{{ "passwordManagerPlanPrice" | i18n }}: {{ passwordManagerSubtotal | currency: "USD $" }} diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 820bee950eb..cbeedc454dc 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -11,10 +11,9 @@ import { } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom, takeUntil } from "rxjs"; +import { firstValueFrom, merge, Subject, takeUntil } from "rxjs"; import { debounceTime, map, switchMap } from "rxjs/operators"; -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { @@ -32,24 +31,12 @@ import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-conso import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { - PaymentMethodType, - PlanSponsorshipType, - PlanType, - ProductTierType, -} from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request"; -import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request"; +import { PlanSponsorshipType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -59,10 +46,20 @@ import { OrgKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { + OrganizationSubscriptionPlan, + SubscriberBillingClient, + TaxClient, +} from "@bitwarden/web-vault/app/billing/clients"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { tokenizablePaymentMethodToLegacyEnum } from "@bitwarden/web-vault/app/billing/payment/types"; import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module"; import { BillingSharedModule, secretsManagerSubscribeFormFactory } from "../shared"; -import { PaymentComponent } from "../shared/payment/payment.component"; interface OnSuccessArgs { organizationId: string; @@ -78,11 +75,16 @@ const Allowed2020PlansForLegacyProviders = [ @Component({ selector: "app-organization-plans", templateUrl: "organization-plans.component.html", - imports: [BillingSharedModule, OrganizationCreateModule], + imports: [ + BillingSharedModule, + OrganizationCreateModule, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, + ], + providers: [SubscriberBillingClient, TaxClient], }) export class OrganizationPlansComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxComponent: ManageTaxInformationComponent; + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; @Input() organizationId?: string; @Input() showFree = true; @@ -105,8 +107,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private _productTier = ProductTierType.Free; - protected taxInformation: TaxInformation; - @Input() get plan(): PlanType { return this._plan; @@ -135,10 +135,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { secretsManagerSubscription = secretsManagerSubscribeFormFactory(this.formBuilder); - selfHostedForm = this.formBuilder.group({ - file: [null, [Validators.required]], - }); - formGroup = this.formBuilder.group({ name: [""], billingEmail: ["", [Validators.email]], @@ -152,6 +148,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { secretsManager: this.secretsManagerSubscription, }); + billingFormGroup = this.formBuilder.group({ + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + passwordManagerPlans: PlanResponse[]; secretsManagerPlans: PlanResponse[]; organization: Organization; @@ -179,10 +180,9 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private organizationApiService: OrganizationApiServiceAbstraction, private providerApiService: ProviderApiServiceAbstraction, private toastService: ToastService, - private configService: ConfigService, - private billingApiService: BillingApiServiceAbstraction, - private taxService: TaxServiceAbstraction, private accountService: AccountService, + private subscriberBillingClient: SubscriberBillingClient, + private taxClient: TaxClient, ) { this.selfHosted = this.platformUtilsService.isSelfHost(); } @@ -199,9 +199,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { ); this.billing = await this.organizationApiService.getBilling(this.organizationId); this.sub = await this.organizationApiService.getSubscription(this.organizationId); - this.taxInformation = await this.organizationApiService.getTaxInfo(this.organizationId); - } else if (!this.selfHosted) { - this.taxInformation = await this.apiService.getTaxInfo(); + const billingAddress = await this.subscriberBillingClient.getBillingAddress({ + type: "organization", + data: this.organization, + }); + this.billingFormGroup.controls.billingAddress.patchValue({ + ...billingAddress, + taxId: billingAddress?.taxId?.value, + }); } if (!this.selfHosted) { @@ -268,15 +273,17 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.loading = false; - this.formGroup.valueChanges.pipe(debounceTime(1000), takeUntil(this.destroy$)).subscribe(() => { - this.refreshSalesTax(); - }); - - this.secretsManagerForm.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - this.refreshSalesTax(); - }); + merge( + this.formGroup.valueChanges, + this.billingFormGroup.valueChanges, + this.secretsManagerForm.valueChanges, + ) + .pipe( + debounceTime(1000), + switchMap(async () => await this.refreshSalesTax()), + takeUntil(this.destroy$), + ) + .subscribe(); if (this.enableSecretsManagerByDefault && this.selectedSecretsManagerPlan) { this.secretsManagerSubscription.patchValue({ @@ -587,34 +594,13 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.changedProduct(); } - protected changedCountry(): void { - this.paymentComponent.showBankAccount = this.taxInformation?.country === "US"; - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - - protected onTaxInformationChanged(event: TaxInformation): void { - this.taxInformation = event; - this.changedCountry(); - this.refreshSalesTax(); - } - protected cancel(): void { this.onCanceled.emit(); } - protected setSelectedFile(event: Event): void { - const fileInputEl = event.target; - this.selectedFile = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null; - } - submit = async () => { - if (this.taxComponent && !this.taxComponent.validate()) { - this.taxComponent.markAllAsTouched(); + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { return; } @@ -688,46 +674,54 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } } - private refreshSalesTax(): void { - if (!this.taxComponent.validate()) { + private async refreshSalesTax(): Promise { + if (this.billingFormGroup.controls.billingAddress.invalid) { return; } - const request: PreviewOrganizationInvoiceRequest = { - organizationId: this.organizationId, - passwordManager: { - additionalStorage: this.formGroup.controls.additionalStorage.value, - plan: this.formGroup.controls.plan.value, - sponsoredPlan: this.planSponsorshipType, - seats: this.formGroup.controls.additionalSeats.value, - }, - taxInformation: { - postalCode: this.taxInformation.postalCode, - country: this.taxInformation.country, - taxId: this.taxInformation.taxId, - }, + const getPlanFromLegacyEnum = (): OrganizationSubscriptionPlan => { + switch (this.formGroup.value.plan) { + case PlanType.FamiliesAnnually: + return { tier: "families", cadence: "annually" }; + case PlanType.TeamsMonthly: + return { tier: "teams", cadence: "monthly" }; + case PlanType.TeamsAnnually: + return { tier: "teams", cadence: "annually" }; + case PlanType.EnterpriseMonthly: + return { tier: "enterprise", cadence: "monthly" }; + case PlanType.EnterpriseAnnually: + return { tier: "enterprise", cadence: "annually" }; + } }; - if (this.secretsManagerForm.controls.enabled.value === true) { - request.secretsManager = { - seats: this.secretsManagerForm.controls.userSeats.value, - additionalMachineAccounts: this.secretsManagerForm.controls.additionalServiceAccounts.value, - }; - } + const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress); - this.taxService - .previewOrganizationInvoice(request) - .then((invoice) => { - this.estimatedTax = invoice.taxAmount; - this.total = invoice.totalAmount; - }) - .catch((error) => { - this.toastService.showToast({ - title: "", - variant: "error", - message: this.i18nService.t(error.message), - }); - }); + const passwordManagerSeats = + this.formGroup.value.productTier === ProductTierType.Families + ? 1 + : this.formGroup.value.additionalSeats; + + const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + { + ...getPlanFromLegacyEnum(), + passwordManager: { + seats: passwordManagerSeats, + additionalStorage: this.formGroup.value.additionalStorage, + sponsored: false, + }, + secretsManager: this.formGroup.value.secretsManager.enabled + ? { + seats: this.secretsManagerForm.value.userSeats, + additionalServiceAccounts: this.secretsManagerForm.value.additionalServiceAccounts, + standalone: false, + } + : undefined, + }, + billingAddress, + ); + + this.estimatedTax = taxAmounts.tax; + this.total = taxAmounts.total; } private async updateOrganization() { @@ -738,21 +732,24 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.selectedPlan.PasswordManager.hasPremiumAccessOption && this.formGroup.controls.premiumAccessAddon.value; request.planType = this.selectedPlan.type; - request.billingAddressCountry = this.taxInformation?.country; - request.billingAddressPostalCode = this.taxInformation?.postalCode; + request.billingAddressCountry = this.billingFormGroup.value.billingAddress.country; + request.billingAddressPostalCode = this.billingFormGroup.value.billingAddress.postalCode; // Secrets Manager this.buildSecretsManagerRequest(request); if (this.upgradeRequiresPaymentMethod) { - const updatePaymentMethodRequest = new UpdatePaymentMethodRequest(); - updatePaymentMethodRequest.paymentSource = await this.paymentComponent.tokenize(); - updatePaymentMethodRequest.taxInformation = ExpandedTaxInfoUpdateRequest.From( - this.taxInformation, - ); - await this.billingApiService.updateOrganizationPaymentMethod( - this.organizationId, - updatePaymentMethodRequest, + if (this.billingFormGroup.invalid) { + return; + } + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + await this.subscriberBillingClient.updatePaymentMethod( + { type: "organization", data: this.organization }, + paymentMethod, + { + country: this.billingFormGroup.value.billingAddress.country, + postalCode: this.billingFormGroup.value.billingAddress.postalCode, + }, ); } @@ -791,23 +788,31 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { if (this.selectedPlan.type === PlanType.Free) { request.planType = PlanType.Free; } else { - const { type, token } = await this.paymentComponent.tokenize(); + if (this.billingFormGroup.invalid) { + return; + } - request.paymentToken = token; - request.paymentMethodType = type; + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + + const billingAddress = getBillingAddressFromForm( + this.billingFormGroup.controls.billingAddress, + ); + + request.paymentToken = paymentMethod.token; + request.paymentMethodType = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); request.additionalSeats = this.formGroup.controls.additionalSeats.value; request.additionalStorageGb = this.formGroup.controls.additionalStorage.value; request.premiumAccessAddon = this.selectedPlan.PasswordManager.hasPremiumAccessOption && this.formGroup.controls.premiumAccessAddon.value; request.planType = this.selectedPlan.type; - request.billingAddressPostalCode = this.taxInformation?.postalCode; - request.billingAddressCountry = this.taxInformation?.country; - request.taxIdNumber = this.taxInformation?.taxId; - request.billingAddressLine1 = this.taxInformation?.line1; - request.billingAddressLine2 = this.taxInformation?.line2; - request.billingAddressCity = this.taxInformation?.city; - request.billingAddressState = this.taxInformation?.state; + request.billingAddressPostalCode = billingAddress.postalCode; + request.billingAddressCountry = billingAddress.country; + request.taxIdNumber = billingAddress.taxId?.value; + request.billingAddressLine1 = billingAddress.line1; + request.billingAddressLine2 = billingAddress.line2; + request.billingAddressCity = billingAddress.city; + request.billingAddressState = billingAddress.state; } // Secrets Manager 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 47742ba0a88..b2bf27e726a 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,15 +1,11 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute } from "@angular/router"; import { BehaviorSubject, - catchError, combineLatest, - EMPTY, filter, firstValueFrom, - from, lastValueFrom, - map, merge, Observable, of, @@ -22,15 +18,13 @@ import { withLatestFrom, } from "rxjs"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { getById } from "@bitwarden/common/platform/misc"; import { DialogService } from "@bitwarden/components"; import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients"; @@ -54,13 +48,6 @@ import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/type import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -class RedirectError { - constructor( - public path: string[], - public relativeTo: ActivatedRoute, - ) {} -} - type View = { organization: BitwardenSubscriber; paymentMethod: MaskedPaymentMethod | null; @@ -93,24 +80,12 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { switchMap((userId) => this.organizationService .organizations$(userId) - .pipe(getOrganizationById(this.activatedRoute.snapshot.params.organizationId)), + .pipe(getById(this.activatedRoute.snapshot.params.organizationId)), ), filter((organization): organization is Organization => !!organization), ); 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( @@ -132,14 +107,6 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { 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( @@ -159,7 +126,6 @@ export class OrganizationPaymentDetailsComponent implements OnInit, OnDestroy { private messageListener: MessageListener, private organizationService: OrganizationService, private organizationWarningsService: OrganizationWarningsService, - private router: Router, private subscriberBillingClient: SubscriberBillingClient, ) {} diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html deleted file mode 100644 index ab31147e916..00000000000 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - {{ "loading" | i18n }} - - - - -

- {{ accountCreditHeaderText }} -

-

{{ Math.abs(accountCredit) | currency: "$" }}

-

{{ "creditAppliedDesc" | i18n }}

- -
- - -

{{ "paymentMethod" | i18n }}

-

{{ "noPaymentMethod" | i18n }}

- - - -

- - {{ paymentSource.description }} - - {{ "unverified" | i18n }} -

-
- -

- {{ "paymentChargedWithUnpaidSubscription" | i18n }} -

-
-
-
diff --git a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts b/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts deleted file mode 100644 index 4106ee4f9cd..00000000000 --- a/apps/web/src/app/billing/organizations/payment-method/organization-payment-method.component.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { Location } from "@angular/common"; -import { Component, OnDestroy } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, firstValueFrom, from, lastValueFrom, map, switchMap } from "rxjs"; - -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { - OrganizationService, - getOrganizationById, -} 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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; -import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; -import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { DialogService, ToastService } from "@bitwarden/components"; - -import { BillingNotificationService } from "../../services/billing-notification.service"; -import { - AddCreditDialogResult, - openAddCreditDialog, -} from "../../shared/add-credit-dialog.component"; -import { - AdjustPaymentDialogComponent, - AdjustPaymentDialogResultType, -} from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component"; -import { - TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE, - TrialPaymentDialogComponent, -} from "../../shared/trial-payment-dialog/trial-payment-dialog.component"; - -@Component({ - templateUrl: "./organization-payment-method.component.html", - standalone: false, -}) -export class OrganizationPaymentMethodComponent implements OnDestroy { - organizationId!: string; - isUnpaid = false; - accountCredit?: number; - paymentSource?: PaymentSourceResponse; - subscriptionStatus?: string; - organization?: Organization; - organizationSubscriptionResponse?: OrganizationSubscriptionResponse; - - loading = true; - - protected readonly Math = Math; - launchPaymentModalAutomatically = false; - - protected taxInformation?: TaxInformation; - - constructor( - private activatedRoute: ActivatedRoute, - private billingApiService: BillingApiServiceAbstraction, - protected organizationApiService: OrganizationApiServiceAbstraction, - private dialogService: DialogService, - private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, - private router: Router, - private toastService: ToastService, - private location: Location, - private organizationService: OrganizationService, - private accountService: AccountService, - protected syncService: SyncService, - private billingNotificationService: BillingNotificationService, - private configService: ConfigService, - ) { - combineLatest([ - this.activatedRoute.params, - this.configService.getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout), - ]) - .pipe( - switchMap(([{ organizationId }, managePaymentDetailsOutsideCheckout]) => { - if (this.platformUtilsService.isSelfHost()) { - return from(this.router.navigate(["/settings/subscription"])); - } - - if (managePaymentDetailsOutsideCheckout) { - return from( - this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }), - ); - } - - this.organizationId = organizationId; - return from(this.load()); - }), - takeUntilDestroyed(), - ) - .subscribe(); - - const state = this.router.getCurrentNavigation()?.extras?.state; - // In case the above state is undefined or null, we use redundantState - const redundantState: any = location.getState(); - const queryParam = this.activatedRoute.snapshot.queryParamMap.get( - "launchPaymentModalAutomatically", - ); - if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { - this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; - } else if ( - redundantState && - Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") - ) { - this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; - } else { - this.launchPaymentModalAutomatically = queryParam === "true"; - } - } - ngOnDestroy(): void { - this.launchPaymentModalAutomatically = false; - } - - protected addAccountCredit = async (): Promise => { - if (this.subscriptionStatus === "trialing") { - const hasValidBillingAddress = await this.checkBillingAddressForTrialingOrg(); - if (!hasValidBillingAddress) { - return; - } - } - const dialogRef = openAddCreditDialog(this.dialogService, { - data: { - organizationId: this.organizationId, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result === AddCreditDialogResult.Added) { - await this.load(); - } - }; - - protected load = async (): Promise => { - this.loading = true; - try { - const { accountCredit, paymentSource, subscriptionStatus, taxInformation } = - await this.billingApiService.getOrganizationPaymentMethod(this.organizationId); - this.accountCredit = accountCredit; - this.paymentSource = paymentSource; - this.subscriptionStatus = subscriptionStatus; - this.taxInformation = taxInformation; - this.isUnpaid = this.subscriptionStatus === "unpaid"; - - if (this.organizationId) { - const organizationSubscriptionPromise = this.organizationApiService.getSubscription( - this.organizationId, - ); - - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - - if (!userId) { - throw new Error("User ID is not found"); - } - - const organizationPromise = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.organizationId)), - ); - - [this.organizationSubscriptionResponse, this.organization] = await Promise.all([ - organizationSubscriptionPromise, - organizationPromise, - ]); - - if (!this.organization) { - throw new Error("Organization is not found"); - } - if (!this.paymentSource) { - throw new Error("Payment source is not found"); - } - } - // If the flag `launchPaymentModalAutomatically` is set to true, - // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. - // This delay ensures that any prior UI/rendering operations complete before triggering the modal. - if (this.launchPaymentModalAutomatically) { - window.setTimeout(async () => { - await this.changePayment(); - this.launchPaymentModalAutomatically = false; - this.location.replaceState(this.location.path(), "", {}); - }, 800); - } - } catch (error) { - this.billingNotificationService.handleError(error); - } finally { - this.loading = false; - } - }; - - protected updatePaymentMethod = async (): Promise => { - const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, { - data: { - initialPaymentMethod: this.paymentSource?.type, - organizationId: this.organizationId, - productTier: this.organization?.productTierType, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result === AdjustPaymentDialogResultType.Submitted) { - await this.load(); - } - }; - - changePayment = async () => { - const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, { - data: { - organizationId: this.organizationId, - subscription: this.organizationSubscriptionResponse!, - productTierType: this.organization!.productTierType, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) { - this.location.replaceState(this.location.path(), "", {}); - if (this.launchPaymentModalAutomatically && !this.organization?.enabled) { - await this.syncService.fullSync(true); - } - this.launchPaymentModalAutomatically = false; - await this.load(); - } - }; - - protected verifyBankAccount = async (request: VerifyBankAccountRequest): Promise => { - await this.billingApiService.verifyOrganizationBankAccount(this.organizationId, request); - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("verifiedBankAccount"), - }); - }; - - protected get accountCreditHeaderText(): string { - const hasAccountCredit = this.accountCredit && this.accountCredit > 0; - const key = hasAccountCredit ? "accountCredit" : "accountBalance"; - return this.i18nService.t(key); - } - - protected get paymentSourceClasses() { - if (this.paymentSource == null) { - return []; - } - switch (this.paymentSource.type) { - case PaymentMethodType.Card: - return ["bwi-credit-card"]; - case PaymentMethodType.BankAccount: - case PaymentMethodType.Check: - return ["bwi-billing"]; - case PaymentMethodType.PayPal: - return ["bwi-paypal text-primary"]; - default: - return []; - } - } - - protected get subscriptionIsUnpaid(): boolean { - return this.subscriptionStatus === "unpaid"; - } - - protected get updatePaymentSourceButtonText(): string { - const key = this.paymentSource == null ? "addPaymentMethod" : "changePaymentMethod"; - return this.i18nService.t(key); - } - - private async checkBillingAddressForTrialingOrg(): Promise { - const hasBillingAddress = this.taxInformation != null; - if (!hasBillingAddress) { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("billingAddressRequiredToAddCredit"), - }); - return false; - } - return true; - } -} 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 index c7a297cc28b..53f72558089 100644 --- 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 @@ -15,8 +15,6 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso 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"; @@ -35,7 +33,6 @@ import { TaxIdWarningTypes } from "@bitwarden/web-vault/app/billing/warnings/typ describe("OrganizationWarningsService", () => { let service: OrganizationWarningsService; - let configService: MockProxy; let dialogService: MockProxy; let i18nService: MockProxy; let organizationApiService: MockProxy; @@ -57,7 +54,6 @@ describe("OrganizationWarningsService", () => { }); beforeEach(() => { - configService = mock(); dialogService = mock(); i18nService = mock(); organizationApiService = mock(); @@ -94,7 +90,6 @@ describe("OrganizationWarningsService", () => { TestBed.configureTestingModule({ providers: [ OrganizationWarningsService, - { provide: ConfigService, useValue: configService }, { provide: DialogService, useValue: dialogService }, { provide: I18nService, useValue: i18nService }, { provide: OrganizationApiServiceAbstraction, useValue: organizationApiService }, @@ -466,7 +461,6 @@ describe("OrganizationWarningsService", () => { } as OrganizationWarningsResponse); dialogService.openSimpleDialog.mockResolvedValue(true); - configService.getFeatureFlag.mockResolvedValue(false); router.navigate.mockResolvedValue(true); service.showInactiveSubscriptionDialog$(organization).subscribe({ @@ -478,11 +472,8 @@ describe("OrganizationWarningsService", () => { acceptButtonText: "Continue", cancelButtonText: "Close", }); - expect(configService.getFeatureFlag).toHaveBeenCalledWith( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); expect(router.navigate).toHaveBeenCalledWith( - ["organizations", "org-id-123", "billing", "payment-method"], + ["organizations", "org-id-123", "billing", "payment-details"], { state: { launchPaymentModalAutomatically: true } }, ); done(); @@ -497,7 +488,6 @@ describe("OrganizationWarningsService", () => { } as OrganizationWarningsResponse); dialogService.openSimpleDialog.mockResolvedValue(true); - configService.getFeatureFlag.mockResolvedValue(true); router.navigate.mockResolvedValue(true); service.showInactiveSubscriptionDialog$(organization).subscribe({ @@ -522,7 +512,6 @@ describe("OrganizationWarningsService", () => { service.showInactiveSubscriptionDialog$(organization).subscribe({ complete: () => { expect(dialogService.openSimpleDialog).toHaveBeenCalled(); - expect(configService.getFeatureFlag).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled(); done(); }, diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts index c6bb1bc231b..46a34def28b 100644 --- a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts @@ -16,8 +16,6 @@ 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 { 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 { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; @@ -53,7 +51,6 @@ export class OrganizationWarningsService { taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable(); constructor( - private configService: ConfigService, private dialogService: DialogService, private i18nService: I18nService, private organizationApiService: OrganizationApiServiceAbstraction, @@ -196,14 +193,8 @@ export class OrganizationWarningsService { cancelButtonText: this.i18nService.t("close"), }); if (confirmed) { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - const route = managePaymentDetailsOutsideCheckout - ? "payment-details" - : "payment-method"; await this.router.navigate( - ["organizations", `${organization.id}`, "billing", route], + ["organizations", `${organization.id}`, "billing", "payment-details"], { state: { launchPaymentModalAutomatically: true }, }, 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 c33d805aed7..5f5e3442935 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 @@ -5,7 +5,7 @@ import { DialogService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { BitwardenSubscriber } from "../../types"; -import { MaskedPaymentMethod } from "../types"; +import { getCardBrandIcon, MaskedPaymentMethod } from "../types"; import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dialog.component"; @@ -40,9 +40,9 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial } @case ("card") {

- @let brandIcon = getBrandIconForCard(); - @if (brandIcon !== null) { - + @let cardBrandIcon = getCardBrandIcon(); + @if (cardBrandIcon !== null) { + } @else { } @@ -74,16 +74,6 @@ export class DisplayPaymentMethodComponent { @Input({ required: true }) paymentMethod!: MaskedPaymentMethod | null; @Output() updated = new EventEmitter(); - protected availableCardIcons: Record = { - amex: "card-amex", - diners: "card-diners-club", - discover: "card-discover", - jcb: "card-jcb", - mastercard: "card-mastercard", - unionpay: "card-unionpay", - visa: "card-visa", - }; - constructor(private dialogService: DialogService) {} changePaymentMethod = async (): Promise => { @@ -100,13 +90,5 @@ export class DisplayPaymentMethodComponent { } }; - protected getBrandIconForCard = (): string | null => { - if (this.paymentMethod?.type !== "card") { - return null; - } - - return this.paymentMethod.brand in this.availableCardIcons - ? this.availableCardIcons[this.paymentMethod.brand] - : null; - }; + protected getCardBrandIcon = () => getCardBrandIcon(this.paymentMethod); } 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 de2f2f94497..6e356097d32 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 @@ -11,10 +11,7 @@ import { 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 { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; import { BitwardenSubscriber } from "@bitwarden/web-vault/app/billing/types"; import { TaxIdWarningType, @@ -22,7 +19,10 @@ import { } from "@bitwarden/web-vault/app/billing/warnings/types"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { EnterBillingAddressComponent } from "./enter-billing-address.component"; +import { + EnterBillingAddressComponent, + getBillingAddressFromForm, +} from "./enter-billing-address.component"; type DialogParams = { subscriber: BitwardenSubscriber; @@ -104,13 +104,7 @@ export class EditBillingAddressDialogComponent { return; } - const { taxId, ...addressFields } = this.formGroup.getRawValue(); - - const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null; - - const billingAddress = taxIdType - ? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } } - : { ...addressFields, taxId: null }; + const billingAddress = getBillingAddressFromForm(this.formGroup); const result = await this.billingClient.updateBillingAddress( this.dialogParams.subscriber, 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 7659b7ed5ca..3f68c12c897 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 @@ -24,6 +24,17 @@ export interface BillingAddressControls { export type BillingAddressFormGroup = FormGroup>; +export const getBillingAddressFromForm = (formGroup: BillingAddressFormGroup): BillingAddress => + getBillingAddressFromControls(formGroup.getRawValue()); + +export const getBillingAddressFromControls = (controls: BillingAddressControls) => { + const { taxId, ...addressFields } = controls; + const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null; + return taxIdType + ? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } } + : { ...addressFields, taxId: null }; +}; + type Scenario = | { type: "checkout"; @@ -67,54 +78,56 @@ type Scenario = />

-
- - {{ "address1" | i18n }} - - -
-
- - {{ "address2" | i18n }} - - -
-
- - {{ "cityTown" | i18n }} - - -
-
- - {{ "stateProvince" | i18n }} - - -
+ @if (scenario.type === "update") { +
+ + {{ "address1" | i18n }} + + +
+
+ + {{ "address2" | i18n }} + + +
+
+ + {{ "cityTown" | i18n }} + + +
+
+ + {{ "stateProvince" | i18n }} + + +
+ } @if (supportsTaxId$ | async) {
@@ -175,7 +188,7 @@ export class EnterBillingAddressComponent implements OnInit, OnDestroy { this.supportsTaxId$ = this.group.controls.country.valueChanges.pipe( startWith(this.group.value.country ?? this.selectableCountries[0].value), map((country) => { - if (!this.scenario.supportsTaxId) { + if (!this.scenario.supportsTaxId || country === "US") { return false; } diff --git a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts index 93c45b873fe..4af5226e7ee 100644 --- a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts @@ -8,7 +8,6 @@ import { PopoverModule, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; -import { PaymentLabelComponent } from "../../shared/payment/payment-label.component"; import { isTokenizablePaymentMethod, selectableCountries, @@ -16,6 +15,8 @@ import { TokenizedPaymentMethod, } from "../types"; +import { PaymentLabelComponent } from "./payment-label.component"; + type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit"; type PaymentMethodFormGroup = FormGroup<{ @@ -102,7 +103,7 @@ type PaymentMethodFormGroup = FormGroup<{ - - - - -
- - - - - - - - - - - - - - - -
diff --git a/apps/web/src/app/billing/shared/add-credit-dialog.component.ts b/apps/web/src/app/billing/shared/add-credit-dialog.component.ts deleted file mode 100644 index cdf72168acf..00000000000 --- a/apps/web/src/app/billing/shared/add-credit-dialog.component.ts +++ /dev/null @@ -1,191 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { firstValueFrom, map } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request"; -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 { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; - -export interface AddCreditDialogData { - organizationId: string; -} - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum AddCreditDialogResult { - Added = "added", - Cancelled = "cancelled", -} - -export type PayPalConfig = { - businessId?: string; - buttonAction?: string; -}; - -@Component({ - templateUrl: "add-credit-dialog.component.html", - standalone: false, -}) -export class AddCreditDialogComponent implements OnInit { - @ViewChild("ppButtonForm", { read: ElementRef, static: true }) ppButtonFormRef: ElementRef; - - paymentMethodType = PaymentMethodType; - ppButtonFormAction: string; - ppButtonBusinessId: string; - ppButtonCustomField: string; - ppLoading = false; - subject: string; - returnUrl: string; - organizationId: string; - - private userId: string; - private name: string; - private email: string; - private region: string; - - protected DialogResult = AddCreditDialogResult; - protected formGroup = new FormGroup({ - method: new FormControl(PaymentMethodType.PayPal), - creditAmount: new FormControl(null, [Validators.required]), - }); - - constructor( - private dialogRef: DialogRef, - @Inject(DIALOG_DATA) protected data: AddCreditDialogData, - private accountService: AccountService, - private apiService: ApiService, - private platformUtilsService: PlatformUtilsService, - private organizationService: OrganizationService, - private logService: LogService, - private configService: ConfigService, - ) { - this.organizationId = data.organizationId; - const payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; - this.ppButtonFormAction = payPalConfig.buttonAction; - this.ppButtonBusinessId = payPalConfig.businessId; - } - - async ngOnInit() { - if (this.organizationId != null) { - if (this.creditAmount == null) { - this.creditAmount = "0.00"; - } - this.ppButtonCustomField = "organization_id:" + this.organizationId; - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - const org = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.organizationId)), - ); - if (org != null) { - this.subject = org.name; - this.name = org.name; - } - } else { - if (this.creditAmount == null) { - this.creditAmount = "0.00"; - } - const [userId, email] = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])), - ); - this.userId = userId; - this.subject = email; - this.email = this.subject; - this.ppButtonCustomField = "user_id:" + this.userId; - } - this.region = await firstValueFrom(this.configService.cloudRegion$); - this.ppButtonCustomField += ",account_credit:1"; - this.ppButtonCustomField += `,region:${this.region}`; - this.returnUrl = window.location.href; - } - - get creditAmount() { - return this.formGroup.value.creditAmount; - } - set creditAmount(value: string) { - this.formGroup.get("creditAmount").setValue(value); - } - - get method() { - return this.formGroup.value.method; - } - - submit = async () => { - if (this.creditAmount == null || this.creditAmount === "") { - return; - } - - if (this.method === PaymentMethodType.PayPal) { - this.ppButtonFormRef.nativeElement.submit(); - this.ppLoading = true; - return; - } - if (this.method === PaymentMethodType.BitPay) { - const req = new BitPayInvoiceRequest(); - req.email = this.email; - req.name = this.name; - req.credit = true; - req.amount = this.creditAmountNumber; - req.organizationId = this.organizationId; - req.userId = this.userId; - req.returnUrl = this.returnUrl; - const bitPayUrl: string = await this.apiService.postBitPayInvoice(req); - this.platformUtilsService.launchUri(bitPayUrl); - return; - } - this.dialogRef.close(AddCreditDialogResult.Added); - }; - - formatAmount() { - try { - if (this.creditAmount != null && this.creditAmount !== "") { - const floatAmount = Math.abs(parseFloat(this.creditAmount)); - if (floatAmount > 0) { - this.creditAmount = parseFloat((Math.round(floatAmount * 100) / 100).toString()) - .toFixed(2) - .toString(); - return; - } - } - } catch (e) { - this.logService.error(e); - } - this.creditAmount = ""; - } - - get creditAmountNumber(): number { - if (this.creditAmount != null && this.creditAmount !== "") { - try { - return parseFloat(this.creditAmount); - } catch (e) { - this.logService.error(e); - } - } - return null; - } -} - -/** - * Strongly typed helper to open a AddCreditDialog - * @param dialogService Instance of the dialog service that will be used to open the dialog - * @param config Configuration for the dialog - */ -export function openAddCreditDialog( - dialogService: DialogService, - config: DialogConfig, -) { - return dialogService.open(AddCreditDialogComponent, config); -} diff --git a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.html b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.html deleted file mode 100644 index 9c70908af8e..00000000000 --- a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - diff --git a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts deleted file mode 100644 index 9944085488f..00000000000 --- a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.ts +++ /dev/null @@ -1,225 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, forwardRef, Inject, OnInit, ViewChild } from "@angular/core"; - -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { PaymentMethodType, ProductTierType } from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request"; -import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request"; -import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { - DIALOG_DATA, - DialogConfig, - DialogRef, - DialogService, - ToastService, -} from "@bitwarden/components"; - -import { PaymentComponent } from "../payment/payment.component"; - -export interface AdjustPaymentDialogParams { - initialPaymentMethod?: PaymentMethodType | null; - organizationId?: string; - productTier?: ProductTierType; - providerId?: string; -} - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum AdjustPaymentDialogResultType { - Closed = "closed", - Submitted = "submitted", -} - -@Component({ - templateUrl: "./adjust-payment-dialog.component.html", - standalone: false, -}) -export class AdjustPaymentDialogComponent implements OnInit { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(forwardRef(() => ManageTaxInformationComponent)) - taxInfoComponent: ManageTaxInformationComponent; - - protected readonly PaymentMethodType = PaymentMethodType; - protected readonly ResultType = AdjustPaymentDialogResultType; - - protected dialogHeader: string; - protected initialPaymentMethod: PaymentMethodType; - protected organizationId?: string; - protected productTier?: ProductTierType; - protected providerId?: string; - - protected loading = true; - - protected taxInformation: TaxInformation; - - constructor( - private apiService: ApiService, - private billingApiService: BillingApiServiceAbstraction, - private organizationApiService: OrganizationApiServiceAbstraction, - @Inject(DIALOG_DATA) protected dialogParams: AdjustPaymentDialogParams, - private dialogRef: DialogRef, - private i18nService: I18nService, - private toastService: ToastService, - ) { - const key = this.dialogParams.initialPaymentMethod ? "changePaymentMethod" : "addPaymentMethod"; - this.dialogHeader = this.i18nService.t(key); - this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card; - this.organizationId = this.dialogParams.organizationId; - this.productTier = this.dialogParams.productTier; - this.providerId = this.dialogParams.providerId; - } - - ngOnInit(): void { - if (this.organizationId) { - this.organizationApiService - .getTaxInfo(this.organizationId) - .then((response: TaxInfoResponse) => { - this.taxInformation = TaxInformation.from(response); - this.toggleBankAccount(); - }) - .catch(() => { - this.taxInformation = new TaxInformation(); - }) - .finally(() => { - this.loading = false; - }); - } else if (this.providerId) { - this.billingApiService - .getProviderTaxInformation(this.providerId) - .then((response) => { - this.taxInformation = TaxInformation.from(response); - this.toggleBankAccount(); - }) - .catch(() => { - this.taxInformation = new TaxInformation(); - }) - .finally(() => { - this.loading = false; - }); - } else { - this.apiService - .getTaxInfo() - .then((response: TaxInfoResponse) => { - this.taxInformation = TaxInformation.from(response); - }) - .catch(() => { - this.taxInformation = new TaxInformation(); - }) - .finally(() => { - this.loading = false; - }); - } - } - - taxInformationChanged(event: TaxInformation) { - this.taxInformation = event; - this.toggleBankAccount(); - } - - toggleBankAccount = () => { - if (this.taxInformation.country === "US") { - this.paymentComponent.showBankAccount = !!this.organizationId || !!this.providerId; - } else { - this.paymentComponent.showBankAccount = false; - if (this.paymentComponent.selected === PaymentMethodType.BankAccount) { - this.paymentComponent.select(PaymentMethodType.Card); - } - } - }; - - submit = async (): Promise => { - if (!this.taxInfoComponent.validate()) { - this.taxInfoComponent.markAllAsTouched(); - return; - } - - try { - if (this.organizationId) { - await this.updateOrganizationPaymentMethod(); - } else if (this.providerId) { - await this.updateProviderPaymentMethod(); - } else { - await this.updatePremiumUserPaymentMethod(); - } - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("updatedPaymentMethod"), - }); - - this.dialogRef.close(AdjustPaymentDialogResultType.Submitted); - } catch (error) { - const msg = typeof error == "object" ? error.message : error; - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t(msg) || msg, - }); - } - }; - - private updateOrganizationPaymentMethod = async () => { - const paymentSource = await this.paymentComponent.tokenize(); - - const request = new UpdatePaymentMethodRequest(); - request.paymentSource = paymentSource; - request.taxInformation = ExpandedTaxInfoUpdateRequest.From(this.taxInformation); - - await this.billingApiService.updateOrganizationPaymentMethod(this.organizationId, request); - }; - - private updatePremiumUserPaymentMethod = async () => { - const { type, token } = await this.paymentComponent.tokenize(); - - const request = new PaymentRequest(); - request.paymentMethodType = type; - request.paymentToken = token; - request.country = this.taxInformation.country; - request.postalCode = this.taxInformation.postalCode; - request.taxId = this.taxInformation.taxId; - request.state = this.taxInformation.state; - request.line1 = this.taxInformation.line1; - request.line2 = this.taxInformation.line2; - request.city = this.taxInformation.city; - request.state = this.taxInformation.state; - await this.apiService.postAccountPayment(request); - }; - - private updateProviderPaymentMethod = async () => { - const paymentSource = await this.paymentComponent.tokenize(); - - const request = new UpdatePaymentMethodRequest(); - request.paymentSource = paymentSource; - request.taxInformation = ExpandedTaxInfoUpdateRequest.From(this.taxInformation); - - await this.billingApiService.updateProviderPaymentMethod(this.providerId, request); - }; - - protected get showTaxIdField(): boolean { - if (this.organizationId) { - switch (this.productTier) { - case ProductTierType.Free: - case ProductTierType.Families: - return false; - default: - return true; - } - } else { - return !!this.providerId; - } - } - - static open = ( - dialogService: DialogService, - dialogConfig: DialogConfig, - ) => - dialogService.open(AdjustPaymentDialogComponent, dialogConfig); -} diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index 7322f047551..fb593b39328 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -1,46 +1,40 @@ import { NgModule } from "@angular/core"; import { BannerModule } from "@bitwarden/components"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, +} from "@bitwarden/web-vault/app/billing/payment/components"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; -import { AddCreditDialogComponent } from "./add-credit-dialog.component"; -import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog/adjust-payment-dialog.component"; import { AdjustStorageDialogComponent } from "./adjust-storage-dialog/adjust-storage-dialog.component"; import { BillingHistoryComponent } from "./billing-history.component"; import { OffboardingSurveyComponent } from "./offboarding-survey.component"; -import { PaymentComponent } from "./payment/payment.component"; -import { PaymentMethodComponent } from "./payment-method.component"; import { PlanCardComponent } from "./plan-card/plan-card.component"; import { PricingSummaryComponent } from "./pricing-summary/pricing-summary.component"; import { IndividualSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/individual-self-hosting-license-uploader.component"; import { OrganizationSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/organization-self-hosting-license-uploader.component"; import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component"; -import { TaxInfoComponent } from "./tax-info.component"; import { TrialPaymentDialogComponent } from "./trial-payment-dialog/trial-payment-dialog.component"; import { UpdateLicenseDialogComponent } from "./update-license-dialog.component"; import { UpdateLicenseComponent } from "./update-license.component"; -import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-account.component"; @NgModule({ imports: [ SharedModule, - TaxInfoComponent, HeaderModule, BannerModule, - PaymentComponent, - VerifyBankAccountComponent, + EnterPaymentMethodComponent, + EnterBillingAddressComponent, ], declarations: [ - AddCreditDialogComponent, BillingHistoryComponent, - PaymentMethodComponent, SecretsManagerSubscribeComponent, UpdateLicenseComponent, UpdateLicenseDialogComponent, OffboardingSurveyComponent, - AdjustPaymentDialogComponent, AdjustStorageDialogComponent, IndividualSelfHostingLicenseUploaderComponent, OrganizationSelfHostingLicenseUploaderComponent, @@ -50,14 +44,11 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac ], exports: [ SharedModule, - TaxInfoComponent, BillingHistoryComponent, SecretsManagerSubscribeComponent, UpdateLicenseComponent, UpdateLicenseDialogComponent, OffboardingSurveyComponent, - VerifyBankAccountComponent, - PaymentComponent, IndividualSelfHostingLicenseUploaderComponent, OrganizationSelfHostingLicenseUploaderComponent, ], diff --git a/apps/web/src/app/billing/shared/index.ts b/apps/web/src/app/billing/shared/index.ts index 54ab5bc0a2a..466d1d3e586 100644 --- a/apps/web/src/app/billing/shared/index.ts +++ b/apps/web/src/app/billing/shared/index.ts @@ -1,4 +1,2 @@ export * from "./billing-shared.module"; -export * from "./payment-method.component"; export * from "./sm-subscribe.component"; -export * from "./tax-info.component"; diff --git a/apps/web/src/app/billing/shared/payment-method.component.html b/apps/web/src/app/billing/shared/payment-method.component.html deleted file mode 100644 index 81ed7e5a631..00000000000 --- a/apps/web/src/app/billing/shared/payment-method.component.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - -

{{ "paymentMethod" | i18n }}

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

- {{ (isCreditBalance ? "accountCredit" : "accountBalance") | i18n }} -

-

{{ creditOrBalance | currency: "$" }}

-

{{ "creditAppliedDesc" | i18n }}

- -
- -

{{ "paymentMethod" | i18n }}

-

{{ "noPaymentMethod" | i18n }}

- - -

- {{ "verifyBankAccountDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }} -

-
- - {{ "amountX" | i18n: "1" }} - - $0. - - - {{ "amountX" | i18n: "2" }} - - $0. - - -
-
-

- - {{ paymentSource.description }} -

-
- -

- {{ "paymentChargedWithUnpaidSubscription" | i18n }} -

-
-
-
diff --git a/apps/web/src/app/billing/shared/payment-method.component.ts b/apps/web/src/app/billing/shared/payment-method.component.ts deleted file mode 100644 index b6431843b83..00000000000 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { Location } from "@angular/common"; -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder, FormControl, Validators } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom, lastValueFrom, map } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { - OrganizationService, - getOrganizationById, -} 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 { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response"; -import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; -import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank.request"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/platform/sync"; -import { DialogService, ToastService } from "@bitwarden/components"; - -import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component"; -import { - AdjustPaymentDialogComponent, - AdjustPaymentDialogResultType, -} from "./adjust-payment-dialog/adjust-payment-dialog.component"; - -@Component({ - templateUrl: "payment-method.component.html", - standalone: false, -}) -export class PaymentMethodComponent implements OnInit, OnDestroy { - loading = false; - firstLoaded = false; - billing?: BillingPaymentResponse; - org?: OrganizationSubscriptionResponse; - sub?: SubscriptionResponse; - paymentMethodType = PaymentMethodType; - organizationId?: string; - isUnpaid = false; - organization?: Organization; - - verifyBankForm = this.formBuilder.group({ - amount1: new FormControl(0, [ - Validators.required, - Validators.max(99), - Validators.min(0), - ]), - amount2: new FormControl(0, [ - Validators.required, - Validators.max(99), - Validators.min(0), - ]), - }); - - launchPaymentModalAutomatically = false; - constructor( - protected apiService: ApiService, - protected organizationApiService: OrganizationApiServiceAbstraction, - protected i18nService: I18nService, - protected platformUtilsService: PlatformUtilsService, - private router: Router, - private location: Location, - private route: ActivatedRoute, - private formBuilder: FormBuilder, - private dialogService: DialogService, - private toastService: ToastService, - private organizationService: OrganizationService, - private accountService: AccountService, - protected syncService: SyncService, - private configService: ConfigService, - ) { - const state = this.router.getCurrentNavigation()?.extras?.state; - // In case the above state is undefined or null, we use redundantState - const redundantState: any = location.getState(); - if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) { - this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically; - } else if ( - redundantState && - Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically") - ) { - this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically; - } else { - this.launchPaymentModalAutomatically = false; - } - } - - async ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.params.subscribe(async (params) => { - if (params.organizationId) { - this.organizationId = params.organizationId; - } else if (this.platformUtilsService.isSelfHost()) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/settings/subscription"]); - return; - } - - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - - if (managePaymentDetailsOutsideCheckout) { - await this.router.navigate(["../payment-details"], { relativeTo: this.route }); - } - - await this.load(); - this.firstLoaded = true; - }); - } - - load = async () => { - if (this.loading) { - return; - } - this.loading = true; - if (this.forOrganization) { - const billingPromise = this.organizationApiService.getBilling(this.organizationId!); - const organizationSubscriptionPromise = this.organizationApiService.getSubscription( - this.organizationId!, - ); - - const userId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); - - if (!userId) { - throw new Error("User ID is not found"); - } - - const organizationPromise = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(this.organizationId!)), - ); - - [this.billing, this.org, this.organization] = await Promise.all([ - billingPromise, - organizationSubscriptionPromise, - organizationPromise, - ]); - } else { - const billingPromise = this.apiService.getUserBillingPayment(); - const subPromise = this.apiService.getUserSubscription(); - - [this.billing, this.sub] = await Promise.all([billingPromise, subPromise]); - } - // TODO: Eslint upgrade. Please resolve this since the ?? does nothing - // eslint-disable-next-line no-constant-binary-expression - this.isUnpaid = this.subscription?.status === "unpaid" ?? false; - this.loading = false; - // If the flag `launchPaymentModalAutomatically` is set to true, - // we schedule a timeout (delay of 800ms) to automatically launch the payment modal. - // This delay ensures that any prior UI/rendering operations complete before triggering the modal. - if (this.launchPaymentModalAutomatically) { - window.setTimeout(async () => { - await this.changePayment(); - this.launchPaymentModalAutomatically = false; - this.location.replaceState(this.location.path(), "", {}); - }, 800); - } - }; - - addCredit = async () => { - if (this.forOrganization) { - const dialogRef = openAddCreditDialog(this.dialogService, { - data: { - organizationId: this.organizationId!, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === AddCreditDialogResult.Added) { - await this.load(); - } - } - }; - - changePayment = async () => { - const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, { - data: { - organizationId: this.organizationId, - initialPaymentMethod: this.paymentSource !== null ? this.paymentSource.type : null, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result === AdjustPaymentDialogResultType.Submitted) { - this.location.replaceState(this.location.path(), "", {}); - if (this.launchPaymentModalAutomatically && !this.organization?.enabled) { - await this.syncService.fullSync(true); - } - this.launchPaymentModalAutomatically = false; - await this.load(); - } - }; - - verifyBank = async () => { - if (this.loading || !this.forOrganization) { - return; - } - - const request = new VerifyBankRequest(); - request.amount1 = this.verifyBankForm.value.amount1!; - request.amount2 = this.verifyBankForm.value.amount2!; - await this.organizationApiService.verifyBank(this.organizationId!, request); - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("verifiedBankAccount"), - }); - await this.load(); - }; - - get isCreditBalance() { - return this.billing == null || this.billing.balance <= 0; - } - - get creditOrBalance() { - return Math.abs(this.billing != null ? this.billing.balance : 0); - } - - get paymentSource() { - return this.billing != null ? this.billing.paymentSource : null; - } - - get forOrganization() { - return this.organizationId != null; - } - - get paymentSourceClasses() { - if (this.paymentSource == null) { - return []; - } - switch (this.paymentSource.type) { - case PaymentMethodType.Card: - return ["bwi-credit-card"]; - case PaymentMethodType.BankAccount: - case PaymentMethodType.Check: - return ["bwi-billing"]; - case PaymentMethodType.PayPal: - return ["bwi-paypal text-primary"]; - default: - return []; - } - } - - get subscription() { - return this.sub?.subscription ?? this.org?.subscription ?? null; - } - - ngOnDestroy(): void { - this.launchPaymentModalAutomatically = false; - } -} diff --git a/apps/web/src/app/billing/shared/payment/payment-label.component.html b/apps/web/src/app/billing/shared/payment/payment-label.component.html deleted file mode 100644 index a931b0524e3..00000000000 --- a/apps/web/src/app/billing/shared/payment/payment-label.component.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - -
- - - ({{ "required" | i18n }}) - -
diff --git a/apps/web/src/app/billing/shared/payment/payment.component.html b/apps/web/src/app/billing/shared/payment/payment.component.html deleted file mode 100644 index d1356c20854..00000000000 --- a/apps/web/src/app/billing/shared/payment/payment.component.html +++ /dev/null @@ -1,149 +0,0 @@ -
-
- - - - - {{ "creditCard" | i18n }} - - - - - - {{ "bankAccount" | i18n }} - - - - - - {{ "payPal" | i18n }} - - - - - - {{ "accountCredit" | i18n }} - - - -
- - -
-
- - {{ "number" | i18n }} - -
-
-
- Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay -
-
- - {{ "expiration" | i18n }} - -
-
-
- - {{ "securityCodeSlashCVV" | i18n }} - - - - -
-
-
-
- - - - {{ "requiredToVerifyBankAccountWithStripe" | i18n }} - -
- - {{ "routingNumber" | i18n }} - - - - {{ "accountNumber" | i18n }} - - - - {{ "accountHolderName" | i18n }} - - - - {{ "bankAccountType" | i18n }} - - - - - - -
-
- - -
-
- {{ "paypalClickSubmit" | i18n }} -
-
- - - - {{ "makeSureEnoughCredit" | i18n }} - - - -
diff --git a/apps/web/src/app/billing/shared/payment/payment.component.ts b/apps/web/src/app/billing/shared/payment/payment.component.ts deleted file mode 100644 index 08476e9952f..00000000000 --- a/apps/web/src/app/billing/shared/payment/payment.component.ts +++ /dev/null @@ -1,215 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { Subject } from "rxjs"; -import { takeUntil } from "rxjs/operators"; - -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; - -import { SharedModule } from "../../../shared"; -import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; - -import { PaymentLabelComponent } from "./payment-label.component"; - -/** - * Render a form that allows the user to enter their payment method, tokenize it against one of our payment providers and, - * optionally, submit it using the {@link onSubmit} function if it is provided. - */ -@Component({ - selector: "app-payment", - templateUrl: "./payment.component.html", - imports: [BillingServicesModule, SharedModule, PaymentLabelComponent], -}) -export class PaymentComponent implements OnInit, OnDestroy { - /** Show account credit as a payment option. */ - @Input() showAccountCredit: boolean = true; - /** Show bank account as a payment option. */ - @Input() showBankAccount: boolean = true; - /** Show PayPal as a payment option. */ - @Input() showPayPal: boolean = true; - - /** The payment method selected by default when the component renders. */ - @Input() private initialPaymentMethod: PaymentMethodType = PaymentMethodType.Card; - /** If provided, will be invoked with the tokenized payment source during form submission. */ - @Input() protected onSubmit?: (request: TokenizedPaymentSourceRequest) => Promise; - - @Input() private bankAccountWarningOverride?: string; - - @Output() submitted = new EventEmitter(); - - private destroy$ = new Subject(); - - protected formGroup = new FormGroup({ - paymentMethod: new FormControl(null), - bankInformation: new FormGroup({ - routingNumber: new FormControl("", [Validators.required]), - accountNumber: new FormControl("", [Validators.required]), - accountHolderName: new FormControl("", [Validators.required]), - accountHolderType: new FormControl("", [Validators.required]), - }), - }); - - protected PaymentMethodType = PaymentMethodType; - - constructor( - private billingApiService: BillingApiServiceAbstraction, - private braintreeService: BraintreeService, - private i18nService: I18nService, - private stripeService: StripeService, - ) {} - - ngOnInit(): void { - this.formGroup.controls.paymentMethod.patchValue(this.initialPaymentMethod); - - this.stripeService.loadStripe( - { - cardNumber: "#stripe-card-number", - cardExpiry: "#stripe-card-expiry", - cardCvc: "#stripe-card-cvc", - }, - this.initialPaymentMethod === PaymentMethodType.Card, - ); - - if (this.showPayPal) { - this.braintreeService.loadBraintree( - "#braintree-container", - this.initialPaymentMethod === PaymentMethodType.PayPal, - ); - } - - this.formGroup - .get("paymentMethod") - .valueChanges.pipe(takeUntil(this.destroy$)) - .subscribe((type) => { - this.onPaymentMethodChange(type); - }); - } - - /** Programmatically select the provided payment method. */ - select = (paymentMethod: PaymentMethodType) => { - this.formGroup.get("paymentMethod").patchValue(paymentMethod); - }; - - protected submit = async () => { - const { type, token } = await this.tokenize(); - await this.onSubmit?.({ type, token }); - this.submitted.emit(type); - }; - - validate = () => { - if (!this.usingBankAccount) { - return true; - } - - this.formGroup.controls.bankInformation.markAllAsTouched(); - return this.formGroup.controls.bankInformation.valid; - }; - - /** - * Tokenize the payment method information entered by the user against one of our payment providers. - * - * - {@link PaymentMethodType.Card} => [Stripe.confirmCardSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_card_setup} - * - {@link PaymentMethodType.BankAccount} => [Stripe.confirmUsBankAccountSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_us_bank_account_setup} - * - {@link PaymentMethodType.PayPal} => [Braintree.requestPaymentMethod]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html#requestPaymentMethod} - * */ - async tokenize(): Promise<{ type: PaymentMethodType; token: string }> { - const type = this.selected; - - if (this.usingStripe) { - const clientSecret = await this.billingApiService.createSetupIntent(type); - - if (this.usingBankAccount) { - this.formGroup.markAllAsTouched(); - if (this.formGroup.valid) { - const token = await this.stripeService.setupBankAccountPaymentMethod(clientSecret, { - accountHolderName: this.formGroup.value.bankInformation.accountHolderName, - routingNumber: this.formGroup.value.bankInformation.routingNumber, - accountNumber: this.formGroup.value.bankInformation.accountNumber, - accountHolderType: this.formGroup.value.bankInformation.accountHolderType, - }); - return { - type, - token, - }; - } else { - throw "Invalid input provided. Please ensure all required fields are filled out correctly and try again."; - } - } - - if (this.usingCard) { - const token = await this.stripeService.setupCardPaymentMethod(clientSecret); - return { - type, - token, - }; - } - } - - if (this.usingPayPal) { - const token = await this.braintreeService.requestPaymentMethod(); - return { - type, - token, - }; - } - - if (this.usingAccountCredit) { - return { - type: PaymentMethodType.Credit, - token: null, - }; - } - - return null; - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - this.stripeService.unloadStripe(); - if (this.showPayPal) { - this.braintreeService.unloadBraintree(); - } - } - - private onPaymentMethodChange(type: PaymentMethodType): void { - switch (type) { - case PaymentMethodType.Card: { - this.stripeService.mountElements(); - break; - } - case PaymentMethodType.PayPal: { - this.braintreeService.createDropin(); - break; - } - } - } - - get selected(): PaymentMethodType { - return this.formGroup.value.paymentMethod; - } - - protected get usingAccountCredit(): boolean { - return this.selected === PaymentMethodType.Credit; - } - - protected get usingBankAccount(): boolean { - return this.selected === PaymentMethodType.BankAccount; - } - - protected get usingCard(): boolean { - return this.selected === PaymentMethodType.Card; - } - - protected get usingPayPal(): boolean { - return this.selected === PaymentMethodType.PayPal; - } - - private get usingStripe(): boolean { - return this.usingBankAccount || this.usingCard; - } -} diff --git a/apps/web/src/app/billing/shared/tax-info.component.html b/apps/web/src/app/billing/shared/tax-info.component.html deleted file mode 100644 index ca2ae046f6e..00000000000 --- a/apps/web/src/app/billing/shared/tax-info.component.html +++ /dev/null @@ -1,83 +0,0 @@ -
-
-
- - {{ "country" | i18n }} - - - - -
-
- - {{ "zipPostalCode" | i18n }} - - -
-
- - {{ "address1" | i18n }} - - -
-
- - {{ "address2" | i18n }} - - -
-
- - {{ "cityTown" | i18n }} - - -
-
- - {{ "stateProvince" | i18n }} - - -
-
- - {{ "taxIdNumber" | i18n }} - - -
-
-
diff --git a/apps/web/src/app/billing/shared/tax-info.component.ts b/apps/web/src/app/billing/shared/tax-info.component.ts deleted file mode 100644 index 35c4a3fcc4e..00000000000 --- a/apps/web/src/app/billing/shared/tax-info.component.ts +++ /dev/null @@ -1,199 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { ActivatedRoute } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; -import { debounceTime } from "rxjs/operators"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { CountryListItem } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; - -import { SharedModule } from "../../shared"; - -/** - * @deprecated Use `ManageTaxInformationComponent` instead. - */ -@Component({ - selector: "app-tax-info", - templateUrl: "tax-info.component.html", - imports: [SharedModule], -}) -export class TaxInfoComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - - @Input() trialFlow = false; - @Output() countryChanged = new EventEmitter(); - @Output() taxInformationChanged: EventEmitter = new EventEmitter(); - - taxFormGroup = new FormGroup({ - country: new FormControl(null, [Validators.required]), - postalCode: new FormControl(null, [Validators.required]), - taxId: new FormControl(null), - line1: new FormControl(null), - line2: new FormControl(null), - city: new FormControl(null), - state: new FormControl(null), - }); - - protected isTaxSupported: boolean; - - loading = true; - organizationId: string; - providerId: string; - countryList: CountryListItem[] = this.taxService.getCountries(); - - constructor( - private apiService: ApiService, - private route: ActivatedRoute, - private logService: LogService, - private organizationApiService: OrganizationApiServiceAbstraction, - private taxService: TaxServiceAbstraction, - ) {} - - get country(): string { - return this.taxFormGroup.controls.country.value; - } - - get postalCode(): string { - return this.taxFormGroup.controls.postalCode.value; - } - - get taxId(): string { - return this.taxFormGroup.controls.taxId.value; - } - - get line1(): string { - return this.taxFormGroup.controls.line1.value; - } - - get line2(): string { - return this.taxFormGroup.controls.line2.value; - } - - get city(): string { - return this.taxFormGroup.controls.city.value; - } - - get state(): string { - return this.taxFormGroup.controls.state.value; - } - - get showTaxIdField(): boolean { - return !!this.organizationId; - } - - async ngOnInit() { - // Provider setup - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - this.route.queryParams.subscribe((params) => { - this.providerId = params.providerId; - }); - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent?.parent?.params.subscribe(async (params) => { - this.organizationId = params.organizationId; - if (this.organizationId) { - try { - const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); - if (taxInfo) { - this.taxFormGroup.controls.taxId.setValue(taxInfo.taxId); - this.taxFormGroup.controls.state.setValue(taxInfo.state); - this.taxFormGroup.controls.line1.setValue(taxInfo.line1); - this.taxFormGroup.controls.line2.setValue(taxInfo.line2); - this.taxFormGroup.controls.city.setValue(taxInfo.city); - this.taxFormGroup.controls.postalCode.setValue(taxInfo.postalCode); - this.taxFormGroup.controls.country.setValue(taxInfo.country); - } - } catch (e) { - this.logService.error(e); - } - } else { - try { - const taxInfo = await this.apiService.getTaxInfo(); - if (taxInfo) { - this.taxFormGroup.controls.postalCode.setValue(taxInfo.postalCode); - this.taxFormGroup.controls.country.setValue(taxInfo.country); - } - } catch (e) { - this.logService.error(e); - } - } - - this.isTaxSupported = await this.taxService.isCountrySupported( - this.taxFormGroup.controls.country.value, - ); - - this.countryChanged.emit(); - }); - - this.taxFormGroup.controls.country.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe((value) => { - this.taxService - .isCountrySupported(this.taxFormGroup.controls.country.value) - .then((isSupported) => { - this.isTaxSupported = isSupported; - }) - .catch(() => { - this.isTaxSupported = false; - }) - .finally(() => { - if (!this.isTaxSupported) { - this.taxFormGroup.controls.taxId.setValue(null); - this.taxFormGroup.controls.line1.setValue(null); - this.taxFormGroup.controls.line2.setValue(null); - this.taxFormGroup.controls.city.setValue(null); - this.taxFormGroup.controls.state.setValue(null); - } - - this.countryChanged.emit(); - }); - this.taxInformationChanged.emit(); - }); - - this.taxFormGroup.controls.postalCode.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - this.taxInformationChanged.emit(); - }); - - this.taxFormGroup.controls.taxId.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - this.taxInformationChanged.emit(); - }); - - this.loading = false; - } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - submitTaxInfo(): Promise { - this.taxFormGroup.updateValueAndValidity(); - this.taxFormGroup.markAllAsTouched(); - - const request = new ExpandedTaxInfoUpdateRequest(); - request.country = this.country; - request.postalCode = this.postalCode; - request.taxId = this.taxId; - request.line1 = this.line1; - request.line2 = this.line2; - request.city = this.city; - request.state = this.state; - - return this.organizationId - ? this.organizationApiService.updateTaxInfo( - this.organizationId, - request as ExpandedTaxInfoUpdateRequest, - ) - : this.apiService.putTaxInfo(request); - } -} diff --git a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html index dbd2899c9e0..1b416eae1bc 100644 --- a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html +++ b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.html @@ -86,17 +86,13 @@

{{ "paymentMethod" | i18n }}

- - + + + + (); protected initialPaymentMethod: PaymentMethodType; - protected taxInformation!: TaxInformation; protected readonly ResultType = TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE; pricingSummaryData!: PricingSummaryData; + formGroup = new FormGroup({ + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + + private destroy$ = new Subject(); + constructor( @Inject(DIALOG_DATA) private dialogParams: TrialPaymentDialogParams, private dialogRef: DialogRef, @@ -93,8 +110,9 @@ export class TrialPaymentDialogComponent implements OnInit { private pricingSummaryService: PricingSummaryService, private apiService: ApiService, private toastService: ToastService, - private billingApiService: BillingApiServiceAbstraction, private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction, + private subscriberBillingClient: SubscriberBillingClient, + private taxClient: TaxClient, ) { this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card; } @@ -134,19 +152,48 @@ export class TrialPaymentDialogComponent implements OnInit { : PlanInterval.Monthly; } - const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId); - this.taxInformation = TaxInformation.from(taxInfo); + const billingAddress = await this.subscriberBillingClient.getBillingAddress({ + type: "organization", + data: this.organization, + }); - this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( - this.currentPlan, - this.sub, - this.organization, - this.selectedInterval, - this.taxInformation, - this.isSecretsManagerTrial(), - ); + if (billingAddress) { + const { taxId, ...location } = billingAddress; + + this.formGroup.controls.billingAddress.patchValue({ + ...location, + taxId: taxId ? taxId.value : null, + }); + } + + await this.refreshPricingSummary(); this.plans = await this.apiService.getPlans(); + + combineLatest([ + this.formGroup.controls.billingAddress.controls.country.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.controls.country.value), + ), + this.formGroup.controls.billingAddress.controls.postalCode.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.controls.postalCode.value), + ), + this.formGroup.controls.billingAddress.controls.taxId.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.controls.taxId.value), + ), + ]) + .pipe( + debounceTime(500), + switchMap(() => { + return this.refreshPricingSummary(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } static open = ( @@ -175,14 +222,7 @@ export class TrialPaymentDialogComponent implements OnInit { await this.selectPlan(); - this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( - this.currentPlan, - this.sub, - this.organization, - this.selectedInterval, - this.taxInformation, - this.isSecretsManagerTrial(), - ); + await this.refreshPricingSummary(); } protected async selectPlan() { @@ -202,7 +242,7 @@ export class TrialPaymentDialogComponent implements OnInit { this.currentPlan = filteredPlans[0]; } try { - await this.refreshSalesTax(); + await this.refreshPricingSummary(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const translatedMessage = this.i18nService.t(errorMessage); @@ -214,72 +254,57 @@ export class TrialPaymentDialogComponent implements OnInit { } } - protected get showTaxIdField(): boolean { - switch (this.currentPlan.productTier) { - case ProductTierType.Free: - case ProductTierType.Families: - return false; - default: - return true; - } - } - - private async refreshSalesTax(): Promise { - if ( - this.taxInformation === undefined || - !this.taxInformation.country || - !this.taxInformation.postalCode - ) { - return; - } - - const request: PreviewOrganizationInvoiceRequest = { - organizationId: this.organizationId, - passwordManager: { - additionalStorage: 0, - plan: this.currentPlan?.type, - seats: this.sub.seats, - }, - taxInformation: { - postalCode: this.taxInformation.postalCode, - country: this.taxInformation.country, - taxId: this.taxInformation.taxId, - }, - }; - - if (this.organization.useSecretsManager) { - request.secretsManager = { - seats: this.sub.smSeats ?? 0, - additionalMachineAccounts: - (this.sub.smServiceAccounts ?? 0) - - (this.sub.plan.SecretsManager?.baseServiceAccount ?? 0), - }; - } - + private refreshPricingSummary = async () => { + const estimatedTax = await this.getEstimatedTax(); this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData( this.currentPlan, this.sub, this.organization, this.selectedInterval, - this.taxInformation, this.isSecretsManagerTrial(), + estimatedTax, ); - } + }; - async taxInformationChanged(event: TaxInformation) { - this.taxInformation = event; - this.toggleBankAccount(); - await this.refreshSalesTax(); - } + private getEstimatedTax = async () => { + if (this.formGroup.controls.billingAddress.invalid) { + return 0; + } - toggleBankAccount = () => { - this.paymentComponent.showBankAccount = this.taxInformation.country === "US"; + const cadence = + this.currentPlan.productTier !== ProductTierType.Families + ? this.currentPlan.isAnnual + ? "annually" + : "monthly" + : null; - if ( - !this.paymentComponent.showBankAccount && - this.paymentComponent.selected === PaymentMethodType.BankAccount - ) { - this.paymentComponent.select(PaymentMethodType.Card); + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + + const getTierFromLegacyEnum = (organization: Organization) => { + switch (organization.productTierType) { + case ProductTierType.Families: + return "families"; + case ProductTierType.Teams: + return "teams"; + case ProductTierType.Enterprise: + return "enterprise"; + } + }; + + const tier = getTierFromLegacyEnum(this.organization); + + if (tier && cadence) { + const costs = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange( + this.organization.id, + { + tier, + cadence, + }, + billingAddress, + ); + return costs.tax; + } else { + return 0; } }; @@ -292,15 +317,24 @@ export class TrialPaymentDialogComponent implements OnInit { } async onSubscribe(): Promise { - if (!this.taxComponent.validate()) { - this.taxComponent.markAllAsTouched(); + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + return; } + try { - await this.updateOrganizationPaymentMethod( - this.organizationId, - this.paymentComponent, - this.taxInformation, - ); + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + return; + } + + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + + const subscriber: BitwardenSubscriber = { type: "organization", data: this.organization }; + await Promise.all([ + this.subscriberBillingClient.updatePaymentMethod(subscriber, paymentMethod, null), + this.subscriberBillingClient.updateBillingAddress(subscriber, billingAddress), + ]); if (this.currentPlan.type !== this.sub.planType) { const changePlanRequest = new ChangePlanFrequencyRequest(); @@ -332,20 +366,6 @@ export class TrialPaymentDialogComponent implements OnInit { } } - private async updateOrganizationPaymentMethod( - organizationId: string, - paymentComponent: PaymentComponent, - taxInformation: TaxInformation, - ): Promise { - const paymentSource = await paymentComponent.tokenize(); - - const request = new UpdatePaymentMethodRequest(); - request.paymentSource = paymentSource; - request.taxInformation = ExpandedTaxInfoUpdateRequest.From(taxInformation); - - await this.billingApiService.updateOrganizationPaymentMethod(organizationId, request); - } - resolvePlanName(productTier: ProductTierType): string { switch (productTier) { case ProductTierType.Enterprise: @@ -362,4 +382,11 @@ export class TrialPaymentDialogComponent implements OnInit { return this.i18nService.t("planNameFree"); } } + + get supportsTaxId() { + if (!this.organization) { + return false; + } + return this.organization.productTierType !== ProductTierType.Families; + } } diff --git a/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.html b/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.html deleted file mode 100644 index 1367e6e3082..00000000000 --- a/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.html +++ /dev/null @@ -1,12 +0,0 @@ - -

{{ "verifyBankAccountWithStatementDescriptorInstructions" | i18n }}

-
- - {{ "descriptorCode" | i18n }} - - - -
-
diff --git a/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.ts b/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.ts deleted file mode 100644 index b7cdfbe60a2..00000000000 --- a/apps/web/src/app/billing/shared/verify-bank-account/verify-bank-account.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { FormBuilder, FormControl, Validators } from "@angular/forms"; - -import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; - -import { SharedModule } from "../../../shared"; - -@Component({ - selector: "app-verify-bank-account", - templateUrl: "./verify-bank-account.component.html", - imports: [SharedModule], -}) -export class VerifyBankAccountComponent { - @Input() onSubmit?: (request: VerifyBankAccountRequest) => Promise; - @Output() submitted = new EventEmitter(); - - protected formGroup = this.formBuilder.group({ - descriptorCode: new FormControl(null, [ - Validators.required, - Validators.minLength(6), - Validators.maxLength(6), - ]), - }); - - constructor(private formBuilder: FormBuilder) {} - - submit = async () => { - const request = new VerifyBankAccountRequest(this.formGroup.value.descriptorCode); - await this.onSubmit?.(request); - this.submitted.emit(); - }; -} diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html index c1a33a4c8df..7377fc45484 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.html @@ -54,17 +54,7 @@ > diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts index 0b1ddda0c12..baccabdc763 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts @@ -30,13 +30,10 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ToastService } from "@bitwarden/components"; import { UserId } from "@bitwarden/user-core"; +import { Trial } from "@bitwarden/web-vault/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service"; -import { - OrganizationCreatedEvent, - SubscriptionProduct, - TrialOrganizationType, -} from "../../../billing/accounts/trial-initiation/trial-billing-step.component"; import { RouterService } from "../../../core/router.service"; +import { OrganizationCreatedEvent } from "../trial-billing-step/trial-billing-step.component"; import { VerticalStepperComponent } from "../vertical-stepper/vertical-stepper.component"; export type InitiationPath = @@ -95,7 +92,6 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { }); private destroy$ = new Subject(); - protected readonly SubscriptionProduct = SubscriptionProduct; protected readonly ProductType = ProductType; protected trialPaymentOptional$ = this.configService.getFeatureFlag$( FeatureFlag.TrialPaymentOptional, @@ -338,14 +334,6 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { } } - get trialOrganizationType(): TrialOrganizationType | null { - if (this.productTier === ProductTierType.Free) { - return null; - } - - return this.productTier; - } - readonly showBillingStep$ = this.trialPaymentOptional$.pipe( map((trialPaymentOptional) => { return ( @@ -434,4 +422,26 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { return null; }); } + + get trial(): Trial { + const product = + this.product === ProductType.PasswordManager ? "passwordManager" : "secretsManager"; + + const tier = + this.productTier === ProductTierType.Families + ? "families" + : this.productTier === ProductTierType.Teams + ? "teams" + : "enterprise"; + + return { + organization: { + name: this.orgInfoFormGroup.value.name!, + email: this.orgInfoFormGroup.value.billingEmail!, + }, + product, + tier, + length: this.trialLength, + }; + } } diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.html b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.html new file mode 100644 index 00000000000..51b7f0c7117 --- /dev/null +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.html @@ -0,0 +1,87 @@ +@if (!(prices$ | async)) { + +} @else { + @let prices = prices$ | async; +
+
+ +
+

{{ "billingPlanLabel" | i18n }}

+ +
+ + + {{ "annual" | i18n }} - + {{ prices.annually | currency: "$" }} + /{{ "yr" | i18n }} + + +
+ @if (prices.monthly) { +
+ + + {{ "monthly" | i18n }} - + {{ prices.monthly | currency: "$" }} + /{{ "monthAbbr" | i18n }} + + +
+ } +
+
+ +
+

{{ "paymentType" | i18n }}

+ + + + @if (trial().length === 0) { + @let label = + trial().product === "passwordManager" + ? "passwordManagerPlanPrice" + : "secretsManagerPlanPrice"; +
+ @let selectionTaxAmounts = selectionCosts$ | async; +
+ {{ label | i18n }}: {{ selectionPrice$ | async | currency: "USD $" }} +
+ {{ "estimatedTax" | i18n }}: + {{ selectionTaxAmounts.tax | currency: "USD $" }} +
+
+
+

+ {{ "total" | i18n }}: + @let interval = formGroup.value.cadence === "annually" ? "year" : "month"; + {{ selectionTaxAmounts.total | currency: "USD $" }}/{{ interval | i18n }} +

+
+ } +
+ +
+ + +
+
+
+} + + + + {{ "loading" | i18n }} + diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts new file mode 100644 index 00000000000..0f185564c2e --- /dev/null +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts @@ -0,0 +1,160 @@ +import { Component, input, OnDestroy, OnInit, output, ViewChild } from "@angular/core"; +import { FormControl, FormGroup } from "@angular/forms"; +import { + combineLatest, + debounceTime, + filter, + map, + Observable, + shareReplay, + startWith, + switchMap, + Subject, + firstValueFrom, +} from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; +import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + BillingAddressControls, + EnterBillingAddressComponent, + EnterPaymentMethodComponent, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + Cadence, + Cadences, + Prices, + Trial, + TrialBillingStepService, +} from "@bitwarden/web-vault/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +export interface OrganizationCreatedEvent { + organizationId: string; + planDescription: string; +} + +@Component({ + selector: "app-trial-billing-step", + templateUrl: "./trial-billing-step.component.html", + imports: [EnterPaymentMethodComponent, EnterBillingAddressComponent, SharedModule], + providers: [TaxClient, TrialBillingStepService], +}) +export class TrialBillingStepComponent implements OnInit, OnDestroy { + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; + + protected trial = input.required(); + protected steppedBack = output(); + protected organizationCreated = output(); + + private destroy$ = new Subject(); + + protected prices$!: Observable; + + protected selectionPrice$!: Observable; + protected selectionCosts$!: Observable<{ + tax: number; + total: number; + }>; + protected selectionDescription$!: Observable; + + protected formGroup = new FormGroup({ + cadence: new FormControl(Cadences.Annually, { + nonNullable: true, + }), + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), + }); + + constructor( + private i18nService: I18nService, + private toastService: ToastService, + private trialBillingStepService: TrialBillingStepService, + ) {} + + async ngOnInit() { + const { product, tier } = this.trial(); + this.prices$ = this.trialBillingStepService.getPrices$(product, tier); + + const cadenceChanged = this.formGroup.controls.cadence.valueChanges.pipe( + startWith(Cadences.Annually), + ); + + this.selectionPrice$ = combineLatest([this.prices$, cadenceChanged]).pipe( + map(([prices, cadence]) => prices[cadence]), + filter((price): price is number => !!price), + ); + + this.selectionCosts$ = combineLatest([ + cadenceChanged, + this.formGroup.controls.billingAddress.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.value), + filter( + (billingAddress): billingAddress is BillingAddressControls => + !!billingAddress.country && !!billingAddress.postalCode, + ), + ), + ]).pipe( + debounceTime(500), + switchMap(([cadence, billingAddress]) => + this.trialBillingStepService.getCosts(product, tier, cadence, billingAddress), + ), + startWith({ + tax: 0, + total: 0, + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + this.selectionDescription$ = combineLatest([this.selectionPrice$, cadenceChanged]).pipe( + map(([price, cadence]) => { + switch (cadence) { + case Cadences.Annually: + return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`; + case Cadences.Monthly: + return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`; + } + }), + ); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + submit = async (): Promise => { + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + return; + } + + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + return; + } + + const billingAddress = this.formGroup.controls.billingAddress.getRawValue(); + + const organization = await this.trialBillingStepService.startTrial( + this.trial(), + this.formGroup.value.cadence!, + billingAddress, + paymentMethod, + ); + + this.toastService.showToast({ + variant: "success", + title: this.i18nService.t("organizationCreated"), + message: this.i18nService.t("organizationReadyToGo"), + }); + + this.organizationCreated.emit({ + organizationId: organization.id, + planDescription: await firstValueFrom(this.selectionDescription$), + }); + }; + + protected stepBack = () => this.steppedBack.emit(); +} diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts new file mode 100644 index 00000000000..9e4f45ede92 --- /dev/null +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts @@ -0,0 +1,209 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom, from, map, shareReplay } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { + OrganizationBillingServiceAbstraction, + SubscriptionInformation, +} from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + BillingAddressControls, + getBillingAddressFromControls, +} from "@bitwarden/web-vault/app/billing/payment/components"; +import { + tokenizablePaymentMethodToLegacyEnum, + TokenizedPaymentMethod, +} from "@bitwarden/web-vault/app/billing/payment/types"; + +export const Tiers = { + Families: "families", + Teams: "teams", + Enterprise: "enterprise", +} as const; + +export const Cadences = { + Annually: "annually", + Monthly: "monthly", +} as const; + +export const Products = { + PasswordManager: "passwordManager", + SecretsManager: "secretsManager", +} as const; + +export type Tier = (typeof Tiers)[keyof typeof Tiers]; +export type Cadence = (typeof Cadences)[keyof typeof Cadences]; +export type Product = (typeof Products)[keyof typeof Products]; + +export type Prices = { + [Cadences.Annually]: number; + [Cadences.Monthly]?: number; +}; + +export interface Trial { + organization: { + name: string; + email: string; + }; + product: Product; + tier: Tier; + length: number; +} + +@Injectable() +export class TrialBillingStepService { + constructor( + private accountService: AccountService, + private apiService: ApiService, + private organizationBillingService: OrganizationBillingServiceAbstraction, + private taxClient: TaxClient, + ) {} + + private plans$ = from(this.apiService.getPlans()).pipe( + shareReplay({ bufferSize: 1, refCount: true }), + ); + + getPrices$ = (product: Product, tier: Tier) => + this.plans$.pipe( + map((plans) => { + switch (tier) { + case "families": { + const annually = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually); + return { + annually: annually!.PasswordManager.basePrice, + }; + } + case "teams": + case "enterprise": { + const annually = plans.data.find( + (plan) => + plan.type === + (tier === "teams" ? PlanType.TeamsAnnually : PlanType.EnterpriseAnnually), + ); + const monthly = plans.data.find( + (plan) => + plan.type === + (tier === "teams" ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly), + ); + switch (product) { + case "passwordManager": { + return { + annually: annually!.PasswordManager.seatPrice, + monthly: monthly!.PasswordManager.seatPrice, + }; + } + case "secretsManager": { + return { + annually: annually!.SecretsManager.seatPrice, + monthly: monthly!.SecretsManager.seatPrice, + }; + } + } + } + } + }), + ); + + getCosts = async ( + product: Product, + tier: Tier, + cadence: Cadence, + billingAddressControls: BillingAddressControls, + ): Promise<{ + tax: number; + total: number; + }> => { + const billingAddress = getBillingAddressFromControls(billingAddressControls); + return await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + { + tier, + cadence, + passwordManager: { + seats: 1, + additionalStorage: 0, + sponsored: false, + }, + secretsManager: + product === "secretsManager" + ? { + seats: 1, + additionalServiceAccounts: 0, + standalone: true, + } + : undefined, + }, + billingAddress, + ); + }; + + startTrial = async ( + trial: Trial, + cadence: Cadence, + billingAddress: BillingAddressControls, + paymentMethod: TokenizedPaymentMethod, + ): Promise => { + const getPlanType = async (tier: Tier, cadence: Cadence) => { + const plans = await firstValueFrom(this.plans$); + switch (tier) { + case "families": + return plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!.type; + case "teams": + return plans.data.find( + (plan) => + plan.type === + (cadence === "annually" ? PlanType.TeamsAnnually : PlanType.TeamsMonthly), + )!.type; + case "enterprise": + return plans.data.find( + (plan) => + plan.type === + (cadence === "annually" ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly), + )!.type; + } + }; + + const legacyPaymentMethod: [string, PaymentMethodType] = [ + paymentMethod.token, + tokenizablePaymentMethodToLegacyEnum(paymentMethod.type), + ]; + const planType = await getPlanType(trial.tier, cadence); + + const request: SubscriptionInformation = { + organization: { + name: trial.organization.name, + billingEmail: trial.organization.email, + initiationPath: + trial.product === "passwordManager" + ? "Password Manager trial from marketing website" + : "Secrets Manager trial from marketing website", + }, + plan: + trial.product === "passwordManager" + ? { type: planType, passwordManagerSeats: 1 } + : { + type: planType, + passwordManagerSeats: 1, + subscribeToSecretsManager: true, + isFromSecretsManagerTrial: true, + secretsManagerSeats: 1, + }, + payment: { + paymentMethod: legacyPaymentMethod, + billing: { + country: billingAddress.country, + postalCode: billingAddress.postalCode, + taxId: billingAddress.taxId ?? undefined, + }, + skipTrial: trial.length === 0, + }, + }; + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + return await this.organizationBillingService.purchaseSubscription(request, activeUserId); + }; +} diff --git a/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts b/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts index 06e1cce7f23..cd393f0dd5e 100644 --- a/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts +++ b/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts @@ -6,11 +6,11 @@ import { InputPasswordComponent } from "@bitwarden/auth/angular"; import { FormFieldModule } from "@bitwarden/components"; import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module"; -import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component"; import { SharedModule } from "../../shared"; import { CompleteTrialInitiationComponent } from "./complete-trial-initiation/complete-trial-initiation.component"; import { ConfirmationDetailsComponent } from "./confirmation-details.component"; +import { TrialBillingStepComponent } from "./trial-billing-step/trial-billing-step.component"; import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module"; @NgModule({ diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 06c31a0bfd4..29b84ddc382 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -98,6 +98,7 @@ import { DefaultThemeStateService, ThemeStateService, } from "@bitwarden/common/platform/theming/theme-state.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { @@ -109,6 +110,7 @@ import { LockComponentService } from "@bitwarden/key-management-ui"; import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service"; +import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/vault/services/web-premium-upgrade-prompt.service"; import { flagEnabled } from "../../utils/flags"; import { @@ -403,6 +405,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultDeviceManagementComponentService, deps: [], }), + safeProvider({ + provide: PremiumUpgradePromptService, + useClass: WebVaultPremiumUpgradePromptService, + deps: [DialogService, Router], + }), ]; @NgModule({ diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 42ad477ff51..b48db2bba91 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -1,7 +1,15 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { + Component, + ElementRef, + forwardRef, + Inject, + OnDestroy, + OnInit, + ViewChild, +} from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; import { firstValueFrom, Subject, switchMap } from "rxjs"; @@ -56,10 +64,10 @@ import { } from "@bitwarden/vault"; import { SharedModule } from "../../../shared/shared.module"; -import { WebVaultPremiumUpgradePromptService } from "../../../vault/services/web-premium-upgrade-prompt.service"; import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; import { RoutedVaultFilterModel } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model"; import { WebCipherFormGenerationService } from "../../services/web-cipher-form-generation.service"; +import { WebVaultPremiumUpgradePromptService } from "../../services/web-premium-upgrade-prompt.service"; export type VaultItemDialogMode = "view" | "form"; @@ -136,7 +144,10 @@ export type VaultItemDialogResult = UnionOfValues; PremiumBadgeComponent, ], providers: [ - { provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService }, + { + provide: PremiumUpgradePromptService, + useClass: forwardRef(() => WebVaultPremiumUpgradePromptService), + }, { provide: ViewPasswordHistoryService, useClass: VaultViewPasswordHistoryService }, { provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService }, RoutedVaultFilterService, diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts index c97b23b1456..6b46cd89956 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts @@ -1,26 +1,17 @@ import { TestBed } from "@angular/core/testing"; -import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, of, take, timeout } from "rxjs"; +import { BehaviorSubject, firstValueFrom, take, timeout } from "rxjs"; -import { - AuthRequestServiceAbstraction, - UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; +import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; -import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { DeviceType } from "@bitwarden/common/enums"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { StateProvider } from "@bitwarden/common/platform/state"; import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { KdfConfigService, KdfType } from "@bitwarden/key-management"; import { PREMIUM_BANNER_REPROMPT_KEY, @@ -36,23 +27,15 @@ describe("VaultBannersService", () => { const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); const getEmailVerified = jest.fn().mockResolvedValue(true); const lastSync$ = new BehaviorSubject(null); - const userDecryptionOptions$ = new BehaviorSubject({ - hasMasterPassword: true, - }); - const kdfConfig$ = new BehaviorSubject({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600000 }); const accounts$ = new BehaviorSubject>({ [userId]: { email: "test@bitwarden.com", emailVerified: true, name: "name" } as AccountInfo, }); - const devices$ = new BehaviorSubject([]); const pendingAuthRequests$ = new BehaviorSubject>([]); - let configService: MockProxy; beforeEach(() => { lastSync$.next(new Date("2024-05-14")); isSelfHost.mockClear(); getEmailVerified.mockClear().mockResolvedValue(true); - configService = mock(); - configService.getFeatureFlag$.mockImplementation(() => of(true)); TestBed.configureTestingModule({ providers: [ @@ -69,40 +52,18 @@ describe("VaultBannersService", () => { provide: StateProvider, useValue: fakeStateProvider, }, - { - provide: PlatformUtilsService, - useValue: { isSelfHost }, - }, { provide: AccountService, useValue: { accounts$ }, }, - { - provide: KdfConfigService, - useValue: { getKdfConfig$: () => kdfConfig$ }, - }, { provide: SyncService, useValue: { lastSync$: () => lastSync$ }, }, - { - provide: UserDecryptionOptionsServiceAbstraction, - useValue: { - userDecryptionOptionsById$: () => userDecryptionOptions$, - }, - }, - { - provide: DevicesServiceAbstraction, - useValue: { getDevices$: () => devices$ }, - }, { provide: AuthRequestServiceAbstraction, useValue: { getPendingAuthRequests$: () => pendingAuthRequests$ }, }, - { - provide: ConfigService, - useValue: configService, - }, ], }); }); @@ -206,45 +167,6 @@ describe("VaultBannersService", () => { }); }); - describe("KDFSettings", () => { - beforeEach(async () => { - userDecryptionOptions$.next({ hasMasterPassword: true }); - kdfConfig$.next({ kdfType: KdfType.PBKDF2_SHA256, iterations: 599999 }); - }); - - it("shows low KDF iteration banner", async () => { - service = TestBed.inject(VaultBannersService); - - expect(await service.shouldShowLowKDFBanner(userId)).toBe(true); - }); - - it("does not show low KDF iteration banner if KDF type is not PBKDF2_SHA256", async () => { - kdfConfig$.next({ kdfType: KdfType.Argon2id, iterations: 600001 }); - - service = TestBed.inject(VaultBannersService); - - expect(await service.shouldShowLowKDFBanner(userId)).toBe(false); - }); - - it("does not show low KDF for iterations about 600,000", async () => { - kdfConfig$.next({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600001 }); - - service = TestBed.inject(VaultBannersService); - - expect(await service.shouldShowLowKDFBanner(userId)).toBe(false); - }); - - it("dismisses low KDF iteration banner", async () => { - service = TestBed.inject(VaultBannersService); - - expect(await service.shouldShowLowKDFBanner(userId)).toBe(true); - - await service.dismissBanner(userId, VisibleVaultBanner.KDFSettings); - - expect(await service.shouldShowLowKDFBanner(userId)).toBe(false); - }); - }); - describe("OutdatedBrowser", () => { beforeEach(async () => { // Hardcode `MSIE` in userAgent string diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts index dd50c832cc6..1c53274d9d7 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts @@ -1,15 +1,9 @@ import { Injectable } from "@angular/core"; import { Observable, combineLatest, firstValueFrom, map, filter, mergeMap, take } from "rxjs"; -import { - AuthRequestServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; +import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateProvider, @@ -21,10 +15,8 @@ import { import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; -import { PBKDF2KdfConfig, KdfConfigService, KdfType } from "@bitwarden/key-management"; export const VisibleVaultBanner = { - KDFSettings: "kdf-settings", OutdatedBrowser: "outdated-browser", Premium: "premium", VerifyEmail: "verify-email", @@ -67,12 +59,8 @@ export class VaultBannersService { private stateProvider: StateProvider, private billingAccountProfileStateService: BillingAccountProfileStateService, private platformUtilsService: PlatformUtilsService, - private kdfConfigService: KdfConfigService, private syncService: SyncService, - private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - private devicesService: DevicesServiceAbstraction, private authRequestService: AuthRequestServiceAbstraction, - private configService: ConfigService, ) {} /** Returns true when the pending auth request banner should be shown */ @@ -80,24 +68,12 @@ export class VaultBannersService { const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes( VisibleVaultBanner.PendingAuthRequest, ); - // TODO: PM-20439 remove feature flag - const browserLoginApprovalFeatureFlag = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval), + + const pendingAuthRequests = await firstValueFrom( + this.authRequestService.getPendingAuthRequests$(), ); - if (browserLoginApprovalFeatureFlag === true) { - const pendingAuthRequests = await firstValueFrom( - this.authRequestService.getPendingAuthRequests$(), - ); - return pendingAuthRequests.length > 0 && !alreadyDismissed; - } else { - const devices = await firstValueFrom(this.devicesService.getDevices$()); - const hasPendingRequest = devices.some( - (device) => device.response?.devicePendingAuthRequest != null, - ); - - return hasPendingRequest && !alreadyDismissed; - } + return pendingAuthRequests.length > 0 && !alreadyDismissed; } shouldShowPremiumBanner$(userId: UserId): Observable { @@ -150,21 +126,6 @@ export class VaultBannersService { return needsVerification && !alreadyDismissed; } - /** Returns true when the low KDF iteration banner should be shown */ - async shouldShowLowKDFBanner(userId: UserId): Promise { - const hasLowKDF = ( - await firstValueFrom(this.userDecryptionOptionsService.userDecryptionOptionsById$(userId)) - )?.hasMasterPassword - ? await this.isLowKdfIteration(userId) - : false; - - const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes( - VisibleVaultBanner.KDFSettings, - ); - - return hasLowKDF && !alreadyDismissed; - } - /** Dismiss the given banner and perform any respective side effects */ async dismissBanner(userId: UserId, banner: SessionBanners): Promise { if (banner === VisibleVaultBanner.Premium) { @@ -238,13 +199,4 @@ export class VaultBannersService { }; }); } - - private async isLowKdfIteration(userId: UserId) { - const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId)); - return ( - kdfConfig != null && - kdfConfig.kdfType === KdfType.PBKDF2_SHA256 && - kdfConfig.iterations < PBKDF2KdfConfig.ITERATIONS.defaultValue - ); - } } diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html index d52ea9f61e6..44b2975ee19 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html @@ -25,18 +25,6 @@ - - {{ "lowKDFIterationsBanner" | i18n }} - - {{ "changeKDFSettings" | i18n }} - - - { shouldShowPremiumBanner$: jest.fn((userId: UserId) => premiumBanner$), shouldShowUpdateBrowserBanner: jest.fn(), shouldShowVerifyEmailBanner: jest.fn(), - shouldShowLowKDFBanner: jest.fn(), shouldShowPendingAuthRequestBanner: jest.fn((userId: UserId) => Promise.resolve(pendingAuthRequest$.value), ), @@ -48,7 +47,6 @@ describe("VaultBannersComponent", () => { messageSubject = new Subject<{ command: string }>(); bannerService.shouldShowUpdateBrowserBanner.mockResolvedValue(false); bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false); - bannerService.shouldShowLowKDFBanner.mockResolvedValue(false); pendingAuthRequest$.next(false); premiumBanner$.next(false); @@ -137,11 +135,6 @@ describe("VaultBannersComponent", () => { method: bannerService.shouldShowVerifyEmailBanner, banner: VisibleVaultBanner.VerifyEmail, }, - { - name: "LowKDF", - method: bannerService.shouldShowLowKDFBanner, - banner: VisibleVaultBanner.KDFSettings, - }, ].forEach(({ name, method, banner }) => { describe(name, () => { beforeEach(async () => { diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts index a16374f19b3..cd32eaf2858 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts @@ -5,8 +5,6 @@ import { filter, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; import { BannerModule } from "@bitwarden/components"; @@ -41,7 +39,6 @@ export class VaultBannersComponent implements OnInit { private router: Router, private accountService: AccountService, private messageListener: MessageListener, - private configService: ConfigService, ) { this.premiumBannerVisible$ = this.activeUserId$.pipe( filter((userId): userId is UserId => userId != null), @@ -75,16 +72,12 @@ export class VaultBannersComponent implements OnInit { } async navigateToPaymentMethod(organizationId: string): Promise { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; const navigationExtras = { state: { launchPaymentModalAutomatically: true }, }; await this.router.navigate( - ["organizations", organizationId, "billing", route], + ["organizations", organizationId, "billing", "payment-details"], navigationExtras, ); } @@ -100,14 +93,12 @@ export class VaultBannersComponent implements OnInit { const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner(activeUserId); const showVerifyEmail = await this.vaultBannerService.shouldShowVerifyEmailBanner(activeUserId); - const showLowKdf = await this.vaultBannerService.shouldShowLowKDFBanner(activeUserId); const showPendingAuthRequest = await this.vaultBannerService.shouldShowPendingAuthRequestBanner(activeUserId); this.visibleBanners = [ showBrowserOutdated ? VisibleVaultBanner.OutdatedBrowser : null, showVerifyEmail ? VisibleVaultBanner.VerifyEmail : null, - showLowKdf ? VisibleVaultBanner.KDFSettings : null, showPendingAuthRequest ? VisibleVaultBanner.PendingAuthRequest : null, ].filter((banner) => banner !== null); } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 6feaa52d190..0a8c0f6f1d0 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -19,12 +19,12 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CipherArchiveService } from "@bitwarden/vault"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { VaultFilterService } from "../services/abstractions/vault-filter.service"; @@ -208,15 +208,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } applyOrganizationFilter = async (orgNode: TreeNode): Promise => { - if (!orgNode?.node.enabled) { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("disabledOrganizationFilterError"), - }); - await firstValueFrom( - this.organizationWarningsService.showInactiveSubscriptionDialog$(orgNode.node), - ); - } const filter = this.activeFilter; if (orgNode?.node.id === "AllVaults") { filter.resetOrganization(); diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 958cf655d17..68219173deb 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -68,19 +68,20 @@ class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start" *ngIf="isEmpty && !performingInitialLoad" > - -
{{ "noItemsInArchive" | i18n }}
-

- {{ "archivedItemsDescription" | i18n }} + +

+ {{ (emptyState$ | async)?.title | i18n }} +
+

+ {{ (emptyState$ | async)?.description | i18n }}

-
{{ "noItemsInList" | i18n }}

{{ "paymentMethod" | i18n }}

- - + + diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index 72ca0bc8391..0fa69c7a0e6 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -1,25 +1,24 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom, Subject, switchMap } from "rxjs"; import { first, takeUntil } from "rxjs/operators"; -import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ProviderKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/payment/payment.component"; +import { + EnterBillingAddressComponent, + EnterPaymentMethodComponent, + getBillingAddressFromForm, +} from "@bitwarden/web-vault/app/billing/payment/components"; @Component({ selector: "provider-setup", @@ -27,16 +26,17 @@ import { PaymentComponent } from "@bitwarden/web-vault/app/billing/shared/paymen standalone: false, }) export class SetupComponent implements OnInit, OnDestroy { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; - @ViewChild(ManageTaxInformationComponent) taxInformationComponent: ManageTaxInformationComponent; + @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; loading = true; - providerId: string; - token: string; + providerId!: string; + token!: string; protected formGroup = this.formBuilder.group({ name: ["", Validators.required], billingEmail: ["", [Validators.required, Validators.email]], + paymentMethod: EnterPaymentMethodComponent.getFormGroup(), + billingAddress: EnterBillingAddressComponent.getFormGroup(), }); private destroy$ = new Subject(); @@ -69,7 +69,7 @@ export class SetupComponent implements OnInit, OnDestroy { if (error) { this.toastService.showToast({ variant: "error", - title: null, + title: "", message: this.i18nService.t("emergencyInviteAcceptFailed"), timeout: 10000, }); @@ -95,6 +95,7 @@ export class SetupComponent implements OnInit, OnDestroy { replaceUrl: true, }); } + this.loading = false; } catch (error) { this.validationService.showError(error); @@ -115,10 +116,7 @@ export class SetupComponent implements OnInit, OnDestroy { try { this.formGroup.markAllAsTouched(); - const paymentValid = this.paymentComponent.validate(); - const taxInformationValid = this.taxInformationComponent.validate(); - - if (!paymentValid || !taxInformationValid || !this.formGroup.valid) { + if (this.formGroup.invalid) { return; } const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); @@ -126,29 +124,24 @@ export class SetupComponent implements OnInit, OnDestroy { const key = providerKey[0].encryptedString; const request = new ProviderSetupRequest(); - request.name = this.formGroup.value.name; - request.billingEmail = this.formGroup.value.billingEmail; + request.name = this.formGroup.value.name!; + request.billingEmail = this.formGroup.value.billingEmail!; request.token = this.token; - request.key = key; + request.key = key!; - request.taxInfo = new ExpandedTaxInfoUpdateRequest(); - const taxInformation = this.taxInformationComponent.getTaxInformation(); + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + if (!paymentMethod) { + return; + } - request.taxInfo.country = taxInformation.country; - request.taxInfo.postalCode = taxInformation.postalCode; - request.taxInfo.taxId = taxInformation.taxId; - request.taxInfo.line1 = taxInformation.line1; - request.taxInfo.line2 = taxInformation.line2; - request.taxInfo.city = taxInformation.city; - request.taxInfo.state = taxInformation.state; - - request.paymentSource = await this.paymentComponent.tokenize(); + request.paymentMethod = paymentMethod; + request.billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); const provider = await this.providerApiService.postProviderSetup(this.providerId, request); this.toastService.showToast({ variant: "success", - title: null, + title: "", message: this.i18nService.t("providerSetup"), }); @@ -156,20 +149,10 @@ export class SetupComponent implements OnInit, OnDestroy { await this.router.navigate(["/providers", provider.id]); } catch (e) { - if ( - this.paymentComponent.selected === PaymentMethodType.PayPal && - typeof e === "string" && - e === "No payment method is available." - ) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("clickPayWithPayPal"), - }); - } else { + if (e !== null && typeof e === "object" && "message" in e && typeof e.message === "string") { e.message = this.i18nService.translate(e.message) || e.message; - this.validationService.showError(e); } + this.validationService.showError(e); } }; } diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html index ef8241b534c..6d2836ee0ba 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html @@ -30,8 +30,8 @@ {{ "ssoIdentifier" | i18n }} - {{ "ssoIdentifierHintPartOne" | i18n }} - {{ "claimedDomains" | i18n }} + {{ "ssoIdentifierHint" | i18n }} + {{ "claimedDomainsLearnMore" | i18n }} @@ -209,7 +209,14 @@ {{ "clientSecret" | i18n }} - + + @@ -488,7 +495,6 @@ formControlName="idpSingleSignOnServiceUrl" appInputStripSpaces /> - {{ "idpSingleSignOnServiceUrlRequired" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts index 9baeaabb33f..f68e35bf240 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts @@ -121,6 +121,8 @@ export class SsoComponent implements OnInit, OnDestroy { spMetadataUrl: string; spAcsUrl: string; + showClientSecret = false; + protected openIdForm = this.formBuilder.group>( { authority: new FormControl("", Validators.required), @@ -156,7 +158,7 @@ export class SsoComponent implements OnInit, OnDestroy { idpEntityId: new FormControl("", Validators.required), idpBindingType: new FormControl(Saml2BindingType.HttpRedirect), - idpSingleSignOnServiceUrl: new FormControl(), + idpSingleSignOnServiceUrl: new FormControl("", Validators.required), idpSingleLogoutServiceUrl: new FormControl(), idpX509PublicCert: new FormControl("", Validators.required), idpOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm), diff --git a/libs/angular/src/billing/components/invoices/invoices.component.html b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.html similarity index 100% rename from libs/angular/src/billing/components/invoices/invoices.component.html rename to bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.html diff --git a/libs/angular/src/billing/components/invoices/invoices.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.ts similarity index 100% rename from libs/angular/src/billing/components/invoices/invoices.component.ts rename to bitwarden_license/bit-web/src/app/billing/providers/billing-history/invoices.component.ts diff --git a/libs/angular/src/billing/components/invoices/no-invoices.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/billing-history/no-invoices.component.ts similarity index 100% rename from libs/angular/src/billing/components/invoices/no-invoices.component.ts rename to bitwarden_license/bit-web/src/app/billing/providers/billing-history/no-invoices.component.ts diff --git a/bitwarden_license/bit-web/src/app/billing/providers/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/index.ts index b1294bc8047..3cd83e68990 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/index.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/index.ts @@ -1,3 +1,5 @@ +export * from "./billing-history/invoices.component"; +export * from "./billing-history/no-invoices.component"; export * from "./billing-history/provider-billing-history.component"; export * from "./clients"; export * from "./guards/has-consolidated-billing.guard"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts index d2ac2cede2f..5a070687de4 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-details/provider-payment-details.component.ts @@ -1,13 +1,10 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute } from "@angular/router"; import { BehaviorSubject, combineLatest, - EMPTY, filter, firstValueFrom, - from, - map, merge, Observable, of, @@ -19,7 +16,6 @@ import { tap, withLatestFrom, } from "rxjs"; -import { catchError } from "rxjs/operators"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; @@ -49,13 +45,6 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { ProviderWarningsService } from "../warnings/services"; -class RedirectError { - constructor( - public path: string[], - public relativeTo: ActivatedRoute, - ) {} -} - type View = { activeUserId: UserId; provider: BitwardenSubscriber; @@ -92,18 +81,6 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { ); private load$: Observable = this.provider$.pipe( - switchMap((provider) => - this.configService - .getFeatureFlag$(FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout) - .pipe( - map((managePaymentDetailsOutsideCheckout) => { - if (!managePaymentDetailsOutsideCheckout) { - throw new RedirectError(["../subscription"], this.activatedRoute); - } - return provider; - }), - ), - ), mapProviderToSubscriber, switchMap(async (provider) => { const getTaxIdWarning = firstValueFrom( @@ -131,14 +108,6 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { }; }), shareReplay({ bufferSize: 1, refCount: false }), - 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( @@ -158,7 +127,6 @@ export class ProviderPaymentDetailsComponent implements OnInit, OnDestroy { private messageListener: MessageListener, private providerService: ProviderService, private providerWarningsService: ProviderWarningsService, - private router: Router, private subscriberBillingClient: SubscriberBillingClient, ) {} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html index 0205d2838d1..05eda7e7ea4 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html @@ -62,51 +62,5 @@
- @if (!managePaymentDetailsOutsideCheckout) { - - -

- {{ "accountCredit" | i18n }} -

-

{{ subscription.accountCredit | currency: "$" }}

-

{{ "creditAppliedDesc" | i18n }}

-
- - -

{{ "paymentMethod" | i18n }}

-

- {{ "noPaymentMethod" | i18n }} -

- - - -

- - {{ subscription.paymentSource.description }} - - {{ "unverified" | i18n }} -

-
- -
- - -

{{ "taxInformation" | i18n }}

-

{{ "taxInformationDesc" | i18n }}

- -
- } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts index 83a23760d80..98aceb0f878 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts @@ -2,26 +2,14 @@ // @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, lastValueFrom, Subject, takeUntil } from "rxjs"; +import { concatMap, Subject, takeUntil } from "rxjs"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { TaxInformation } from "@bitwarden/common/billing/models/domain"; -import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; -import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; import { ProviderPlanResponse, ProviderSubscriptionResponse, } from "@bitwarden/common/billing/models/response/provider-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 { DialogService, ToastService } from "@bitwarden/components"; import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service"; -import { - AdjustPaymentDialogComponent, - AdjustPaymentDialogResultType, -} from "@bitwarden/web-vault/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component"; @Component({ selector: "app-provider-subscription", @@ -36,18 +24,11 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { protected loading: boolean; private destroy$ = new Subject(); protected totalCost: number; - protected managePaymentDetailsOutsideCheckout: boolean; - - protected readonly TaxInformation = TaxInformation; constructor( private billingApiService: BillingApiServiceAbstraction, - private i18nService: I18nService, private route: ActivatedRoute, private billingNotificationService: BillingNotificationService, - private dialogService: DialogService, - private toastService: ToastService, - private configService: ConfigService, ) {} async ngOnInit() { @@ -55,9 +36,6 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { .pipe( concatMap(async (params) => { this.providerId = params.providerId; - this.managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, - ); await this.load(); this.firstLoaded = true; }), @@ -83,40 +61,6 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { } } - protected updatePaymentMethod = async (): Promise => { - const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, { - data: { - initialPaymentMethod: this.subscription.paymentSource?.type, - providerId: this.providerId, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if (result === AdjustPaymentDialogResultType.Submitted) { - await this.load(); - } - }; - - protected updateTaxInformation = async (taxInformation: TaxInformation) => { - try { - const request = ExpandedTaxInfoUpdateRequest.From(taxInformation); - await this.billingApiService.updateProviderTaxInformation(this.providerId, request); - this.billingNotificationService.showSuccess(this.i18nService.t("updatedTaxInformation")); - } catch (error) { - this.billingNotificationService.handleError(error); - } - }; - - protected verifyBankAccount = async (request: VerifyBankAccountRequest): Promise => { - await this.billingApiService.verifyProviderBankAccount(this.providerId, request); - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("verifiedBankAccount"), - }); - }; - protected getFormattedCost( cost: number, seatMinimum: number, @@ -161,7 +105,7 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { } protected getBillingCadenceLabel(providerPlanResponse: ProviderPlanResponse): string { - if (providerPlanResponse == null || providerPlanResponse == undefined) { + if (providerPlanResponse == null) { return "month"; } @@ -174,27 +118,4 @@ export class ProviderSubscriptionComponent implements OnInit, OnDestroy { return "month"; } } - - protected get paymentSourceClasses() { - if (this.subscription.paymentSource == null) { - return []; - } - switch (this.subscription.paymentSource.type) { - case PaymentMethodType.Card: - return ["bwi-credit-card"]; - case PaymentMethodType.BankAccount: - case PaymentMethodType.Check: - return ["bwi-billing"]; - case PaymentMethodType.PayPal: - return ["bwi-paypal text-primary"]; - default: - return []; - } - } - - protected get updatePaymentSourceButtonText(): string { - const key = - this.subscription.paymentSource == null ? "addPaymentMethod" : "changePaymentMethod"; - return this.i18nService.t(key); - } } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/warnings/types/index.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts index 0fe1737bde3..6848220446b 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts @@ -3,14 +3,19 @@ import { NgModule } from "@angular/core"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { CriticalAppsService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { + AllActivitiesService, CriticalAppsApiService, MemberCipherDetailsApiService, + PasswordHealthService, + RiskInsightsApiService, RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights/services"; import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction"; @@ -28,18 +33,32 @@ import { RiskInsightsComponent } from "./risk-insights.component"; deps: [ApiService], }, { - provide: RiskInsightsReportService, - deps: [ - PasswordStrengthServiceAbstraction, - AuditService, - CipherService, - MemberCipherDetailsApiService, - ], + provide: PasswordHealthService, + deps: [PasswordStrengthServiceAbstraction, AuditService], }, { - provide: RiskInsightsDataService, - deps: [RiskInsightsReportService], + provide: RiskInsightsApiService, + deps: [ApiService], }, + { + provide: RiskInsightsReportService, + deps: [ + CipherService, + MemberCipherDetailsApiService, + RiskInsightsApiService, + RiskInsightsEncryptionService, + PasswordHealthService, + ], + }, + safeProvider({ + provide: RiskInsightsDataService, + deps: [ + AccountServiceAbstraction, + CriticalAppsService, + OrganizationService, + RiskInsightsReportService, + ], + }), { provide: RiskInsightsEncryptionService, useClass: RiskInsightsEncryptionService, @@ -55,6 +74,11 @@ import { RiskInsightsComponent } from "./risk-insights.component"; useClass: CriticalAppsApiService, deps: [ApiService], }), + safeProvider({ + provide: AllActivitiesService, + useClass: AllActivitiesService, + deps: [], + }), ], }) export class AccessIntelligenceModule {} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.html index 227ca555786..17ae964dbed 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.html @@ -1,9 +1,18 @@
- {{ title | i18n }} + {{ title }}
- {{ cardMetrics | i18n: value }} + {{ cardMetrics }}
-
- {{ metricDescription | i18n }} +
+ {{ metricDescription }}
+ @if (showNavigationLink) { + + }
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts index 5b9433634e6..7de339358f3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-card.component.ts @@ -1,13 +1,14 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; +import { Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { TypographyModule } from "@bitwarden/components"; +import { ButtonModule, LinkModule, TypographyModule } from "@bitwarden/components"; @Component({ selector: "dirt-activity-card", templateUrl: "./activity-card.component.html", - imports: [CommonModule, TypographyModule, JslibModule], + imports: [CommonModule, TypographyModule, JslibModule, LinkModule, ButtonModule], host: { class: "tw-box-border tw-bg-background tw-block tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-border [&:not(bit-layout_*)]:tw-rounded-lg tw-rounded-lg tw-p-6", @@ -18,10 +19,6 @@ export class ActivityCardComponent { * The title of the card goes here */ @Input() title: string = ""; - /** - * The current value of the card as emphasized text - */ - @Input() value: number | null = null; /** * The card metrics text to display next to the value */ @@ -30,4 +27,25 @@ export class ActivityCardComponent { * The description text to display below the value and metrics */ @Input() metricDescription: string = ""; + + /** + * The link to navigate to for more information + */ + @Input() navigationLink: string = ""; + + /** + * The text to display for the navigation link + */ + @Input() navigationText: string = ""; + + /** + * Show Navigation link + */ + @Input() showNavigationLink: boolean = false; + + constructor(private router: Router) {} + + navigateToLink = async (navigationLink: string) => { + await this.router.navigateByUrl(navigationLink); + }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html index 8459dc565ae..8d564502ee4 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.html @@ -12,11 +12,6 @@ {{ "noAppsInOrgTitle" | i18n: organization?.name }} - - -
} @@ -26,19 +21,33 @@
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts index da78f385cbd..e69dc2b06e5 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-activity.component.ts @@ -1,23 +1,22 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { BehaviorSubject, combineLatest, firstValueFrom, of, switchMap } from "rxjs"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; import { - CriticalAppsService, + AllActivitiesService, RiskInsightsDataService, - RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { OrganizationService } 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 { getById } from "@bitwarden/common/platform/misc"; -import { OrganizationId } from "@bitwarden/common/types/guid"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { ActivityCardComponent } from "./activity-card.component"; import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; +import { RiskInsightsTabType } from "./risk-insights.component"; @Component({ selector: "tools-all-activity", @@ -28,8 +27,9 @@ export class AllActivityComponent implements OnInit { protected isLoading$ = this.dataService.isLoading$; protected noData$ = new BehaviorSubject(true); organization: Organization | null = null; - atRiskMemberCount = 0; - criticalApplicationsCount = 0; + totalCriticalAppsAtRiskMemberCount = 0; + totalCriticalAppsCount = 0; + totalCriticalAppsAtRiskCount = 0; destroyRef = inject(DestroyRef); @@ -43,21 +43,13 @@ export class AllActivityComponent implements OnInit { this.organizationService.organizations$(userId).pipe(getById(organizationId)), )) ?? null; - combineLatest([ - this.dataService.applications$, - this.criticalAppsService.getAppsListForOrg(organizationId as OrganizationId), - ]) - .pipe( - takeUntilDestroyed(this.destroyRef), - switchMap(([apps, criticalApps]) => { - const atRiskMembers = this.reportService.generateAtRiskMemberList(apps ?? []); - return of({ apps, atRiskMembers, criticalApps }); - }), - ) - .subscribe(({ apps, atRiskMembers, criticalApps }) => { - this.noData$.next((apps?.length ?? 0) === 0); - this.atRiskMemberCount = atRiskMembers?.length ?? 0; - this.criticalApplicationsCount = criticalApps?.length ?? 0; + this.allActivitiesService.reportSummary$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((summary) => { + this.noData$.next(summary.totalApplicationCount === 0); + this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount; + this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; + this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount; }); } } @@ -67,7 +59,15 @@ export class AllActivityComponent implements OnInit { private accountService: AccountService, protected organizationService: OrganizationService, protected dataService: RiskInsightsDataService, - protected reportService: RiskInsightsReportService, - protected criticalAppsService: CriticalAppsService, + protected allActivitiesService: AllActivitiesService, ) {} + + get RiskInsightsTabType() { + return RiskInsightsTabType; + } + + getLinkForRiskInsightsTab(tabIndex: RiskInsightsTabType): string { + const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); + return `/organizations/${organizationId}/access-intelligence/risk-insights?tabIndex=${tabIndex}`; + } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html index 79f18803727..febdb5fa0de 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.html @@ -25,70 +25,72 @@

{{ "allApplications" | i18n }}

-
- - + -
-
- - -
+ + + +
+
+ + +
- + + }
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts index 239236ee065..3b7490dbc19 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications.component.ts @@ -6,18 +6,17 @@ import { combineLatest, debounceTime, firstValueFrom, map, Observable, of, switc import { Security } from "@bitwarden/assets/svg"; import { + AllActivitiesService, CriticalAppsService, RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; import { LEGACY_ApplicationHealthReportDetailWithCriticalFlag, LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; -import { - ApplicationHealthReportDetail, - OrganizationReportSummary, -} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; +import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; import { RiskInsightsEncryptionService } from "@bitwarden/bit-common/dirt/reports/risk-insights/services/risk-insights-encryption.service"; import { getOrganizationById, @@ -69,17 +68,7 @@ export class AllApplicationsComponent implements OnInit { protected organization = new Organization(); noItemsIcon = Security; protected markingAsCritical = false; - protected applicationSummary: OrganizationReportSummary = { - totalMemberCount: 0, - totalAtRiskMemberCount: 0, - totalApplicationCount: 0, - totalAtRiskApplicationCount: 0, - totalCriticalMemberCount: 0, - totalCriticalAtRiskMemberCount: 0, - totalCriticalApplicationCount: 0, - totalCriticalAtRiskApplicationCount: 0, - newApplications: [], - }; + protected applicationSummary: OrganizationReportSummary = createNewSummaryData(); destroyRef = inject(DestroyRef); isLoading$: Observable = of(false); @@ -132,6 +121,7 @@ export class AllApplicationsComponent implements OnInit { if (data) { this.dataSource.data = data; this.applicationSummary = this.reportService.generateApplicationsSummary(data); + this.allActivitiesService.setAllAppsReportSummary(this.applicationSummary); } if (organization) { this.organization = organization; @@ -154,6 +144,7 @@ export class AllApplicationsComponent implements OnInit { private accountService: AccountService, protected criticalAppsService: CriticalAppsService, protected riskInsightsEncryptionService: RiskInsightsEncryptionService, + protected allActivitiesService: AllActivitiesService, ) { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) @@ -193,10 +184,6 @@ export class AllApplicationsComponent implements OnInit { } }; - trackByFunction(_: number, item: ApplicationHealthReportDetail) { - return item.applicationName; - } - showAppAtRiskMembers = async (applicationName: string) => { const info = { members: @@ -225,10 +212,4 @@ export class AllApplicationsComponent implements OnInit { this.selectedUrls.delete(applicationName); } }; - - getSelectedUrls = () => Array.from(this.selectedUrls); - - isDrawerOpenForTableRow = (applicationName: string): boolean => { - return this.dataService.drawerInvokerId === applicationName; - }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html index 720c3ec04d0..ea41dd0aff3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html @@ -3,19 +3,19 @@ - {{ "application" | i18n }} - + {{ "application" | i18n }} + {{ "atRiskPasswords" | i18n }} - {{ "totalPasswords" | i18n }} - {{ "atRiskMembers" | i18n }} - {{ "totalMembers" | i18n }} + {{ "totalPasswords" | i18n }} + {{ "atRiskMembers" | i18n }} + {{ "totalMembers" | i18n }} - + {{ row.applicationName }} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts index e9574413825..01f3b8fb494 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts @@ -18,7 +18,7 @@ export class AppTableRowScrollableComponent { @Input() showRowMenuForCriticalApps: boolean = false; @Input() showRowCheckBox: boolean = false; @Input() selectedUrls: Set = new Set(); - @Input() isDrawerIsOpenForThisRecord!: (applicationName: string) => boolean; + @Input() openApplication: string = ""; @Input() showAppAtRiskMembers!: (applicationName: string) => void; @Input() unmarkAsCritical!: (applicationName: string) => void; @Input() checkboxChange!: (applicationName: string, $event: Event) => void; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html index f78880b34a0..ed2a6b96524 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.html @@ -43,58 +43,60 @@ }}
-
- - + -
-
- -
+ + + +
+
+ +
- + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts index c8bc3e81680..f092b1575f0 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications.component.ts @@ -8,6 +8,7 @@ import { combineLatest, debounceTime, firstValueFrom, map, switchMap } from "rxj import { Security } from "@bitwarden/assets/svg"; import { + AllActivitiesService, CriticalAppsService, RiskInsightsDataService, RiskInsightsReportService, @@ -66,40 +67,43 @@ export class CriticalApplicationsComponent implements OnInit { "organizationId", ) as OrganizationId; const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId, userId); - // this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId); - combineLatest([ - this.dataService.applications$, - this.criticalAppsService.getAppsListForOrg(this.organizationId as OrganizationId), - ]) - .pipe( - takeUntilDestroyed(this.destroyRef), - map(([applications, criticalApps]) => { - const criticalUrls = criticalApps.map((ca) => ca.uri); - const data = applications?.map((app) => ({ - ...app, - isMarkedAsCritical: criticalUrls.includes(app.applicationName), - })) as LEGACY_ApplicationHealthReportDetailWithCriticalFlag[]; - return data?.filter((app) => app.isMarkedAsCritical); - }), - switchMap(async (data) => { - if (data) { - const dataWithCiphers = await this.reportService.identifyCiphers( - data, - this.organizationId, - ); - return dataWithCiphers; + this.criticalAppsService.loadOrganizationContext(this.organizationId as OrganizationId, userId); + + if (this.organizationId) { + combineLatest([ + this.dataService.applications$, + this.criticalAppsService.getAppsListForOrg(this.organizationId as OrganizationId), + ]) + .pipe( + takeUntilDestroyed(this.destroyRef), + map(([applications, criticalApps]) => { + const criticalUrls = criticalApps.map((ca) => ca.uri); + const data = applications?.map((app) => ({ + ...app, + isMarkedAsCritical: criticalUrls.includes(app.applicationName), + })) as LEGACY_ApplicationHealthReportDetailWithCriticalFlag[]; + return data?.filter((app) => app.isMarkedAsCritical); + }), + switchMap(async (data) => { + if (data) { + const dataWithCiphers = await this.reportService.identifyCiphers( + data, + this.organizationId, + ); + return dataWithCiphers; + } + return null; + }), + ) + .subscribe((applications) => { + if (applications) { + this.dataSource.data = applications; + this.applicationSummary = this.reportService.generateApplicationsSummary(applications); + this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0; + this.allActivitiesService.setCriticalAppsReportSummary(this.applicationSummary); } - return null; - }), - ) - .subscribe((applications) => { - if (applications) { - this.dataSource.data = applications; - this.applicationSummary = this.reportService.generateApplicationsSummary(applications); - this.enableRequestPasswordChange = this.applicationSummary.totalAtRiskMemberCount > 0; - } - }); + }); + } } goToAllAppsTab = async () => { @@ -174,6 +178,7 @@ export class CriticalApplicationsComponent implements OnInit { private configService: ConfigService, private adminTaskService: DefaultAdminTaskService, private accountService: AccountService, + private allActivitiesService: AllActivitiesService, ) { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) @@ -199,11 +204,4 @@ export class CriticalApplicationsComponent implements OnInit { const data = this.reportService.generateAtRiskApplicationList(this.dataSource.data); this.dataService.setDrawerForOrgAtRiskApps(data, invokerId); }; - - trackByFunction(_: number, item: LEGACY_ApplicationHealthReportDetailWithCriticalFlag) { - return item.applicationName; - } - isDrawerOpenForTableRow = (applicationName: string) => { - return this.dataService.drawerInvokerId === applicationName; - }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 3c2d5cd0a15..50af2c9e9a7 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -53,93 +53,91 @@ - - - - - - {{ - (dataService.atRiskMemberDetails.length > 0 - ? "atRiskMembersDescription" - : "atRiskMembersDescriptionNone" - ) | i18n - }} - -
-
{{ "email" | i18n }}
-
- {{ "atRiskPasswords" | i18n }} + @if (dataService.drawerDetails$ | async; as drawerDetails) { + + + + + + {{ + (drawerDetails.atRiskMemberDetails.length > 0 + ? "atRiskMembersDescription" + : "atRiskMembersDescriptionNone" + ) | i18n + }} + +
+
{{ "email" | i18n }}
+
+ {{ "atRiskPasswords" | i18n }} +
+ +
+
{{ member.email }}
+
{{ member.atRiskPasswordCount }}
+
+
+
+
+
+ + @if (dataService.isActiveDrawerType(drawerTypes.AppAtRiskMembers)) { + + + +
+ {{ "atRiskMembersWithCount" | i18n: drawerDetails.appAtRiskMembers.members.length }}
- -
+
+ {{ + (drawerDetails.appAtRiskMembers.members.length > 0 + ? "atRiskMembersDescriptionWithApp" + : "atRiskMembersDescriptionWithAppNone" + ) | i18n: drawerDetails.appAtRiskMembers.applicationName + }} +
+
+
{{ member.email }}
-
{{ member.atRiskPasswordCount }}
-
- - - - - - - - - -
- {{ "atRiskMembersWithCount" | i18n: dataService.appAtRiskMembers.members.length }} -
-
- {{ - (dataService.appAtRiskMembers.members.length > 0 - ? "atRiskMembersDescriptionWithApp" - : "atRiskMembersDescriptionWithAppNone" - ) | i18n: dataService.appAtRiskMembers.applicationName - }} -
-
- -
{{ member.email }}
-
-
-
-
- - - - - - - {{ - (dataService.atRiskAppDetails.length > 0 - ? "atRiskApplicationsDescription" - : "atRiskApplicationsDescriptionNone" - ) | i18n - }} - -
-
- {{ "application" | i18n }} -
-
- {{ "atRiskPasswords" | i18n }} -
+
- -
-
{{ app.applicationName }}
-
{{ app.atRiskPasswordCount }}
+ + } + + @if (dataService.isActiveDrawerType(drawerTypes.OrgAtRiskApps)) { + + + + + {{ + (drawerDetails.atRiskAppDetails.length > 0 + ? "atRiskApplicationsDescription" + : "atRiskApplicationsDescriptionNone" + ) | i18n + }} + +
+
+ {{ "application" | i18n }} +
+
+ {{ "atRiskPasswords" | i18n }} +
+ +
+
{{ app.applicationName }}
+
{{ app.atRiskPasswordCount }}
+
+
- -
- - + + } + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index b7e440880e3..208ba59fb9d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -61,6 +61,9 @@ export enum RiskInsightsTabType { ], }) export class RiskInsightsComponent implements OnInit { + private destroyRef = inject(DestroyRef); + private _isDrawerOpen: boolean = false; + tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps; isRiskInsightsActivityTabFeatureEnabled: boolean = false; @@ -73,7 +76,6 @@ export class RiskInsightsComponent implements OnInit { notifiedMembersCount: number = 0; private organizationId: OrganizationId = "" as OrganizationId; - private destroyRef = inject(DestroyRef); isLoading$: Observable = new Observable(); isRefreshing$: Observable = new Observable(); @@ -127,13 +129,26 @@ export class RiskInsightsComponent implements OnInit { this.appsCount = applications.length; } - this.criticalAppsService.setOrganizationId(this.organizationId as OrganizationId, userId); + this.criticalAppsService.loadOrganizationContext( + this.organizationId as OrganizationId, + userId, + ); this.criticalApps$ = this.criticalAppsService.getAppsListForOrg( this.organizationId as OrganizationId, ); }, }); + + // Subscribe to drawer state changes + this.dataService.drawerDetails$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((details) => { + this._isDrawerOpen = details.open; + }); } + runReport = () => { + this.dataService.triggerReport(); + }; /** * Refreshes the data by re-fetching the applications report. @@ -160,4 +175,27 @@ export class RiskInsightsComponent implements OnInit { get drawerTypes(): typeof DrawerType { return DrawerType; } + + /** + * Special case getter for syncing drawer state from service to component. + * This allows the template to use two-way binding while staying reactive. + */ + get isDrawerOpen() { + return this._isDrawerOpen; + } + + /** + * Special case setter for syncing drawer state from component to service. + * When the drawer component closes the drawer, this syncs the state back to the service. + */ + set isDrawerOpen(value: boolean) { + if (this._isDrawerOpen !== value) { + this._isDrawerOpen = value; + + // Close the drawer in the service if the drawer component closed the drawer + if (!value) { + this.dataService.closeDrawer(); + } + } + } } diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts index 0facf282ba3..74c39613502 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts @@ -6,6 +6,7 @@ import { BehaviorSubject, of } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; @@ -314,7 +315,7 @@ describe("IntegrationCardComponent", () => { jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(false); - mockIntegrationService.saveHec.mockResolvedValue(undefined); + mockIntegrationService.saveHec.mockResolvedValue({ mustBeOwner: false, success: true }); await component.setupConnection(); @@ -340,7 +341,7 @@ describe("IntegrationCardComponent", () => { }), }); - mockIntegrationService.deleteHec.mockResolvedValue(undefined); + mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true }); await component.setupConnection(); @@ -368,7 +369,7 @@ describe("IntegrationCardComponent", () => { }), }); - mockIntegrationService.deleteHec.mockResolvedValue(undefined); + mockIntegrationService.deleteHec.mockResolvedValue({ mustBeOwner: false, success: true }); await component.setupConnection(); @@ -407,6 +408,52 @@ describe("IntegrationCardComponent", () => { }); }); + it("should show mustBeOwner toast on error while inserting data", async () => { + (openHecConnectDialog as jest.Mock).mockReturnValue({ + closed: of({ + success: HecConnectDialogResultStatus.Edited, + url: "test-url", + bearerToken: "token", + index: "index", + }), + }); + + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); + mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404)); + + await component.setupConnection(); + + expect(mockIntegrationService.updateHec).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: mockI18nService.t("mustBeOrgOwnerToPerformAction"), + }); + }); + + it("should show mustBeOwner toast on error while updating data", async () => { + (openHecConnectDialog as jest.Mock).mockReturnValue({ + closed: of({ + success: HecConnectDialogResultStatus.Edited, + url: "test-url", + bearerToken: "token", + index: "index", + }), + }); + + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); + mockIntegrationService.updateHec.mockRejectedValue(new ErrorResponse("Not Found", 404)); + + await component.setupConnection(); + + expect(mockIntegrationService.updateHec).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: mockI18nService.t("mustBeOrgOwnerToPerformAction"), + }); + }); + it("should show toast on error while deleting", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ @@ -429,5 +476,28 @@ describe("IntegrationCardComponent", () => { message: mockI18nService.t("failedToDeleteIntegration"), }); }); + + it("should show mustbeOwner toast on 404 while deleting", async () => { + (openHecConnectDialog as jest.Mock).mockReturnValue({ + closed: of({ + success: HecConnectDialogResultStatus.Delete, + url: "test-url", + bearerToken: "token", + index: "index", + }), + }); + + jest.spyOn(component, "isUpdateAvailable", "get").mockReturnValue(true); + mockIntegrationService.deleteHec.mockRejectedValue(new ErrorResponse("Not Found", 404)); + + await component.setupConnection(); + + expect(mockIntegrationService.deleteHec).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: mockI18nService.t("mustBeOrgOwnerToPerformAction"), + }); + }); }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts index 99e8d950d81..091de63d7a1 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts @@ -171,6 +171,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async saveHec(result: HecConnectDialogResult) { + let saveResponse = { mustBeOwner: false, success: false }; if (this.isUpdateAvailable) { // retrieve org integration and configuration ids const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; @@ -182,7 +183,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } // update existing integration and configuration - await this.hecOrganizationIntegrationService.updateHec( + saveResponse = await this.hecOrganizationIntegrationService.updateHec( this.organizationId, orgIntegrationId, orgIntegrationConfigurationId, @@ -193,7 +194,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { ); } else { // create new integration and configuration - await this.hecOrganizationIntegrationService.saveHec( + saveResponse = await this.hecOrganizationIntegrationService.saveHec( this.organizationId, this.integrationSettings.name as OrganizationIntegrationServiceType, result.url, @@ -201,6 +202,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { result.index, ); } + + if (saveResponse.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + this.toastService.showToast({ variant: "success", title: "", @@ -217,16 +224,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { throw Error("Organization Integration ID or Configuration ID is missing"); } - await this.hecOrganizationIntegrationService.deleteHec( + const response = await this.hecOrganizationIntegrationService.deleteHec( this.organizationId, orgIntegrationId, orgIntegrationConfigurationId, ); + if (response.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + this.toastService.showToast({ variant: "success", title: "", message: this.i18nService.t("success"), }); } + + private showMustBeOwnerToast() { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("mustBeOrgOwnerToPerformAction"), + }); + } } diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index e3af5e273ea..c249bf42282 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -5,16 +5,14 @@ import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; import { OrganizationIntegrationServiceType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } 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 { IntegrationType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { getById } from "@bitwarden/common/platform/misc"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @@ -218,7 +216,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.organization$ = this.route.params.pipe( switchMap((params) => this.organizationService.organizations$(userId).pipe( - getOrganizationById(params.organizationId), + getById(params.organizationId), // Filter out undefined values takeWhile((org: Organization | undefined) => !!org), ), @@ -229,6 +227,24 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.organization$.pipe(takeUntil(this.destroy$)).subscribe((org) => { this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id); }); + + // For all existing event based configurations loop through and assign the + // organizationIntegration for the correct services. + this.hecOrganizationIntegrationService.integrations$ + .pipe(takeUntil(this.destroy$)) + .subscribe((integrations) => { + // reset all integrations to null first - in case one was deleted + this.integrationsList.forEach((i) => { + i.organizationIntegration = null; + }); + + integrations.map((integration) => { + const item = this.integrationsList.find((i) => i.name === integration.serviceType); + if (item) { + item.organizationIntegration = integration; + } + }); + }); } constructor( @@ -258,24 +274,6 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.integrationsList.push(crowdstrikeIntegration); } - - // For all existing event based configurations loop through and assign the - // organizationIntegration for the correct services. - this.hecOrganizationIntegrationService.integrations$ - .pipe(takeUntil(this.destroy$)) - .subscribe((integrations) => { - // reset all integrations to null first - in case one was deleted - this.integrationsList.forEach((i) => { - i.organizationIntegration = null; - }); - - integrations.map((integration) => { - const item = this.integrationsList.find((i) => i.name === integration.serviceType); - if (item) { - item.organizationIntegration = integration; - } - }); - }); } ngOnDestroy(): void { this.destroy$.next(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index d9ff3ec5619..e301c0462c3 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -23,8 +23,6 @@ import { 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -117,7 +115,6 @@ export class OverviewComponent implements OnInit, OnDestroy { private smOnboardingTasksService: SMOnboardingTasksService, private logService: LogService, private router: Router, - private configService: ConfigService, ) {} ngOnInit() { @@ -218,13 +215,12 @@ export class OverviewComponent implements OnInit, OnDestroy { } async navigateToPaymentMethod() { - const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag( - FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout, + await this.router.navigate( + ["organizations", `${this.organizationId}`, "billing", "payment-details"], + { + state: { launchPaymentModalAutomatically: true }, + }, ); - const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method"; - await this.router.navigate(["organizations", `${this.organizationId}`, "billing", route], { - state: { launchPaymentModalAutomatically: true }, - }); } ngOnDestroy(): void { diff --git a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html b/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html deleted file mode 100644 index c9c0c296ada..00000000000 --- a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html +++ /dev/null @@ -1,55 +0,0 @@ -
- - -

{{ "creditDelayed" | i18n }}

-
- - - {{ "payPal" | i18n }} - - - {{ "bitcoin" | i18n }} - - -
-
- - {{ "amount" | i18n }} - - $USD - -
-
- - - - -
-
-
- - - - - - - - - - - - - - - -
diff --git a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.ts b/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.ts deleted file mode 100644 index 3dc56c55b0c..00000000000 --- a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.ts +++ /dev/null @@ -1,167 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { firstValueFrom, map } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; -import { AccountService, AccountInfo } from "@bitwarden/common/auth/abstractions/account.service"; -import { PaymentMethodType } from "@bitwarden/common/billing/enums"; -import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { UserId } from "@bitwarden/common/types/guid"; -import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; - -export type AddAccountCreditDialogParams = { - organizationId?: string; - providerId?: string; -}; - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum AddAccountCreditDialogResultType { - Closed = "closed", - Submitted = "submitted", -} - -export const openAddAccountCreditDialog = ( - dialogService: DialogService, - dialogConfig: DialogConfig, -) => - dialogService.open( - AddAccountCreditDialogComponent, - dialogConfig, - ); - -type PayPalConfig = { - businessId?: string; - buttonAction?: string; - returnUrl?: string; - customField?: string; - subject?: string; -}; - -@Component({ - templateUrl: "./add-account-credit-dialog.component.html", - standalone: false, -}) -export class AddAccountCreditDialogComponent implements OnInit { - @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm: ElementRef; - protected formGroup = new FormGroup({ - paymentMethod: new FormControl(PaymentMethodType.PayPal), - creditAmount: new FormControl(null, [Validators.required, Validators.min(0.01)]), - }); - protected payPalConfig: PayPalConfig; - protected ResultType = AddAccountCreditDialogResultType; - - private organization?: Organization; - private provider?: Provider; - private user?: { id: UserId } & AccountInfo; - - constructor( - private accountService: AccountService, - private apiService: ApiService, - private configService: ConfigService, - @Inject(DIALOG_DATA) private dialogParams: AddAccountCreditDialogParams, - private dialogRef: DialogRef, - private organizationService: OrganizationService, - private platformUtilsService: PlatformUtilsService, - private providerService: ProviderService, - ) { - this.payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; - } - - protected readonly paymentMethodType = PaymentMethodType; - - submit = async () => { - this.formGroup.markAllAsTouched(); - - if (this.formGroup.invalid) { - return; - } - - if (this.formGroup.value.paymentMethod === PaymentMethodType.PayPal) { - this.payPalForm.nativeElement.submit(); - return; - } - - if (this.formGroup.value.paymentMethod === PaymentMethodType.BitPay) { - const request = this.getBitPayInvoiceRequest(); - const bitPayUrl = await this.apiService.postBitPayInvoice(request); - this.platformUtilsService.launchUri(bitPayUrl); - return; - } - - this.dialogRef.close(AddAccountCreditDialogResultType.Submitted); - }; - - async ngOnInit(): Promise { - let payPalCustomField: string; - - if (this.dialogParams.organizationId) { - this.formGroup.patchValue({ - creditAmount: 20.0, - }); - this.user = await firstValueFrom(this.accountService.activeAccount$); - this.organization = await firstValueFrom( - this.organizationService - .organizations$(this.user.id) - .pipe( - map((organizations) => - organizations.find((org) => org.id === this.dialogParams.organizationId), - ), - ), - ); - payPalCustomField = "organization_id:" + this.organization.id; - this.payPalConfig.subject = this.organization.name; - } else if (this.dialogParams.providerId) { - this.formGroup.patchValue({ - creditAmount: 20.0, - }); - this.provider = await firstValueFrom( - this.providerService.get$(this.dialogParams.providerId, this.user.id), - ); - payPalCustomField = "provider_id:" + this.provider.id; - this.payPalConfig.subject = this.provider.name; - } else { - this.formGroup.patchValue({ - creditAmount: 10.0, - }); - payPalCustomField = "user_id:" + this.user.id; - this.payPalConfig.subject = this.user.email; - } - - const region = await firstValueFrom(this.configService.cloudRegion$); - - payPalCustomField += ",account_credit:1"; - payPalCustomField += `,region:${region}`; - - this.payPalConfig.customField = payPalCustomField; - this.payPalConfig.returnUrl = window.location.href; - } - - getBitPayInvoiceRequest(): BitPayInvoiceRequest { - const request = new BitPayInvoiceRequest(); - if (this.organization) { - request.name = this.organization.name; - request.organizationId = this.organization.id; - } else if (this.provider) { - request.name = this.provider.name; - request.providerId = this.provider.id; - } else { - request.email = this.user.email; - request.userId = this.user.id; - } - - request.credit = true; - request.amount = this.formGroup.value.creditAmount; - request.returnUrl = window.location.href; - - return request; - } -} diff --git a/libs/angular/src/billing/components/index.ts b/libs/angular/src/billing/components/index.ts index dacb5b265bd..34e1d27c1ed 100644 --- a/libs/angular/src/billing/components/index.ts +++ b/libs/angular/src/billing/components/index.ts @@ -1,5 +1 @@ -export * from "./add-account-credit-dialog/add-account-credit-dialog.component"; -export * from "./invoices/invoices.component"; -export * from "./invoices/no-invoices.component"; -export * from "./manage-tax-information/manage-tax-information.component"; export * from "./premium.component"; diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html deleted file mode 100644 index 391765251b0..00000000000 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html +++ /dev/null @@ -1,90 +0,0 @@ -
-
-
- - {{ "country" | i18n }} - - - - -
-
- - {{ "zipPostalCode" | i18n }} - - -
- -
- - {{ "address1" | i18n }} - - -
-
- - {{ "address2" | i18n }} - - -
-
- - {{ "cityTown" | i18n }} - - -
-
- - {{ "stateProvince" | i18n }} - - -
-
- - {{ "taxIdNumber" | i18n }} - - -
-
-
- -
-
-
diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.spec.ts b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.spec.ts deleted file mode 100644 index c662e20b275..00000000000 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.spec.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { SimpleChange } from "@angular/core"; -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ReactiveFormsModule } from "@angular/forms"; -import { mock, MockProxy } from "jest-mock-extended"; - -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SelectModule, FormFieldModule, BitSubmitDirective } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; - -import { ManageTaxInformationComponent } from "./manage-tax-information.component"; - -describe("ManageTaxInformationComponent", () => { - let sut: ManageTaxInformationComponent; - let fixture: ComponentFixture; - let mockTaxService: MockProxy; - - beforeEach(async () => { - mockTaxService = mock(); - await TestBed.configureTestingModule({ - declarations: [ManageTaxInformationComponent], - providers: [ - { provide: TaxServiceAbstraction, useValue: mockTaxService }, - { provide: I18nService, useValue: { t: (key: string) => key } }, - ], - imports: [ - CommonModule, - ReactiveFormsModule, - SelectModule, - FormFieldModule, - BitSubmitDirective, - I18nPipe, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(ManageTaxInformationComponent); - sut = fixture.componentInstance; - fixture.autoDetectChanges(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it("creates successfully", () => { - expect(sut).toBeTruthy(); - }); - - it("should initialize with all values empty in startWith", async () => { - // Arrange - sut.startWith = { - country: "", - postalCode: "", - taxId: "", - line1: "", - line2: "", - city: "", - state: "", - }; - - // Act - fixture.detectChanges(); - - // Assert - const startWithValue = sut.startWith; - expect(startWithValue.line1).toHaveLength(0); - expect(startWithValue.line2).toHaveLength(0); - expect(startWithValue.city).toHaveLength(0); - expect(startWithValue.state).toHaveLength(0); - expect(startWithValue.postalCode).toHaveLength(0); - expect(startWithValue.country).toHaveLength(0); - expect(startWithValue.taxId).toHaveLength(0); - }); - - it("should update the tax information protected state when form is updated", async () => { - // Arrange - const line1Value = "123 Street"; - const line2Value = "Apt. 5"; - const cityValue = "New York"; - const stateValue = "NY"; - const countryValue = "USA"; - const postalCodeValue = "123 Street"; - - sut.startWith = { - country: countryValue, - postalCode: "", - taxId: "", - line1: "", - line2: "", - city: "", - state: "", - }; - sut.showTaxIdField = false; - mockTaxService.isCountrySupported.mockResolvedValue(true); - - // Act - await sut.ngOnInit(); - fixture.detectChanges(); - - const line1: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='line1']", - ); - const line2: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='line2']", - ); - const city: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='city']", - ); - const state: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='state']", - ); - const postalCode: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='postalCode']", - ); - - line1.value = line1Value; - line2.value = line2Value; - city.value = cityValue; - state.value = stateValue; - postalCode.value = postalCodeValue; - - line1.dispatchEvent(new Event("input")); - line2.dispatchEvent(new Event("input")); - city.dispatchEvent(new Event("input")); - state.dispatchEvent(new Event("input")); - postalCode.dispatchEvent(new Event("input")); - await fixture.whenStable(); - - // Assert - - // Assert that the internal tax information reflects the form - const taxInformation = sut.getTaxInformation(); - expect(taxInformation.line1).toBe(line1Value); - expect(taxInformation.line2).toBe(line2Value); - expect(taxInformation.city).toBe(cityValue); - expect(taxInformation.state).toBe(stateValue); - expect(taxInformation.postalCode).toBe(postalCodeValue); - expect(taxInformation.country).toBe(countryValue); - expect(taxInformation.taxId).toHaveLength(0); - - expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue); - expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(2); - }); - - it("should not show address fields except postal code if country is not supported for taxes", async () => { - // Arrange - const countryValue = "UNKNOWN"; - sut.startWith = { - country: countryValue, - postalCode: "", - taxId: "", - line1: "", - line2: "", - city: "", - state: "", - }; - sut.showTaxIdField = false; - mockTaxService.isCountrySupported.mockResolvedValue(false); - - // Act - await sut.ngOnInit(); - fixture.detectChanges(); - - const line1: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='line1']", - ); - const line2: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='line2']", - ); - const city: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='city']", - ); - const state: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='state']", - ); - const postalCode: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='postalCode']", - ); - - // Assert - expect(line1).toBeNull(); - expect(line2).toBeNull(); - expect(city).toBeNull(); - expect(state).toBeNull(); - //Should be visible - expect(postalCode).toBeTruthy(); - - expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue); - expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1); - }); - - it("should not show the tax id field if showTaxIdField is set to false", async () => { - // Arrange - const countryValue = "USA"; - sut.startWith = { - country: countryValue, - postalCode: "", - taxId: "", - line1: "", - line2: "", - city: "", - state: "", - }; - - sut.showTaxIdField = false; - mockTaxService.isCountrySupported.mockResolvedValue(true); - - // Act - await sut.ngOnInit(); - fixture.detectChanges(); - - // Assert - const taxId: HTMLInputElement = fixture.nativeElement.querySelector( - "input[formControlName='taxId']", - ); - expect(taxId).toBeNull(); - - expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue); - expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1); - }); - - it("should clear the tax id field if showTaxIdField is set to false after being true", async () => { - // Arrange - const countryValue = "USA"; - const taxIdValue = "A12345678"; - - sut.startWith = { - country: countryValue, - postalCode: "", - taxId: taxIdValue, - line1: "", - line2: "", - city: "", - state: "", - }; - sut.showTaxIdField = true; - - mockTaxService.isCountrySupported.mockResolvedValue(true); - await sut.ngOnInit(); - fixture.detectChanges(); - const initialTaxIdValue = fixture.nativeElement.querySelector( - "input[formControlName='taxId']", - ).value; - - // Act - sut.showTaxIdField = false; - sut.ngOnChanges({ showTaxIdField: new SimpleChange(true, false, false) }); - fixture.detectChanges(); - - // Assert - const taxId = fixture.nativeElement.querySelector("input[formControlName='taxId']"); - expect(taxId).toBeNull(); - - const taxInformation = sut.getTaxInformation(); - expect(taxInformation.taxId).toBeNull(); - expect(initialTaxIdValue).toEqual(taxIdValue); - - expect(mockTaxService.isCountrySupported).toHaveBeenCalledWith(countryValue); - expect(mockTaxService.isCountrySupported).toHaveBeenCalledTimes(1); - }); -}); 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 deleted file mode 100644 index 0b87f3f931d..00000000000 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts +++ /dev/null @@ -1,166 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { - Component, - EventEmitter, - Input, - OnChanges, - OnDestroy, - OnInit, - Output, - SimpleChanges, -} from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { Subject, takeUntil } from "rxjs"; -import { debounceTime } from "rxjs/operators"; - -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; -import { CountryListItem, TaxInformation } from "@bitwarden/common/billing/models/domain"; - -@Component({ - selector: "app-manage-tax-information", - templateUrl: "./manage-tax-information.component.html", - standalone: false, -}) -export class ManageTaxInformationComponent implements OnInit, OnDestroy, OnChanges { - @Input() startWith: TaxInformation; - @Input() onSubmit?: (taxInformation: TaxInformation) => Promise; - @Input() showTaxIdField: boolean = true; - - /** - * Emits when the tax information has changed. - */ - @Output() taxInformationChanged = new EventEmitter(); - - /** - * Emits when the tax information has been updated. - */ - @Output() taxInformationUpdated = new EventEmitter(); - - private taxInformation: TaxInformation; - - protected formGroup = this.formBuilder.group({ - country: ["", Validators.required], - postalCode: ["", Validators.required], - taxId: "", - line1: "", - line2: "", - city: "", - state: "", - }); - - protected isTaxSupported: boolean; - - private destroy$ = new Subject(); - - protected readonly countries: CountryListItem[] = this.taxService.getCountries(); - - constructor( - private formBuilder: FormBuilder, - private taxService: TaxServiceAbstraction, - ) {} - - getTaxInformation(): TaxInformation { - return this.taxInformation; - } - - submit = async () => { - this.markAllAsTouched(); - if (this.formGroup.invalid) { - return; - } - await this.onSubmit?.(this.taxInformation); - this.taxInformationUpdated.emit(); - }; - - validate(): boolean { - this.markAllAsTouched(); - return this.formGroup.valid; - } - - markAllAsTouched() { - this.formGroup.markAllAsTouched(); - } - - async ngOnInit() { - this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => { - this.taxInformation = { - country: values.country, - postalCode: values.postalCode, - taxId: values.taxId, - line1: values.line1, - line2: values.line2, - city: values.city, - state: values.state, - }; - }); - - if (this.startWith) { - this.formGroup.controls.country.setValue(this.startWith.country); - this.formGroup.controls.postalCode.setValue(this.startWith.postalCode); - - this.isTaxSupported = - this.startWith && this.startWith.country - ? await this.taxService.isCountrySupported(this.startWith.country) - : false; - - if (this.isTaxSupported) { - this.formGroup.controls.taxId.setValue(this.startWith.taxId); - this.formGroup.controls.line1.setValue(this.startWith.line1); - this.formGroup.controls.line2.setValue(this.startWith.line2); - this.formGroup.controls.city.setValue(this.startWith.city); - this.formGroup.controls.state.setValue(this.startWith.state); - } - } - - this.formGroup.controls.country.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe((country: string) => { - this.taxService - .isCountrySupported(country) - .then((isSupported) => (this.isTaxSupported = isSupported)) - .catch(() => (this.isTaxSupported = false)) - .finally(() => { - if (!this.isTaxSupported) { - this.formGroup.controls.taxId.setValue(null); - this.formGroup.controls.line1.setValue(null); - this.formGroup.controls.line2.setValue(null); - this.formGroup.controls.city.setValue(null); - this.formGroup.controls.state.setValue(null); - } - if (this.taxInformationChanged) { - this.taxInformationChanged.emit(this.taxInformation); - } - }); - }); - - this.formGroup.controls.postalCode.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - if (this.taxInformationChanged) { - this.taxInformationChanged.emit(this.taxInformation); - } - }); - - this.formGroup.controls.taxId.valueChanges - .pipe(debounceTime(1000), takeUntil(this.destroy$)) - .subscribe(() => { - if (this.taxInformationChanged) { - this.taxInformationChanged.emit(this.taxInformation); - } - }); - } - - ngOnChanges(changes: SimpleChanges): void { - // Clear the value of the tax-id if states have been changed in the parent component - const showTaxIdField = changes["showTaxIdField"]; - if (showTaxIdField && !showTaxIdField.currentValue) { - this.formGroup.controls.taxId.setValue(null); - } - } - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } -} diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index c0bf1425d47..446530a1111 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -2,12 +2,6 @@ import { CommonModule, DatePipe } from "@angular/common"; import { NgModule } from "@angular/core"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; -import { - AddAccountCreditDialogComponent, - InvoicesComponent, - NoInvoicesComponent, - ManageTaxInformationComponent, -} from "@bitwarden/angular/billing/components"; import { AsyncActionsModule, AutofocusDirective, @@ -112,10 +106,6 @@ import { IconComponent } from "./vault/components/icon.component"; UserTypePipe, IfFeatureDirective, FingerprintPipe, - AddAccountCreditDialogComponent, - InvoicesComponent, - NoInvoicesComponent, - ManageTaxInformationComponent, TwoFactorIconComponent, ], exports: [ @@ -146,10 +136,6 @@ import { IconComponent } from "./vault/components/icon.component"; UserTypePipe, IfFeatureDirective, FingerprintPipe, - AddAccountCreditDialogComponent, - InvoicesComponent, - NoInvoicesComponent, - ManageTaxInformationComponent, TwoFactorIconComponent, TextDragDirective, ], diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index ff704394bc3..8c727a98d11 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -144,14 +144,12 @@ import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/a import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; -import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.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 { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service"; import { DefaultKeyGenerationService, @@ -264,6 +262,7 @@ import { InternalSendService, SendService as SendServiceAbstraction, } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; @@ -284,6 +283,7 @@ import { DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; +import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; @@ -296,7 +296,6 @@ import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks"; import { AnonLayoutWrapperDataService, DefaultAnonLayoutWrapperDataService, - DialogService, ToastService, } from "@bitwarden/components"; import { @@ -345,11 +344,7 @@ import { import { SafeInjectionToken } from "@bitwarden/ui-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 { - CipherArchiveService, - DefaultCipherArchiveService, - PasswordRepromptService, -} from "@bitwarden/vault"; +import { PasswordRepromptService } from "@bitwarden/vault"; import { IndividualVaultExportService, IndividualVaultExportServiceAbstraction, @@ -1401,11 +1396,6 @@ const safeProviders: SafeProvider[] = [ useClass: BillingApiService, deps: [ApiServiceAbstraction], }), - safeProvider({ - provide: TaxServiceAbstraction, - useClass: TaxService, - deps: [ApiServiceAbstraction], - }), safeProvider({ provide: BillingAccountProfileStateService, useClass: DefaultBillingAccountProfileStateService, @@ -1652,8 +1642,6 @@ const safeProviders: SafeProvider[] = [ deps: [ CipherServiceAbstraction, ApiServiceAbstraction, - DialogService, - PasswordRepromptService, BillingAccountProfileStateService, ConfigService, ], diff --git a/libs/assets/src/svg/svgs/admin-console.ts b/libs/assets/src/svg/svgs/admin-console.ts index 596822dde57..83c8cf9f0e1 100644 --- a/libs/assets/src/svg/svgs/admin-console.ts +++ b/libs/assets/src/svg/svgs/admin-console.ts @@ -1,7 +1,17 @@ import { svgIcon } from "../icon-service"; const AdminConsoleLogo = svgIcon` - + + + + + + + + + + + `; export default AdminConsoleLogo; diff --git a/libs/assets/src/svg/svgs/bitwarden-icon.ts b/libs/assets/src/svg/svgs/bitwarden-icon.ts index 710d20937bd..2f88b38043c 100644 --- a/libs/assets/src/svg/svgs/bitwarden-icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-icon.ts @@ -3,18 +3,18 @@ import { svgIcon } from "../icon-service"; export const BitwardenIcon = svgIcon` - - - - - - - + + + + + + + - - - + + + `; diff --git a/libs/assets/src/svg/svgs/business-unit-portal.ts b/libs/assets/src/svg/svgs/business-unit-portal.ts index bae5ebada6a..db3a6b8ef4f 100644 --- a/libs/assets/src/svg/svgs/business-unit-portal.ts +++ b/libs/assets/src/svg/svgs/business-unit-portal.ts @@ -1,7 +1,17 @@ import { svgIcon } from "../icon-service"; const BusinessUnitPortalLogo = svgIcon` - + + + + + + + + + + + `; export default BusinessUnitPortalLogo; diff --git a/libs/assets/src/svg/svgs/extension-bitwarden-logo.icon.ts b/libs/assets/src/svg/svgs/extension-bitwarden-logo.icon.ts deleted file mode 100644 index 42a74e006bc..00000000000 --- a/libs/assets/src/svg/svgs/extension-bitwarden-logo.icon.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { svgIcon } from "../icon-service"; - -export const ExtensionBitwardenLogo = svgIcon` - - Bitwarden - - -`; diff --git a/libs/assets/src/svg/svgs/favorites.icon.ts b/libs/assets/src/svg/svgs/favorites.icon.ts new file mode 100644 index 00000000000..4725d0b0a7c --- /dev/null +++ b/libs/assets/src/svg/svgs/favorites.icon.ts @@ -0,0 +1,26 @@ +import { svgIcon } from "../icon-service"; + +export const FavoritesIcon = svgIcon` + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/libs/assets/src/svg/svgs/index.ts b/libs/assets/src/svg/svgs/index.ts index 10f2f7cd8d4..40a91f78d4d 100644 --- a/libs/assets/src/svg/svgs/index.ts +++ b/libs/assets/src/svg/svgs/index.ts @@ -12,7 +12,7 @@ export * from "./deactivated-org"; export * from "./devices.icon"; export * from "./domain.icon"; export * from "./empty-trash"; -export * from "./extension-bitwarden-logo.icon"; +export * from "./favorites.icon"; export * from "./gear"; export * from "./generator"; export * from "./item-types"; diff --git a/libs/assets/src/svg/svgs/password-manager.ts b/libs/assets/src/svg/svgs/password-manager.ts index 5c8ef852025..17b6f148be3 100644 --- a/libs/assets/src/svg/svgs/password-manager.ts +++ b/libs/assets/src/svg/svgs/password-manager.ts @@ -1,7 +1,17 @@ import { svgIcon } from "../icon-service"; const PasswordManagerLogo = svgIcon` - + + + + + + + + + + `; export default PasswordManagerLogo; diff --git a/libs/assets/src/svg/svgs/provider-portal.ts b/libs/assets/src/svg/svgs/provider-portal.ts index 54e4b219385..51c04e1553b 100644 --- a/libs/assets/src/svg/svgs/provider-portal.ts +++ b/libs/assets/src/svg/svgs/provider-portal.ts @@ -1,7 +1,17 @@ import { svgIcon } from "../icon-service"; const ProviderPortalLogo = svgIcon` - + + + + + + + + + + + `; export default ProviderPortalLogo; diff --git a/libs/assets/src/svg/svgs/secrets-manager.ts b/libs/assets/src/svg/svgs/secrets-manager.ts index 3a5095b95bd..27589e7e2f9 100644 --- a/libs/assets/src/svg/svgs/secrets-manager.ts +++ b/libs/assets/src/svg/svgs/secrets-manager.ts @@ -1,7 +1,17 @@ import { svgIcon } from "../icon-service"; const SecretsManagerLogo = svgIcon` - + + + + + + + + + + + `; export default SecretsManagerLogo; diff --git a/libs/assets/src/svg/svgs/shield.ts b/libs/assets/src/svg/svgs/shield.ts index b0c781bd00d..eaf9780773e 100644 --- a/libs/assets/src/svg/svgs/shield.ts +++ b/libs/assets/src/svg/svgs/shield.ts @@ -4,13 +4,22 @@ import { svgIcon } from "../icon-service"; * Shield logo with extra space in the viewbox. */ const AnonLayoutBitwardenShield = svgIcon` - + `; const BitwardenShield = svgIcon` - + + + + + + + + + + `; export { AnonLayoutBitwardenShield, BitwardenShield }; diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index c79df474c16..a3f4d98cd7d 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -77,14 +77,10 @@ import { } from "../auth/models/response/two-factor-web-authn.response"; import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response"; import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request"; -import { PaymentRequest } from "../billing/models/request/payment.request"; -import { TaxInfoUpdateRequest } from "../billing/models/request/tax-info-update.request"; import { BillingHistoryResponse } from "../billing/models/response/billing-history.response"; -import { BillingPaymentResponse } from "../billing/models/response/billing-payment.response"; import { PaymentResponse } from "../billing/models/response/payment.response"; import { PlanResponse } from "../billing/models/response/plan.response"; import { SubscriptionResponse } from "../billing/models/response/subscription.response"; -import { TaxInfoResponse } from "../billing/models/response/tax-info.response"; import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request"; import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request"; import { DeleteRecoverRequest } from "../models/request/delete-recover.request"; @@ -171,10 +167,8 @@ export abstract class ApiService { abstract getProfile(): Promise; abstract getUserSubscription(): Promise; - abstract getTaxInfo(): Promise; abstract putProfile(request: UpdateProfileRequest): Promise; abstract putAvatar(request: UpdateAvatarRequest): Promise; - abstract putTaxInfo(request: TaxInfoUpdateRequest): Promise; abstract postPrelogin(request: PreloginRequest): Promise; abstract postEmailToken(request: EmailTokenRequest): Promise; abstract postEmail(request: EmailRequest): Promise; @@ -185,7 +179,6 @@ export abstract class ApiService { abstract postPremium(data: FormData): Promise; abstract postReinstatePremium(): Promise; abstract postAccountStorage(request: StorageRequest): Promise; - abstract postAccountPayment(request: PaymentRequest): Promise; abstract postAccountLicense(data: FormData): Promise; abstract postAccountKeys(request: KeysRequest): Promise; abstract postAccountVerifyEmail(): Promise; @@ -209,7 +202,6 @@ export abstract class ApiService { abstract getLastAuthRequest(): Promise; abstract getUserBillingHistory(): Promise; - abstract getUserBillingPayment(): Promise; abstract getCipher(id: string): Promise; abstract getFullCipherDetails(id: string): Promise; diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index 10626d6758f..6c91c2ea0cf 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -3,21 +3,17 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; -import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; -import { PaymentRequest } from "../../../billing/models/request/payment.request"; import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingHistoryResponse } from "../../../billing/models/response/billing-history.response"; import { BillingResponse } from "../../../billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response"; import { PaymentResponse } from "../../../billing/models/response/payment.response"; -import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response"; import { ImportDirectoryRequest } from "../../../models/request/import-directory.request"; import { SeatRequest } from "../../../models/request/seat.request"; import { StorageRequest } from "../../../models/request/storage.request"; -import { VerifyBankRequest } from "../../../models/request/verify-bank.request"; import { ListResponse } from "../../../models/response/list.response"; import { OrganizationApiKeyType } from "../../enums"; import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request"; @@ -45,7 +41,6 @@ export abstract class OrganizationApiServiceAbstraction { ): Promise; abstract createLicense(data: FormData): Promise; abstract save(id: string, request: OrganizationUpdateRequest): Promise; - abstract updatePayment(id: string, request: PaymentRequest): Promise; abstract upgrade(id: string, request: OrganizationUpgradeRequest): Promise; abstract updatePasswordManagerSeats( id: string, @@ -57,7 +52,6 @@ export abstract class OrganizationApiServiceAbstraction { ): Promise; abstract updateSeats(id: string, request: SeatRequest): Promise; abstract updateStorage(id: string, request: StorageRequest): Promise; - abstract verifyBank(id: string, request: VerifyBankRequest): Promise; abstract reinstate(id: string): Promise; abstract leave(id: string): Promise; abstract delete(id: string, request: SecretVerificationRequest): Promise; @@ -76,8 +70,6 @@ export abstract class OrganizationApiServiceAbstraction { organizationApiKeyType?: OrganizationApiKeyType, ): Promise>; abstract rotateApiKey(id: string, request: OrganizationApiKeyRequest): Promise; - abstract getTaxInfo(id: string): Promise; - abstract updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise; abstract getKeys(id: string): Promise; abstract updateKeys( id: string, diff --git a/libs/common/src/admin-console/enums/policy-type.enum.ts b/libs/common/src/admin-console/enums/policy-type.enum.ts index 91f3a8229f8..a4a860a2f3f 100644 --- a/libs/common/src/admin-console/enums/policy-type.enum.ts +++ b/libs/common/src/admin-console/enums/policy-type.enum.ts @@ -17,4 +17,5 @@ export enum PolicyType { FreeFamiliesSponsorshipPolicy = 13, // Disables free families plan for organization RemoveUnlockWithPin = 14, // Do not allow members to unlock their account with a PIN. RestrictedItemTypes = 15, // Restricts item types that can be created within an organization + AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app } diff --git a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts index 5c9ea5526a0..001bba11cf4 100644 --- a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts +++ b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts @@ -1,7 +1,19 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { ExpandedTaxInfoUpdateRequest } from "../../../../billing/models/request/expanded-tax-info-update.request"; -import { TokenizedPaymentSourceRequest } from "../../../../billing/models/request/tokenized-payment-source.request"; +interface TokenizedPaymentMethod { + type: "bankAccount" | "card" | "payPal"; + token: string; +} + +interface BillingAddress { + country: string; + postalCode: string; + line1: string | null; + line2: string | null; + city: string | null; + state: string | null; + taxId: { code: string; value: string } | null; +} export class ProviderSetupRequest { name: string; @@ -9,6 +21,6 @@ export class ProviderSetupRequest { billingEmail: string; token: string; key: string; - taxInfo: ExpandedTaxInfoUpdateRequest; - paymentSource?: TokenizedPaymentSourceRequest; + paymentMethod: TokenizedPaymentMethod; + billingAddress: BillingAddress; } diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 598bb2a29db..6a7b71389bb 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -7,21 +7,17 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; -import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; -import { PaymentRequest } from "../../../billing/models/request/payment.request"; import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingHistoryResponse } from "../../../billing/models/response/billing-history.response"; import { BillingResponse } from "../../../billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response"; import { PaymentResponse } from "../../../billing/models/response/payment.response"; -import { TaxInfoResponse } from "../../../billing/models/response/tax-info.response"; import { ImportDirectoryRequest } from "../../../models/request/import-directory.request"; import { SeatRequest } from "../../../models/request/seat.request"; import { StorageRequest } from "../../../models/request/storage.request"; -import { VerifyBankRequest } from "../../../models/request/verify-bank.request"; import { ListResponse } from "../../../models/response/list.response"; import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction"; @@ -143,10 +139,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return data; } - async updatePayment(id: string, request: PaymentRequest): Promise { - return this.apiService.send("POST", "/organizations/" + id + "/payment", request, true, false); - } - async upgrade(id: string, request: OrganizationUpgradeRequest): Promise { const r = await this.apiService.send( "POST", @@ -208,16 +200,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new PaymentResponse(r); } - async verifyBank(id: string, request: VerifyBankRequest): Promise { - await this.apiService.send( - "POST", - "/organizations/" + id + "/verify-bank", - request, - true, - false, - ); - } - async reinstate(id: string): Promise { return this.apiService.send("POST", "/organizations/" + id + "/reinstate", null, true, false); } @@ -299,16 +281,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new ApiKeyResponse(r); } - async getTaxInfo(id: string): Promise { - const r = await this.apiService.send("GET", "/organizations/" + id + "/tax", null, true, true); - return new TaxInfoResponse(r); - } - - async updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise { - // Can't broadcast anything because the response doesn't have content - return this.apiService.send("PUT", "/organizations/" + id + "/tax", request, true, false); - } - async getKeys(id: string): Promise { const r = await this.apiService.send("GET", "/organizations/" + id + "/keys", null, true, true); return new OrganizationKeysResponse(r); diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index 2f3fe9125db..b5695e2e8a0 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -1,19 +1,12 @@ -import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; - import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response"; import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; import { PlanResponse } from "../../billing/models/response/plan.response"; import { ListResponse } from "../../models/response/list.response"; -import { PaymentMethodType } from "../enums"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; -import { ExpandedTaxInfoUpdateRequest } from "../models/request/expanded-tax-info-update.request"; import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request"; -import { UpdatePaymentMethodRequest } from "../models/request/update-payment-method.request"; -import { VerifyBankAccountRequest } from "../models/request/verify-bank-account.request"; import { InvoicesResponse } from "../models/response/invoices.response"; -import { PaymentMethodResponse } from "../models/response/payment-method.response"; import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; export abstract class BillingApiServiceAbstraction { @@ -29,14 +22,10 @@ export abstract class BillingApiServiceAbstraction { request: CreateClientOrganizationRequest, ): Promise; - abstract createSetupIntent(paymentMethodType: PaymentMethodType): Promise; - abstract getOrganizationBillingMetadata( organizationId: string, ): Promise; - abstract getOrganizationPaymentMethod(organizationId: string): Promise; - abstract getPlans(): Promise>; abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise; @@ -49,44 +38,12 @@ export abstract class BillingApiServiceAbstraction { abstract getProviderSubscription(providerId: string): Promise; - abstract getProviderTaxInformation(providerId: string): Promise; - - abstract updateOrganizationPaymentMethod( - organizationId: string, - request: UpdatePaymentMethodRequest, - ): Promise; - - abstract updateOrganizationTaxInformation( - organizationId: string, - request: ExpandedTaxInfoUpdateRequest, - ): Promise; - abstract updateProviderClientOrganization( providerId: string, organizationId: string, request: UpdateClientOrganizationRequest, ): Promise; - abstract updateProviderPaymentMethod( - providerId: string, - request: UpdatePaymentMethodRequest, - ): Promise; - - abstract updateProviderTaxInformation( - providerId: string, - request: ExpandedTaxInfoUpdateRequest, - ): Promise; - - abstract verifyOrganizationBankAccount( - organizationId: string, - request: VerifyBankAccountRequest, - ): Promise; - - abstract verifyProviderBankAccount( - providerId: string, - request: VerifyBankAccountRequest, - ): Promise; - abstract restartSubscription( organizationId: string, request: OrganizationCreateRequest, diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index 3254787457a..215fabfd955 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -3,7 +3,6 @@ import { UserId } from "@bitwarden/user-core"; import { OrganizationResponse } from "../../admin-console/models/response/organization.response"; import { InitiationPath } from "../../models/request/reference-event.request"; import { PaymentMethodType, PlanType } from "../enums"; -import { PaymentSourceResponse } from "../models/response/payment-source.response"; export type OrganizationInformation = { name: string; @@ -45,8 +44,6 @@ export type SubscriptionInformation = { }; export abstract class OrganizationBillingServiceAbstraction { - abstract getPaymentSource(organizationId: string): Promise; - abstract purchaseSubscription( subscription: SubscriptionInformation, activeUserId: UserId, diff --git a/libs/common/src/billing/abstractions/tax.service.abstraction.ts b/libs/common/src/billing/abstractions/tax.service.abstraction.ts deleted file mode 100644 index c94fbcba652..00000000000 --- a/libs/common/src/billing/abstractions/tax.service.abstraction.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { CountryListItem } from "../models/domain"; -import { PreviewIndividualInvoiceRequest } from "../models/request/preview-individual-invoice.request"; -import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request"; -import { PreviewTaxAmountForOrganizationTrialRequest } from "../models/request/tax"; -import { PreviewInvoiceResponse } from "../models/response/preview-invoice.response"; - -export abstract class TaxServiceAbstraction { - abstract getCountries(): CountryListItem[]; - - abstract isCountrySupported(country: string): Promise; - - abstract previewIndividualInvoice( - request: PreviewIndividualInvoiceRequest, - ): Promise; - - abstract previewOrganizationInvoice( - request: PreviewOrganizationInvoiceRequest, - ): Promise; - - abstract previewTaxAmountForOrganizationTrial: ( - request: PreviewTaxAmountForOrganizationTrialRequest, - ) => Promise; -} diff --git a/libs/common/src/billing/enums/bitwarden-product-type.enum.ts b/libs/common/src/billing/enums/bitwarden-product-type.enum.ts deleted file mode 100644 index 4389d283c00..00000000000 --- a/libs/common/src/billing/enums/bitwarden-product-type.enum.ts +++ /dev/null @@ -1,6 +0,0 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum BitwardenProductType { - PasswordManager = 0, - SecretsManager = 1, -} diff --git a/libs/common/src/billing/enums/index.ts b/libs/common/src/billing/enums/index.ts index 1a9f3f8219c..ee8cd1f5948 100644 --- a/libs/common/src/billing/enums/index.ts +++ b/libs/common/src/billing/enums/index.ts @@ -2,7 +2,6 @@ export * from "./payment-method-type.enum"; export * from "./plan-sponsorship-type.enum"; export * from "./plan-type.enum"; export * from "./transaction-type.enum"; -export * from "./bitwarden-product-type.enum"; export * from "./product-tier-type.enum"; export * from "./product-type.enum"; export * from "./plan-interval.enum"; diff --git a/libs/common/src/billing/models/request/expanded-tax-info-update.request.ts b/libs/common/src/billing/models/request/expanded-tax-info-update.request.ts deleted file mode 100644 index 83b254ac512..00000000000 --- a/libs/common/src/billing/models/request/expanded-tax-info-update.request.ts +++ /dev/null @@ -1,29 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { TaxInformation } from "../domain/tax-information"; - -import { TaxInfoUpdateRequest } from "./tax-info-update.request"; - -export class ExpandedTaxInfoUpdateRequest extends TaxInfoUpdateRequest { - taxId: string; - line1: string; - line2: string; - city: string; - state: string; - - static From(taxInformation: TaxInformation): ExpandedTaxInfoUpdateRequest { - if (!taxInformation) { - return null; - } - - const request = new ExpandedTaxInfoUpdateRequest(); - request.country = taxInformation.country; - request.postalCode = taxInformation.postalCode; - request.taxId = taxInformation.taxId; - request.line1 = taxInformation.line1; - request.line2 = taxInformation.line2; - request.city = taxInformation.city; - request.state = taxInformation.state; - return request; - } -} diff --git a/libs/common/src/billing/models/request/payment.request.ts b/libs/common/src/billing/models/request/payment.request.ts deleted file mode 100644 index e2edd9aabb3..00000000000 --- a/libs/common/src/billing/models/request/payment.request.ts +++ /dev/null @@ -1,10 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { PaymentMethodType } from "../../enums"; - -import { ExpandedTaxInfoUpdateRequest } from "./expanded-tax-info-update.request"; - -export class PaymentRequest extends ExpandedTaxInfoUpdateRequest { - paymentMethodType: PaymentMethodType; - paymentToken: string; -} diff --git a/libs/common/src/billing/models/request/preview-individual-invoice.request.ts b/libs/common/src/billing/models/request/preview-individual-invoice.request.ts deleted file mode 100644 index f817398c629..00000000000 --- a/libs/common/src/billing/models/request/preview-individual-invoice.request.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-strict-ignore -export class PreviewIndividualInvoiceRequest { - passwordManager: PasswordManager; - taxInformation: TaxInformation; - - constructor(passwordManager: PasswordManager, taxInformation: TaxInformation) { - this.passwordManager = passwordManager; - this.taxInformation = taxInformation; - } -} - -class PasswordManager { - additionalStorage: number; - - constructor(additionalStorage: number) { - this.additionalStorage = additionalStorage; - } -} - -class TaxInformation { - postalCode: string; - country: string; - - constructor(postalCode: string, country: string) { - this.postalCode = postalCode; - this.country = country; - } -} diff --git a/libs/common/src/billing/models/request/preview-organization-invoice.request.ts b/libs/common/src/billing/models/request/preview-organization-invoice.request.ts deleted file mode 100644 index bfeecb4eb23..00000000000 --- a/libs/common/src/billing/models/request/preview-organization-invoice.request.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { PlanSponsorshipType, PlanType } from "../../enums"; - -export class PreviewOrganizationInvoiceRequest { - organizationId?: string; - passwordManager: PasswordManager; - secretsManager?: SecretsManager; - taxInformation: TaxInformation; - - constructor( - passwordManager: PasswordManager, - taxInformation: TaxInformation, - organizationId?: string, - secretsManager?: SecretsManager, - ) { - this.organizationId = organizationId; - this.passwordManager = passwordManager; - this.secretsManager = secretsManager; - this.taxInformation = taxInformation; - } -} - -class PasswordManager { - plan: PlanType; - sponsoredPlan?: PlanSponsorshipType; - seats: number; - additionalStorage: number; - - constructor(plan: PlanType, seats: number, additionalStorage: number) { - this.plan = plan; - this.seats = seats; - this.additionalStorage = additionalStorage; - } -} - -class SecretsManager { - seats: number; - additionalMachineAccounts: number; - - constructor(seats: number, additionalMachineAccounts: number) { - this.seats = seats; - this.additionalMachineAccounts = additionalMachineAccounts; - } -} - -class TaxInformation { - postalCode: string; - country: string; - taxId: string; - - constructor(postalCode: string, country: string, taxId: string) { - this.postalCode = postalCode; - this.country = country; - this.taxId = taxId; - } -} diff --git a/libs/common/src/billing/models/request/tax-info-update.request.ts b/libs/common/src/billing/models/request/tax-info-update.request.ts deleted file mode 100644 index 6f767535472..00000000000 --- a/libs/common/src/billing/models/request/tax-info-update.request.ts +++ /dev/null @@ -1,6 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -export class TaxInfoUpdateRequest { - country: string; - postalCode: string; -} diff --git a/libs/common/src/billing/models/request/tax/index.ts b/libs/common/src/billing/models/request/tax/index.ts deleted file mode 100644 index cda1930c614..00000000000 --- a/libs/common/src/billing/models/request/tax/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./preview-tax-amount-for-organization-trial.request"; diff --git a/libs/common/src/billing/models/request/tax/preview-tax-amount-for-organization-trial.request.ts b/libs/common/src/billing/models/request/tax/preview-tax-amount-for-organization-trial.request.ts deleted file mode 100644 index 3f366335a47..00000000000 --- a/libs/common/src/billing/models/request/tax/preview-tax-amount-for-organization-trial.request.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { PlanType, ProductType } from "../../../enums"; - -export type PreviewTaxAmountForOrganizationTrialRequest = { - planType: PlanType; - productType: ProductType; - taxInformation: { - country: string; - postalCode: string; - taxId?: string; - }; -}; diff --git a/libs/common/src/billing/models/request/tokenized-payment-source.request.ts b/libs/common/src/billing/models/request/tokenized-payment-source.request.ts deleted file mode 100644 index e4bf575cc6a..00000000000 --- a/libs/common/src/billing/models/request/tokenized-payment-source.request.ts +++ /dev/null @@ -1,8 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { PaymentMethodType } from "../../enums"; - -export class TokenizedPaymentSourceRequest { - type: PaymentMethodType; - token: string; -} diff --git a/libs/common/src/billing/models/request/update-payment-method.request.ts b/libs/common/src/billing/models/request/update-payment-method.request.ts deleted file mode 100644 index 10b03103716..00000000000 --- a/libs/common/src/billing/models/request/update-payment-method.request.ts +++ /dev/null @@ -1,9 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { ExpandedTaxInfoUpdateRequest } from "./expanded-tax-info-update.request"; -import { TokenizedPaymentSourceRequest } from "./tokenized-payment-source.request"; - -export class UpdatePaymentMethodRequest { - paymentSource: TokenizedPaymentSourceRequest; - taxInformation: ExpandedTaxInfoUpdateRequest; -} diff --git a/libs/common/src/billing/models/request/verify-bank-account.request.ts b/libs/common/src/billing/models/request/verify-bank-account.request.ts deleted file mode 100644 index ee85d1a2aad..00000000000 --- a/libs/common/src/billing/models/request/verify-bank-account.request.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class VerifyBankAccountRequest { - descriptorCode: string; - - constructor(descriptorCode: string) { - this.descriptorCode = descriptorCode; - } -} diff --git a/libs/common/src/billing/models/response/billing-payment.response.ts b/libs/common/src/billing/models/response/billing-payment.response.ts deleted file mode 100644 index e60a11c0772..00000000000 --- a/libs/common/src/billing/models/response/billing-payment.response.ts +++ /dev/null @@ -1,17 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { BaseResponse } from "../../../models/response/base.response"; - -import { BillingSourceResponse } from "./billing.response"; - -export class BillingPaymentResponse extends BaseResponse { - balance: number; - paymentSource: BillingSourceResponse; - - constructor(response: any) { - super(response); - this.balance = this.getResponseProperty("Balance"); - const paymentSource = this.getResponseProperty("PaymentSource"); - this.paymentSource = paymentSource == null ? null : new BillingSourceResponse(paymentSource); - } -} diff --git a/libs/common/src/billing/models/response/payment-method.response.ts b/libs/common/src/billing/models/response/payment-method.response.ts deleted file mode 100644 index 34e95032aef..00000000000 --- a/libs/common/src/billing/models/response/payment-method.response.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BaseResponse } from "../../../models/response/base.response"; - -import { PaymentSourceResponse } from "./payment-source.response"; -import { TaxInfoResponse } from "./tax-info.response"; - -export class PaymentMethodResponse extends BaseResponse { - accountCredit: number; - paymentSource?: PaymentSourceResponse; - subscriptionStatus?: string; - taxInformation?: TaxInfoResponse; - - constructor(response: any) { - super(response); - this.accountCredit = this.getResponseProperty("AccountCredit"); - - const paymentSource = this.getResponseProperty("PaymentSource"); - if (paymentSource) { - this.paymentSource = new PaymentSourceResponse(paymentSource); - } - - this.subscriptionStatus = this.getResponseProperty("SubscriptionStatus"); - - const taxInformation = this.getResponseProperty("TaxInformation"); - if (taxInformation) { - this.taxInformation = new TaxInfoResponse(taxInformation); - } - } -} diff --git a/libs/common/src/billing/models/response/tax-id-types.response.ts b/libs/common/src/billing/models/response/tax-id-types.response.ts deleted file mode 100644 index f31f2133b34..00000000000 --- a/libs/common/src/billing/models/response/tax-id-types.response.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { BaseResponse } from "../../../models/response/base.response"; - -export class TaxIdTypesResponse extends BaseResponse { - taxIdTypes: TaxIdTypeResponse[] = []; - - constructor(response: any) { - super(response); - const taxIdTypes = this.getResponseProperty("TaxIdTypes"); - if (taxIdTypes && taxIdTypes.length) { - this.taxIdTypes = taxIdTypes.map((t: any) => new TaxIdTypeResponse(t)); - } - } -} - -export class TaxIdTypeResponse extends BaseResponse { - code: string; - country: string; - description: string; - example: string; - - constructor(response: any) { - super(response); - this.code = this.getResponseProperty("Code"); - this.country = this.getResponseProperty("Country"); - this.description = this.getResponseProperty("Description"); - this.example = this.getResponseProperty("Example"); - } -} diff --git a/libs/common/src/billing/models/response/tax/index.ts b/libs/common/src/billing/models/response/tax/index.ts deleted file mode 100644 index 525d6d7c80a..00000000000 --- a/libs/common/src/billing/models/response/tax/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./preview-tax-amount.response"; diff --git a/libs/common/src/billing/models/response/tax/preview-tax-amount.response.ts b/libs/common/src/billing/models/response/tax/preview-tax-amount.response.ts deleted file mode 100644 index cf15156551a..00000000000 --- a/libs/common/src/billing/models/response/tax/preview-tax-amount.response.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BaseResponse } from "../../../../models/response/base.response"; - -export class PreviewTaxAmountResponse extends BaseResponse { - taxAmount: number; - - constructor(response: any) { - super(response); - - this.taxAmount = this.getResponseProperty("TaxAmount"); - } -} diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index 2292f26e616..a34809e9f02 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -1,23 +1,16 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; - import { ApiService } from "../../abstractions/api.service"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response"; import { ListResponse } from "../../models/response/list.response"; import { BillingApiServiceAbstraction } from "../abstractions"; -import { PaymentMethodType } from "../enums"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; -import { ExpandedTaxInfoUpdateRequest } from "../models/request/expanded-tax-info-update.request"; import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request"; import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request"; -import { UpdatePaymentMethodRequest } from "../models/request/update-payment-method.request"; -import { VerifyBankAccountRequest } from "../models/request/verify-bank-account.request"; import { InvoicesResponse } from "../models/response/invoices.response"; import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response"; -import { PaymentMethodResponse } from "../models/response/payment-method.response"; import { PlanResponse } from "../models/response/plan.response"; import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; @@ -54,21 +47,6 @@ export class BillingApiService implements BillingApiServiceAbstraction { ); } - async createSetupIntent(type: PaymentMethodType) { - const getPath = () => { - switch (type) { - case PaymentMethodType.BankAccount: { - return "/setup-intent/bank-account"; - } - case PaymentMethodType.Card: { - return "/setup-intent/card"; - } - } - }; - const response = await this.apiService.send("POST", getPath(), null, true, true); - return response as string; - } - async getOrganizationBillingMetadata( organizationId: string, ): Promise { @@ -83,17 +61,6 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new OrganizationBillingMetadataResponse(r); } - async getOrganizationPaymentMethod(organizationId: string): Promise { - const response = await this.apiService.send( - "GET", - "/organizations/" + organizationId + "/billing/payment-method", - null, - true, - true, - ); - return new PaymentMethodResponse(response); - } - async getPlans(): Promise> { const r = await this.apiService.send("GET", "/plans", null, false, true); return new ListResponse(r, PlanResponse); @@ -145,43 +112,6 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new ProviderSubscriptionResponse(response); } - async getProviderTaxInformation(providerId: string): Promise { - const response = await this.apiService.send( - "GET", - "/providers/" + providerId + "/billing/tax-information", - null, - true, - true, - ); - return new TaxInfoResponse(response); - } - - async updateOrganizationPaymentMethod( - organizationId: string, - request: UpdatePaymentMethodRequest, - ): Promise { - return await this.apiService.send( - "PUT", - "/organizations/" + organizationId + "/billing/payment-method", - request, - true, - false, - ); - } - - async updateOrganizationTaxInformation( - organizationId: string, - request: ExpandedTaxInfoUpdateRequest, - ): Promise { - return await this.apiService.send( - "PUT", - "/organizations/" + organizationId + "/billing/tax-information", - request, - true, - false, - ); - } - async updateProviderClientOrganization( providerId: string, organizationId: string, @@ -196,55 +126,6 @@ export class BillingApiService implements BillingApiServiceAbstraction { ); } - async updateProviderPaymentMethod( - providerId: string, - request: UpdatePaymentMethodRequest, - ): Promise { - return await this.apiService.send( - "PUT", - "/providers/" + providerId + "/billing/payment-method", - request, - true, - false, - ); - } - - async updateProviderTaxInformation(providerId: string, request: ExpandedTaxInfoUpdateRequest) { - return await this.apiService.send( - "PUT", - "/providers/" + providerId + "/billing/tax-information", - request, - true, - false, - ); - } - - async verifyOrganizationBankAccount( - organizationId: string, - request: VerifyBankAccountRequest, - ): Promise { - return await this.apiService.send( - "POST", - "/organizations/" + organizationId + "/billing/payment-method/verify-bank-account", - request, - true, - false, - ); - } - - async verifyProviderBankAccount( - providerId: string, - request: VerifyBankAccountRequest, - ): Promise { - return await this.apiService.send( - "POST", - "/providers/" + providerId + "/billing/payment-method/verify-bank-account", - request, - true, - false, - ); - } - async restartSubscription( organizationId: string, request: OrganizationCreateRequest, diff --git a/libs/common/src/billing/services/organization-billing.service.spec.ts b/libs/common/src/billing/services/organization-billing.service.spec.ts index 42cfb4a5371..a14dd0f0279 100644 --- a/libs/common/src/billing/services/organization-billing.service.spec.ts +++ b/libs/common/src/billing/services/organization-billing.service.spec.ts @@ -23,7 +23,6 @@ import { OrganizationResponse } from "../../admin-console/models/response/organi import { EncString } from "../../key-management/crypto/models/enc-string"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { OrgKey } from "../../types/key"; -import { PaymentMethodResponse } from "../models/response/payment-method.response"; describe("OrganizationBillingService", () => { let apiService: jest.Mocked; @@ -62,47 +61,6 @@ describe("OrganizationBillingService", () => { return jest.resetAllMocks(); }); - describe("getPaymentSource()", () => { - it("given a valid organization id, then it returns a payment source", async () => { - //Arrange - const orgId = "organization-test"; - const paymentMethodResponse = { - paymentSource: { type: PaymentMethodType.Card }, - } as PaymentMethodResponse; - billingApiService.getOrganizationPaymentMethod.mockResolvedValue(paymentMethodResponse); - - //Act - const returnedPaymentSource = await sut.getPaymentSource(orgId); - - //Assert - expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); - expect(returnedPaymentSource).toEqual(paymentMethodResponse.paymentSource); - }); - - it("given an invalid organizationId, it should return undefined", async () => { - //Arrange - const orgId = "invalid-id"; - billingApiService.getOrganizationPaymentMethod.mockResolvedValue(null); - - //Act - const returnedPaymentSource = await sut.getPaymentSource(orgId); - - //Assert - expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); - expect(returnedPaymentSource).toBeUndefined(); - }); - - it("given an API error occurs, then it throws the error", async () => { - // Arrange - const orgId = "error-org"; - billingApiService.getOrganizationPaymentMethod.mockRejectedValue(new Error("API Error")); - - // Act & Assert - await expect(sut.getPaymentSource(orgId)).rejects.toThrow("API Error"); - expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); - }); - }); - describe("purchaseSubscription()", () => { it("given valid subscription information, then it returns successful response", async () => { //Arrange @@ -118,7 +76,7 @@ describe("OrganizationBillingService", () => { const organizationResponse = { name: subscriptionInformation.organization.name, billingEmail: subscriptionInformation.organization.billingEmail, - planType: subscriptionInformation.plan.type, + planType: subscriptionInformation.plan!.type, } as OrganizationResponse; organizationApiService.create.mockResolvedValue(organizationResponse); @@ -201,8 +159,8 @@ describe("OrganizationBillingService", () => { const organizationResponse = { name: subscriptionInformation.organization.name, - plan: { type: subscriptionInformation.plan.type }, - planType: subscriptionInformation.plan.type, + plan: { type: subscriptionInformation.plan!.type }, + planType: subscriptionInformation.plan!.type, } as OrganizationResponse; organizationApiService.createWithoutPayment.mockResolvedValue(organizationResponse); @@ -262,7 +220,7 @@ describe("OrganizationBillingService", () => { const organizationResponse = { name: subscriptionInformation.organization.name, billingEmail: subscriptionInformation.organization.billingEmail, - planType: subscriptionInformation.plan.type, + planType: subscriptionInformation.plan!.type, } as OrganizationResponse; organizationApiService.create.mockResolvedValue(organizationResponse); diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index 53ce727df68..4120047a15f 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -25,7 +25,6 @@ import { } from "../abstractions"; import { PlanType } from "../enums"; import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request"; -import { PaymentSourceResponse } from "../models/response/payment-source.response"; interface OrganizationKeys { encryptedKey: EncString; @@ -45,11 +44,6 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs private syncService: SyncService, ) {} - async getPaymentSource(organizationId: string): Promise { - const paymentMethod = await this.billingApiService.getOrganizationPaymentMethod(organizationId); - return paymentMethod?.paymentSource; - } - async purchaseSubscription( subscription: SubscriptionInformation, activeUserId: UserId, diff --git a/libs/common/src/billing/services/tax.service.ts b/libs/common/src/billing/services/tax.service.ts deleted file mode 100644 index 27966016913..00000000000 --- a/libs/common/src/billing/services/tax.service.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { PreviewTaxAmountForOrganizationTrialRequest } from "@bitwarden/common/billing/models/request/tax"; - -import { ApiService } from "../../abstractions/api.service"; -import { TaxServiceAbstraction } from "../abstractions/tax.service.abstraction"; -import { CountryListItem } from "../models/domain"; -import { PreviewIndividualInvoiceRequest } from "../models/request/preview-individual-invoice.request"; -import { PreviewOrganizationInvoiceRequest } from "../models/request/preview-organization-invoice.request"; -import { PreviewInvoiceResponse } from "../models/response/preview-invoice.response"; - -export class TaxService implements TaxServiceAbstraction { - constructor(private apiService: ApiService) {} - - getCountries(): CountryListItem[] { - return [ - { name: "-- Select --", value: "", disabled: false }, - { name: "United States", value: "US", disabled: false }, - { name: "China", value: "CN", disabled: false }, - { name: "France", value: "FR", disabled: false }, - { name: "Germany", value: "DE", disabled: false }, - { name: "Canada", value: "CA", disabled: false }, - { name: "United Kingdom", value: "GB", disabled: false }, - { name: "Australia", value: "AU", disabled: false }, - { name: "India", value: "IN", disabled: false }, - { name: "", value: "-", disabled: true }, - { name: "Afghanistan", value: "AF", disabled: false }, - { name: "Åland Islands", value: "AX", disabled: false }, - { name: "Albania", value: "AL", disabled: false }, - { name: "Algeria", value: "DZ", disabled: false }, - { name: "American Samoa", value: "AS", disabled: false }, - { name: "Andorra", value: "AD", disabled: false }, - { name: "Angola", value: "AO", disabled: false }, - { name: "Anguilla", value: "AI", disabled: false }, - { name: "Antarctica", value: "AQ", disabled: false }, - { name: "Antigua and Barbuda", value: "AG", disabled: false }, - { name: "Argentina", value: "AR", disabled: false }, - { name: "Armenia", value: "AM", disabled: false }, - { name: "Aruba", value: "AW", disabled: false }, - { name: "Austria", value: "AT", disabled: false }, - { name: "Azerbaijan", value: "AZ", disabled: false }, - { name: "Bahamas", value: "BS", disabled: false }, - { name: "Bahrain", value: "BH", disabled: false }, - { name: "Bangladesh", value: "BD", disabled: false }, - { name: "Barbados", value: "BB", disabled: false }, - { name: "Belarus", value: "BY", disabled: false }, - { name: "Belgium", value: "BE", disabled: false }, - { name: "Belize", value: "BZ", disabled: false }, - { name: "Benin", value: "BJ", disabled: false }, - { name: "Bermuda", value: "BM", disabled: false }, - { name: "Bhutan", value: "BT", disabled: false }, - { name: "Bolivia, Plurinational State of", value: "BO", disabled: false }, - { name: "Bonaire, Sint Eustatius and Saba", value: "BQ", disabled: false }, - { name: "Bosnia and Herzegovina", value: "BA", disabled: false }, - { name: "Botswana", value: "BW", disabled: false }, - { name: "Bouvet Island", value: "BV", disabled: false }, - { name: "Brazil", value: "BR", disabled: false }, - { name: "British Indian Ocean Territory", value: "IO", disabled: false }, - { name: "Brunei Darussalam", value: "BN", disabled: false }, - { name: "Bulgaria", value: "BG", disabled: false }, - { name: "Burkina Faso", value: "BF", disabled: false }, - { name: "Burundi", value: "BI", disabled: false }, - { name: "Cambodia", value: "KH", disabled: false }, - { name: "Cameroon", value: "CM", disabled: false }, - { name: "Cape Verde", value: "CV", disabled: false }, - { name: "Cayman Islands", value: "KY", disabled: false }, - { name: "Central African Republic", value: "CF", disabled: false }, - { name: "Chad", value: "TD", disabled: false }, - { name: "Chile", value: "CL", disabled: false }, - { name: "Christmas Island", value: "CX", disabled: false }, - { name: "Cocos (Keeling) Islands", value: "CC", disabled: false }, - { name: "Colombia", value: "CO", disabled: false }, - { name: "Comoros", value: "KM", disabled: false }, - { name: "Congo", value: "CG", disabled: false }, - { name: "Congo, the Democratic Republic of the", value: "CD", disabled: false }, - { name: "Cook Islands", value: "CK", disabled: false }, - { name: "Costa Rica", value: "CR", disabled: false }, - { name: "Côte d'Ivoire", value: "CI", disabled: false }, - { name: "Croatia", value: "HR", disabled: false }, - { name: "Cuba", value: "CU", disabled: false }, - { name: "Curaçao", value: "CW", disabled: false }, - { name: "Cyprus", value: "CY", disabled: false }, - { name: "Czech Republic", value: "CZ", disabled: false }, - { name: "Denmark", value: "DK", disabled: false }, - { name: "Djibouti", value: "DJ", disabled: false }, - { name: "Dominica", value: "DM", disabled: false }, - { name: "Dominican Republic", value: "DO", disabled: false }, - { name: "Ecuador", value: "EC", disabled: false }, - { name: "Egypt", value: "EG", disabled: false }, - { name: "El Salvador", value: "SV", disabled: false }, - { name: "Equatorial Guinea", value: "GQ", disabled: false }, - { name: "Eritrea", value: "ER", disabled: false }, - { name: "Estonia", value: "EE", disabled: false }, - { name: "Ethiopia", value: "ET", disabled: false }, - { name: "Falkland Islands (Malvinas)", value: "FK", disabled: false }, - { name: "Faroe Islands", value: "FO", disabled: false }, - { name: "Fiji", value: "FJ", disabled: false }, - { name: "Finland", value: "FI", disabled: false }, - { name: "French Guiana", value: "GF", disabled: false }, - { name: "French Polynesia", value: "PF", disabled: false }, - { name: "French Southern Territories", value: "TF", disabled: false }, - { name: "Gabon", value: "GA", disabled: false }, - { name: "Gambia", value: "GM", disabled: false }, - { name: "Georgia", value: "GE", disabled: false }, - { name: "Ghana", value: "GH", disabled: false }, - { name: "Gibraltar", value: "GI", disabled: false }, - { name: "Greece", value: "GR", disabled: false }, - { name: "Greenland", value: "GL", disabled: false }, - { name: "Grenada", value: "GD", disabled: false }, - { name: "Guadeloupe", value: "GP", disabled: false }, - { name: "Guam", value: "GU", disabled: false }, - { name: "Guatemala", value: "GT", disabled: false }, - { name: "Guernsey", value: "GG", disabled: false }, - { name: "Guinea", value: "GN", disabled: false }, - { name: "Guinea-Bissau", value: "GW", disabled: false }, - { name: "Guyana", value: "GY", disabled: false }, - { name: "Haiti", value: "HT", disabled: false }, - { name: "Heard Island and McDonald Islands", value: "HM", disabled: false }, - { name: "Holy See (Vatican City State)", value: "VA", disabled: false }, - { name: "Honduras", value: "HN", disabled: false }, - { name: "Hong Kong", value: "HK", disabled: false }, - { name: "Hungary", value: "HU", disabled: false }, - { name: "Iceland", value: "IS", disabled: false }, - { name: "Indonesia", value: "ID", disabled: false }, - { name: "Iran, Islamic Republic of", value: "IR", disabled: false }, - { name: "Iraq", value: "IQ", disabled: false }, - { name: "Ireland", value: "IE", disabled: false }, - { name: "Isle of Man", value: "IM", disabled: false }, - { name: "Israel", value: "IL", disabled: false }, - { name: "Italy", value: "IT", disabled: false }, - { name: "Jamaica", value: "JM", disabled: false }, - { name: "Japan", value: "JP", disabled: false }, - { name: "Jersey", value: "JE", disabled: false }, - { name: "Jordan", value: "JO", disabled: false }, - { name: "Kazakhstan", value: "KZ", disabled: false }, - { name: "Kenya", value: "KE", disabled: false }, - { name: "Kiribati", value: "KI", disabled: false }, - { name: "Korea, Democratic People's Republic of", value: "KP", disabled: false }, - { name: "Korea, Republic of", value: "KR", disabled: false }, - { name: "Kuwait", value: "KW", disabled: false }, - { name: "Kyrgyzstan", value: "KG", disabled: false }, - { name: "Lao People's Democratic Republic", value: "LA", disabled: false }, - { name: "Latvia", value: "LV", disabled: false }, - { name: "Lebanon", value: "LB", disabled: false }, - { name: "Lesotho", value: "LS", disabled: false }, - { name: "Liberia", value: "LR", disabled: false }, - { name: "Libya", value: "LY", disabled: false }, - { name: "Liechtenstein", value: "LI", disabled: false }, - { name: "Lithuania", value: "LT", disabled: false }, - { name: "Luxembourg", value: "LU", disabled: false }, - { name: "Macao", value: "MO", disabled: false }, - { name: "Macedonia, the former Yugoslav Republic of", value: "MK", disabled: false }, - { name: "Madagascar", value: "MG", disabled: false }, - { name: "Malawi", value: "MW", disabled: false }, - { name: "Malaysia", value: "MY", disabled: false }, - { name: "Maldives", value: "MV", disabled: false }, - { name: "Mali", value: "ML", disabled: false }, - { name: "Malta", value: "MT", disabled: false }, - { name: "Marshall Islands", value: "MH", disabled: false }, - { name: "Martinique", value: "MQ", disabled: false }, - { name: "Mauritania", value: "MR", disabled: false }, - { name: "Mauritius", value: "MU", disabled: false }, - { name: "Mayotte", value: "YT", disabled: false }, - { name: "Mexico", value: "MX", disabled: false }, - { name: "Micronesia, Federated States of", value: "FM", disabled: false }, - { name: "Moldova, Republic of", value: "MD", disabled: false }, - { name: "Monaco", value: "MC", disabled: false }, - { name: "Mongolia", value: "MN", disabled: false }, - { name: "Montenegro", value: "ME", disabled: false }, - { name: "Montserrat", value: "MS", disabled: false }, - { name: "Morocco", value: "MA", disabled: false }, - { name: "Mozambique", value: "MZ", disabled: false }, - { name: "Myanmar", value: "MM", disabled: false }, - { name: "Namibia", value: "NA", disabled: false }, - { name: "Nauru", value: "NR", disabled: false }, - { name: "Nepal", value: "NP", disabled: false }, - { name: "Netherlands", value: "NL", disabled: false }, - { name: "New Caledonia", value: "NC", disabled: false }, - { name: "New Zealand", value: "NZ", disabled: false }, - { name: "Nicaragua", value: "NI", disabled: false }, - { name: "Niger", value: "NE", disabled: false }, - { name: "Nigeria", value: "NG", disabled: false }, - { name: "Niue", value: "NU", disabled: false }, - { name: "Norfolk Island", value: "NF", disabled: false }, - { name: "Northern Mariana Islands", value: "MP", disabled: false }, - { name: "Norway", value: "NO", disabled: false }, - { name: "Oman", value: "OM", disabled: false }, - { name: "Pakistan", value: "PK", disabled: false }, - { name: "Palau", value: "PW", disabled: false }, - { name: "Palestinian Territory, Occupied", value: "PS", disabled: false }, - { name: "Panama", value: "PA", disabled: false }, - { name: "Papua New Guinea", value: "PG", disabled: false }, - { name: "Paraguay", value: "PY", disabled: false }, - { name: "Peru", value: "PE", disabled: false }, - { name: "Philippines", value: "PH", disabled: false }, - { name: "Pitcairn", value: "PN", disabled: false }, - { name: "Poland", value: "PL", disabled: false }, - { name: "Portugal", value: "PT", disabled: false }, - { name: "Puerto Rico", value: "PR", disabled: false }, - { name: "Qatar", value: "QA", disabled: false }, - { name: "Réunion", value: "RE", disabled: false }, - { name: "Romania", value: "RO", disabled: false }, - { name: "Russian Federation", value: "RU", disabled: false }, - { name: "Rwanda", value: "RW", disabled: false }, - { name: "Saint Barthélemy", value: "BL", disabled: false }, - { name: "Saint Helena, Ascension and Tristan da Cunha", value: "SH", disabled: false }, - { name: "Saint Kitts and Nevis", value: "KN", disabled: false }, - { name: "Saint Lucia", value: "LC", disabled: false }, - { name: "Saint Martin (French part)", value: "MF", disabled: false }, - { name: "Saint Pierre and Miquelon", value: "PM", disabled: false }, - { name: "Saint Vincent and the Grenadines", value: "VC", disabled: false }, - { name: "Samoa", value: "WS", disabled: false }, - { name: "San Marino", value: "SM", disabled: false }, - { name: "Sao Tome and Principe", value: "ST", disabled: false }, - { name: "Saudi Arabia", value: "SA", disabled: false }, - { name: "Senegal", value: "SN", disabled: false }, - { name: "Serbia", value: "RS", disabled: false }, - { name: "Seychelles", value: "SC", disabled: false }, - { name: "Sierra Leone", value: "SL", disabled: false }, - { name: "Singapore", value: "SG", disabled: false }, - { name: "Sint Maarten (Dutch part)", value: "SX", disabled: false }, - { name: "Slovakia", value: "SK", disabled: false }, - { name: "Slovenia", value: "SI", disabled: false }, - { name: "Solomon Islands", value: "SB", disabled: false }, - { name: "Somalia", value: "SO", disabled: false }, - { name: "South Africa", value: "ZA", disabled: false }, - { name: "South Georgia and the South Sandwich Islands", value: "GS", disabled: false }, - { name: "South Sudan", value: "SS", disabled: false }, - { name: "Spain", value: "ES", disabled: false }, - { name: "Sri Lanka", value: "LK", disabled: false }, - { name: "Sudan", value: "SD", disabled: false }, - { name: "Suriname", value: "SR", disabled: false }, - { name: "Svalbard and Jan Mayen", value: "SJ", disabled: false }, - { name: "Swaziland", value: "SZ", disabled: false }, - { name: "Sweden", value: "SE", disabled: false }, - { name: "Switzerland", value: "CH", disabled: false }, - { name: "Syrian Arab Republic", value: "SY", disabled: false }, - { name: "Taiwan", value: "TW", disabled: false }, - { name: "Tajikistan", value: "TJ", disabled: false }, - { name: "Tanzania, United Republic of", value: "TZ", disabled: false }, - { name: "Thailand", value: "TH", disabled: false }, - { name: "Timor-Leste", value: "TL", disabled: false }, - { name: "Togo", value: "TG", disabled: false }, - { name: "Tokelau", value: "TK", disabled: false }, - { name: "Tonga", value: "TO", disabled: false }, - { name: "Trinidad and Tobago", value: "TT", disabled: false }, - { name: "Tunisia", value: "TN", disabled: false }, - { name: "Turkey", value: "TR", disabled: false }, - { name: "Turkmenistan", value: "TM", disabled: false }, - { name: "Turks and Caicos Islands", value: "TC", disabled: false }, - { name: "Tuvalu", value: "TV", disabled: false }, - { name: "Uganda", value: "UG", disabled: false }, - { name: "Ukraine", value: "UA", disabled: false }, - { name: "United Arab Emirates", value: "AE", disabled: false }, - { name: "United States Minor Outlying Islands", value: "UM", disabled: false }, - { name: "Uruguay", value: "UY", disabled: false }, - { name: "Uzbekistan", value: "UZ", disabled: false }, - { name: "Vanuatu", value: "VU", disabled: false }, - { name: "Venezuela, Bolivarian Republic of", value: "VE", disabled: false }, - { name: "Viet Nam", value: "VN", disabled: false }, - { name: "Virgin Islands, British", value: "VG", disabled: false }, - { name: "Virgin Islands, U.S.", value: "VI", disabled: false }, - { name: "Wallis and Futuna", value: "WF", disabled: false }, - { name: "Western Sahara", value: "EH", disabled: false }, - { name: "Yemen", value: "YE", disabled: false }, - { name: "Zambia", value: "ZM", disabled: false }, - { name: "Zimbabwe", value: "ZW", disabled: false }, - ]; - } - - async isCountrySupported(country: string): Promise { - const response = await this.apiService.send( - "GET", - "/tax/is-country-supported?country=" + country, - null, - true, - true, - ); - return response; - } - - async previewIndividualInvoice( - request: PreviewIndividualInvoiceRequest, - ): Promise { - const response = await this.apiService.send( - "POST", - "/accounts/billing/preview-invoice", - request, - true, - true, - ); - return new PreviewInvoiceResponse(response); - } - - async previewOrganizationInvoice( - request: PreviewOrganizationInvoiceRequest, - ): Promise { - const response = await this.apiService.send( - "POST", - `/invoices/preview-organization`, - request, - true, - true, - ); - return new PreviewInvoiceResponse(response); - } - - async previewTaxAmountForOrganizationTrial( - request: PreviewTaxAmountForOrganizationTrialRequest, - ): Promise { - const response = await this.apiService.send( - "POST", - "/tax/preview-amount/organization-trial", - request, - true, - true, - ); - return response as number; - } -} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 18134fee2c3..578d09c9aea 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -12,10 +12,8 @@ import { ServerConfig } from "../platform/abstractions/config/server-config"; export enum FeatureFlag { /* Admin Console Team */ CreateDefaultLocation = "pm-19467-create-default-location", - CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors", /* Auth */ - PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals", PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods", /* Autofill */ @@ -25,7 +23,6 @@ export enum FeatureFlag { /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", - PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", @@ -75,7 +72,6 @@ const FALSE = false as boolean; export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.CreateDefaultLocation]: FALSE, - [FeatureFlag.CollectionVaultRefactor]: FALSE, /* Autofill */ [FeatureFlag.MacOsNativeCredentialSync]: FALSE, @@ -98,13 +94,11 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, /* Auth */ - [FeatureFlag.PM14938_BrowserExtensionLoginApproval]: FALSE, [FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE, /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, - [FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, diff --git a/libs/common/src/platform/misc/utils.spec.ts b/libs/common/src/platform/misc/utils.spec.ts index 818138863fb..9f01db61fa6 100644 --- a/libs/common/src/platform/misc/utils.spec.ts +++ b/libs/common/src/platform/misc/utils.spec.ts @@ -302,7 +302,7 @@ describe("Utils Service", () => { expect(b64String).toBe(b64HelloWorldString); }); - runInBothEnvironments("should return an empty string for an empty ArrayBuffer", () => { + runInBothEnvironments("should return empty string for an empty ArrayBuffer", () => { const buffer = new Uint8Array([]).buffer; const b64String = Utils.fromBufferToB64(buffer); expect(b64String).toBe(""); @@ -312,6 +312,81 @@ describe("Utils Service", () => { const b64String = Utils.fromBufferToB64(null); expect(b64String).toBeNull(); }); + + runInBothEnvironments("returns null for undefined input", () => { + const b64 = Utils.fromBufferToB64(undefined as unknown as ArrayBuffer); + expect(b64).toBeNull(); + }); + + runInBothEnvironments("returns empty string for empty input", () => { + const b64 = Utils.fromBufferToB64(new ArrayBuffer(0)); + expect(b64).toBe(""); + }); + + runInBothEnvironments("accepts Uint8Array directly", () => { + const u8 = new Uint8Array(asciiHelloWorldArray); + const b64 = Utils.fromBufferToB64(u8); + expect(b64).toBe(b64HelloWorldString); + }); + + runInBothEnvironments("respects byteOffset/byteLength (view window)", () => { + // [xx, 'hello world', yy] — view should only encode the middle slice + const prefix = [1, 2, 3]; + const suffix = [4, 5]; + const all = new Uint8Array([...prefix, ...asciiHelloWorldArray, ...suffix]); + const view = new Uint8Array(all.buffer, prefix.length, asciiHelloWorldArray.length); + const b64 = Utils.fromBufferToB64(view); + expect(b64).toBe(b64HelloWorldString); + }); + + runInBothEnvironments("handles DataView (ArrayBufferView other than Uint8Array)", () => { + const u8 = new Uint8Array(asciiHelloWorldArray); + const dv = new DataView(u8.buffer, 0, u8.byteLength); + const b64 = Utils.fromBufferToB64(dv); + expect(b64).toBe(b64HelloWorldString); + }); + + runInBothEnvironments("handles DataView with offset/length window", () => { + // Buffer: [xx, 'hello world', yy] + const prefix = [9, 9, 9]; + const suffix = [8, 8]; + const all = new Uint8Array([...prefix, ...asciiHelloWorldArray, ...suffix]); + + // DataView over just the "hello world" window + const dv = new DataView(all.buffer, prefix.length, asciiHelloWorldArray.length); + + const b64 = Utils.fromBufferToB64(dv); + expect(b64).toBe(b64HelloWorldString); + }); + + runInBothEnvironments( + "encodes empty view (offset-length window of zero) as empty string", + () => { + const backing = new Uint8Array([1, 2, 3, 4]); + const emptyView = new Uint8Array(backing.buffer, 2, 0); + const b64 = Utils.fromBufferToB64(emptyView); + expect(b64).toBe(""); + }, + ); + + runInBothEnvironments("does not mutate the input", () => { + const original = new Uint8Array(asciiHelloWorldArray); + const copyBefore = new Uint8Array(original); // snapshot + Utils.fromBufferToB64(original); + expect(original).toEqual(copyBefore); // unchanged + }); + + it("produces the same Base64 in Node vs non-Node mode", () => { + const bytes = new Uint8Array(asciiHelloWorldArray); + + Utils.isNode = true; + const nodeB64 = Utils.fromBufferToB64(bytes); + + Utils.isNode = false; + const browserB64 = Utils.fromBufferToB64(bytes); + + expect(browserB64).toBe(nodeB64); + }); }); describe("fromB64ToArray(...)", () => { diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index c103e346a85..43a9e43d92b 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -128,15 +128,52 @@ export class Utils { return arr; } - static fromBufferToB64(buffer: ArrayBuffer): string { + /** + * Convert binary data into a Base64 string. + * + * Overloads are provided for two categories of input: + * + * 1. ArrayBuffer + * - A raw, fixed-length chunk of memory (no element semantics). + * - Example: `const buf = new ArrayBuffer(16);` + * + * 2. ArrayBufferView + * - A *view* onto an existing buffer that gives the bytes meaning. + * - Examples: Uint8Array, Int32Array, DataView, etc. + * - Views can expose only a *window* of the underlying buffer via + * `byteOffset` and `byteLength`. + * Example: + * ```ts + * const buf = new ArrayBuffer(8); + * const full = new Uint8Array(buf); // sees all 8 bytes + * const half = new Uint8Array(buf, 4, 4); // sees only last 4 bytes + * ``` + * + * Returns: + * - Base64 string for non-empty inputs, + * - null if `buffer` is `null` or `undefined` + * - empty string if `buffer` is empty (0 bytes) + */ + static fromBufferToB64(buffer: null | undefined): null; + static fromBufferToB64(buffer: ArrayBuffer): string; + static fromBufferToB64(buffer: ArrayBufferView): string; + static fromBufferToB64(buffer: ArrayBuffer | ArrayBufferView | null | undefined): string | null { + // Handle null / undefined input if (buffer == null) { return null; } + + const bytes: Uint8Array = Utils.normalizeToUint8Array(buffer); + + // Handle empty input + if (bytes.length === 0) { + return ""; + } + if (Utils.isNode) { - return Buffer.from(buffer).toString("base64"); + return Buffer.from(bytes).toString("base64"); } else { let binary = ""; - const bytes = new Uint8Array(buffer); for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } @@ -144,6 +181,30 @@ export class Utils { } } + /** + * Normalizes input into a Uint8Array so we always have a uniform, + * byte-level view of the data. This avoids dealing with differences + * between ArrayBuffer (raw memory with no indexing) and other typed + * views (which may have element sizes, offsets, and lengths). + * @param buffer ArrayBuffer or ArrayBufferView (e.g. Uint8Array, DataView, etc.) + */ + private static normalizeToUint8Array(buffer: ArrayBuffer | ArrayBufferView): Uint8Array { + /** + * 1) Uint8Array: already bytes → use directly. + * 2) ArrayBuffer: wrap whole buffer. + * 3) Other ArrayBufferView (e.g., DataView, Int32Array): + * wrap the view’s window (byteOffset..byteOffset+byteLength). + */ + if (buffer instanceof Uint8Array) { + return buffer; + } else if (buffer instanceof ArrayBuffer) { + return new Uint8Array(buffer); + } else { + const view = buffer as ArrayBufferView; + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + } + } + static fromBufferToUrlB64(buffer: ArrayBuffer): string { return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer)); } diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts index 9bc47437b39..b4d47698e4d 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts @@ -132,7 +132,6 @@ describe("DefaultServerNotificationsService (multi-user)", () => { const flagValueByFlag: Partial> = { [FeatureFlag.InactiveUserServerNotification]: true, [FeatureFlag.PushNotificationsWhenLocked]: true, - [FeatureFlag.PM14938_BrowserExtensionLoginApproval]: true, }; return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any; }); diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts index e934dec185d..47af8f5e00c 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts @@ -278,16 +278,21 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification); break; case NotificationType.AuthRequest: - if ( - await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval), - ) - ) { - await this.authRequestAnsweringService.receivedPendingAuthRequest( - notification.payload.userId, - notification.payload.id, - ); - } + await this.authRequestAnsweringService.receivedPendingAuthRequest( + notification.payload.userId, + notification.payload.id, + ); + + /** + * This call is necessary for Desktop, which for the time being uses a noop for the + * authRequestAnsweringService.receivedPendingAuthRequest() call just above. Desktop + * will eventually use the new AuthRequestAnsweringService, at which point we can remove + * this second call. + * + * The Extension AppComponent has logic (see processingPendingAuth) that only allows one + * pending auth request to process at a time, so this second call will not cause any + * duplicate processing conflicts on Extension. + */ this.messagingService.send("openLoginApproval", { notificationId: notification.payload.id, }); 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 3c3e1c3677f..352e45b88b1 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -292,5 +292,100 @@ describe("DefaultSyncService", () => { expect(masterPasswordAbstraction.setMasterPasswordUnlockData).not.toHaveBeenCalled(); }); }); + + describe("mutate 'last update time'", () => { + let mockUserState: { update: jest.Mock }; + + const setupMockUserState = () => { + const mockUserState = { update: jest.fn() }; + jest.spyOn(stateProvider, "getUser").mockReturnValue(mockUserState as any); + return mockUserState; + }; + + const setupSyncScenario = (revisionDate: Date, lastSyncDate: Date) => { + jest.spyOn(apiService, "getAccountRevisionDate").mockResolvedValue(revisionDate.getTime()); + jest.spyOn(sut as any, "getLastSync").mockResolvedValue(lastSyncDate); + }; + + const expectUpdateCallCount = ( + mockUserState: { update: jest.Mock }, + expectedCount: number, + ) => { + if (expectedCount === 0) { + expect(mockUserState.update).not.toHaveBeenCalled(); + } else { + expect(mockUserState.update).toHaveBeenCalledTimes(expectedCount); + } + }; + + const defaultSyncOptions = { allowThrowOnError: true, skipTokenRefresh: true }; + const errorTolerantSyncOptions = { allowThrowOnError: false, skipTokenRefresh: true }; + + beforeEach(() => { + mockUserState = setupMockUserState(); + }); + + it("uses the current time when a sync is forced", async () => { + // Mock the value of this observable because it's used in `syncProfile`. Without it, the test breaks. + keyConnectorService.convertAccountRequired$ = of(false); + + // Baseline date/time to compare sync time to, in order to avoid needing to use some kind of fake date provider. + const beforeSync = Date.now(); + + // send it! + await sut.fullSync(true, defaultSyncOptions); + + expectUpdateCallCount(mockUserState, 1); + // Get the first and only call to update(...) + const updateCall = mockUserState.update.mock.calls[0]; + // Get the first argument to update(...) -- this will be the date callback that returns the date of the last successful sync + const dateCallback = updateCall[0]; + const actualTime = dateCallback() as Date; + + expect(Math.abs(actualTime.getTime() - beforeSync)).toBeLessThan(1); + }); + + it("updates last sync time when no sync is necessary", async () => { + const revisionDate = new Date(1); + setupSyncScenario(revisionDate, revisionDate); + + const syncResult = await sut.fullSync(false, defaultSyncOptions); + + // Sync should complete but return false since no sync was needed + expect(syncResult).toBe(false); + expectUpdateCallCount(mockUserState, 1); + }); + + it("updates last sync time when sync is successful", async () => { + setupSyncScenario(new Date(2), new Date(1)); + + const syncResult = await sut.fullSync(false, defaultSyncOptions); + + expect(syncResult).toBe(true); + expectUpdateCallCount(mockUserState, 1); + }); + + describe("error scenarios", () => { + it("does not update last sync time when sync fails", async () => { + apiService.getSync.mockRejectedValue(new Error("not connected")); + + const syncResult = await sut.fullSync(true, errorTolerantSyncOptions); + + expect(syncResult).toBe(false); + expectUpdateCallCount(mockUserState, 0); + }); + + it("does not update last sync time when account revision check fails", async () => { + jest + .spyOn(apiService, "getAccountRevisionDate") + .mockRejectedValue(new Error("not connected")); + + const syncResult = await sut.fullSync(false, errorTolerantSyncOptions); + + expect(syncResult).toBe(false); + expectUpdateCallCount(mockUserState, 0); + }); + }); + }); }); }); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 582e4b58a64..a02d602dbf0 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -134,9 +134,11 @@ export class DefaultSyncService extends CoreSyncService { const now = new Date(); let needsSync = false; + let needsSyncSucceeded = true; try { needsSync = await this.needsSyncing(forceSync); } catch (e) { + needsSyncSucceeded = false; if (allowThrowOnError) { this.syncCompleted(false, userId); throw e; @@ -144,7 +146,9 @@ export class DefaultSyncService extends CoreSyncService { } if (!needsSync) { - await this.setLastSync(now, userId); + if (needsSyncSucceeded) { + await this.setLastSync(now, userId); + } return this.syncCompleted(false, userId); } diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index f6460521222..463cfeaa691 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -90,14 +90,10 @@ import { } from "../auth/models/response/two-factor-web-authn.response"; import { TwoFactorYubiKeyResponse } from "../auth/models/response/two-factor-yubi-key.response"; import { BitPayInvoiceRequest } from "../billing/models/request/bit-pay-invoice.request"; -import { PaymentRequest } from "../billing/models/request/payment.request"; -import { TaxInfoUpdateRequest } from "../billing/models/request/tax-info-update.request"; import { BillingHistoryResponse } from "../billing/models/response/billing-history.response"; -import { BillingPaymentResponse } from "../billing/models/response/billing-payment.response"; import { PaymentResponse } from "../billing/models/response/payment.response"; import { PlanResponse } from "../billing/models/response/plan.response"; import { SubscriptionResponse } from "../billing/models/response/subscription.response"; -import { TaxInfoResponse } from "../billing/models/response/tax-info.response"; import { ClientType, DeviceType } from "../enums"; import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request"; import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request"; @@ -294,11 +290,6 @@ export class ApiService implements ApiServiceAbstraction { return new SubscriptionResponse(r); } - async getTaxInfo(): Promise { - const r = await this.send("GET", "/accounts/tax", null, true, true); - return new TaxInfoResponse(r); - } - async putProfile(request: UpdateProfileRequest): Promise { const r = await this.send("PUT", "/accounts/profile", request, true, true); return new ProfileResponse(r); @@ -309,10 +300,6 @@ export class ApiService implements ApiServiceAbstraction { return new ProfileResponse(r); } - putTaxInfo(request: TaxInfoUpdateRequest): Promise { - return this.send("PUT", "/accounts/tax", request, true, false); - } - async postPrelogin(request: PreloginRequest): Promise { const env = await firstValueFrom(this.environmentService.environment$); const r = await this.send( @@ -365,10 +352,6 @@ export class ApiService implements ApiServiceAbstraction { return new PaymentResponse(r); } - postAccountPayment(request: PaymentRequest): Promise { - return this.send("POST", "/accounts/payment", request, true, false); - } - postAccountLicense(data: FormData): Promise { return this.send("POST", "/accounts/license", data, true, false); } @@ -429,11 +412,6 @@ export class ApiService implements ApiServiceAbstraction { return new BillingHistoryResponse(r); } - async getUserBillingPayment(): Promise { - const r = await this.send("GET", "/accounts/billing/payment-method", null, true, true); - return new BillingPaymentResponse(r); - } - // Cipher APIs async getCipher(id: string): Promise { diff --git a/libs/vault/src/abstractions/cipher-archive.service.ts b/libs/common/src/vault/abstractions/cipher-archive.service.ts similarity index 81% rename from libs/vault/src/abstractions/cipher-archive.service.ts rename to libs/common/src/vault/abstractions/cipher-archive.service.ts index 6240e4001c8..cb6c38ddf67 100644 --- a/libs/vault/src/abstractions/cipher-archive.service.ts +++ b/libs/common/src/vault/abstractions/cipher-archive.service.ts @@ -1,7 +1,6 @@ import { Observable } from "rxjs"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; export abstract class CipherArchiveService { @@ -10,5 +9,4 @@ export abstract class CipherArchiveService { abstract showArchiveVault$(userId: UserId): Observable; abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; - abstract canInteract(cipher: CipherView): Promise; } diff --git a/libs/common/src/vault/abstractions/search.service.ts b/libs/common/src/vault/abstractions/search.service.ts index 6b01302613c..233dee9ec75 100644 --- a/libs/common/src/vault/abstractions/search.service.ts +++ b/libs/common/src/vault/abstractions/search.service.ts @@ -30,6 +30,7 @@ export abstract class SearchService { ciphers: C[], query: string, deleted?: boolean, + archived?: boolean, ): C[]; abstract searchSends(sends: SendView[], query: string): SendView[]; } diff --git a/libs/vault/src/services/default-cipher-archive.service.spec.ts b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts similarity index 78% rename from libs/vault/src/services/default-cipher-archive.service.spec.ts rename to libs/common/src/vault/services/default-cipher-archive.service.spec.ts index ec2943ce7e4..972b04d2c4e 100644 --- a/libs/vault/src/services/default-cipher-archive.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts @@ -11,21 +11,14 @@ import { CipherBulkArchiveRequest, CipherBulkUnarchiveRequest, } from "@bitwarden/common/vault/models/request/cipher-bulk-archive.request"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; import { CipherListView } from "@bitwarden/sdk-internal"; -import { DecryptionFailureDialogComponent } from "../components/decryption-failure-dialog/decryption-failure-dialog.component"; - import { DefaultCipherArchiveService } from "./default-cipher-archive.service"; -import { PasswordRepromptService } from "./password-reprompt.service"; describe("DefaultCipherArchiveService", () => { let service: DefaultCipherArchiveService; let mockCipherService: jest.Mocked; let mockApiService: jest.Mocked; - let mockDialogService: jest.Mocked; - let mockPasswordRepromptService: jest.Mocked; let mockBillingAccountProfileStateService: jest.Mocked; let mockConfigService: jest.Mocked; @@ -35,16 +28,12 @@ describe("DefaultCipherArchiveService", () => { beforeEach(() => { mockCipherService = mock(); mockApiService = mock(); - mockDialogService = mock(); - mockPasswordRepromptService = mock(); mockBillingAccountProfileStateService = mock(); mockConfigService = mock(); service = new DefaultCipherArchiveService( mockCipherService, mockApiService, - mockDialogService, - mockPasswordRepromptService, mockBillingAccountProfileStateService, mockConfigService, ); @@ -244,46 +233,4 @@ describe("DefaultCipherArchiveService", () => { ); }); }); - - describe("canInteract", () => { - let mockCipherView: CipherView; - - beforeEach(() => { - mockCipherView = { - id: cipherId, - decryptionFailure: false, - } as unknown as CipherView; - }); - - it("should return false and open dialog when cipher has decryption failure", async () => { - mockCipherView.decryptionFailure = true; - const openSpy = jest.spyOn(DecryptionFailureDialogComponent, "open").mockImplementation(); - - const result = await service.canInteract(mockCipherView); - - expect(result).toBe(false); - expect(openSpy).toHaveBeenCalledWith(mockDialogService, { - cipherIds: [cipherId], - }); - }); - - it("should return password reprompt result when no decryption failure", async () => { - mockPasswordRepromptService.passwordRepromptCheck.mockResolvedValue(true); - - const result = await service.canInteract(mockCipherView); - - expect(result).toBe(true); - expect(mockPasswordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith( - mockCipherView, - ); - }); - - it("should return false when password reprompt fails", async () => { - mockPasswordRepromptService.passwordRepromptCheck.mockResolvedValue(false); - - const result = await service.canInteract(mockCipherView); - - expect(result).toBe(false); - }); - }); }); diff --git a/libs/vault/src/services/default-cipher-archive.service.ts b/libs/common/src/vault/services/default-cipher-archive.service.ts similarity index 83% rename from libs/vault/src/services/default-cipher-archive.service.ts rename to libs/common/src/vault/services/default-cipher-archive.service.ts index d9a0ec54d73..5c627d687b2 100644 --- a/libs/vault/src/services/default-cipher-archive.service.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.ts @@ -12,27 +12,21 @@ import { CipherBulkUnarchiveRequest, } from "@bitwarden/common/vault/models/request/cipher-bulk-archive.request"; import { CipherResponse } from "@bitwarden/common/vault/models/response/cipher.response"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherViewLike, CipherViewLikeUtils, } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; -import { DialogService } from "@bitwarden/components"; import { CipherArchiveService } from "../abstractions/cipher-archive.service"; -import { DecryptionFailureDialogComponent } from "../components/decryption-failure-dialog/decryption-failure-dialog.component"; - -import { PasswordRepromptService } from "./password-reprompt.service"; export class DefaultCipherArchiveService implements CipherArchiveService { constructor( private cipherService: CipherService, private apiService: ApiService, - private dialogService: DialogService, - private passwordRepromptService: PasswordRepromptService, private billingAccountProfileStateService: BillingAccountProfileStateService, private configService: ConfigService, ) {} + /** * Observable that contains the list of ciphers that have been archived. */ @@ -125,21 +119,4 @@ export class DefaultCipherArchiveService implements CipherArchiveService { await this.cipherService.replace(currentCiphers, userId); } - - /** - * Check if the user is able to interact with the cipher - * (password re-prompt / decryption failure checks). - * @param cipher - * @private - */ - async canInteract(cipher: CipherView) { - if (cipher.decryptionFailure) { - DecryptionFailureDialogComponent.open(this.dialogService, { - cipherIds: [cipher.id as CipherId], - }); - return false; - } - - return await this.passwordRepromptService.passwordRepromptCheck(cipher); - } } diff --git a/libs/common/src/vault/services/search.service.ts b/libs/common/src/vault/services/search.service.ts index cbd89cf1ab1..80fddda42d5 100644 --- a/libs/common/src/vault/services/search.service.ts +++ b/libs/common/src/vault/services/search.service.ts @@ -296,12 +296,20 @@ export class SearchService implements SearchServiceAbstraction { return results; } - searchCiphersBasic(ciphers: C[], query: string, deleted = false) { + searchCiphersBasic( + ciphers: C[], + query: string, + deleted = false, + archived = false, + ) { query = SearchService.normalizeSearchQuery(query.trim().toLowerCase()); return ciphers.filter((c) => { if (deleted !== CipherViewLikeUtils.isDeleted(c)) { return false; } + if (archived !== CipherViewLikeUtils.isArchived(c)) { + return false; + } if (c.name != null && c.name.toLowerCase().indexOf(query) > -1) { return true; } diff --git a/libs/components/src/form-field/form-field.component.html b/libs/components/src/form-field/form-field.component.html index ae3bad40698..c2c92104727 100644 --- a/libs/components/src/form-field/form-field.component.html +++ b/libs/components/src/form-field/form-field.component.html @@ -56,7 +56,7 @@
@@ -12,6 +12,7 @@ [ngClass]="{ '!tw-h-[55px] [&_svg]:!tw-w-[26px] [&_svg]:tw-inset-y-[theme(spacing.3)]': !sideNavService.open, + 'tw-w-56': sideNavService.open, }" [attr.aria-label]="label()" [title]="label()" diff --git a/libs/components/src/navigation/side-nav.service.ts b/libs/components/src/navigation/side-nav.service.ts index 87691244ca4..5a67f2c965b 100644 --- a/libs/components/src/navigation/side-nav.service.ts +++ b/libs/components/src/navigation/side-nav.service.ts @@ -1,4 +1,5 @@ import { Injectable } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { BehaviorSubject, Observable, combineLatest, fromEvent, map, startWith } from "rxjs"; @Injectable({ @@ -8,10 +9,20 @@ export class SideNavService { private _open$ = new BehaviorSubject(!window.matchMedia("(max-width: 768px)").matches); open$ = this._open$.asObservable(); - isOverlay$ = combineLatest([this.open$, media("(max-width: 768px)")]).pipe( + private isSmallScreen$ = media("(max-width: 768px)"); + + isOverlay$ = combineLatest([this.open$, this.isSmallScreen$]).pipe( map(([open, isSmallScreen]) => open && isSmallScreen), ); + constructor() { + this.isSmallScreen$.pipe(takeUntilDestroyed()).subscribe((isSmallScreen) => { + if (isSmallScreen) { + this.setClose(); + } + }); + } + get open() { return this._open$.getValue(); } diff --git a/libs/components/src/tw-theme-preflight.css b/libs/components/src/tw-theme-preflight.css index e5f35885993..372c80e0881 100644 --- a/libs/components/src/tw-theme-preflight.css +++ b/libs/components/src/tw-theme-preflight.css @@ -54,6 +54,14 @@ display: none !important; } + dl { + @apply tw-mb-4; + } + + dt { + @apply tw-font-bold; + } + hr { border-color: rgba(0, 0, 0, 0.1); } diff --git a/libs/node/package.json b/libs/node/package.json index 823913c997d..6a375a2a5ad 100644 --- a/libs/node/package.json +++ b/libs/node/package.json @@ -13,6 +13,7 @@ }, "license": "GPL-3.0", "scripts": { + "test": "jest", "clean": "rimraf dist", "build": "npm run clean && tsc", "build:watch": "npm run clean && tsc -watch" diff --git a/libs/node/project.json b/libs/node/project.json new file mode 100644 index 00000000000..d7757cda2ad --- /dev/null +++ b/libs/node/project.json @@ -0,0 +1,41 @@ +{ + "name": "@bitwarden/node", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/node/src", + "projectType": "library", + "tags": ["scope:node", "type:lib"], + "targets": { + "build": { + "executor": "nx:run-script", + "dependsOn": [], + "options": { + "script": "build" + } + }, + "build:watch": { + "executor": "nx:run-script", + "options": { + "script": "build:watch" + } + }, + "clean": { + "executor": "nx:run-script", + "options": { + "script": "clean" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/node/**/*.ts"] + } + }, + "test": { + "executor": "nx:run-script", + "options": { + "script": "test" + } + } + } +} diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html index 4e3d9fb17d6..c5c1b752aef 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html @@ -107,7 +107,7 @@ (keydown)="handleKeyDown($event, field.value.name, i)" data-testid="reorder-toggle-button" [disabled]="parentFormDisabled" - *ngIf="canEdit(field.value.type)" + *ngIf="canEdit(field.value.type) && fields.controls.length > 1" >
diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index c41e58f679e..4da299ed039 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -7,7 +7,7 @@ import { BehaviorSubject, of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionType, CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; @@ -33,6 +33,7 @@ const createMockCollection = ( organizationId: string, readOnly = false, canEdit = true, + type: CollectionType = CollectionTypes.DefaultUserCollection, ): CollectionView => { const cv = new CollectionView({ name, @@ -41,7 +42,7 @@ const createMockCollection = ( }); cv.readOnly = readOnly; cv.manage = true; - cv.type = CollectionTypes.DefaultUserCollection; + cv.type = type; cv.externalId = ""; cv.hidePasswords = false; cv.assigned = true; @@ -519,6 +520,42 @@ describe("ItemDetailsSectionComponent", () => { expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]); }); + + it("should exclude default collections when the cipher is only assigned to shared collections", async () => { + component.config.admin = false; + component.config.organizationDataOwnershipDisabled = true; + component.config.organizations = [{ id: "org1" } as Organization]; + component.config.collections = new Array(4) + .fill(null) + .map((_, i) => i + 1) + .map( + (i) => + createMockCollection( + `col${i}`, + `Collection ${i}`, + "org1", + false, + false, + i < 4 ? CollectionTypes.SharedCollection : CollectionTypes.DefaultUserCollection, + ) as CollectionView, + ); + component.originalCipherView = { + name: "cipher1", + organizationId: "org1", + folderId: "folder1", + collectionIds: ["col2", "col3"], + favorite: true, + } as CipherView; + fixture.detectChanges(); + await fixture.whenStable(); + + component.itemDetailsForm.controls.organizationId.setValue("org1"); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]); + }); }); describe("readonlyCollections", () => { diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 6af2fa19e26..ced6c809724 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -406,6 +406,17 @@ export class ItemDetailsSectionComponent implements OnInit { this.showCollectionsControl = true; } + /** + * Determine if the the cipher is only assigned to shared collections. + * i.e. The cipher is not assigned to a default collections. + * Note: `.every` will return true for an empty array + */ + const cipherIsOnlyInOrgCollections = + (this.originalCipherView?.collectionIds ?? []).length > 0 && + this.originalCipherView.collectionIds.every( + (cId) => + this.collections.find((c) => c.id === cId)?.type === CollectionTypes.SharedCollection, + ); this.collectionOptions = this.collections .filter((c) => { // The collection belongs to the organization @@ -423,10 +434,17 @@ export class ItemDetailsSectionComponent implements OnInit { return true; } + // When the cipher is only assigned to shared collections, do not allow a user to + // move it back to a default collection. Exclude the default collection from the list. + if (cipherIsOnlyInOrgCollections && c.type === CollectionTypes.DefaultUserCollection) { + return false; + } + // Non-admins can only select assigned collections that are not read only. (Non-AC) return c.assigned && !c.readOnly; }) .sort((a, b) => { + // Show default collection first const aIsDefaultCollection = a.type === CollectionTypes.DefaultUserCollection ? -1 : 0; const bIsDefaultCollection = b.type === CollectionTypes.DefaultUserCollection ? -1 : 0; return aIsDefaultCollection - bIsDefaultCollection; diff --git a/libs/vault/src/components/assign-collections.component.spec.ts b/libs/vault/src/components/assign-collections.component.spec.ts index e54bada30ba..414613e67d8 100644 --- a/libs/vault/src/components/assign-collections.component.spec.ts +++ b/libs/vault/src/components/assign-collections.component.spec.ts @@ -5,7 +5,11 @@ import { of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { + CollectionService, + CollectionTypes, + CollectionView, +} from "@bitwarden/admin-console/common"; import { OrganizationService } 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"; @@ -34,7 +38,6 @@ describe("AssignCollectionsComponent", () => { organizationId: "org-id" as OrganizationId, name: "Editable Collection", }); - editCollection.readOnly = false; editCollection.manage = true; @@ -52,6 +55,24 @@ describe("AssignCollectionsComponent", () => { }); readOnlyCollection2.readOnly = true; + const sharedCollection = new CollectionView({ + id: "shared-collection-id" as CollectionId, + organizationId: "org-id" as OrganizationId, + name: "Shared Collection", + }); + sharedCollection.readOnly = false; + sharedCollection.assigned = true; + sharedCollection.type = CollectionTypes.SharedCollection; + + const defaultCollection = new CollectionView({ + id: "default-collection-id" as CollectionId, + organizationId: "org-id" as OrganizationId, + name: "Default Collection", + }); + defaultCollection.readOnly = false; + defaultCollection.manage = true; + defaultCollection.type = CollectionTypes.DefaultUserCollection; + const params = { organizationId: "org-id" as OrganizationId, ciphers: [ @@ -116,4 +137,75 @@ describe("AssignCollectionsComponent", () => { ]); }); }); + + describe("default collections", () => { + const cipher1 = new CipherView(); + cipher1.id = "cipher-id-1"; + cipher1.collectionIds = [editCollection.id, sharedCollection.id]; + cipher1.edit = true; + + const cipher2 = new CipherView(); + cipher2.id = "cipher-id-2"; + cipher2.collectionIds = [defaultCollection.id]; + cipher2.edit = true; + + const cipher3 = new CipherView(); + cipher3.id = "cipher-id-3"; + cipher3.collectionIds = [defaultCollection.id]; + cipher3.edit = true; + + const cipher4 = new CipherView(); + cipher4.id = "cipher-id-4"; + cipher4.collectionIds = []; + cipher4.edit = true; + + it('does not show the "Default Collection" if any cipher is in a shared collection', async () => { + component.params = { + ...component.params, + ciphers: [cipher1, cipher2], + availableCollections: [editCollection, sharedCollection, defaultCollection], + }; + + await component.ngOnInit(); + fixture.detectChanges(); + + expect(component["availableCollections"].map((c) => c.id)).toEqual([ + editCollection.id, + sharedCollection.id, + ]); + }); + + it('shows the "Default Collection" if no ciphers are in a shared collection', async () => { + component.params = { + ...component.params, + ciphers: [cipher2, cipher3], + availableCollections: [editCollection, sharedCollection, defaultCollection], + }; + + await component.ngOnInit(); + fixture.detectChanges(); + + expect(component["availableCollections"].map((c) => c.id)).toEqual([ + editCollection.id, + sharedCollection.id, + defaultCollection.id, + ]); + }); + + it('shows the "Default Collection" for singular cipher', async () => { + component.params = { + ...component.params, + ciphers: [cipher4], + availableCollections: [readOnlyCollection1, sharedCollection, defaultCollection], + }; + + await component.ngOnInit(); + fixture.detectChanges(); + + expect(component["availableCollections"].map((c) => c.id)).toEqual([ + sharedCollection.id, + defaultCollection.id, + ]); + }); + }); }); diff --git a/libs/vault/src/components/assign-collections.component.ts b/libs/vault/src/components/assign-collections.component.ts index b2bd6e31ee5..453ba93f380 100644 --- a/libs/vault/src/components/assign-collections.component.ts +++ b/libs/vault/src/components/assign-collections.component.ts @@ -26,7 +26,11 @@ import { // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { + CollectionService, + CollectionTypes, + CollectionView, +} from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { getOrganizationById, @@ -311,9 +315,19 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI await this.setReadOnlyCollectionNames(); + const canAccessDefaultCollection = this.canAccessDefaultCollection( + this.params.availableCollections, + ); + this.availableCollections = this.params.availableCollections .filter((collection) => { - return collection.canEditItems(org); + if (canAccessDefaultCollection) { + return collection.canEditItems(org); + } + + return ( + collection.canEditItems(org) && collection.type !== CollectionTypes.DefaultUserCollection + ); }) .map((c) => ({ icon: "bwi-collection-shared", @@ -447,8 +461,16 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI const org = organizations.find((o) => o.id === orgId); this.orgName = org.name; - return collections.filter((c) => { - return c.organizationId === orgId && !c.readOnly; + const orgCollections = collections.filter((c) => c.organizationId === orgId); + + const canAccessDefaultCollection = this.canAccessDefaultCollection(collections); + + return orgCollections.filter((c) => { + if (canAccessDefaultCollection) { + return !c.readOnly; + } + + return !c.readOnly && c.type !== CollectionTypes.DefaultUserCollection; }); }), shareReplay({ refCount: true, bufferSize: 1 }), @@ -536,4 +558,26 @@ export class AssignCollectionsComponent implements OnInit, OnDestroy, AfterViewI }) .map((c) => c.name); } + + /** + * Determines if the ciphers to be assigned can be assigned to the Default Collection. + * When false, the Default Collections should be excluded from the list of available collections. + */ + private canAccessDefaultCollection(collections: CollectionView[]): boolean { + const collectionsObject = Object.fromEntries(collections.map((c) => [c.id, c])); + + const allCiphersUnassignedOrInDefault = this.params.ciphers.every( + (cipher) => + !cipher.collectionIds.length || + cipher.collectionIds.some( + (cId) => collectionsObject[cId]?.type === CollectionTypes.DefaultUserCollection, + ), + ); + + // When all ciphers are either: + // - unassigned + // - already in a Default Collection + // then the Default Collection can be shown. + return allCiphersUnassignedOrInDefault; + } } diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 5acac9ec009..efaefc77ade 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -27,5 +27,3 @@ export { SshImportPromptService } from "./services/ssh-import-prompt.service"; export * from "./abstractions/change-login-password.service"; export * from "./services/default-change-login-password.service"; -export * from "./abstractions/cipher-archive.service"; -export * from "./services/default-cipher-archive.service"; diff --git a/package-lock.json b/package-lock.json index 2ea907ec598..1b126255e63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,7 +97,7 @@ "@storybook/test-runner": "0.22.0", "@storybook/theming": "8.6.12", "@storybook/web-components-webpack5": "8.6.12", - "@types/chrome": "0.0.306", + "@types/chrome": "0.1.12", "@types/firefox-webext-browser": "120.0.4", "@types/inquirer": "8.2.10", "@types/jest": "29.5.14", @@ -13377,9 +13377,9 @@ } }, "node_modules/@types/chrome": { - "version": "0.0.306", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.306.tgz", - "integrity": "sha512-95kgcqvTNcaZCXmx/kIKY6uo83IaRNT3cuPxYqlB2Iu+HzKDCP4t7TUe7KhJijTdibcvn+SzziIcfSLIlgRnhQ==", + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.12.tgz", + "integrity": "sha512-jEkxs9GPQHx7g49WjkA8QDNcqODbMGDuBbWQOtjiS/Wf9AiEcDmQMIAgJvC/Xi36WoCVNx584g0Dd9ThJQCAiw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 65ead3fffc5..e94d0e98522 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@storybook/test-runner": "0.22.0", "@storybook/theming": "8.6.12", "@storybook/web-components-webpack5": "8.6.12", - "@types/chrome": "0.0.306", + "@types/chrome": "0.1.12", "@types/firefox-webext-browser": "120.0.4", "@types/inquirer": "8.2.10", "@types/jest": "29.5.14",