diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 8e31ab7a384..6fcffb1f875 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -147,6 +147,7 @@ "@nx/eslint", "@nx/jest", "@nx/js", + "@nx/webpack", "@types/chrome", "@types/firefox-webext-browser", "@types/glob", @@ -323,6 +324,7 @@ "storybook", "tailwindcss", "zone.js", + "@tailwindcss/container-queries", ], description: "UI Foundation owned dependencies", commitMessagePrefix: "[deps] UI Foundation:", diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index bef686592d4..4f776876f17 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -119,7 +119,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Install Snap - uses: samuelmeuli/action-snapcraft@d33c176a9b784876d966f80fb1b461808edc0641 # v2.1.1 + uses: samuelmeuli/action-snapcraft@fceeb3c308e76f3487e72ef608618de625fb7fe8 # v3.0.1 - name: Download artifacts run: wget https://github.com/bitwarden/clients/releases/download/cli-v${{ env._PKG_VERSION }}/bw_${{ env._PKG_VERSION }}_amd64.snap diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index 9fe8909f8d6..f0de331431c 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -233,7 +233,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Install Snap - uses: samuelmeuli/action-snapcraft@d33c176a9b784876d966f80fb1b461808edc0641 # v2.1.1 + uses: samuelmeuli/action-snapcraft@fceeb3c308e76f3487e72ef608618de625fb7fe8 # v3.0.1 - name: Setup run: mkdir dist diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml new file mode 100644 index 00000000000..83cbc3bb547 --- /dev/null +++ b/.github/workflows/review-code.yml @@ -0,0 +1,124 @@ +name: Review code + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: {} + +jobs: + review: + name: Review + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + pull-requests: write + + steps: + - name: Check out repo + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check for Vault team changes + id: check_changes + run: | + # Ensure we have the base branch + git fetch origin ${{ github.base_ref }} + + echo "Comparing changes between origin/${{ github.base_ref }} and HEAD" + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + + if [ -z "$CHANGED_FILES" ]; then + echo "Zero files changed" + echo "vault_team_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Handle variations in spacing and multiple teams + VAULT_PATTERNS=$(grep -E "@bitwarden/team-vault-dev(\s|$)" .github/CODEOWNERS 2>/dev/null | awk '{print $1}') + + if [ -z "$VAULT_PATTERNS" ]; then + echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS" + echo "vault_team_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + vault_team_changes=false + for pattern in $VAULT_PATTERNS; do + echo "Checking pattern: $pattern" + + # Handle **/directory patterns + if [[ "$pattern" == "**/"* ]]; then + # Remove the **/ prefix + dir_pattern="${pattern#\*\*/}" + # Check if any file contains this directory in its path + if echo "$CHANGED_FILES" | grep -qE "(^|/)${dir_pattern}(/|$)"; then + vault_team_changes=true + echo "✅ Found files matching pattern: $pattern" + echo "$CHANGED_FILES" | grep -E "(^|/)${dir_pattern}(/|$)" | sed 's/^/ - /' + break + fi + else + # Handle other patterns (shouldn't happen based on your CODEOWNERS) + if echo "$CHANGED_FILES" | grep -q "$pattern"; then + vault_team_changes=true + echo "✅ Found files matching pattern: $pattern" + echo "$CHANGED_FILES" | grep "$pattern" | sed 's/^/ - /' + break + fi + fi + done + + echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT + + if [ "$vault_team_changes" = "true" ]; then + echo "" + echo "✅ Vault team changes detected - proceeding with review" + else + echo "" + echo "❌ No Vault team changes detected - skipping review" + fi + + - name: Review with Claude Code + if: steps.check_changes.outputs.vault_team_changes == 'true' + uses: anthropics/claude-code-action@ac1a3207f3f00b4a37e2f3a6f0935733c7c64651 # v1.0.11 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + track_progress: true + use_sticky_comment: true + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + TITLE: ${{ github.event.pull_request.title }} + BODY: ${{ github.event.pull_request.body }} + AUTHOR: ${{ github.event.pull_request.user.login }} + COMMIT: ${{ github.event.pull_request.head.sha }} + + Please review this pull request with a focus on: + - Code quality and best practices + - Potential bugs or issues + - Security implications + - Performance considerations + + Note: The PR branch is already checked out in the current working directory. + + Provide a comprehensive review including: + - Summary of changes since last review + - Critical issues found (be thorough) + - Suggested improvements (be thorough) + - Good practices observed (be concise - list only the most notable items without elaboration) + - Action items for the author + - Leverage collapsible
sections where appropriate for lengthy explanations or code snippets to enhance human readability + + When reviewing subsequent commits: + - Track status of previously identified issues (fixed/unfixed/reopened) + - Identify NEW problems introduced since last review + - Note if fixes introduced new issues + + IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively. + + claude_args: | + --allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*)" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64c4e0dff13..2770c1257ea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -125,8 +125,8 @@ jobs: - name: Test Windows if: ${{ matrix.os=='windows-2022'}} - working-directory: ./apps/desktop/desktop_native/core - run: cargo test -- --test-threads=1 + working-directory: ./apps/desktop/desktop_native + run: cargo test --workspace --exclude=desktop_napi -- --test-threads=1 rust-coverage: name: Rust Coverage diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 59b5287f3a3..2480eef505d 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -8,7 +8,7 @@ setCompodocJson(docJson); const wrapperDecorator = componentWrapperDecorator((story) => { return /*html*/ ` -
+
${story}
`; diff --git a/CLAUDE.md b/CLAUDE.md index 0870553f8d3..9739288aac8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,13 @@ # Bitwarden Clients - Claude Code Configuration +## Project Context Files + +**Read these files before reviewing to ensure that you fully understand the project and contributing guidelines** + +1. @README.md +2. @CONTRIBUTING.md +3. @.github/PULL_REQUEST_TEMPLATE.md + ## Critical Rules - **NEVER** use code regions: If complexity suggests regions, refactor for better readability @@ -8,7 +16,7 @@ - **NEVER** send unencrypted vault data to API services -- **NEVER** commit secrets, credentials, or sensitive information. Follow the guidelines in `SECURITY.md`. +- **NEVER** commit secrets, credentials, or sensitive information. - **NEVER** log decrypted data, encryption keys, or PII - No vault data in error messages or console logs diff --git a/apps/browser/package.json b/apps/browser/package.json index 24a53f43f66..744b53688b2 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.9.0", + "version": "2025.10.1", "scripts": { "build": "npm run build:chrome", "build:bit": "npm run build:bit:chrome", diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index e8a37262993..de3383ef2d3 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "قراءة مفتاح الأمان" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "في انتظار التفاعل مع مفتاح الأمن..." }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index c877b87f90f..caaee731fd9 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -551,11 +551,11 @@ "message": "Axtarışı sıfırla" }, "archiveNoun": { - "message": "Archive", + "message": "Arxiv", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Arxivlə", "description": "Verb" }, "unarchive": { @@ -734,7 +734,7 @@ "message": "Yararsız ana parol" }, "invalidMasterPasswordConfirmEmailAndHost": { - "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "message": "Yararsız ana parol. E-poçtunuzun doğru olduğunu və hesabınızın $HOST$ üzərində yaradıldığını təsdiqləyin.", "placeholders": { "host": { "content": "$1", @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Güvənlik açarını oxu" }, + "readingPasskeyLoading": { + "message": "Keçid açarı oxunur..." + }, + "passkeyAuthenticationFailed": { + "message": "Keçid açarı kimlik doğrulaması uğursuzdur" + }, + "useADifferentLogInMethod": { + "message": "Fərqli bir giriş üsulu istifadə edin" + }, "awaitingSecurityKeyInteraction": { "message": "Güvənlik açarı ilə əlaqə gözlənilir..." }, @@ -3208,7 +3217,7 @@ } }, "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat seyfi xaricə köçürüləcək.", "placeholders": { "organization": { "content": "$1", @@ -3217,7 +3226,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat seyfi xaricə köçürüləcək. Element kolleksiyalarım daxil edilməyəcək.", "placeholders": { "organization": { "content": "$1", diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index fb645e0b815..d395e4f01a6 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index d27e426d4aa..d3d7c3dc502 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Прочитане на ключа за сигурност" }, + "readingPasskeyLoading": { + "message": "Прочитане на секретния ключ…" + }, + "passkeyAuthenticationFailed": { + "message": "Удостоверяването чрез секретен ключ беше неуспешно" + }, + "useADifferentLogInMethod": { + "message": "Използване на друг метод на вписване" + }, "awaitingSecurityKeyInteraction": { "message": "Изчакване на действие с ключ за сигурност…" }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 66d3fbe598a..389a47bd248 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index 39df44ca95e..994aec7473c 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 7f897e9cb4c..201298369a3 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 8820af69f98..d6b3cc9083b 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Přečíst bezpečnostní klíč" }, + "readingPasskeyLoading": { + "message": "Načítání přístupového klíče..." + }, + "passkeyAuthenticationFailed": { + "message": "Ověření přístupového klíče selhalo" + }, + "useADifferentLogInMethod": { + "message": "Použít jinou metodu přihlášení" + }, "awaitingSecurityKeyInteraction": { "message": "Čeká se na interakci s bezpečnostním klíčem..." }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index ded0ad406df..b8758c60f00 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 04d287947ee..2fc49cceb1e 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index ce26078d427..003d4e6b8b0 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Sicherheitsschlüssel auslesen" }, + "readingPasskeyLoading": { + "message": "Passkey wird gelesen..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey-Authentifizierung fehlgeschlagen" + }, + "useADifferentLogInMethod": { + "message": "Eine andere Anmeldemethode verwenden" + }, "awaitingSecurityKeyInteraction": { "message": "Warte auf Sicherheitsschlüssel-Interaktion..." }, diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 139899b10ac..9912d79920d 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index df47d357746..d91a33c6796 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 8dc87aa722a..0a551ee92bd 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index cf8b7e08d22..c2160e67d03 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 27404fdc5f6..0bacebadab1 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Leer clave de seguridad" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Esperando interacción de la clave de seguridad..." }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index a4fb0892e46..28e143553dd 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index cc6b510f37e..deea3a4a245 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 4ec4d513039..ea1d68d7cf4 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "خواندن کلید امنیتی" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "در انتظار تعامل با کلید امنیتی..." }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 5edc9e84c63..472ee14efab 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Lue suojausavain" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Odotetaan suojausavaimen aktivointia..." }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 2afa450a7d0..4281b98d7c2 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index c74f0248693..98c10cb2283 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -3,7 +3,7 @@ "message": "Bitwarden" }, "appLogoLabel": { - "message": "Logo de Bitwarden" + "message": "Logo Bitwarden" }, "extName": { "message": "Gestionnaire de mots de passe Bitwarden", @@ -555,32 +555,32 @@ "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Archiver", "description": "Verb" }, "unarchive": { - "message": "Unarchive" + "message": "Ne plus archiver" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Éléments dans l'archive" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Aucun élément dans l'archive" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "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": "Item sent to archive" + "message": "Élément envoyé à l'archive" }, "itemRemovedFromArchive": { - "message": "Item removed from archive" + "message": "Élément retiré de l'archive" }, "archiveItem": { - "message": "Archive item" + "message": "Archiver l'élément" }, "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "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 ?" }, "edit": { "message": "Modifier" @@ -589,7 +589,7 @@ "message": "Afficher" }, "viewLogin": { - "message": "View login" + "message": "Afficher l'Identifiant" }, "noItemsInList": { "message": "Aucun identifiant à afficher." @@ -734,7 +734,7 @@ "message": "Mot de passe principal invalide" }, "invalidMasterPasswordConfirmEmailAndHost": { - "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "message": "Mot de passe principal invalide. Confirmez que votre adresse courriel est correcte et que votre compte a été créé sur $HOST$.", "placeholders": { "host": { "content": "$1", @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Lire la clé de sécurité" }, + "readingPasskeyLoading": { + "message": "Lecture de la clé d'accès..." + }, + "passkeyAuthenticationFailed": { + "message": "Échec de l'authentification avec clé d'accès" + }, + "useADifferentLogInMethod": { + "message": "Utiliser une méthode de connexion différente" + }, "awaitingSecurityKeyInteraction": { "message": "En attente d'interaction de la clé de sécurité..." }, @@ -1796,7 +1805,7 @@ "message": "Cliquer en dehors de la fenêtre popup pour vérifier votre courriel avec le code de vérification va causer la fermeture de cette fenêtre popup. Voulez-vous ouvrir cette fenêtre popup dans une nouvelle fenêtre afin qu'elle ne se ferme pas ?" }, "showIconsChangePasswordUrls": { - "message": "Show website icons and retrieve change password URLs" + "message": "Afficher les icônes du site web et récupérer les URL de changement de mot de passe" }, "cardholderName": { "message": "Nom du titulaire de la carte" @@ -1961,79 +1970,79 @@ "message": "Note" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Nouvel Identifiant", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Nouvelle Carte de paiement", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Nouvelle Identité", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Nouvelle Note", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Nouvelle clé SSH", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Nouveau Send de texte", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Nouveau Send de fichier", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Modifier l'Identifiant", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Modifier la Carte de paiement", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Modifier l'Identité", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Modifier la Note", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Modifier la clé SSH", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Modifier le Send de texte", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Modifier le Send de fichier", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Afficher l'Identifiant", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Afficher la Carte de paiement", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "View Identity", + "message": "Afficher l'Identité", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Afficher la Note", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Afficher la clé SSH", "description": "Header for view SSH key item type" }, "passwordHistory": { @@ -3208,7 +3217,7 @@ } }, "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "message": "Seul le coffre de l'organisation associé à $ORGANIZATION$ sera exporté.", "placeholders": { "organization": { "content": "$1", @@ -3217,7 +3226,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "message": "Seul le coffre de l'organisation associé à $ORGANIZATION$ sera exporté. Mes éléments de mes collections ne seront pas inclus.", "placeholders": { "organization": { "content": "$1", @@ -5181,10 +5190,10 @@ "message": "Afficher le nombre de suggestions de saisie automatique d'identifiant sur l'icône d'extension" }, "accountAccessRequested": { - "message": "Account access requested" + "message": "Accès au compte demandé" }, "confirmAccessAttempt": { - "message": "Confirm access attempt for $EMAIL$", + "message": "Confirmer la tentative d’accès pour $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -5517,10 +5526,10 @@ "message": "Changer le mot de passe à risque" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Cet identifiant est à risques et manque un site web. Ajoutez un site web et changez le mot de passe pour une meilleure sécurité." }, "missingWebsite": { - "message": "Missing website" + "message": "Site Web manquant" }, "settingsVaultOptions": { "message": "Options du coffre" @@ -5571,16 +5580,16 @@ "message": "Bienvenue dans votre coffre !" }, "phishingPageTitle": { - "message": "Phishing website" + "message": "Site Web d'hameçonnage" }, "phishingPageCloseTab": { - "message": "Close tab" + "message": "Fermer l'onglet" }, "phishingPageContinue": { - "message": "Continue" + "message": "Continuer" }, "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "message": "Pourquoi voyez-vous cela?" }, "hasItemsVaultNudgeBodyOne": { "message": "Remplissage automatique des éléments de la page actuelle" @@ -5658,10 +5667,10 @@ "description": "Aria label for the body content of the generator nudge" }, "aboutThisSetting": { - "message": "About this setting" + "message": "À propos de ce paramètre" }, "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 utilisera les URI de connexion enregistrées pour identifier quelle icône ou URL de changement de mot de passe doit être utilisée pour améliorer votre expérience. Aucune information n'est recueillie ou enregistrée lorsque vous utilisez ce service." }, "noPermissionsViewPage": { "message": "Vous n'avez pas les autorisations pour consulter cette page. Essayez de vous connecter avec un autre compte." @@ -5671,13 +5680,13 @@ "description": "'WebAssembly' is a technical term and should not be translated." }, "showMore": { - "message": "Show more" + "message": "Afficher plus" }, "showLess": { - "message": "Show less" + "message": "Afficher moins" }, "next": { - "message": "Next" + "message": "Suivant" }, "moreBreadcrumbs": { "message": "Plus de fil d'Ariane", diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 421adf16a2f..11ef9ced579 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index affba06eafd..f040ab6a1b6 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "קרא מפתח אבטחה" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "ממתין לאינטראקציה עם מפתח אבטחה..." }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 323d72f802d..42231b15ef6 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 34c114c214d..b892c2c152b 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Pročitaj sigurnosni ključ" }, + "readingPasskeyLoading": { + "message": "Čitanje pristupnog ključa..." + }, + "passkeyAuthenticationFailed": { + "message": "Autentifikacija pristupnog ključa nije uspjela" + }, + "useADifferentLogInMethod": { + "message": "Koristi drugi način prijave" + }, "awaitingSecurityKeyInteraction": { "message": "Čekanje na interakciju sa sigurnosnim ključem..." }, diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 2cafe38fab4..ac9f04f7389 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Biztonsági kulcs olvasása" }, + "readingPasskeyLoading": { + "message": "Hozzáférési kulcs beolvasása..." + }, + "passkeyAuthenticationFailed": { + "message": "A hozzáférési kulcs hitelesítés sikertelen volt." + }, + "useADifferentLogInMethod": { + "message": "Más bejelentkezési mód használata" + }, "awaitingSecurityKeyInteraction": { "message": "Várakozás a biztonsági kulcs interakciójára..." }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 80886711095..98f46f494c5 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Baca kunci keamanan" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Menunggu interaksi kunci keamanan..." }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index c9a51f416e4..8a1357df910 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Leggi chiave di sicurezza" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "In attesa di interazione con la chiave di sicurezza..." }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 0fa768e3716..b053a0cb609 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "セキュリティキーの読み取り" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "セキュリティキーとの通信を待ち受け中…" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 13f3da81404..245a822cc0d 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index dcd86eb4337..6180d490f09 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 16e6cc2a631..008a0deb1b3 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 9b80dae4d7c..30a157c7119 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 478164a7f9c..f8d7be17fde 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index f9a8c3fba2b..1f2268a59ac 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Nolasīt drošības atslēgu" }, + "readingPasskeyLoading": { + "message": "Nolasa piekļuves atslēgu..." + }, + "passkeyAuthenticationFailed": { + "message": "Autentificēšanās ar piekļuves atslēgu neizdevās" + }, + "useADifferentLogInMethod": { + "message": "Jāizmanto cits pieteikšanās veids" + }, "awaitingSecurityKeyInteraction": { "message": "Gaida mijiedarbību ar drošības atslēgu..." }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index cd99f32714e..644a2ef36b2 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index e639bae2662..b5d78c14b58 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index dcd86eb4337..6180d490f09 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index b01d5a5ebbf..ef3b307e194 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Les sikkerhetsnøkkel" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index dcd86eb4337..6180d490f09 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 62f81cf0897..cf152e2ded4 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Beveiligingssleutel lezen" }, + "readingPasskeyLoading": { + "message": "Passkey uitlezen..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey-authenticatie mislukt" + }, + "useADifferentLogInMethod": { + "message": "Gebruik een andere loginmethode" + }, "awaitingSecurityKeyInteraction": { "message": "Wacht op interactie met beveiligingssleutel…" }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index dcd86eb4337..6180d490f09 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index dcd86eb4337..6180d490f09 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 8d96ccaffc6..29e76da67ce 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Odczytaj klucz bezpieczeństwa" }, + "readingPasskeyLoading": { + "message": "Odczytywanie klucza dostępu..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Użyj innej metody logowania" + }, "awaitingSecurityKeyInteraction": { "message": "Oczekiwanie na klucz bezpieczeństwa..." }, @@ -2188,7 +2197,7 @@ "description": "ex. Date this item was created" }, "datePasswordUpdated": { - "message": "Hasło zostało zaktualizowane", + "message": "Ostatnia aktualizacja hasła", "description": "ex. Date this password was updated" }, "neverLockWarning": { @@ -3208,7 +3217,7 @@ } }, "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "message": "Tylko sejf organizacji $ORGANIZATION$ zostanie wyeksportowany.", "placeholders": { "organization": { "content": "$1", @@ -3217,7 +3226,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "message": "Tylko sejf organizacji $ORGANIZATION$ zostanie wyeksportowany. Twoje kolekcje nie zostaną uwzględnione.", "placeholders": { "organization": { "content": "$1", diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 746c04bdf0f..57367d84329 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Ler chave de segurança" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Aguardando interação com a chave de segurança..." }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 345be1766de..df830be16ad 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Ler chave de segurança" }, + "readingPasskeyLoading": { + "message": "A ler chave de acesso..." + }, + "passkeyAuthenticationFailed": { + "message": "Falha na autenticação da chave de acesso" + }, + "useADifferentLogInMethod": { + "message": "Utilizar um método de início de sessão diferente" + }, "awaitingSecurityKeyInteraction": { "message": "A aguardar interação da chave de segurança..." }, diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 2dcf2fb6181..15dd663451d 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 593e2b5d672..c23f51dc39d 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Считать ключ безопасности" }, + "readingPasskeyLoading": { + "message": "Чтение passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Не удалось выполнить аутентификацию с помощью passkey" + }, + "useADifferentLogInMethod": { + "message": "Использовать другой способ авторизации" + }, "awaitingSecurityKeyInteraction": { "message": "Ожидание взаимодействия с ключом безопасности..." }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 8218a5d8787..66bc97ef431 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index cfc1404e9bf..6d0bbfc02d6 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Prečítať bezpečnostný kľúč" }, + "readingPasskeyLoading": { + "message": "Načítava sa prístupový kľúč…" + }, + "passkeyAuthenticationFailed": { + "message": "Overenie prístupovým kľúčom zlyhalo" + }, + "useADifferentLogInMethod": { + "message": "Použiť iný spôsob prihlásenia" + }, "awaitingSecurityKeyInteraction": { "message": "Čaká sa na interakciu s bezpečnostným kľúčom..." }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 23327bbb1ae..ab914ee0804 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index a915463799a..c931242e4a8 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Читај сигурносни кључ" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Чека се интеракција сигурносног кључа..." }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index fb83fdf22b6..5976deb4200 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Läs säkerhetsnyckel" }, + "readingPasskeyLoading": { + "message": "Läser inloggningsnyckel..." + }, + "passkeyAuthenticationFailed": { + "message": "Autentisering med inloggningsnyckel misslyckades" + }, + "useADifferentLogInMethod": { + "message": "Använd en annan inloggningsmetod" + }, "awaitingSecurityKeyInteraction": { "message": "Väntar på interaktion med säkerhetsnyckel..." }, diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index 8d1f1ccf87f..e0a64ab13d1 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "பாதுகாப்பு விசையைப் படி" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "பாதுகாப்பு விசை தொடர்புகொள்ளக் காத்திருக்கிறது..." }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index dcd86eb4337..6180d490f09 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 064b70c26ec..744f1e6aa49 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Read security key" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 470589c286d..f1ae2b1accd 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Güvenlik anahtarını oku" }, + "readingPasskeyLoading": { + "message": "Geçiş anahtarı okunuyor..." + }, + "passkeyAuthenticationFailed": { + "message": "Geçiş anahtarı doğrulaması başarısız oldu" + }, + "useADifferentLogInMethod": { + "message": "Başka bir giriş yöntemi kullan" + }, "awaitingSecurityKeyInteraction": { "message": "Güvenlik anahtarı etkileşimi bekleniyor…" }, diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index fb64a91330d..313c30b568e 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Зчитати ключ безпеки" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Очікується взаємодія з ключем безпеки..." }, diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 8b69fdc2512..738b22fcad2 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "Đọc khóa bảo mật" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Đang chờ tương tác với khóa bảo mật..." }, diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 9ced7b1efd7..aa3c9de9e13 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -1515,7 +1515,7 @@ "message": "身份验证超时" }, "authenticationSessionTimedOut": { - "message": "身份验证会话超时。请重新启动登录过程。" + "message": "身份验证会话超时。请重新开始登录过程。" }, "verificationCodeEmailSent": { "message": "验证邮件已发送到 $EMAIL$。", @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "读取安全密钥" }, + "readingPasskeyLoading": { + "message": "正在读取通行密钥..." + }, + "passkeyAuthenticationFailed": { + "message": "通行密钥验证失败" + }, + "useADifferentLogInMethod": { + "message": "使用其他登录方式" + }, "awaitingSecurityKeyInteraction": { "message": "等待安全密钥交互..." }, diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 5f6defcfe24..373fa0c75e1 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -1548,6 +1548,15 @@ "readSecurityKey": { "message": "讀取安全金鑰" }, + "readingPasskeyLoading": { + "message": "Reading passkey..." + }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed" + }, + "useADifferentLogInMethod": { + "message": "Use a different log in method" + }, "awaitingSecurityKeyInteraction": { "message": "Awaiting security key interaction..." }, diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html index b9f9b984c69..cef2a748d58 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html @@ -41,12 +41,10 @@ -
+
-

- {{ "options" | i18n }} -

+

{{ "options" | i18n }}

diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts index 7bb12fc260d..99d2c83283e 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts @@ -160,7 +160,7 @@ export class AccountSwitcherService { throwError(() => new Error(AccountSwitcherService.incompleteAccountSwitchError)), }), ), - ).catch((err) => { + ).catch((err): any => { if ( err instanceof Error && err.message === AccountSwitcherService.incompleteAccountSwitchError diff --git a/apps/browser/src/auth/popup/login/extension-login-component.service.ts b/apps/browser/src/auth/popup/login/extension-login-component.service.ts index 37d74616391..621c7d74876 100644 --- a/apps/browser/src/auth/popup/login/extension-login-component.service.ts +++ b/apps/browser/src/auth/popup/login/extension-login-component.service.ts @@ -68,4 +68,18 @@ export class ExtensionLoginComponentService showBackButton(showBackButton: boolean): void { this.extensionAnonLayoutWrapperDataService.setAnonLayoutWrapperData({ showBackButton }); } + + /** + * Enable passkey login support for chromium-based browsers only. + * Neither Firefox nor safari support overriding the relying party ID in an extension. + * + * https://github.com/w3c/webextensions/issues/238 + * + * Tracking links: + * https://bugzilla.mozilla.org/show_bug.cgi?id=1956484 + * https://developer.apple.com/forums/thread/774351 + */ + isLoginWithPasskeySupported(): boolean { + return this.platformUtilsService.isChromium(); + } } diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index 52720b1f9f5..912d9657124 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -35,7 +35,7 @@ interface NotificationQueueMessage { } type ChangePasswordNotificationData = { - cipherId: CipherView["id"]; + cipherIds: CipherView["id"][]; newPassword: string; }; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 032baf2e32b..f9e2e1c534f 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -133,19 +133,11 @@ describe("NotificationBackground", () => { expect(cipherView.name).toEqual("example.com"); expect(cipherView.login).toEqual({ - autofillOnPageLoad: null, - fido2Credentials: null, + fido2Credentials: [], password: message.password, - passwordRevisionDate: null, - totp: null, uris: [ { - _canLaunch: null, - _domain: null, - _host: null, - _hostname: null, _uri: message.uri, - match: null, }, ], username: message.username, @@ -289,7 +281,6 @@ describe("NotificationBackground", () => { let tab: chrome.tabs.Tab; let sender: chrome.runtime.MessageSender; let getEnableAddedLoginPromptSpy: jest.SpyInstance; - let getEnableChangedPasswordPromptSpy: jest.SpyInstance; let pushAddLoginToQueueSpy: jest.SpyInstance; let pushChangePasswordToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; @@ -306,10 +297,7 @@ describe("NotificationBackground", () => { notificationBackground as any, "getEnableAddedLoginPrompt", ); - getEnableChangedPasswordPromptSpy = jest.spyOn( - notificationBackground as any, - "getEnableChangedPasswordPrompt", - ); + pushAddLoginToQueueSpy = jest.spyOn(notificationBackground as any, "pushAddLoginToQueue"); pushChangePasswordToQueueSpy = jest.spyOn( notificationBackground as any, @@ -368,24 +356,6 @@ describe("NotificationBackground", () => { expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); - it("skips attempting to change the password for an existing login if the user has disabled changing the password notification", async () => { - const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getEnableAddedLoginPromptSpy.mockReturnValueOnce(true); - getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ login: { username: "test", password: "oldPassword" } }), - ]); - - await notificationBackground.triggerAddLoginNotification(data, tab); - - expect(getEnableAddedLoginPromptSpy).toHaveBeenCalled(); - expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); - expect(getEnableChangedPasswordPromptSpy).toHaveBeenCalled(); - expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); - expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); - }); - it("skips attempting to change the password for an existing login if the password has not changed", async () => { const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -445,37 +415,12 @@ describe("NotificationBackground", () => { sender.tab, ); }); - - it("adds a change password message to the queue if the user has changed an existing cipher's password", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - username: "tEsT", - }; - - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getEnableAddedLoginPromptSpy.mockResolvedValueOnce(true); - getEnableChangedPasswordPromptSpy.mockResolvedValueOnce(true); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ - id: "cipher-id", - login: { username: "test", password: "oldPassword" }, - }), - ]); - - await notificationBackground.triggerAddLoginNotification(data, tab); - - expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - "cipher-id", - "example.com", - data.password, - sender.tab, - ); - }); }); describe("bgTriggerChangedPasswordNotification message handler", () => { let tab: chrome.tabs.Tab; let sender: chrome.runtime.MessageSender; + let getEnableChangedPasswordPromptSpy: jest.SpyInstance; let pushChangePasswordToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; const mockModifyLoginCipherFormData: ModifyLoginCipherFormData = { @@ -488,6 +433,11 @@ describe("NotificationBackground", () => { beforeEach(() => { tab = createChromeTabMock(); sender = mock({ tab }); + getEnableChangedPasswordPromptSpy = jest.spyOn( + notificationBackground as any, + "getEnableChangedPasswordPrompt", + ); + pushChangePasswordToQueueSpy = jest.spyOn( notificationBackground as any, "pushChangePasswordToQueue", @@ -495,6 +445,40 @@ describe("NotificationBackground", () => { getAllDecryptedForUrlSpy = jest.spyOn(cipherService, "getAllDecryptedForUrl"); }); + afterEach(() => { + getEnableChangedPasswordPromptSpy.mockRestore(); + pushChangePasswordToQueueSpy.mockRestore(); + getAllDecryptedForUrlSpy.mockRestore(); + }); + + it("skips attempting to change the password for an existing login if the user has disabled changing the password notification", async () => { + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + }; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + getAllDecryptedForUrlSpy.mockResolvedValueOnce([ + mock({ login: { username: "test", password: "oldPassword" } }), + ]); + + await notificationBackground.triggerChangedPasswordNotification(data, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + }); + + it("skips attempting to add the change password message to the queue if the user is logged out", async () => { + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", + }; + + activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); + + await notificationBackground.triggerChangedPasswordNotification(data, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + }); + it("skips attempting to add the change password message to the queue if the passed url is not valid", async () => { const data: ModifyLoginCipherFormData = mockModifyLoginCipherFormData; @@ -503,7 +487,92 @@ describe("NotificationBackground", () => { expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); - it("adds a change password message to the queue if the user does not have an unlocked account", async () => { + it("only only includes ciphers in notification data matching a username if username was present in the modify form data", async () => { + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", + username: "userName", + }; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce([ + mock({ + id: "cipher-id-1", + login: { username: "test", password: "currentPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "username", password: "currentPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "uSeRnAmE", password: "currentPassword" }, + }), + ]); + + await notificationBackground.triggerChangedPasswordNotification(data, tab); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-2", "cipher-id-3"], + "example.com", + data?.newPassword, + sender.tab, + ); + }); + + it("adds a change password message to the queue with current password, if there is a current password, but no new password", async () => { + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", + password: "newPasswordUpdatedElsewhere", + newPassword: null, + }; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce([ + mock({ + id: "cipher-id-1", + login: { password: "currentPassword" }, + }), + ]); + await notificationBackground.triggerChangedPasswordNotification(data, tab); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1"], + "example.com", + data?.password, + sender.tab, + ); + }); + + it("adds a change password message to the queue with new password, if new password is provided", async () => { + const data: ModifyLoginCipherFormData = { + ...mockModifyLoginCipherFormData, + uri: "https://example.com", + password: "password2", + newPassword: "password3", + }; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce([ + mock({ + id: "cipher-id-1", + login: { password: "password1" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "password4" }, + }), + ]); + await notificationBackground.triggerChangedPasswordNotification(data, tab); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-4"], + "example.com", + data?.newPassword, + sender.tab, + ); + }); + + it("adds a change password message to the queue if the user has a locked account", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", @@ -522,10 +591,12 @@ describe("NotificationBackground", () => { ); }); - it("skips adding a change password message to the queue if the multiple ciphers exist for the passed URL and the current password is not found within the list of ciphers", async () => { + it("doesn't add a password if there is no current or new password", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", + password: null, + newPassword: null, }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -537,23 +608,6 @@ describe("NotificationBackground", () => { expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); }); - it("skips adding a change password message if more than one existing cipher is found with a matching password ", async () => { - const data: ModifyLoginCipherFormData = { - ...mockModifyLoginCipherFormData, - uri: "https://example.com", - }; - activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); - getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ login: { username: "test", password: "password" } }), - mock({ login: { username: "test2", password: "password" } }), - ]); - - await notificationBackground.triggerChangedPasswordNotification(data, tab); - - expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); - expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); - }); - it("adds a change password message to the queue if a single cipher matches the passed current password", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, @@ -570,28 +624,39 @@ describe("NotificationBackground", () => { await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - "cipher-id", + ["cipher-id"], "example.com", data?.newPassword, sender.tab, ); }); - it("skips adding a change password message if no current password is passed in the message and more than one cipher is found for a url", async () => { + it("adds a change password message with all matching ciphers if no current password is passed and more than one cipher is found for a url", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", + password: null, }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ - mock({ login: { username: "test", password: "password" } }), - mock({ login: { username: "test2", password: "password" } }), + mock({ + id: "cipher-id-1", + login: { username: "test", password: "password" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "test2", password: "password" }, + }), ]); await notificationBackground.triggerChangedPasswordNotification(data, tab); - expect(getAllDecryptedForUrlSpy).toHaveBeenCalled(); - expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + "example.com", + data?.newPassword, + sender.tab, + ); }); it("adds a change password message to the queue if no current password is passed with the message, but a single cipher is matched for the uri", async () => { @@ -611,7 +676,7 @@ describe("NotificationBackground", () => { await notificationBackground.triggerChangedPasswordNotification(data, tab); expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( - "cipher-id", + ["cipher-id"], "example.com", data?.newPassword, sender.tab, diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index d44bf2f1507..e27b50f13cd 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -213,14 +213,26 @@ export default class NotificationBackground { let cipherView: CipherView; if (cipherQueueMessage.type === NotificationType.ChangePassword) { const { - data: { cipherId }, + data: { cipherIds }, } = cipherQueueMessage; - cipherView = await this.getDecryptedCipherById(cipherId, activeUserId); + const cipherViews = await this.cipherService.getAllDecrypted(activeUserId); + return cipherViews + .filter((cipher) => cipherIds.includes(cipher.id)) + .map((cipherView) => { + const organizationType = getOrganizationType(cipherView.organizationId); + return this.convertToNotificationCipherData( + cipherView, + iconsServerUrl, + showFavicons, + organizationType, + ); + }); } else { cipherView = this.convertAddLoginQueueMessageToCipherView(cipherQueueMessage); } const organizationType = getOrganizationType(cipherView.organizationId); + return [ this.convertToNotificationCipherData( cipherView, @@ -555,16 +567,6 @@ export default class NotificationBackground { return true; } - const changePasswordIsEnabled = await this.getEnableChangedPasswordPrompt(); - - if ( - changePasswordIsEnabled && - usernameMatches.length === 1 && - usernameMatches[0].login.password !== login.password - ) { - await this.pushChangePasswordToQueue(usernameMatches[0].id, loginDomain, login.password, tab); - return true; - } return false; } @@ -603,45 +605,92 @@ export default class NotificationBackground { data: ModifyLoginCipherFormData, tab: chrome.tabs.Tab, ): Promise { - const changeData = { - url: data.uri, - currentPassword: data.password, - newPassword: data.newPassword, - }; - - const loginDomain = Utils.getDomain(changeData.url); - if (loginDomain == null) { + const changePasswordIsEnabled = await this.getEnableChangedPasswordPrompt(); + if (!changePasswordIsEnabled) { return false; } - - if ((await this.getAuthStatus()) < AuthenticationStatus.Unlocked) { - await this.pushChangePasswordToQueue(null, loginDomain, changeData.newPassword, tab, true); - return true; + const authStatus = await this.getAuthStatus(); + if (authStatus === AuthenticationStatus.LoggedOut) { + return false; } - - let id: string = null; const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getOptionalUserId), ); - if (activeUserId == null) { + if (activeUserId === null) { + return false; + } + const loginDomain = Utils.getDomain(data.uri); + if (loginDomain === null) { return false; } - const ciphers = await this.cipherService.getAllDecryptedForUrl(changeData.url, activeUserId); - if (changeData.currentPassword != null) { - const passwordMatches = ciphers.filter( - (c) => c.login.password === changeData.currentPassword, - ); - if (passwordMatches.length === 1) { - id = passwordMatches[0].id; - } - } else if (ciphers.length === 1) { - id = ciphers[0].id; - } - if (id != null) { - await this.pushChangePasswordToQueue(id, loginDomain, changeData.newPassword, tab); + const username: string | null = data.username || null; + const currentPassword = data.password || null; + const newPassword = data.newPassword || null; + + if (authStatus === AuthenticationStatus.Locked && newPassword !== null) { + await this.pushChangePasswordToQueue(null, loginDomain, newPassword, tab, true); return true; } + + let ciphers: CipherView[] = await this.cipherService.getAllDecryptedForUrl( + data.uri, + activeUserId, + ); + + const normalizedUsername: string = username ? username.toLowerCase() : ""; + + const shouldMatchUsername = typeof username === "string" && username.length > 0; + + if (shouldMatchUsername) { + // Presence of a username should filter ciphers further. + ciphers = ciphers.filter( + (cipher) => + cipher.login.username !== null && + cipher.login.username.toLowerCase() === normalizedUsername, + ); + } + + if (ciphers.length === 1) { + const [cipher] = ciphers; + if ( + username !== null && + newPassword === null && + cipher.login.username === normalizedUsername && + cipher.login.password === currentPassword + ) { + // Assumed to be a login + return false; + } + } + + if (currentPassword && !newPassword) { + // Only use current password for change if no new password present. + if (ciphers.length > 0) { + await this.pushChangePasswordToQueue( + ciphers.map((cipher) => cipher.id), + loginDomain, + currentPassword, + tab, + ); + return true; + } + } + + if (newPassword) { + // Otherwise include all known ciphers. + if (ciphers.length > 0) { + await this.pushChangePasswordToQueue( + ciphers.map((cipher) => cipher.id), + loginDomain, + newPassword, + tab, + ); + + return true; + } + } + return false; } @@ -666,7 +715,7 @@ export default class NotificationBackground { } private async pushChangePasswordToQueue( - cipherId: string, + cipherIds: CipherView["id"][], loginDomain: string, newPassword: string, tab: chrome.tabs.Tab, @@ -677,7 +726,7 @@ export default class NotificationBackground { const launchTimestamp = new Date().getTime(); const message: AddChangePasswordNotificationQueueMessage = { type: NotificationType.ChangePassword, - data: { cipherId: cipherId, newPassword: newPassword }, + data: { cipherIds: cipherIds, newPassword: newPassword }, domain: loginDomain, tab: tab, launchTimestamp, @@ -716,12 +765,12 @@ export default class NotificationBackground { return; } - await this.saveOrUpdateCredentials(sender.tab, message.edit, message.folder); + await this.saveOrUpdateCredentials(sender.tab, message.cipherId, message.edit, message.folder); } async handleCipherUpdateRepromptResponse(message: NotificationBackgroundExtensionMessage) { if (message.success) { - await this.saveOrUpdateCredentials(message.tab, false, undefined, true); + await this.saveOrUpdateCredentials(message.tab, message.cipherId, false, undefined, true); } else { await BrowserApi.tabSendMessageData(message.tab, "saveCipherAttemptCompleted", { error: "Password reprompt failed", @@ -740,6 +789,7 @@ export default class NotificationBackground { */ private async saveOrUpdateCredentials( tab: chrome.tabs.Tab, + cipherId: CipherView["id"], edit: boolean, folderId?: string, skipReprompt: boolean = false, @@ -764,7 +814,7 @@ export default class NotificationBackground { if (queueMessage.type === NotificationType.ChangePassword) { const { - data: { cipherId, newPassword }, + data: { newPassword }, } = queueMessage; const cipherView = await this.getDecryptedCipherById(cipherId, activeUserId); @@ -1221,7 +1271,6 @@ export default class NotificationBackground { cipherView.folderId = folderId; cipherView.type = CipherType.Login; cipherView.login = loginView; - cipherView.organizationId = null; return cipherView; } diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 4657dfb6d1f..e08fe540710 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -455,12 +455,12 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg notificationType: NotificationType, ): boolean => { switch (notificationType) { - case NotificationTypes.Change: - return modifyLoginData?.newPassword && !modifyLoginData.username; case NotificationTypes.Add: return ( modifyLoginData?.username && !!(modifyLoginData.password || modifyLoginData.newPassword) ); + case NotificationTypes.Change: + return !!(modifyLoginData.password || modifyLoginData.newPassword); case NotificationTypes.AtRiskPassword: return !modifyLoginData.newPassword; case NotificationTypes.Unlock: diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-action.ts b/apps/browser/src/autofill/content/components/cipher/cipher-action.ts index 34ad5e1c9a9..7a392849996 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-action.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-action.ts @@ -4,8 +4,10 @@ import { BadgeButton } from "../../../content/components/buttons/badge-button"; import { EditButton } from "../../../content/components/buttons/edit-button"; import { NotificationTypes } from "../../../notification/abstractions/notification-bar"; import { I18n } from "../common-types"; +import { selectedCipher as selectedCipherSignal } from "../signals/selected-cipher"; export type CipherActionProps = { + cipherId: string; handleAction?: (e: Event) => void; i18n: I18n; itemName: string; @@ -15,6 +17,7 @@ export type CipherActionProps = { }; export function CipherAction({ + cipherId, handleAction = () => { /* no-op */ }, @@ -24,9 +27,17 @@ export function CipherAction({ theme, username, }: CipherActionProps) { + const selectCipherHandleAction = (e: Event) => { + selectedCipherSignal.set(cipherId); + try { + handleAction(e); + } finally { + selectedCipherSignal.set(null); + } + }; return notificationType === NotificationTypes.Change ? BadgeButton({ - buttonAction: handleAction, + buttonAction: selectCipherHandleAction, buttonText: i18n.notificationUpdate, itemName, theme, diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-item.ts b/apps/browser/src/autofill/content/components/cipher/cipher-item.ts index ab3b57f535c..3bfc44636b3 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-item.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-item.ts @@ -40,6 +40,7 @@ export function CipherItem({ if (notificationType === NotificationTypes.Change || notificationType === NotificationTypes.Add) { cipherActionButton = html`
${CipherAction({ + cipherId: cipher.id, handleAction, i18n, itemName: name, diff --git a/apps/browser/src/autofill/content/components/signals/selected-cipher.ts b/apps/browser/src/autofill/content/components/signals/selected-cipher.ts new file mode 100644 index 00000000000..360457233f5 --- /dev/null +++ b/apps/browser/src/autofill/content/components/signals/selected-cipher.ts @@ -0,0 +1,3 @@ +import { signal } from "@lit-labs/signals"; + +export const selectedCipher = signal(null); diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index 9ae6fcedc8f..fcf91ca2e91 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -1,6 +1,7 @@ import { render } from "lit"; import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { NotificationCipherData } from "../content/components/cipher/types"; @@ -8,6 +9,7 @@ import { CollectionView, I18n, OrgView } from "../content/components/common-type import { AtRiskNotification } from "../content/components/notification/at-risk-password/container"; import { NotificationConfirmationContainer } from "../content/components/notification/confirmation/container"; import { NotificationContainer } from "../content/components/notification/container"; +import { selectedCipher as selectedCipherSignal } from "../content/components/signals/selected-cipher"; import { selectedFolder as selectedFolderSignal } from "../content/components/signals/selected-folder"; import { selectedVault as selectedVaultSignal } from "../content/components/signals/selected-vault"; @@ -180,9 +182,9 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { const i18n = getI18n(); const resolvedTheme = getResolvedTheme(theme ?? ThemeTypes.Light); - const resolvedType = resolveNotificationType(notificationBarIframeInitData); - const headerMessage = getNotificationHeaderMessage(i18n, resolvedType); - const notificationTestId = getNotificationTestId(resolvedType); + const notificationType = resolveNotificationType(notificationBarIframeInitData); + const headerMessage = getNotificationHeaderMessage(i18n, notificationType); + const notificationTestId = getNotificationTestId(notificationType); appendHeaderMessageToTitle(headerMessage); document.body.innerHTML = ""; @@ -191,7 +193,7 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { const notificationConfig = { ...notificationBarIframeInitData, headerMessage, - type: resolvedType, + type: notificationType, notificationTestId, theme: resolvedTheme, personalVaultIsAllowed: !personalVaultDisallowed, @@ -201,7 +203,8 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { }; const handleSaveAction = () => { - sendSaveCipherMessage(true); + // cipher ID is null while vault is locked. + sendSaveCipherMessage(null, true); render( NotificationContainer({ @@ -262,7 +265,7 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { NotificationContainer({ ...notificationBarIframeInitData, headerMessage, - type: resolvedType, + type: notificationType, theme: resolvedTheme, notificationTestId, personalVaultIsAllowed: !personalVaultDisallowed, @@ -276,9 +279,8 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { }); function handleEditOrUpdateAction(e: Event) { - const notificationType = initData?.type; e.preventDefault(); - notificationType === "add" ? sendSaveCipherMessage(true) : sendSaveCipherMessage(false); + sendSaveCipherMessage(selectedCipherSignal.get(), notificationType === NotificationTypes.Add); } } @@ -291,6 +293,7 @@ function handleCloseNotification(e: Event) { } function handleSaveAction(e: Event) { + const selectedCipher = selectedCipherSignal.get(); const selectedVault = selectedVaultSignal.get(); const selectedFolder = selectedFolderSignal.get(); @@ -304,16 +307,16 @@ function handleSaveAction(e: Event) { } e.preventDefault(); - - sendSaveCipherMessage(removeIndividualVault(), selectedFolder); + sendSaveCipherMessage(selectedCipher, removeIndividualVault(), selectedFolder); if (removeIndividualVault()) { return; } } -function sendSaveCipherMessage(edit: boolean, folder?: string) { +function sendSaveCipherMessage(cipherId: CipherView["id"] | null, edit: boolean, folder?: string) { sendPlatformMessage({ command: "bgSaveCipher", + cipherId, folder, edit, }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index 247104e13a5..b550ae203d5 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -237,8 +237,11 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } }, ); + this.buttonElement = globalThis.document.createElement(customElementName); this.buttonElement.setAttribute("popover", "manual"); + + this.createInternalStyleNode(this.buttonElement); } /** @@ -264,8 +267,33 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } }, ); + this.listElement = globalThis.document.createElement(customElementName); this.listElement.setAttribute("popover", "manual"); + + this.createInternalStyleNode(this.listElement); + } + + /** + * Builds and prepends an internal stylesheet to the container node with rules + * to prevent targeting by the host's global styling rules. This should only be + * used for pseudo elements such as `::backdrop` or `::before`. All other + * styles should be applied inline upon the parent container itself. + */ + private createInternalStyleNode(parent: HTMLElement) { + const css = document.createTextNode(` + ${parent.tagName}::backdrop { + background: none !important; + pointer-events: none !important; + } + ${parent.tagName}::before, ${parent.tagName}::after { + content:"" !important; + } + `); + const style = globalThis.document.createElement("style"); + style.setAttribute("type", "text/css"); + style.appendChild(css); + parent.prepend(style); } /** diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 0e238d14d23..73262962dbc 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -161,7 +161,7 @@ export default class AutofillService implements AutofillServiceInterface { // Create a timeout observable that emits an empty array if pageDetailsFromTab$ hasn't emitted within 1 second. const pageDetailsTimeout$ = timer(1000).pipe( - map(() => []), + map((): any => []), takeUntil(sharedPageDetailsFromTab$), ); @@ -2270,6 +2270,8 @@ export default class AutofillService implements AutofillServiceInterface { withoutForm: boolean, ): AutofillField | null { let usernameField: AutofillField = null; + let usernameFieldInSameForm: AutofillField = null; + for (let i = 0; i < pageDetails.fields.length; i++) { const f = pageDetails.fields[i]; if (AutofillService.forCustomFieldsOnly(f)) { @@ -2282,22 +2284,29 @@ export default class AutofillService implements AutofillServiceInterface { const includesUsernameFieldName = this.findMatchingFieldIndex(f, AutoFillConstants.UsernameFieldNames) > -1; + const isInSameForm = f.form === passwordField.form; if ( !f.disabled && (canBeReadOnly || !f.readonly) && - (withoutForm || f.form === passwordField.form || includesUsernameFieldName) && + (withoutForm || isInSameForm || includesUsernameFieldName) && (canBeHidden || f.viewable) && (f.type === "text" || f.type === "email" || f.type === "tel") ) { - usernameField = f; - // We found an exact match. No need to keep looking. - if (includesUsernameFieldName) { - break; + // Prioritize fields in the same form as the password field + if (isInSameForm) { + usernameFieldInSameForm = f; + if (includesUsernameFieldName) { + return f; + } + } else { + usernameField = f; } } } - return usernameField; + + // Prefer username field in same form, fall back to any username field + return usernameFieldInSameForm || usernameField; } /** diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index fef0181352c..bd28ddfbbbf 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -830,6 +830,7 @@ export default class MainBackground { this.accountService, this.kdfConfigService, this.keyService, + this.apiService, this.stateProvider, this.configService, ); diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index d7aef0db375..9dc2bff65e5 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -145,6 +145,7 @@ export default class RuntimeBackground { if (totpCode != null) { this.platformUtilsService.copyToClipboard(totpCode); } + await this.main.updateOverlayCiphers(); break; } case ExtensionCommand.AutofillCard: { diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 2a45c846060..e218abd2d10 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.9.0", + "version": "2025.10.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 6eeac8c8b39..6f4fc905f44 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.9.0", + "version": "2025.10.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/popup/services/browser-router.service.ts b/apps/browser/src/platform/popup/services/browser-router.service.ts index 2d449b8a0f2..e1de1fdd29d 100644 --- a/apps/browser/src/platform/popup/services/browser-router.service.ts +++ b/apps/browser/src/platform/popup/services/browser-router.service.ts @@ -21,9 +21,7 @@ export class BrowserRouterService { child = child.firstChild; } - // TODO: Eslint upgrade. Please resolve this since the ?? does nothing - // eslint-disable-next-line no-constant-binary-expression - const updateUrl = !child?.data?.doNotSaveUrl ?? true; + const updateUrl = !child?.data?.doNotSaveUrl; if (updateUrl) { this.setPreviousUrl(event.url); diff --git a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts index b545618c0ce..2e9746642f4 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts @@ -62,9 +62,7 @@ export class PopupRouterCacheService { child = child.firstChild; } - // TODO: Eslint upgrade. Please resolve this since the ?? does nothing - // eslint-disable-next-line no-constant-binary-expression - return !child?.data?.doNotSaveUrl ?? true; + return !child?.data?.doNotSaveUrl; }), switchMap((event) => this.push(event.url)), ) 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 b835c711853..7dcd8a12392 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 @@ -70,8 +70,8 @@ export class BrowserSystemNotificationService implements SystemNotificationsServ } async clear(clearInfo: SystemNotificationClearInfo): Promise { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - chrome.notifications.clear(clearInfo.id); + await chrome.notifications.clear(clearInfo.id); + return undefined; } isSupported(): boolean { diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index b69d7b73672..17a812f451c 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -12,6 +12,7 @@ import { tdeDecryptionRequiredGuard, unauthGuardFn, } from "@bitwarden/angular/auth/guards"; +import { LoginViaWebAuthnComponent } from "@bitwarden/angular/auth/login-via-webauthn/login-via-webauthn.component"; 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 { @@ -22,6 +23,7 @@ import { UserLockIcon, VaultIcon, LockIcon, + TwoFactorAuthSecurityKeyIcon, DeactivatedOrg, } from "@bitwarden/assets/svg"; import { @@ -403,6 +405,29 @@ const routes: Routes = [ }, ], }, + { + path: "login-with-passkey", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { + pageIcon: TwoFactorAuthSecurityKeyIcon, + pageTitle: { + key: "logInWithPasskey", + }, + pageSubtitle: { + key: "readingPasskeyLoadingInfo", + }, + elevation: 1, + showBackButton: true, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { path: "", component: LoginViaWebAuthnComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, { path: "sso", canActivate: [unauthGuardFn(unauthRouteOverrides)], diff --git a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts b/apps/browser/src/tools/popup/components/file-popout-callout.component.ts index 25b80c82c57..f597998fa56 100644 --- a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts +++ b/apps/browser/src/tools/popup/components/file-popout-callout.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, OnInit } from "@angular/core"; @@ -15,10 +13,10 @@ import { FilePopoutUtilsService } from "../services/file-popout-utils.service"; imports: [CommonModule, JslibModule, CalloutModule], }) export class FilePopoutCalloutComponent implements OnInit { - protected showFilePopoutMessage: boolean; - protected showFirefoxFileWarning: boolean; - protected showSafariFileWarning: boolean; - protected showChromiumFileWarning: boolean; + protected showFilePopoutMessage: boolean = false; + protected showFirefoxFileWarning: boolean = false; + protected showSafariFileWarning: boolean = false; + protected showChromiumFileWarning: boolean = false; constructor(private filePopoutUtilsService: FilePopoutUtilsService) {} diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts index 251f19cf252..26b3a2abbc7 100644 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts @@ -1,7 +1,5 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, Input, OnInit } from "@angular/core"; +import { Component, input, OnInit } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; @@ -18,7 +16,7 @@ import { SendFilePopoutDialogComponent } from "./send-file-popout-dialog.compone imports: [JslibModule, CommonModule], }) export class SendFilePopoutDialogContainerComponent implements OnInit { - @Input() config: SendFormConfig; + config = input.required(); constructor( private dialogService: DialogService, @@ -27,8 +25,8 @@ export class SendFilePopoutDialogContainerComponent implements OnInit { ngOnInit() { if ( - this.config?.sendType === SendType.File && - this.config?.mode === "add" && + this.config().sendType === SendType.File && + this.config().mode === "add" && this.filePopoutUtilsService.showFilePopoutMessage(window) ) { this.dialogService.open(SendFilePopoutDialogComponent); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index 7ee7e141ee5..3a48f7eb449 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -26,6 +26,11 @@ + @if (canEdit) { + + } {{ "clone" | i18n }} @@ -43,5 +48,12 @@ {{ "archiveVerb" | i18n }} } + @if (canDelete$ | async) { + + } 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 324ea2ffcdf..ebcd8707597 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 @@ -120,6 +120,10 @@ export class ItemMoreOptionsComponent { }), ); + protected canDelete$ = this._cipher$.pipe( + switchMap((cipher) => this.cipherAuthorizationService.canDeleteCipher$(cipher)), + ); + constructor( private cipherService: CipherService, private passwordRepromptService: PasswordRepromptService, @@ -252,6 +256,37 @@ export class ItemMoreOptionsComponent { }); } + protected async edit() { + if (this.cipher.reprompt && !(await this.passwordRepromptService.showPasswordPrompt())) { + return; + } + + await this.router.navigate(["/edit-cipher"], { + queryParams: { cipherId: this.cipher.id, type: CipherViewLikeUtils.getType(this.cipher) }, + }); + } + + protected async delete() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { key: "deleteItemConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + await this.cipherService.softDeleteWithServer(this.cipher.id as CipherId, activeUserId); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("deletedItem"), + }); + } + async archive() { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts index f271b255c3e..718043b4e85 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts @@ -204,6 +204,7 @@ describe("VaultPopupAutofillService", () => { describe("doAutofill()", () => { it("should return true if autofill is successful", async () => { + mockCipher.id = "test-cipher-id"; mockAutofillService.doAutoFill.mockResolvedValue(null); const result = await service.doAutofill(mockCipher); expect(result).toBe(true); @@ -251,6 +252,7 @@ describe("VaultPopupAutofillService", () => { }); it("should copy TOTP code to clipboard if available", async () => { + mockCipher.id = "test-cipher-id-with-totp"; const totpCode = "123456"; mockAutofillService.doAutoFill.mockResolvedValue(totpCode); await service.doAutofill(mockCipher); @@ -405,5 +407,26 @@ describe("VaultPopupAutofillService", () => { }); }); }); + describe("handleAutofillSuggestionUsed", () => { + const cipherId = "cipher-123"; + + beforeEach(() => { + mockCipherService.updateLastUsedDate.mockResolvedValue(undefined); + }); + + it("updates last used date when there is an active user", async () => { + await service.handleAutofillSuggestionUsed({ cipherId }); + + expect(mockCipherService.updateLastUsedDate).toHaveBeenCalledTimes(1); + expect(mockCipherService.updateLastUsedDate).toHaveBeenCalledWith(cipherId, mockUserId); + }); + + it("does nothing when there is no active user", async () => { + accountService.activeAccount$ = of(null); + await service.handleAutofillSuggestionUsed({ cipherId }); + + expect(mockCipherService.updateLastUsedDate).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts index 2d30e857573..3d5b35cded6 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts @@ -16,6 +16,7 @@ import { } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { isUrlInList } from "@bitwarden/common/autofill/utils"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -268,6 +269,7 @@ export class VaultPopupAutofillService { }); return false; } + await this.handleAutofillSuggestionUsed({ cipherId: cipher.id }); return true; } @@ -326,6 +328,21 @@ export class VaultPopupAutofillService { return didAutofill; } + /** + * When a user autofills with an autofill suggestion outside of the inline menu, + * update the cipher's last used date. + * + * @param message - The message containing the cipher ID that was used + */ + async handleAutofillSuggestionUsed(message: { cipherId: string }) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getOptionalUserId), + ); + if (activeUserId) { + await this.cipherService.updateLastUsedDate(message.cipherId, activeUserId); + } + } + /** * Attempts to autofill the given cipher and, upon successful autofill, saves the URI to the cipher. * Will copy any TOTP code to the clipboard if available after successful autofill. 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 fa56b45c080..a1820a975f1 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 @@ -120,7 +120,7 @@ export class VaultPopupItemsService { .cipherListViews$(userId) .pipe(filter((ciphers) => ciphers != null)), this.cipherService.failedToDecryptCiphers$(userId), - this.restrictedItemTypesService.restricted$.pipe(startWith([])), + this.restrictedItemTypesService.restricted$, ]), ), map(([ciphers, failedToDecryptCiphers, restrictions]) => { diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index eecd1f2fd68..692e21d0084 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -761,11 +761,13 @@ function createSeededVaultPopupListFiltersService( const collectionServiceMock = { decryptedCollections$: () => seededCollections$, getAllNested: () => - seededCollections$.value.map((c) => ({ - children: [], - node: c, - parent: null, - })), + seededCollections$.value.map( + (c): TreeNode => ({ + children: [], + node: c, + parent: null as any, + }), + ), } as any; const folderServiceMock = { diff --git a/apps/browser/store/locales/fr/copy.resx b/apps/browser/store/locales/fr/copy.resx index 327d39a23c4..3ccb9c26b71 100644 --- a/apps/browser/store/locales/fr/copy.resx +++ b/apps/browser/store/locales/fr/copy.resx @@ -168,7 +168,8 @@ Applications multiplateformes Sécurisez et partagez des données sensibles dans votre coffre Bitwarden à partir de n'importe quel navigateur, appareil mobile ou système d'exploitation de bureau, et plus encore. Bitwarden sécurise bien plus que les mots de passe -Les solutions de gestion de bout en bout des identifiants chiffrés de Bitwarden permettent aux organisations de tout sécuriser, y compris les secrets des développeurs et les expériences de clés de passe. Visitez Bitwarden.com pour en savoir plus sur Bitwarden Secrets Manager et Bitwarden Passwordless.dev ! +Les solutions de gestion de bout en bout des identifiants chiffrés de Bitwarden permettent aux organisations de tout sécuriser, y compris les secrets des développeurs et les expériences de clés d'accès. Visitez Bitwarden.com pour en savoir plus sur Bitwarden Secrets Manager et Bitwarden Passwordless.dev ! + À la maison, au travail ou en déplacement, Bitwarden sécurise facilement tous vos mots de passe, clés d'accès et informations sensibles. diff --git a/apps/cli/package.json b/apps/cli/package.json index 659a68d13a5..35dc7920382 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/cli", "description": "A secure and free password manager for all of your devices.", - "version": "2025.9.0", + "version": "2025.10.1", "keywords": [ "bitwarden", "password", diff --git a/apps/cli/project.json b/apps/cli/project.json new file mode 100644 index 00000000000..229738818a7 --- /dev/null +++ b/apps/cli/project.json @@ -0,0 +1,86 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "cli", + "projectType": "application", + "sourceRoot": "apps/cli/src", + "tags": ["scope:cli", "type:app"], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "oss-dev", + "options": { + "outputPath": "dist/apps/cli", + "webpackConfig": "apps/cli/webpack.config.js", + "tsConfig": "apps/cli/tsconfig.json", + "main": "apps/cli/src/bw.ts", + "target": "node", + "compiler": "tsc" + }, + "configurations": { + "oss": { + "mode": "production", + "outputPath": "dist/apps/cli/oss" + }, + "oss-dev": { + "mode": "development", + "outputPath": "dist/apps/cli/oss-dev" + }, + "commercial": { + "mode": "production", + "outputPath": "dist/apps/cli/commercial", + "webpackConfig": "bitwarden_license/bit-cli/webpack.config.js", + "main": "bitwarden_license/bit-cli/src/bw.ts", + "tsConfig": "bitwarden_license/bit-cli/tsconfig.json" + }, + "commercial-dev": { + "mode": "development", + "outputPath": "dist/apps/cli/commercial-dev", + "webpackConfig": "bitwarden_license/bit-cli/webpack.config.js", + "main": "bitwarden_license/bit-cli/src/bw.ts", + "tsConfig": "bitwarden_license/bit-cli/tsconfig.json" + } + } + }, + "serve": { + "executor": "@nx/webpack:webpack", + "defaultConfiguration": "oss-dev", + "options": { + "outputPath": "dist/apps/cli", + "webpackConfig": "apps/cli/webpack.config.js", + "tsConfig": "apps/cli/tsconfig.json", + "main": "apps/cli/src/bw.ts", + "target": "node", + "compiler": "tsc", + "watch": true + }, + "configurations": { + "oss-dev": { + "mode": "development", + "outputPath": "dist/apps/cli/oss-dev" + }, + "commercial-dev": { + "mode": "development", + "outputPath": "dist/apps/cli/commercial-dev", + "webpackConfig": "bitwarden_license/bit-cli/webpack.config.js", + "main": "bitwarden_license/bit-cli/src/bw.ts", + "tsConfig": "bitwarden_license/bit-cli/tsconfig.json" + } + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/cli/jest.config.js" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/cli/**/*.ts"] + } + } + } +} diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 8fb48fbc1ee..c2fffef6685 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -612,6 +612,7 @@ export class ServiceContainer { this.accountService, this.kdfConfigService, this.keyService, + this.apiService, this.stateProvider, this.configService, customUserAgent, diff --git a/apps/cli/webpack.base.js b/apps/cli/webpack.base.js new file mode 100644 index 00000000000..01d5fc5b175 --- /dev/null +++ b/apps/cli/webpack.base.js @@ -0,0 +1,124 @@ +const path = require("path"); +const webpack = require("webpack"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); +const nodeExternals = require("webpack-node-externals"); +const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); +const config = require("./config/config"); + +module.exports.getEnv = function getEnv() { + const ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; + return { ENV }; +}; + +const DEFAULT_PARAMS = { + localesPath: "./src/locales", + modulesPath: [path.resolve("../../node_modules")], + externalsModulesDir: "../../node_modules", + outputPath: path.resolve(__dirname, "build"), + watch: false, +}; + +/** + * + * @param {{ + * configName: string; + * entry: string; + * tsConfig: string; + * outputPath?: string; + * mode?: string; + * env?: string; + * modulesPath?: string[]; + * localesPath?: string; + * externalsModulesDir?: string; + * watch?: boolean; + * }} params + */ +module.exports.buildConfig = function buildConfig(params) { + params = { ...DEFAULT_PARAMS, ...params }; + const ENV = params.env || module.exports.getEnv().ENV; + + const envConfig = config.load(ENV); + config.log(`Building CLI - ${params.configName} version`); + config.log(envConfig); + + const moduleRules = [ + { + test: /\.ts$/, + use: "ts-loader", + exclude: path.resolve(__dirname, "node_modules"), + }, + ]; + + const plugins = [ + new CopyWebpackPlugin({ + patterns: [{ from: params.localesPath, to: "locales" }], + }), + new webpack.DefinePlugin({ + "process.env.BWCLI_ENV": JSON.stringify(ENV), + }), + new webpack.BannerPlugin({ + banner: "#!/usr/bin/env node", + raw: true, + }), + new webpack.IgnorePlugin({ + resourceRegExp: /^encoding$/, + contextRegExp: /node-fetch/, + }), + new webpack.EnvironmentPlugin({ + ENV: ENV, + BWCLI_ENV: ENV, + FLAGS: envConfig.flags, + DEV_FLAGS: envConfig.devFlags, + }), + new webpack.IgnorePlugin({ + resourceRegExp: /canvas/, + contextRegExp: /jsdom$/, + }), + ]; + + const webpackConfig = { + mode: params.mode || ENV, + target: "node", + devtool: ENV === "development" ? "eval-source-map" : "source-map", + node: { + __dirname: false, + __filename: false, + }, + entry: { + bw: params.entry, + }, + optimization: { + minimize: false, + }, + resolve: { + extensions: [".ts", ".js"], + symlinks: false, + modules: params.modulesPath, + plugins: [new TsconfigPathsPlugin({ configFile: params.tsConfig })], + }, + output: { + filename: "[name].js", + path: path.resolve(params.outputPath), + clean: true, + }, + module: { rules: moduleRules }, + plugins: plugins, + externals: [ + nodeExternals({ + modulesDir: params.externalsModulesDir, + allowlist: [/@bitwarden/], + }), + ], + experiments: { + asyncWebAssembly: true, + }, + }; + if (params.watch) { + webpackConfig.watch = true; + webpackConfig.watchOptions = { + ignored: /node_modules/, + poll: 1000, + }; + } + return webpackConfig; +}; diff --git a/apps/cli/webpack.config.js b/apps/cli/webpack.config.js index d5f66af73ec..b8eae3dce4d 100644 --- a/apps/cli/webpack.config.js +++ b/apps/cli/webpack.config.js @@ -1,89 +1,48 @@ const path = require("path"); -const webpack = require("webpack"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const nodeExternals = require("webpack-node-externals"); -const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); -const config = require("./config/config"); +const { buildConfig } = require("./webpack.base"); -if (process.env.NODE_ENV == null) { - process.env.NODE_ENV = "development"; -} -const ENV = (process.env.ENV = process.env.NODE_ENV); +module.exports = (webpackConfig, context) => { + // Detect if called by Nx (context parameter exists) + const isNxBuild = context && context.options; -const envConfig = config.load(ENV); -config.log(envConfig); + if (isNxBuild) { + // Nx build configuration + const mode = context.options.mode || "development"; + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = mode; + } + const ENV = (process.env.ENV = process.env.NODE_ENV); -const moduleRules = [ - { - test: /\.ts$/, - use: "ts-loader", - exclude: path.resolve(__dirname, "node_modules"), - }, -]; + return buildConfig({ + configName: "OSS", + entry: context.options.main || "apps/cli/src/bw.ts", + tsConfig: "tsconfig.base.json", + outputPath: path.resolve(context.context.root, context.options.outputPath), + mode: mode, + env: ENV, + modulesPath: [path.resolve("node_modules")], + localesPath: "apps/cli/src/locales", + externalsModulesDir: "node_modules", + watch: context.options.watch || false, + }); + } else { + // npm build configuration + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = "development"; + } + const ENV = (process.env.ENV = process.env.NODE_ENV); + const mode = ENV; -const plugins = [ - new CopyWebpackPlugin({ - patterns: [{ from: "./src/locales", to: "locales" }], - }), - new webpack.DefinePlugin({ - "process.env.BWCLI_ENV": JSON.stringify(ENV), - }), - new webpack.BannerPlugin({ - banner: "#!/usr/bin/env node", - raw: true, - }), - new webpack.IgnorePlugin({ - resourceRegExp: /^encoding$/, - contextRegExp: /node-fetch/, - }), - new webpack.EnvironmentPlugin({ - ENV: ENV, - BWCLI_ENV: ENV, - FLAGS: envConfig.flags, - DEV_FLAGS: envConfig.devFlags, - }), - new webpack.IgnorePlugin({ - resourceRegExp: /canvas/, - contextRegExp: /jsdom$/, - }), -]; - -const webpackConfig = { - mode: ENV, - target: "node", - devtool: ENV === "development" ? "eval-source-map" : "source-map", - node: { - __dirname: false, - __filename: false, - }, - entry: { - bw: "./src/bw.ts", - }, - optimization: { - minimize: false, - }, - resolve: { - extensions: [".ts", ".js"], - symlinks: false, - modules: [path.resolve("../../node_modules")], - plugins: [new TsconfigPathsPlugin({ configFile: "./tsconfig.json" })], - }, - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - clean: true, - }, - module: { rules: moduleRules }, - plugins: plugins, - externals: [ - nodeExternals({ - modulesDir: "../../node_modules", - allowlist: [/@bitwarden/], - }), - ], - experiments: { - asyncWebAssembly: true, - }, + return buildConfig({ + configName: "OSS", + entry: "./src/bw.ts", + tsConfig: "./tsconfig.json", + outputPath: path.resolve(__dirname, "build"), + mode: mode, + env: ENV, + modulesPath: [path.resolve("../../node_modules")], + localesPath: "./src/locales", + externalsModulesDir: "../../node_modules", + }); + } }; - -module.exports = webpackConfig; diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index ab9a162f8c5..2e780bf6b1d 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -20,7 +20,7 @@ "**/node_modules/@bitwarden/desktop-napi/index.js", "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" ], - "electronVersion": "38.2.0", + "electronVersion": "36.9.3", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6179c9d48c1..0c68142a849 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.9.1", + "version": "2025.10.1", "keywords": [ "bitwarden", "password", @@ -22,18 +22,18 @@ "build-native": "cd desktop_native && node build.js", "build": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"", "build:dev": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\" \"npm run build:preload:dev\"", - "build:preload": "cross-env NODE_ENV=production webpack --config webpack.preload.js", - "build:preload:dev": "cross-env NODE_ENV=development webpack --config webpack.preload.js", - "build:preload:watch": "cross-env NODE_ENV=development webpack --config webpack.preload.js --watch", + "build:preload": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload", + "build:preload:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload", + "build:preload:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload --watch", "build:macos-extension:mac": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mac", "build:macos-extension:mas": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas", "build:macos-extension:masdev": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas-dev", - "build:main": "cross-env NODE_ENV=production webpack --config webpack.main.js", - "build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.main.js", - "build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.main.js --watch", - "build:renderer": "cross-env NODE_ENV=production webpack --config webpack.renderer.js", - "build:renderer:dev": "cross-env NODE_ENV=development webpack --config webpack.renderer.js", - "build:renderer:watch": "cross-env NODE_ENV=development webpack --config webpack.renderer.js --watch", + "build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main", + "build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", + "build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch", + "build:renderer": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer", + "build:renderer:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer", + "build:renderer:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer --watch", "electron": "node ./scripts/start.js", "electron:ignore": "node ./scripts/start.js --ignore-certificate-errors", "clean:dist": "rimraf ./dist", diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 7fdca9ff29b..dfddff034e6 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -350,7 +350,7 @@
{{ "important" | i18n }} - {{ "enableAutotypeDescriptionTransitionKey" | i18n }} + {{ "enableAutotypeShortcutDescription" | i18n }} (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", 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 3fdb14aa154..5f888e081c1 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -45,6 +45,7 @@ import { CipherViewLike, CipherViewLikeUtils, } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { BadgeModule, ButtonModule, @@ -168,6 +169,7 @@ export class VaultV2Component private organizations$: Observable = this.accountService.activeAccount$.pipe( map((a) => a?.id), + filterOutNullish(), switchMap((id) => this.organizationService.organizations$(id)), ); @@ -290,7 +292,7 @@ export class VaultV2Component ) { const value = await firstValueFrom( this.totpService.getCode$(this.cipher.login.totp), - ).catch(() => null); + ).catch((): any => null); if (value) { this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP"); } @@ -319,7 +321,7 @@ export class VaultV2Component this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); const authRequests = await firstValueFrom( - this.authRequestService.getLatestPendingAuthRequest$(), + this.authRequestService.getLatestPendingAuthRequest$()!, ); if (authRequests != null) { this.messagingService.send("openLoginApproval", { @@ -329,7 +331,7 @@ export class VaultV2Component this.activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), - ).catch(() => null); + ).catch((): any => null); if (this.activeUserId) { this.cipherService @@ -448,7 +450,7 @@ export class VaultV2Component const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: this.cipherId as CipherId, }); - const result = await firstValueFrom(dialogRef.closed).catch(() => null); + const result = await firstValueFrom(dialogRef.closed).catch((): any => null); if ( result?.action === AttachmentDialogResult.Removed || result?.action === AttachmentDialogResult.Uploaded @@ -574,7 +576,7 @@ export class VaultV2Component click: async () => { const value = await firstValueFrom( this.totpService.getCode$(cipher.login.totp), - ).catch(() => null); + ).catch((): any => null); if (value) { this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); } @@ -617,7 +619,7 @@ export class VaultV2Component async buildFormConfig(action: CipherFormMode) { this.config = await this.formConfigService .buildConfig(action, this.cipherId as CipherId, this.addType) - .catch(() => null); + .catch((): any => null); } async editCipher(cipher: CipherView) { diff --git a/apps/desktop/webpack.base.js b/apps/desktop/webpack.base.js new file mode 100644 index 00000000000..fe3079b730f --- /dev/null +++ b/apps/desktop/webpack.base.js @@ -0,0 +1,320 @@ +const path = require("path"); +const webpack = require("webpack"); +const { merge } = require("webpack-merge"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); +const { AngularWebpackPlugin } = require("@ngtools/webpack"); +const TerserPlugin = require("terser-webpack-plugin"); +const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); +const { EnvironmentPlugin, DefinePlugin } = require("webpack"); +const configurator = require("./config/config"); + +module.exports.getEnv = function getEnv() { + const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; + const ENV = process.env.ENV == null ? "development" : process.env.ENV; + + return { NODE_ENV, ENV }; +}; + +/** + * @param {{ + * configName: string; + * renderer: { + * entry: string; + * entryModule: string; + * tsConfig: string; + * }; + * main: { + * entry: string; + * tsConfig: string; + * }; + * preload: { + * entry: string; + * tsConfig: string; + * }; + * }} params + */ +module.exports.buildConfig = function buildConfig(params) { + const { NODE_ENV, ENV } = module.exports.getEnv(); + + console.log(`Building ${params.configName} Desktop App`); + + const envConfig = configurator.load(NODE_ENV); + configurator.log(envConfig); + + const commonConfig = { + resolve: { + extensions: [".tsx", ".ts", ".js"], + symlinks: false, + modules: [path.resolve("../../node_modules")], + }, + }; + + const getOutputConfig = (isDev) => ({ + filename: "[name].js", + path: path.resolve(__dirname, "build"), + ...(isDev && { devtoolModuleFilenameTemplate: "[absolute-resource-path]" }), + }); + + const mainConfig = { + name: "main", + mode: NODE_ENV, + target: "electron-main", + node: { + __dirname: false, + __filename: false, + }, + entry: { + main: params.main.entry, + }, + optimization: { + minimize: false, + }, + output: getOutputConfig(NODE_ENV === "development"), + devtool: NODE_ENV === "development" ? "cheap-source-map" : false, + module: { + rules: [ + { + test: /\.tsx?$/, + use: "ts-loader", + exclude: /node_modules\/(?!(@bitwarden)\/).*/, + }, + { + test: /\.node$/, + loader: "node-loader", + }, + ], + }, + experiments: { + asyncWebAssembly: true, + }, + resolve: { + ...commonConfig.resolve, + plugins: [new TsconfigPathsPlugin({ configFile: params.main.tsConfig })], + }, + plugins: [ + new CopyWebpackPlugin({ + patterns: [ + "./src/package.json", + { from: "./src/images", to: "images" }, + { from: "./src/locales", to: "locales" }, + ], + }), + new DefinePlugin({ + BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), + }), + new EnvironmentPlugin({ + FLAGS: envConfig.flags, + DEV_FLAGS: NODE_ENV === "development" ? envConfig.devFlags : {}, + }), + ], + externals: { + "electron-reload": "commonjs2 electron-reload", + "@bitwarden/desktop-napi": "commonjs2 @bitwarden/desktop-napi", + }, + }; + + const preloadConfig = { + name: "preload", + mode: NODE_ENV, + target: "electron-preload", + node: { + __dirname: false, + __filename: false, + }, + entry: { + preload: params.preload.entry, + }, + optimization: { + minimize: false, + }, + output: getOutputConfig(NODE_ENV === "development"), + devtool: NODE_ENV === "development" ? "cheap-source-map" : false, + module: { + rules: [ + { + test: /\.tsx?$/, + use: "ts-loader", + exclude: /node_modules\/(?!(@bitwarden)\/).*/, + }, + ], + }, + resolve: { + ...commonConfig.resolve, + plugins: [new TsconfigPathsPlugin({ configFile: params.preload.tsConfig })], + }, + plugins: [ + new DefinePlugin({ + BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), + }), + ], + }; + + const rendererConfig = { + name: "renderer", + mode: NODE_ENV, + devtool: "source-map", + target: "web", + node: { + __dirname: false, + }, + entry: { + "app/main": params.renderer.entry, + }, + output: { + filename: "[name].js", + path: path.resolve(__dirname, "build"), + }, + optimization: { + minimizer: [ + new TerserPlugin({ + terserOptions: { + // Replicate Angular CLI behaviour + compress: { + global_defs: { + ngDevMode: false, + ngI18nClosureMode: false, + }, + }, + }, + }), + ], + splitChunks: { + cacheGroups: { + commons: { + test: /[\\/]node_modules[\\/]/, + name: "app/vendor", + chunks: (chunk) => { + return chunk.name === "app/main"; + }, + }, + }, + }, + }, + module: { + rules: [ + { + test: /\.[cm]?js$/, + use: [ + { + loader: "babel-loader", + options: { + configFile: "../../babel.config.json", + }, + }, + ], + }, + { + test: /\.[jt]sx?$/, + loader: "@ngtools/webpack", + }, + { + test: /\.(html)$/, + loader: "html-loader", + }, + { + test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, + exclude: /loading.svg/, + generator: { + filename: "fonts/[name].[contenthash][ext]", + }, + type: "asset/resource", + }, + { + test: /\.(jpe?g|png|gif|svg)$/i, + exclude: /.*(bwi-font)\.svg/, + generator: { + filename: "images/[name][ext]", + }, + type: "asset/resource", + }, + { + test: /\.css$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + "css-loader", + "resolve-url-loader", + { + loader: "postcss-loader", + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.scss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + options: { + publicPath: "../", + }, + }, + "css-loader", + "resolve-url-loader", + { + loader: "sass-loader", + options: { + sourceMap: true, + }, + }, + ], + }, + // Hide System.import warnings. ref: https://github.com/angular/angular/issues/21560 + { + test: /[\/\\]@angular[\/\\].+\.js$/, + parser: { system: true }, + }, + ], + }, + experiments: { + asyncWebAssembly: true, + }, + resolve: { + ...commonConfig.resolve, + fallback: { + path: require.resolve("path-browserify"), + fs: false, + }, + }, + plugins: [ + new AngularWebpackPlugin({ + tsConfigPath: params.renderer.tsConfig, + entryModule: params.renderer.entryModule, + sourceMap: true, + }), + // ref: https://github.com/angular/angular/issues/20357 + new webpack.ContextReplacementPlugin( + /\@angular(\\|\/)core(\\|\/)fesm5/, + path.resolve(__dirname, "./src"), + ), + new HtmlWebpackPlugin({ + template: "./src/index.html", + filename: "index.html", + chunks: ["app/vendor", "app/main"], + }), + new webpack.SourceMapDevToolPlugin({ + include: ["app/main.js"], + }), + new MiniCssExtractPlugin({ + filename: "[name].[contenthash].css", + chunkFilename: "[id].[contenthash].css", + }), + new webpack.DefinePlugin({ + BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), + }), + new webpack.EnvironmentPlugin({ + ENV: ENV, + FLAGS: envConfig.flags, + DEV_FLAGS: NODE_ENV === "development" ? envConfig.devFlags : {}, + ADDITIONAL_REGIONS: envConfig.additionalRegions ?? [], + }), + ], + }; + + return [mainConfig, rendererConfig, preloadConfig]; +}; diff --git a/apps/desktop/webpack.config.js b/apps/desktop/webpack.config.js new file mode 100644 index 00000000000..5ba0df337ee --- /dev/null +++ b/apps/desktop/webpack.config.js @@ -0,0 +1,18 @@ +const { buildConfig } = require("./webpack.base"); + +module.exports = buildConfig({ + configName: "OSS", + renderer: { + entry: "./src/app/main.ts", + entryModule: "src/app/app.module#AppModule", + tsConfig: "./tsconfig.renderer.json", + }, + main: { + entry: "./src/entry.ts", + tsConfig: "./tsconfig.json", + }, + preload: { + entry: "./src/preload.ts", + tsConfig: "./tsconfig.json", + }, +}); diff --git a/apps/desktop/webpack.main.js b/apps/desktop/webpack.main.js deleted file mode 100644 index 151b1d0cea2..00000000000 --- a/apps/desktop/webpack.main.js +++ /dev/null @@ -1,93 +0,0 @@ -const path = require("path"); -const { merge } = require("webpack-merge"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); -const configurator = require("./config/config"); -const { EnvironmentPlugin, DefinePlugin } = require("webpack"); - -const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; - -console.log("Main process config"); -const envConfig = configurator.load(NODE_ENV); -configurator.log(envConfig); - -const common = { - module: { - rules: [ - { - test: /\.tsx?$/, - use: "ts-loader", - exclude: /node_modules\/(?!(@bitwarden)\/).*/, - }, - ], - }, - plugins: [], - resolve: { - extensions: [".tsx", ".ts", ".js"], - plugins: [new TsconfigPathsPlugin({ configFile: "./tsconfig.json" })], - }, -}; - -const prod = { - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - }, -}; - -const dev = { - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - devtoolModuleFilenameTemplate: "[absolute-resource-path]", - }, - devtool: "cheap-source-map", -}; - -const main = { - mode: NODE_ENV, - target: "electron-main", - node: { - __dirname: false, - __filename: false, - }, - entry: { - main: "./src/entry.ts", - }, - optimization: { - minimize: false, - }, - module: { - rules: [ - { - test: /\.node$/, - loader: "node-loader", - }, - ], - }, - experiments: { - asyncWebAssembly: true, - }, - plugins: [ - new CopyWebpackPlugin({ - patterns: [ - "./src/package.json", - { from: "./src/images", to: "images" }, - { from: "./src/locales", to: "locales" }, - ], - }), - new DefinePlugin({ - BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), - }), - new EnvironmentPlugin({ - FLAGS: envConfig.flags, - DEV_FLAGS: NODE_ENV === "development" ? envConfig.devFlags : {}, - }), - ], - externals: { - "electron-reload": "commonjs2 electron-reload", - "@bitwarden/desktop-napi": "commonjs2 @bitwarden/desktop-napi", - }, -}; - -module.exports = merge(common, NODE_ENV === "development" ? dev : prod, main); diff --git a/apps/desktop/webpack.preload.js b/apps/desktop/webpack.preload.js deleted file mode 100644 index db75e882644..00000000000 --- a/apps/desktop/webpack.preload.js +++ /dev/null @@ -1,66 +0,0 @@ -const path = require("path"); -const { merge } = require("webpack-merge"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); -const configurator = require("./config/config"); -const { EnvironmentPlugin, DefinePlugin } = require("webpack"); - -const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; - -console.log("Preload process config"); -const envConfig = configurator.load(NODE_ENV); -configurator.log(envConfig); - -const common = { - module: { - rules: [ - { - test: /\.tsx?$/, - use: "ts-loader", - exclude: /node_modules\/(?!(@bitwarden)\/).*/, - }, - ], - }, - plugins: [ - new DefinePlugin({ - BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), - }), - ], - resolve: { - extensions: [".tsx", ".ts", ".js"], - plugins: [new TsconfigPathsPlugin({ configFile: "./tsconfig.json" })], - }, -}; - -const prod = { - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - }, -}; - -const dev = { - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - devtoolModuleFilenameTemplate: "[absolute-resource-path]", - }, - devtool: "cheap-source-map", -}; - -const main = { - mode: NODE_ENV, - target: "electron-preload", - node: { - __dirname: false, - __filename: false, - }, - entry: { - preload: "./src/preload.ts", - }, - optimization: { - minimize: false, - }, -}; - -module.exports = merge(common, NODE_ENV === "development" ? dev : prod, main); diff --git a/apps/desktop/webpack.renderer.js b/apps/desktop/webpack.renderer.js deleted file mode 100644 index 9c5b0fd2584..00000000000 --- a/apps/desktop/webpack.renderer.js +++ /dev/null @@ -1,192 +0,0 @@ -const path = require("path"); -const webpack = require("webpack"); -const { merge } = require("webpack-merge"); -const HtmlWebpackPlugin = require("html-webpack-plugin"); -const MiniCssExtractPlugin = require("mini-css-extract-plugin"); -const { AngularWebpackPlugin } = require("@ngtools/webpack"); -const TerserPlugin = require("terser-webpack-plugin"); -const configurator = require("./config/config"); - -const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; - -console.log("Renderer process config"); -const envConfig = configurator.load(NODE_ENV); -configurator.log(envConfig); - -const ENV = process.env.ENV == null ? "development" : process.env.ENV; - -const common = { - module: { - rules: [ - { - test: /\.[cm]?js$/, - use: [ - { - loader: "babel-loader", - options: { - configFile: "../../babel.config.json", - }, - }, - ], - }, - { - test: /\.[jt]sx?$/, - loader: "@ngtools/webpack", - }, - { - test: /\.(jpe?g|png|gif|svg)$/i, - exclude: /.*(bwi-font)\.svg/, - generator: { - filename: "images/[name][ext]", - }, - type: "asset/resource", - }, - ], - }, - plugins: [], - resolve: { - extensions: [".tsx", ".ts", ".js"], - symlinks: false, - modules: [path.resolve("../../node_modules")], - fallback: { - path: require.resolve("path-browserify"), - fs: false, - }, - }, - output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - }, -}; - -const renderer = { - mode: NODE_ENV, - devtool: "source-map", - target: "web", - node: { - __dirname: false, - }, - entry: { - "app/main": "./src/app/main.ts", - }, - optimization: { - minimizer: [ - new TerserPlugin({ - terserOptions: { - // Replicate Angular CLI behaviour - compress: { - global_defs: { - ngDevMode: false, - ngI18nClosureMode: false, - }, - }, - }, - }), - ], - splitChunks: { - cacheGroups: { - commons: { - test: /[\\/]node_modules[\\/]/, - name: "app/vendor", - chunks: (chunk) => { - return chunk.name === "app/main"; - }, - }, - }, - }, - }, - module: { - rules: [ - { - test: /\.(html)$/, - loader: "html-loader", - }, - { - test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, - exclude: /loading.svg/, - generator: { - filename: "fonts/[name].[contenthash][ext]", - }, - type: "asset/resource", - }, - { - test: /\.css$/, - use: [ - { - loader: MiniCssExtractPlugin.loader, - }, - "css-loader", - "resolve-url-loader", - { - loader: "postcss-loader", - options: { - sourceMap: true, - }, - }, - ], - }, - { - test: /\.scss$/, - use: [ - { - loader: MiniCssExtractPlugin.loader, - options: { - publicPath: "../", - }, - }, - "css-loader", - "resolve-url-loader", - { - loader: "sass-loader", - options: { - sourceMap: true, - }, - }, - ], - }, - // Hide System.import warnings. ref: https://github.com/angular/angular/issues/21560 - { - test: /[\/\\]@angular[\/\\].+\.js$/, - parser: { system: true }, - }, - ], - }, - experiments: { - asyncWebAssembly: true, - }, - plugins: [ - new AngularWebpackPlugin({ - tsConfigPath: "tsconfig.renderer.json", - entryModule: "src/app/app.module#AppModule", - sourceMap: true, - }), - // ref: https://github.com/angular/angular/issues/20357 - new webpack.ContextReplacementPlugin( - /\@angular(\\|\/)core(\\|\/)fesm5/, - path.resolve(__dirname, "./src"), - ), - new HtmlWebpackPlugin({ - template: "./src/index.html", - filename: "index.html", - chunks: ["app/vendor", "app/main"], - }), - new webpack.SourceMapDevToolPlugin({ - include: ["app/main.js"], - }), - new MiniCssExtractPlugin({ - filename: "[name].[contenthash].css", - chunkFilename: "[id].[contenthash].css", - }), - new webpack.DefinePlugin({ - BIT_ENVIRONMENT: JSON.stringify(NODE_ENV), - }), - new webpack.EnvironmentPlugin({ - ENV: ENV, - FLAGS: envConfig.flags, - DEV_FLAGS: NODE_ENV === "development" ? envConfig.devFlags : {}, - ADDITIONAL_REGIONS: envConfig.additionalRegions ?? [], - }), - ], -}; - -module.exports = merge(common, renderer); diff --git a/apps/web/package.json b/apps/web/package.json index 517b8aa8004..1052630acd0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2025.9.1", + "version": "2025.10.1", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts index 67cb4c7cdc8..33325b3a4bd 100644 --- a/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts +++ b/apps/web/src/app/admin-console/organizations/collections/utils/collection-utils.ts @@ -5,6 +5,7 @@ import { CollectionView, NestingDelimiter, } from "@bitwarden/admin-console/common"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; @@ -26,15 +27,21 @@ export function getNestedCollectionTree( .sort((a, b) => a.name.localeCompare(b.name)) .map(cloneCollection); - const nodes: TreeNode[] = []; - clonedCollections.forEach((collection) => { - const parts = - collection.name != null - ? collection.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) - : []; - ServiceUtils.nestedTraverse(nodes, 0, parts, collection, null, NestingDelimiter); + const all: TreeNode[] = []; + const groupedByOrg = new Map(); + clonedCollections.map((c) => { + const key = c.organizationId; + (groupedByOrg.get(key) ?? groupedByOrg.set(key, []).get(key)!).push(c); }); - return nodes; + for (const group of groupedByOrg.values()) { + const nodes: TreeNode[] = []; + for (const c of group) { + const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, c, undefined, NestingDelimiter); + } + all.push(...nodes); + } + return all; } export function cloneCollection(collection: CollectionView): CollectionView; 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 3aab02b3b49..b961de9e24c 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 @@ -978,7 +978,7 @@ export class VaultComponent implements OnInit, OnDestroy { // Allow restore of an Unassigned Item try { - if (c.id == null) { + if (c.id == null || c.id === "") { throw new Error("Cipher must have an Id to be restored"); } const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); @@ -1211,7 +1211,7 @@ export class VaultComponent implements OnInit, OnDestroy { aType = "Password"; value = cipher.login.password; typeI18nKey = "password"; - } else if (field === "totp") { + } else if (field === "totp" && cipher.login.totp != null) { aType = "TOTP"; const totpResponse = await firstValueFrom(this.totpService.getCode$(cipher.login.totp)); value = totpResponse.code; @@ -1232,7 +1232,7 @@ export class VaultComponent implements OnInit, OnDestroy { return; } - if (!cipher.viewPassword) { + if (!cipher.viewPassword || value == null) { return; } 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 1a093ff8352..d7c6a468eba 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 @@ -2,7 +2,6 @@ import { NgModule } from "@angular/core"; import { SharedModule } from "../../../shared/shared.module"; import { OrganizationBadgeModule } from "../../../vault/individual-vault/organization-badge/organization-badge.module"; -import { ViewComponent } from "../../../vault/individual-vault/view.component"; import { CollectionDialogComponent } from "../shared/components/collection-dialog"; import { CollectionNameBadgeComponent } from "./collection-badge"; @@ -19,7 +18,6 @@ import { VaultComponent } from "./vault.component"; OrganizationBadgeModule, CollectionDialogComponent, VaultComponent, - ViewComponent, ], }) export class VaultModule {} diff --git a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts index 2e5faea4702..9293c686b7f 100644 --- a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts @@ -9,6 +9,8 @@ import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/po import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import type { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; + /** * A metadata class that defines how a policy is displayed in the Admin Console Policies page for editing. * Add this to the `ossPolicyRegister` or `bitPolicyRegister` file to register it in the application. @@ -32,6 +34,13 @@ export abstract class BasePolicyEditDefinition { */ abstract component: Constructor; + /** + * The dialog component that will be opened when editing this policy. + * This allows customizing the look and feel of each policy's dialog contents. + * If not specified, defaults to {@link PolicyEditDialogComponent}. + */ + editDialogComponent?: typeof PolicyEditDialogComponent; + /** * If true, the {@link description} will be reused in the policy edit modal. Set this to false if you * have more complex requirements that you will implement in your template instead. 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 e2c51b77d45..95c00f74f1c 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 @@ -127,7 +127,8 @@ export class PoliciesComponent implements OnInit { } async edit(policy: BasePolicyEditDefinition) { - const dialogRef = PolicyEditDialogComponent.open(this.dialogService, { + const dialogComponent = policy.editDialogComponent ?? PolicyEditDialogComponent; + const dialogRef = dialogComponent.open(this.dialogService, { data: { policy: policy, organizationId: this.organizationId, diff --git a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html deleted file mode 100644 index 94dfac42976..00000000000 --- a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html +++ /dev/null @@ -1,54 +0,0 @@ - diff --git a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts deleted file mode 100644 index 695e935b919..00000000000 --- a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Component } from "@angular/core"; - -import { BaseLoginViaWebAuthnComponent } from "@bitwarden/angular/auth/components/base-login-via-webauthn.component"; -import { - TwoFactorAuthSecurityKeyIcon, - TwoFactorAuthSecurityKeyFailedIcon, -} from "@bitwarden/assets/svg"; - -@Component({ - selector: "app-login-via-webauthn", - templateUrl: "login-via-webauthn.component.html", - standalone: false, -}) -export class LoginViaWebAuthnComponent extends BaseLoginViaWebAuthnComponent { - protected readonly Icons = { - TwoFactorAuthSecurityKeyIcon, - TwoFactorAuthSecurityKeyFailedIcon, - }; -} diff --git a/apps/web/src/app/auth/login/login.module.ts b/apps/web/src/app/auth/login/login.module.ts deleted file mode 100644 index 9a99c84f727..00000000000 --- a/apps/web/src/app/auth/login/login.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { NgModule } from "@angular/core"; - -import { CheckboxModule } from "@bitwarden/components"; - -import { SharedModule } from "../../../app/shared"; - -import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component"; - -@NgModule({ - imports: [SharedModule, CheckboxModule], - declarations: [LoginViaWebAuthnComponent], - exports: [LoginViaWebAuthnComponent], -}) -export class LoginModule {} diff --git a/apps/web/src/app/auth/settings/account/change-email.component.html b/apps/web/src/app/auth/settings/account/change-email.component.html index d4462c3b056..279ecdcfadf 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.html +++ b/apps/web/src/app/auth/settings/account/change-email.component.html @@ -3,7 +3,7 @@ {{ "changeEmailTwoFactorWarning" | i18n }} -
+
{{ "masterPass" | i18n }} {{ "dangerZone" | i18n }}
-
+
diff --git a/apps/web/src/app/auth/settings/account/profile.component.html b/apps/web/src/app/auth/settings/account/profile.component.html index a49e6c31d2e..b3972925598 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.html +++ b/apps/web/src/app/auth/settings/account/profile.component.html @@ -8,7 +8,7 @@
-
+
{{ "name" | i18n }} @@ -18,7 +18,7 @@
-
+
diff --git a/apps/web/src/app/billing/clients/account-billing.client.ts b/apps/web/src/app/billing/clients/account-billing.client.ts new file mode 100644 index 00000000000..e5b97126fb3 --- /dev/null +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -0,0 +1,24 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +import { BillingAddress, TokenizedPaymentMethod } from "../payment/types"; + +@Injectable() +export class AccountBillingClient { + private endpoint = "/account/billing/vnext"; + private apiService: ApiService; + + constructor(apiService: ApiService) { + this.apiService = apiService; + } + + purchasePremiumSubscription = async ( + paymentMethod: TokenizedPaymentMethod, + billingAddress: Pick, + ): Promise => { + const path = `${this.endpoint}/subscription`; + const request = { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }; + await this.apiService.send("POST", path, request, true, true); + }; +} diff --git a/apps/web/src/app/billing/clients/index.ts b/apps/web/src/app/billing/clients/index.ts index 17f64248cfa..0251693a3b2 100644 --- a/apps/web/src/app/billing/clients/index.ts +++ b/apps/web/src/app/billing/clients/index.ts @@ -1,3 +1,4 @@ export * from "./organization-billing.client"; export * from "./subscriber-billing.client"; export * from "./tax.client"; +export * from "./account-billing.client"; 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 52ebe7803df..0a3762a1e41 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/premium.component.html @@ -104,6 +104,8 @@ {{ "total" | i18n }}: {{ total | currency: "USD $" }}/{{ "year" | i18n }}

- 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 d5062e34881..d541ab95b95 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -4,34 +4,41 @@ import { Component, ViewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, concatMap, from, Observable, of, switchMap } from "rxjs"; +import { combineLatest, concatMap, from, map, Observable, of, startWith, switchMap } from "rxjs"; import { debounceTime } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType } from "@bitwarden/common/billing/enums"; 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 { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { 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 { + tokenizablePaymentMethodToLegacyEnum, + NonTokenizablePaymentMethods, +} from "@bitwarden/web-vault/app/billing/payment/types"; +import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; @Component({ templateUrl: "./premium.component.html", standalone: false, - providers: [TaxClient], + providers: [SubscriberBillingClient, TaxClient], }) export class PremiumComponent { @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected hasPremiumFromAnyOrganization$: Observable; + protected accountCredit$: Observable; + protected hasEnoughAccountCredit$: Observable; protected formGroup = new FormGroup({ additionalStorage: new FormControl(0, [Validators.min(0), Validators.max(99)]), @@ -58,6 +65,7 @@ export class PremiumComponent { private syncService: SyncService, private toastService: ToastService, private accountService: AccountService, + private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); @@ -68,6 +76,26 @@ export class PremiumComponent { ), ); + // Fetch account credit + this.accountCredit$ = this.accountService.activeAccount$.pipe( + mapAccountToSubscriber, + switchMap((account) => this.subscriberBillingClient.getCredit(account)), + ); + + // Check if user has enough account credit for the purchase + this.hasEnoughAccountCredit$ = combineLatest([ + this.accountCredit$, + this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)), + ]).pipe( + map(([credit, formValue]) => { + const selectedPaymentType = formValue.paymentMethod?.type; + if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { + return true; // Not using account credit, so this check doesn't apply + } + return credit >= this.total; + }), + ); + combineLatest([ this.accountService.activeAccount$.pipe( switchMap((account) => @@ -120,13 +148,26 @@ export class PremiumComponent { return; } - const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + // Check if account credit is selected + const selectedPaymentType = this.formGroup.value.paymentMethod.type; - const legacyEnum = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); + let paymentMethodType: number; + let paymentToken: string; + + if (selectedPaymentType === NonTokenizablePaymentMethods.accountCredit) { + // Account credit doesn't need tokenization + paymentMethodType = PaymentMethodType.Credit; + paymentToken = ""; + } else { + // Tokenize for card, bank account, or PayPal + const paymentMethod = await this.enterPaymentMethodComponent.tokenize(); + paymentMethodType = tokenizablePaymentMethodToLegacyEnum(paymentMethod.type); + paymentToken = paymentMethod.token; + } const formData = new FormData(); - formData.append("paymentMethodType", legacyEnum.toString()); - formData.append("paymentToken", paymentMethod.token); + formData.append("paymentMethodType", paymentMethodType.toString()); + formData.append("paymentToken", paymentToken); formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString()); formData.append("country", this.formGroup.value.billingAddress.country); formData.append("postalCode", this.formGroup.value.billingAddress.postalCode); diff --git a/apps/web/src/app/billing/individual/upgrade/services/index.ts b/apps/web/src/app/billing/individual/upgrade/services/index.ts new file mode 100644 index 00000000000..e81e2eaeb01 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/index.ts @@ -0,0 +1 @@ +export * from "./unified-upgrade-prompt.service"; diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts new file mode 100644 index 00000000000..a9133d220c3 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts @@ -0,0 +1,172 @@ +import { mock, mockReset } from "jest-mock-extended"; +import * as rxjs from "rxjs"; +import { of } from "rxjs"; + +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { AccountService, Account } 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 { DialogRef, DialogService } from "@bitwarden/components"; + +import { + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogStatus, +} from "../unified-upgrade-dialog/unified-upgrade-dialog.component"; + +import { UnifiedUpgradePromptService } from "./unified-upgrade-prompt.service"; + +describe("UnifiedUpgradePromptService", () => { + let sut: UnifiedUpgradePromptService; + const mockAccountService = mock(); + const mockConfigService = mock(); + const mockBillingService = mock(); + const mockVaultProfileService = mock(); + const mockDialogService = mock(); + const mockDialogOpen = jest.spyOn(UnifiedUpgradeDialogComponent, "open"); + + /** + * Creates a mock DialogRef that implements the required properties for testing + * @param result The result that will be emitted by the closed observable + * @returns A mock DialogRef object + */ + function createMockDialogRef(result: T): DialogRef { + // Create a mock that implements the DialogRef interface + return { + // The closed property is readonly in the actual DialogRef + closed: of(result), + } as DialogRef; + } + + // Mock the open method of a dialog component to return the provided DialogRefs + // Supports multiple calls by returning different refs in sequence + function mockDialogOpenMethod(...refs: DialogRef[]) { + refs.forEach((ref) => mockDialogOpen.mockReturnValueOnce(ref)); + } + + function setupTestService() { + sut = new UnifiedUpgradePromptService( + mockAccountService, + mockConfigService, + mockBillingService, + mockVaultProfileService, + mockDialogService, + ); + } + + const mockAccount: Account = { + id: "test-user-id", + } as Account; + const accountSubject = new rxjs.BehaviorSubject(mockAccount); + + describe("initialization", () => { + beforeEach(() => { + setupTestService(); + }); + it("should be created", () => { + expect(sut).toBeTruthy(); + }); + + it("should subscribe to account and feature flag observables on construction", () => { + expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog, + ); + }); + }); + + describe("displayUpgradePromptConditionally", () => { + beforeEach(async () => { + mockAccountService.activeAccount$ = accountSubject.asObservable(); + mockDialogOpen.mockReset(); + mockReset(mockConfigService); + mockReset(mockBillingService); + mockReset(mockVaultProfileService); + }); + it("should not show dialog when feature flag is disabled", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + setupTestService(); + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + }); + + it("should not show dialog when user has premium", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + }); + + it("should not show dialog when profile is older than 5 minutes", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + const oldDate = new Date(); + oldDate.setMinutes(oldDate.getMinutes() - 10); // 10 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(oldDate); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + }); + + it("should show dialog when all conditions are met", async () => { + //Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + const recentDate = new Date(); + recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); + + const expectedResult = { status: UnifiedUpgradeDialogStatus.Closed }; + mockDialogOpenMethod(createMockDialogRef(expectedResult)); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toEqual(expectedResult); + expect(mockDialogOpen).toHaveBeenCalled(); + }); + + it("should not show dialog when account is null/undefined", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + accountSubject.next(null); // Set account to null + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + }); + + it("should not show dialog when profile creation date is unavailable", async () => { + // Arrange + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + mockVaultProfileService.getProfileCreationDate.mockResolvedValue(null); + setupTestService(); + + // Act + const result = await sut.displayUpgradePromptConditionally(); + + // Assert + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts new file mode 100644 index 00000000000..e90f696cfb5 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts @@ -0,0 +1,114 @@ +import { Injectable } from "@angular/core"; +import { combineLatest, firstValueFrom } from "rxjs"; +import { switchMap, take } from "rxjs/operators"; + +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +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 { DialogRef, DialogService } from "@bitwarden/components"; + +import { + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogResult, +} from "../unified-upgrade-dialog/unified-upgrade-dialog.component"; + +@Injectable({ + providedIn: "root", +}) +export class UnifiedUpgradePromptService { + private unifiedUpgradeDialogRef: DialogRef | null = null; + constructor( + private accountService: AccountService, + private configService: ConfigService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private vaultProfileService: VaultProfileService, + private dialogService: DialogService, + ) {} + + private shouldShowPrompt$ = combineLatest([ + this.accountService.activeAccount$, + this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), + ]).pipe( + switchMap(async ([account, isFlagEnabled]) => { + if (!account || !account?.id) { + return false; + } + // Early return if feature flag is disabled + if (!isFlagEnabled) { + return false; + } + + // Check if user has premium + const hasPremium = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ); + + // Early return if user already has premium + if (hasPremium) { + return false; + } + + // Check profile age only if needed + const isProfileLessThanFiveMinutesOld = await this.isProfileLessThanFiveMinutesOld( + account.id, + ); + + return isFlagEnabled && !hasPremium && isProfileLessThanFiveMinutesOld; + }), + take(1), + ); + + /** + * Conditionally prompt the user based on predefined criteria. + * + * @returns A promise that resolves to the dialog result if shown, or null if not shown + */ + async displayUpgradePromptConditionally(): Promise { + const shouldShow = await firstValueFrom(this.shouldShowPrompt$); + + if (shouldShow) { + return this.launchUpgradeDialog(); + } + + return null; + } + + /** + * Checks if a user's profile was created less than five minutes ago + * @param userId User ID to check + * @returns Promise that resolves to true if profile was created less than five minutes ago + */ + private async isProfileLessThanFiveMinutesOld(userId: string): Promise { + const createdAtDate = await this.vaultProfileService.getProfileCreationDate(userId); + if (!createdAtDate) { + return false; + } + const createdAtInMs = createdAtDate.getTime(); + const nowInMs = new Date().getTime(); + + const differenceInMs = nowInMs - createdAtInMs; + const msInAMinute = 1000 * 60; // Milliseconds in a minute for conversion 1 minute = 60 seconds * 1000 ms + const differenceInMinutes = Math.round(differenceInMs / msInAMinute); + + return differenceInMinutes <= 5; + } + + private async launchUpgradeDialog(): Promise { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return null; + } + + this.unifiedUpgradeDialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, { + data: { account }, + }); + + const result = await firstValueFrom(this.unifiedUpgradeDialogRef.closed); + this.unifiedUpgradeDialogRef = null; + + // Return the result or null if the dialog was dismissed without a result + return result || null; + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html new file mode 100644 index 00000000000..6cffd818afc --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.html @@ -0,0 +1,10 @@ +@if (step() == PlanSelectionStep) { + +} @else if (step() == PaymentStep && selectedPlan() !== null) { + +} diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts new file mode 100644 index 00000000000..092e6b163e6 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -0,0 +1,153 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject, OnInit, signal } from "@angular/core"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { + ButtonModule, + DialogConfig, + DialogModule, + DialogRef, + DialogService, +} from "@bitwarden/components"; + +import { AccountBillingClient, TaxClient } from "../../../clients"; +import { BillingServicesModule } from "../../../services"; +import { PersonalSubscriptionPricingTierId } from "../../../types/subscription-pricing-tier"; +import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component"; +import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service"; +import { + UpgradePaymentComponent, + UpgradePaymentResult, +} from "../upgrade-payment/upgrade-payment.component"; + +export const UnifiedUpgradeDialogStatus = { + Closed: "closed", + UpgradedToPremium: "upgradedToPremium", + UpgradedToFamilies: "upgradedToFamilies", +} as const; + +export const UnifiedUpgradeDialogStep = { + PlanSelection: "planSelection", + Payment: "payment", +} as const; + +export type UnifiedUpgradeDialogStatus = UnionOfValues; +export type UnifiedUpgradeDialogStep = UnionOfValues; + +export type UnifiedUpgradeDialogResult = { + status: UnifiedUpgradeDialogStatus; + organizationId?: string | null; +}; + +/** + * Parameters for the UnifiedUpgradeDialog component. + * In order to open the dialog to a specific step, you must provide the `initialStep` parameter and a `selectedPlan` if the step is `Payment`. + * + * @property {Account} account - The user account information. + * @property {UnifiedUpgradeDialogStep | null} [initialStep] - The initial step to show in the dialog, if any. + * @property {PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any. + */ +export type UnifiedUpgradeDialogParams = { + account: Account; + initialStep?: UnifiedUpgradeDialogStep | null; + selectedPlan?: PersonalSubscriptionPricingTierId | null; +}; + +@Component({ + selector: "app-unified-upgrade-dialog", + imports: [ + CommonModule, + DialogModule, + ButtonModule, + UpgradeAccountComponent, + UpgradePaymentComponent, + BillingServicesModule, + ], + providers: [UpgradePaymentService, AccountBillingClient, TaxClient], + templateUrl: "./unified-upgrade-dialog.component.html", +}) +export class UnifiedUpgradeDialogComponent implements OnInit { + // Use signals for dialog state because inputs depend on parent component + protected step = signal(UnifiedUpgradeDialogStep.PlanSelection); + protected selectedPlan = signal(null); + protected account = signal(null); + + protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment; + protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection; + + constructor( + private dialogRef: DialogRef, + @Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams, + ) {} + + ngOnInit(): void { + this.account.set(this.params.account); + this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection); + this.selectedPlan.set(this.params.selectedPlan ?? null); + } + + protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void { + this.selectedPlan.set(planId); + this.nextStep(); + } + protected onCloseClicked(): void { + this.close({ status: UnifiedUpgradeDialogStatus.Closed }); + } + + private close(result: UnifiedUpgradeDialogResult): void { + this.dialogRef.close(result); + } + + protected nextStep() { + if (this.step() === UnifiedUpgradeDialogStep.PlanSelection) { + this.step.set(UnifiedUpgradeDialogStep.Payment); + } + } + + protected previousStep(): void { + // If we are on the payment step and there was no initial step, go back to plan selection this is to prevent + // going back to payment step if the dialog was opened directly to payment step + if (this.step() === UnifiedUpgradeDialogStep.Payment && this.params?.initialStep == null) { + this.step.set(UnifiedUpgradeDialogStep.PlanSelection); + this.selectedPlan.set(null); + } else { + this.close({ status: UnifiedUpgradeDialogStatus.Closed }); + } + } + + protected onComplete(result: UpgradePaymentResult): void { + let status: UnifiedUpgradeDialogStatus; + switch (result.status) { + case "upgradedToPremium": + status = UnifiedUpgradeDialogStatus.UpgradedToPremium; + break; + case "upgradedToFamilies": + status = UnifiedUpgradeDialogStatus.UpgradedToFamilies; + break; + case "closed": + status = UnifiedUpgradeDialogStatus.Closed; + break; + default: + status = UnifiedUpgradeDialogStatus.Closed; + } + this.close({ status, organizationId: result.organizationId }); + } + + /** + * Opens the unified upgrade dialog. + * + * @param dialogService - The dialog service used to open the component + * @param dialogConfig - The configuration for the dialog including UnifiedUpgradeDialogParams data + * @returns A dialog reference object of type DialogRef + */ + static open( + dialogService: DialogService, + dialogConfig: DialogConfig, + ): DialogRef { + return dialogService.open(UnifiedUpgradeDialogComponent, { + data: dialogConfig.data, + }); + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html new file mode 100644 index 00000000000..960e8f1349f --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.html @@ -0,0 +1,68 @@ +@if (!loading()) { +
+
+ +
+
+
+

+ {{ "individualUpgradeWelcomeMessage" | i18n }} +

+

+ {{ "individualUpgradeDescriptionMessage" | i18n }} +

+
+ +
+ @if (premiumCardDetails) { + +

+ {{ premiumCardDetails.title }} +

+
+ } + + @if (familiesCardDetails) { + +

+ {{ familiesCardDetails.title }} +

+
+ } +
+
+

+ {{ "individualUpgradeTaxInformationMessage" | i18n }} +

+ +
+
+
+} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts new file mode 100644 index 00000000000..93cfa1da20f --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -0,0 +1,149 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PricingCardComponent } from "@bitwarden/pricing"; + +import { BillingServicesModule } from "../../../services"; +import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "../../../types/subscription-pricing-tier"; + +import { UpgradeAccountComponent, UpgradeAccountStatus } from "./upgrade-account.component"; + +describe("UpgradeAccountComponent", () => { + let sut: UpgradeAccountComponent; + let fixture: ComponentFixture; + const mockI18nService = mock(); + const mockSubscriptionPricingService = mock(); + + // Mock pricing tiers data + const mockPricingTiers: PersonalSubscriptionPricingTier[] = [ + { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "premium", // Name changed to match i18n key expectation + description: "Premium plan for individuals", + passwordManager: { + annualPrice: 10, + features: [{ value: "Feature 1" }, { value: "Feature 2" }, { value: "Feature 3" }], + }, + } as PersonalSubscriptionPricingTier, + { + id: PersonalSubscriptionPricingTierIds.Families, + name: "planNameFamilies", // Name changed to match i18n key expectation + description: "Family plan for up to 6 users", + passwordManager: { + annualPrice: 40, + features: [{ value: "Feature A" }, { value: "Feature B" }, { value: "Feature C" }], + users: 6, + }, + } as PersonalSubscriptionPricingTier, + ]; + + beforeEach(async () => { + jest.resetAllMocks(); + + mockI18nService.t.mockImplementation((key) => key); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of(mockPricingTiers), + ); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UpgradeAccountComponent, PricingCardComponent, CdkTrapFocus], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService }, + ], + }) + .overrideComponent(UpgradeAccountComponent, { + // Remove BillingServicesModule to avoid conflicts with mocking SubscriptionPricingService dependencies + remove: { imports: [BillingServicesModule] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(UpgradeAccountComponent); + sut = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(sut).toBeTruthy(); + }); + + it("should set up pricing tier details properly", () => { + expect(sut["premiumCardDetails"]).toBeDefined(); + expect(sut["familiesCardDetails"]).toBeDefined(); + }); + + it("should create premium card details correctly", () => { + // Because the i18n service is mocked to return the key itself + expect(sut["premiumCardDetails"].title).toBe("premium"); + expect(sut["premiumCardDetails"].tagline).toBe("Premium plan for individuals"); + expect(sut["premiumCardDetails"].price.amount).toBe(10 / 12); + expect(sut["premiumCardDetails"].price.cadence).toBe("monthly"); + expect(sut["premiumCardDetails"].button.type).toBe("primary"); + expect(sut["premiumCardDetails"].button.text).toBe("upgradeToPremium"); + expect(sut["premiumCardDetails"].features).toEqual(["Feature 1", "Feature 2", "Feature 3"]); + }); + + it("should create families card details correctly", () => { + // Because the i18n service is mocked to return the key itself + expect(sut["familiesCardDetails"].title).toBe("planNameFamilies"); + expect(sut["familiesCardDetails"].tagline).toBe("Family plan for up to 6 users"); + expect(sut["familiesCardDetails"].price.amount).toBe(40 / 12); + expect(sut["familiesCardDetails"].price.cadence).toBe("monthly"); + expect(sut["familiesCardDetails"].button.type).toBe("secondary"); + expect(sut["familiesCardDetails"].button.text).toBe("upgradeToFamilies"); + expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]); + }); + + it("should emit planSelected with premium pricing tier when premium plan is selected", () => { + // Arrange + const emitSpy = jest.spyOn(sut.planSelected, "emit"); + + // Act + sut.planSelected.emit(PersonalSubscriptionPricingTierIds.Premium); + + // Assert + expect(emitSpy).toHaveBeenCalledWith(PersonalSubscriptionPricingTierIds.Premium); + }); + + it("should emit planSelected with families pricing tier when families plan is selected", () => { + // Arrange + const emitSpy = jest.spyOn(sut.planSelected, "emit"); + + // Act + sut.planSelected.emit(PersonalSubscriptionPricingTierIds.Families); + + // Assert + expect(emitSpy).toHaveBeenCalledWith(PersonalSubscriptionPricingTierIds.Families); + }); + + it("should emit closeClicked with closed status when close button is clicked", () => { + // Arrange + const emitSpy = jest.spyOn(sut.closeClicked, "emit"); + + // Act + sut.closeClicked.emit(UpgradeAccountStatus.Closed); + + // Assert + expect(emitSpy).toHaveBeenCalledWith(UpgradeAccountStatus.Closed); + }); + + describe("isFamiliesPlan", () => { + it("should return true for families plan", () => { + const result = sut["isFamiliesPlan"](PersonalSubscriptionPricingTierIds.Families); + expect(result).toBe(true); + }); + + it("should return false for premium plan", () => { + const result = sut["isFamiliesPlan"](PersonalSubscriptionPricingTierIds.Premium); + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts new file mode 100644 index 00000000000..e9cb390d604 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -0,0 +1,125 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, OnInit, output, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonType, DialogModule } from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; + +import { SharedModule } from "../../../../shared"; +import { BillingServicesModule } from "../../../services"; +import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, + SubscriptionCadence, + SubscriptionCadenceIds, +} from "../../../types/subscription-pricing-tier"; + +export const UpgradeAccountStatus = { + Closed: "closed", + ProceededToPayment: "proceeded-to-payment", +} as const; + +export type UpgradeAccountStatus = UnionOfValues; + +export type UpgradeAccountResult = { + status: UpgradeAccountStatus; + plan: PersonalSubscriptionPricingTierId | null; +}; + +type CardDetails = { + title: string; + tagline: string; + price: { amount: number; cadence: SubscriptionCadence }; + button: { text: string; type: ButtonType }; + features: string[]; +}; + +@Component({ + selector: "app-upgrade-account", + imports: [ + CommonModule, + DialogModule, + SharedModule, + BillingServicesModule, + PricingCardComponent, + CdkTrapFocus, + ], + templateUrl: "./upgrade-account.component.html", +}) +export class UpgradeAccountComponent implements OnInit { + planSelected = output(); + closeClicked = output(); + protected loading = signal(true); + protected premiumCardDetails!: CardDetails; + protected familiesCardDetails!: CardDetails; + + protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families; + protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium; + protected closeStatus = UpgradeAccountStatus.Closed; + + constructor( + private i18nService: I18nService, + private subscriptionPricingService: SubscriptionPricingService, + private destroyRef: DestroyRef, + ) {} + + ngOnInit(): void { + this.subscriptionPricingService + .getPersonalSubscriptionPricingTiers$() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((plans) => { + this.setupCardDetails(plans); + this.loading.set(false); + }); + } + + /** Setup card details for the pricing tiers. + * This can be extended in the future for business plans, etc. + */ + private setupCardDetails(plans: PersonalSubscriptionPricingTier[]): void { + const premiumTier = plans.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, + ); + const familiesTier = plans.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Families, + ); + + if (premiumTier) { + this.premiumCardDetails = this.createCardDetails(premiumTier, "primary"); + } + + if (familiesTier) { + this.familiesCardDetails = this.createCardDetails(familiesTier, "secondary"); + } + } + + private createCardDetails( + tier: PersonalSubscriptionPricingTier, + buttonType: ButtonType, + ): CardDetails { + return { + title: tier.name, + tagline: tier.description, + price: { + amount: tier.passwordManager.annualPrice / 12, + cadence: SubscriptionCadenceIds.Monthly, + }, + button: { + text: this.i18nService.t( + this.isFamiliesPlan(tier.id) ? "upgradeToFamilies" : "upgradeToPremium", + ), + type: buttonType, + }, + features: tier.passwordManager.features.map((f: { key: string; value: string }) => f.value), + }; + } + + private isFamiliesPlan(plan: PersonalSubscriptionPricingTierId): boolean { + return plan === PersonalSubscriptionPricingTierIds.Families; + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts new file mode 100644 index 00000000000..49f3e10c582 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -0,0 +1,313 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, mockReset } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; +import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { UserId } from "@bitwarden/common/types/guid"; +import { LogService } from "@bitwarden/logging"; + +import { AccountBillingClient, TaxAmounts, TaxClient } from "../../../../clients"; +import { BillingAddress, TokenizedPaymentMethod } from "../../../../payment/types"; +import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier"; + +import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service"; + +describe("UpgradePaymentService", () => { + const mockOrganizationBillingService = mock(); + const mockAccountBillingClient = mock(); + const mockTaxClient = mock(); + const mockLogService = mock(); + const mockApiService = mock(); + const mockSyncService = mock(); + + mockApiService.refreshIdentityToken.mockResolvedValue({}); + mockSyncService.fullSync.mockResolvedValue(true); + + let sut: UpgradePaymentService; + + const mockAccount = { + id: "user-id" as UserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }; + + const mockTokenizedPaymentMethod: TokenizedPaymentMethod = { + token: "test-token", + type: "card", + }; + + const mockBillingAddress: BillingAddress = { + line1: "123 Test St", + line2: null, + city: "Test City", + state: "TS", + country: "US", + postalCode: "12345", + taxId: null, + }; + + const mockPremiumPlanDetails: PlanDetails = { + tier: PersonalSubscriptionPricingTierIds.Premium, + details: { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "Premium", + description: "Premium plan", + availableCadences: ["annually"], + passwordManager: { + type: "standalone", + annualPrice: 10, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "feature1", value: "Feature 1" }, + { key: "feature2", value: "Feature 2" }, + ], + }, + }, + }; + + const mockFamiliesPlanDetails: PlanDetails = { + tier: PersonalSubscriptionPricingTierIds.Families, + details: { + id: PersonalSubscriptionPricingTierIds.Families, + name: "Families", + description: "Families plan", + availableCadences: ["annually"], + passwordManager: { + type: "packaged", + annualPrice: 40, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "feature1", value: "Feature 1" }, + { key: "feature2", value: "Feature 2" }, + ], + users: 6, + }, + }, + }; + + beforeEach(() => { + mockReset(mockOrganizationBillingService); + mockReset(mockAccountBillingClient); + mockReset(mockTaxClient); + mockReset(mockLogService); + + TestBed.configureTestingModule({ + providers: [ + UpgradePaymentService, + + { + provide: OrganizationBillingServiceAbstraction, + useValue: mockOrganizationBillingService, + }, + { provide: AccountBillingClient, useValue: mockAccountBillingClient }, + { provide: TaxClient, useValue: mockTaxClient }, + { provide: LogService, useValue: mockLogService }, + { provide: ApiService, useValue: mockApiService }, + { provide: SyncService, useValue: mockSyncService }, + ], + }); + + sut = TestBed.inject(UpgradePaymentService); + }); + + describe("calculateEstimatedTax", () => { + it("should calculate tax for premium plan", async () => { + // Arrange + const mockResponse = mock(); + mockResponse.tax = 2.5; + + mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue(mockResponse); + + // Act + const result = await sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress); + + // Assert + expect(result).toEqual(2.5); + expect(mockTaxClient.previewTaxForPremiumSubscriptionPurchase).toHaveBeenCalledWith( + 0, + mockBillingAddress, + ); + }); + + it("should calculate tax for families plan", async () => { + // Arrange + const mockResponse = mock(); + mockResponse.tax = 5.0; + + mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue(mockResponse); + + // Act + const result = await sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress); + + // Assert + expect(result).toEqual(5.0); + expect(mockTaxClient.previewTaxForOrganizationSubscriptionPurchase).toHaveBeenCalledWith( + { + cadence: "annually", + tier: "families", + passwordManager: { + additionalStorage: 0, + seats: 6, + sponsored: false, + }, + }, + mockBillingAddress, + ); + }); + + it("should throw and log error if personal tax calculation fails", async () => { + // Arrange + const error = new Error("Tax service error"); + mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error); + + // Act & Assert + await expect( + sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress), + ).rejects.toThrow(); + expect(mockLogService.error).toHaveBeenCalledWith("Tax calculation failed:", error); + }); + + it("should throw and log error if organization tax calculation fails", async () => { + // Arrange + const error = new Error("Tax service error"); + mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue(error); + // Act & Assert + await expect( + sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress), + ).rejects.toThrow(); + expect(mockLogService.error).toHaveBeenCalledWith("Tax calculation failed:", error); + }); + }); + + describe("upgradeToPremium", () => { + it("should call accountBillingClient to purchase premium subscription and refresh data", async () => { + // Arrange + mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + + // Act + await sut.upgradeToPremium(mockTokenizedPaymentMethod, mockBillingAddress); + + // Assert + expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + mockTokenizedPaymentMethod, + mockBillingAddress, + ); + expect(mockApiService.refreshIdentityToken).toHaveBeenCalled(); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("should throw error if payment method is incomplete", async () => { + // Arrange + const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod; + + // Act & Assert + await expect( + sut.upgradeToPremium(incompletePaymentMethod, mockBillingAddress), + ).rejects.toThrow("Payment method type or token is missing"); + }); + + it("should throw error if billing address is incomplete", async () => { + // Arrange + const incompleteBillingAddress = { country: "US", postalCode: null } as any; + + // Act & Assert + await expect( + sut.upgradeToPremium(mockTokenizedPaymentMethod, incompleteBillingAddress), + ).rejects.toThrow("Billing address information is incomplete"); + }); + }); + + describe("upgradeToFamilies", () => { + it("should call organizationBillingService to purchase subscription and refresh data", async () => { + // Arrange + mockOrganizationBillingService.purchaseSubscription.mockResolvedValue({ + id: "org-id", + name: "Test Organization", + billingEmail: "test@example.com", + } as OrganizationResponse); + + // Act + await sut.upgradeToFamilies( + mockAccount, + mockFamiliesPlanDetails, + mockTokenizedPaymentMethod, + { + organizationName: "Test Organization", + billingAddress: mockBillingAddress, + }, + ); + + // Assert + expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + organization: { + name: "Test Organization", + billingEmail: "test@example.com", + }, + plan: { + type: PlanType.FamiliesAnnually, + passwordManagerSeats: 6, + }, + payment: { + paymentMethod: ["test-token", PaymentMethodType.Card], + billing: { + country: "US", + postalCode: "12345", + }, + }, + }), + "user-id", + ); + expect(mockApiService.refreshIdentityToken).toHaveBeenCalled(); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + }); + + it("should throw error if password manager seats are 0", async () => { + // Arrange + const invalidPlanDetails: PlanDetails = { + tier: PersonalSubscriptionPricingTierIds.Families, + details: { + passwordManager: { + type: "packaged", + users: 0, + annualPrice: 0, + features: [], + annualPricePerAdditionalStorageGB: 0, + }, + id: "families", + name: "", + description: "", + availableCadences: ["annually"], + }, + }; + + mockOrganizationBillingService.purchaseSubscription.mockRejectedValue( + new Error("Seats must be greater than 0 for families plan"), + ); + + // Act & Assert + await expect( + sut.upgradeToFamilies(mockAccount, invalidPlanDetails, mockTokenizedPaymentMethod, { + organizationName: "Test Organization", + billingAddress: mockBillingAddress, + }), + ).rejects.toThrow("Seats must be greater than 0 for families plan"); + expect(mockOrganizationBillingService.purchaseSubscription).toHaveBeenCalledTimes(1); + }); + + it("should throw error if payment method is incomplete", async () => { + const incompletePaymentMethod = { type: "card" } as TokenizedPaymentMethod; + + await expect( + sut.upgradeToFamilies(mockAccount, mockFamiliesPlanDetails, incompletePaymentMethod, { + organizationName: "Test Organization", + billingAddress: mockBillingAddress, + }), + ).rejects.toThrow("Payment method type or token is missing"); + }); + }); +}); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts new file mode 100644 index 00000000000..cabd148a539 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -0,0 +1,190 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { + OrganizationBillingServiceAbstraction, + SubscriptionInformation, +} from "@bitwarden/common/billing/abstractions"; +import { PlanType } from "@bitwarden/common/billing/enums"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { LogService } from "@bitwarden/logging"; + +import { + AccountBillingClient, + OrganizationSubscriptionPurchase, + TaxAmounts, + TaxClient, +} from "../../../../clients"; +import { + BillingAddress, + tokenizablePaymentMethodToLegacyEnum, + TokenizedPaymentMethod, +} from "../../../../payment/types"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "../../../../types/subscription-pricing-tier"; + +export type PlanDetails = { + tier: PersonalSubscriptionPricingTierId; + details: PersonalSubscriptionPricingTier; +}; + +export type PaymentFormValues = { + organizationName?: string | null; + billingAddress: { + country: string; + postalCode: string; + }; +}; + +/** + * Service for handling payment submission and sales tax calculation for upgrade payment component + */ +@Injectable() +export class UpgradePaymentService { + constructor( + private organizationBillingService: OrganizationBillingServiceAbstraction, + private accountBillingClient: AccountBillingClient, + private taxClient: TaxClient, + private logService: LogService, + private apiService: ApiService, + private syncService: SyncService, + ) {} + + /** + * Calculate estimated tax for the selected plan + */ + async calculateEstimatedTax( + planDetails: PlanDetails, + billingAddress: BillingAddress, + ): Promise { + try { + const isOrganizationPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families; + const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium; + + let taxClientCall: Promise | null = null; + + if (isOrganizationPlan) { + const seats = this.getPasswordManagerSeats(planDetails); + if (seats === 0) { + throw new Error("Seats must be greater than 0 for organization plan"); + } + // Currently, only Families plan is supported for organization plans + const request: OrganizationSubscriptionPurchase = { + tier: "families", + cadence: "annually", + passwordManager: { seats, additionalStorage: 0, sponsored: false }, + }; + + taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + request, + billingAddress, + ); + } + + if (isPremiumPlan) { + taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress); + } + + if (taxClientCall === null) { + throw new Error("Tax client call is not defined"); + } + + const preview = await taxClientCall; + return preview.tax; + } catch (error: unknown) { + this.logService.error("Tax calculation failed:", error); + throw error; + } + } + + /** + * Process premium upgrade + */ + async upgradeToPremium( + paymentMethod: TokenizedPaymentMethod, + billingAddress: Pick, + ): Promise { + this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); + + await this.accountBillingClient.purchasePremiumSubscription(paymentMethod, billingAddress); + + await this.refreshAndSync(); + } + + /** + * Process families upgrade + */ + async upgradeToFamilies( + account: Account, + planDetails: PlanDetails, + paymentMethod: TokenizedPaymentMethod, + formValues: PaymentFormValues, + ): Promise { + const billingAddress = formValues.billingAddress; + + if (!formValues.organizationName) { + throw new Error("Organization name is required for families upgrade"); + } + + this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); + + const passwordManagerSeats = this.getPasswordManagerSeats(planDetails); + + const subscriptionInformation: SubscriptionInformation = { + organization: { + name: formValues.organizationName, + billingEmail: account.email, // Use account email as billing email + }, + plan: { + type: PlanType.FamiliesAnnually, + passwordManagerSeats: passwordManagerSeats, + }, + payment: { + paymentMethod: [ + paymentMethod.token, + tokenizablePaymentMethodToLegacyEnum(paymentMethod.type), + ], + billing: { + country: billingAddress.country, + postalCode: billingAddress.postalCode, + }, + }, + }; + + const result = await this.organizationBillingService.purchaseSubscription( + subscriptionInformation, + account.id, + ); + await this.refreshAndSync(); + return result; + } + + private getPasswordManagerSeats(planDetails: PlanDetails): number { + return "users" in planDetails.details.passwordManager + ? planDetails.details.passwordManager.users + : 0; + } + + private validatePaymentAndBillingInfo( + paymentMethod: TokenizedPaymentMethod, + billingAddress: { country: string; postalCode: string }, + ): void { + if (!paymentMethod?.token || !paymentMethod?.type) { + throw new Error("Payment method type or token is missing"); + } + + if (!billingAddress?.country || !billingAddress?.postalCode) { + throw new Error("Billing address information is incomplete"); + } + } + + private async refreshAndSync(): Promise { + await this.apiService.refreshIdentityToken(); + await this.syncService.fullSync(true); + } +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html new file mode 100644 index 00000000000..0198f3baebb --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -0,0 +1,62 @@ + + + {{ upgradeToMessage }} + +
+ @if (isFamiliesPlan) { +
+ + {{ "organizationName" | i18n }} + + +

+ {{ "organizationNameDescription" | i18n }} +

+
+ } +
+ +
+
+ +
+ @if (passwordManager) { + + @if (isFamiliesPlan) { +

+ {{ "paymentChargedWithTrial" | i18n }} +

+ } + } +
+
+ + + + + +
+ diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts new file mode 100644 index 00000000000..8aaced5e1fc --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -0,0 +1,279 @@ +import { + AfterViewInit, + Component, + DestroyRef, + input, + OnInit, + output, + signal, + ViewChild, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { debounceTime, Observable } from "rxjs"; + +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { CartSummaryComponent, LineItem } from "@bitwarden/pricing"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { EnterPaymentMethodComponent } from "../../../payment/components"; +import { BillingServicesModule } from "../../../services"; +import { SubscriptionPricingService } from "../../../services/subscription-pricing.service"; +import { BitwardenSubscriber } from "../../../types"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "../../../types/subscription-pricing-tier"; + +import { PlanDetails, UpgradePaymentService } from "./services/upgrade-payment.service"; + +/** + * Status types for upgrade payment dialog + */ +export const UpgradePaymentStatus = { + Back: "back", + Closed: "closed", + UpgradedToPremium: "upgradedToPremium", + UpgradedToFamilies: "upgradedToFamilies", +} as const; + +export type UpgradePaymentStatus = UnionOfValues; + +export type UpgradePaymentResult = { + status: UpgradePaymentStatus; + organizationId: string | null; +}; + +/** + * Parameters for upgrade payment + */ +export type UpgradePaymentParams = { + plan: PersonalSubscriptionPricingTierId | null; + subscriber: BitwardenSubscriber; +}; + +@Component({ + selector: "app-upgrade-payment", + imports: [ + DialogModule, + SharedModule, + CartSummaryComponent, + ButtonModule, + EnterPaymentMethodComponent, + BillingServicesModule, + ], + providers: [UpgradePaymentService], + templateUrl: "./upgrade-payment.component.html", +}) +export class UpgradePaymentComponent implements OnInit, AfterViewInit { + protected selectedPlanId = input.required(); + protected account = input.required(); + protected goBack = output(); + protected complete = output(); + protected selectedPlan: PlanDetails | null = null; + + @ViewChild(EnterPaymentMethodComponent) paymentComponent!: EnterPaymentMethodComponent; + @ViewChild(CartSummaryComponent) cartSummaryComponent!: CartSummaryComponent; + + protected formGroup = new FormGroup({ + organizationName: new FormControl("", [Validators.required]), + paymentForm: EnterPaymentMethodComponent.getFormGroup(), + }); + + protected loading = signal(true); + private pricingTiers$!: Observable; + + // Cart Summary data + protected passwordManager!: LineItem; + protected estimatedTax = 0; + + // Display data + protected upgradeToMessage = ""; + + constructor( + private i18nService: I18nService, + private subscriptionPricingService: SubscriptionPricingService, + private toastService: ToastService, + private logService: LogService, + private destroyRef: DestroyRef, + private upgradePaymentService: UpgradePaymentService, + ) {} + + async ngOnInit(): Promise { + if (!this.isFamiliesPlan) { + this.formGroup.controls.organizationName.disable(); + } + + this.pricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(); + this.pricingTiers$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((plans) => { + const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); + + if (planDetails) { + this.selectedPlan = { + tier: this.selectedPlanId(), + details: planDetails, + }; + } + }); + + if (!this.selectedPlan) { + this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); + return; + } + + this.passwordManager = { + name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + cost: this.selectedPlan.details.passwordManager.annualPrice, + quantity: 1, + cadence: "year", + }; + + this.upgradeToMessage = this.i18nService.t( + this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium", + ); + + this.estimatedTax = 0; + + this.formGroup.valueChanges + .pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.refreshSalesTax()); + this.loading.set(false); + } + + ngAfterViewInit(): void { + this.cartSummaryComponent.isExpanded.set(false); + } + + protected get isPremiumPlan(): boolean { + return this.selectedPlanId() === PersonalSubscriptionPricingTierIds.Premium; + } + + protected get isFamiliesPlan(): boolean { + return this.selectedPlanId() === PersonalSubscriptionPricingTierIds.Families; + } + + protected submit = async (): Promise => { + if (!this.isFormValid()) { + this.formGroup.markAllAsTouched(); + return; + } + + if (!this.selectedPlan) { + throw new Error("No plan selected"); + } + + try { + const result = await this.processUpgrade(); + if (result.status === UpgradePaymentStatus.UpgradedToFamilies) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("familiesUpdated"), + }); + } else if (result.status === UpgradePaymentStatus.UpgradedToPremium) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("premiumUpdated"), + }); + } + this.complete.emit(result); + } catch (error: unknown) { + this.logService.error("Upgrade failed:", error); + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("upgradeErrorMessage"), + }); + } + }; + + protected isFormValid(): boolean { + return this.formGroup.valid && this.paymentComponent?.validate(); + } + + private async processUpgrade(): Promise { + // Get common values + const country = this.formGroup.value?.paymentForm?.billingAddress?.country; + const postalCode = this.formGroup.value?.paymentForm?.billingAddress?.postalCode; + + if (!this.selectedPlan) { + throw new Error("No plan selected"); + } + if (!country || !postalCode) { + throw new Error("Billing address is incomplete"); + } + + // Validate organization name for Families plan + const organizationName = this.formGroup.value?.organizationName; + if (this.isFamiliesPlan && !organizationName) { + throw new Error("Organization name is required"); + } + + // Get payment method + const tokenizedPaymentMethod = await this.paymentComponent?.tokenize(); + + if (!tokenizedPaymentMethod) { + throw new Error("Payment method is required"); + } + + // Process the upgrade based on plan type + if (this.isFamiliesPlan) { + const paymentFormValues = { + organizationName, + billingAddress: { country, postalCode }, + }; + + const response = await this.upgradePaymentService.upgradeToFamilies( + this.account(), + this.selectedPlan, + tokenizedPaymentMethod, + paymentFormValues, + ); + + return { status: UpgradePaymentStatus.UpgradedToFamilies, organizationId: response.id }; + } else { + await this.upgradePaymentService.upgradeToPremium(tokenizedPaymentMethod, { + country, + postalCode, + }); + return { status: UpgradePaymentStatus.UpgradedToPremium, organizationId: null }; + } + } + + private async refreshSalesTax(): Promise { + const billingAddress = { + country: this.formGroup.value.paymentForm?.billingAddress?.country, + postalCode: this.formGroup.value.paymentForm?.billingAddress?.postalCode, + }; + + if (!this.selectedPlan || !billingAddress.country || !billingAddress.postalCode) { + this.estimatedTax = 0; + return; + } + + this.upgradePaymentService + .calculateEstimatedTax(this.selectedPlan, { + line1: null, + line2: null, + city: null, + state: null, + country: billingAddress.country, + postalCode: billingAddress.postalCode, + taxId: null, + }) + .then((tax) => { + this.estimatedTax = tax; + }) + .catch((error: unknown) => { + this.logService.error("Tax calculation failed:", error); + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("taxCalculationError"), + }); + this.estimatedTax = 0; + }); + } +} 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 2b5c27e0f09..9d093ec4514 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 @@ -50,6 +50,7 @@ import { SubscriberBillingClient, TaxClient, } from "@bitwarden/web-vault/app/billing/clients"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { EnterBillingAddressComponent, EnterPaymentMethodComponent, @@ -221,6 +222,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private billingNotificationService: BillingNotificationService, private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, + private organizationWarningsService: OrganizationWarningsService, ) {} async ngOnInit(): Promise { @@ -808,6 +810,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { paymentMethod, billingAddress, ); + this.organizationWarningsService.refreshInactiveSubscriptionWarning(); } private async updateOrganization() { 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 53f72558089..8c2a7634264 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 @@ -421,11 +421,9 @@ describe("OrganizationWarningsService", () => { it("should not show dialog when no inactive subscription warning exists", (done) => { organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + done(); }); }); @@ -437,20 +435,18 @@ describe("OrganizationWarningsService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ - title: "Test Organization subscription suspended", - content: { - key: "suspendedManagedOrgMessage", - placeholders: ["Test Reseller Inc"], - }, - type: "danger", - acceptButtonText: "Close", - cancelButtonText: null, - }); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { + key: "suspendedManagedOrgMessage", + placeholders: ["Test Reseller Inc"], + }, + type: "danger", + acceptButtonText: "Close", + cancelButtonText: null, + }); + done(); }); }); @@ -463,21 +459,19 @@ describe("OrganizationWarningsService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); router.navigate.mockResolvedValue(true); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ - title: "Test Organization subscription suspended", - content: { key: "suspendedOwnerOrgMessage" }, - type: "danger", - acceptButtonText: "Continue", - cancelButtonText: "Close", - }); - expect(router.navigate).toHaveBeenCalledWith( - ["organizations", "org-id-123", "billing", "payment-details"], - { state: { launchPaymentModalAutomatically: true } }, - ); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { key: "suspendedOwnerOrgMessage" }, + type: "danger", + acceptButtonText: "Continue", + cancelButtonText: "Close", + }); + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", "org-id-123", "billing", "payment-details"], + { state: { launchPaymentModalAutomatically: true } }, + ); + done(); }); }); @@ -490,14 +484,12 @@ describe("OrganizationWarningsService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); router.navigate.mockResolvedValue(true); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(router.navigate).toHaveBeenCalledWith( - ["organizations", "org-id-123", "billing", "payment-details"], - { state: { launchPaymentModalAutomatically: true } }, - ); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(router.navigate).toHaveBeenCalledWith( + ["organizations", "org-id-123", "billing", "payment-details"], + { state: { launchPaymentModalAutomatically: true } }, + ); + done(); }); }); @@ -509,12 +501,10 @@ describe("OrganizationWarningsService", () => { dialogService.openSimpleDialog.mockResolvedValue(false); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).toHaveBeenCalled(); - expect(router.navigate).not.toHaveBeenCalled(); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + done(); }); }); @@ -534,18 +524,16 @@ describe("OrganizationWarningsService", () => { (openChangePlanDialog as jest.Mock).mockReturnValue(mockDialogRef); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id); - expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, { - data: { - organizationId: organization.id, - subscription: subscription, - productTierType: organization.productTierType, - }, - }); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(organizationApiService.getSubscription).toHaveBeenCalledWith(organization.id); + expect(openChangePlanDialog).toHaveBeenCalledWith(dialogService, { + data: { + organizationId: organization.id, + subscription: subscription, + productTierType: organization.productTierType, + }, + }); + done(); }); }); @@ -557,17 +545,15 @@ describe("OrganizationWarningsService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); - service.showInactiveSubscriptionDialog$(organization).subscribe({ - complete: () => { - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ - title: "Test Organization subscription suspended", - content: { key: "suspendedUserOrgMessage" }, - type: "danger", - acceptButtonText: "Close", - cancelButtonText: null, - }); - done(); - }, + service.showInactiveSubscriptionDialog$(organization).subscribe(() => { + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Test Organization subscription suspended", + content: { key: "suspendedUserOrgMessage" }, + type: "danger", + acceptButtonText: "Close", + cancelButtonText: null, + }); + 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 46a34def28b..8bec7acffe1 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 @@ -46,6 +46,7 @@ export class OrganizationWarningsService { private refreshFreeTrialWarningTrigger = new Subject(); private refreshTaxIdWarningTrigger = new Subject(); + private refreshInactiveSubscriptionWarningTrigger = new Subject(); private taxIdWarningRefreshedSubject = new BehaviorSubject(null); taxIdWarningRefreshed$ = this.taxIdWarningRefreshedSubject.asObservable(); @@ -164,12 +165,24 @@ export class OrganizationWarningsService { refreshFreeTrialWarning = () => this.refreshFreeTrialWarningTrigger.next(); + refreshInactiveSubscriptionWarning = () => this.refreshInactiveSubscriptionWarningTrigger.next(); + refreshTaxIdWarning = () => this.refreshTaxIdWarningTrigger.next(); showInactiveSubscriptionDialog$ = (organization: Organization): Observable => - this.getWarning$(organization, (response) => response.inactiveSubscription).pipe( - filter((warning) => warning !== null), + merge( + this.getWarning$(organization, (response) => response.inactiveSubscription), + this.refreshInactiveSubscriptionWarningTrigger.pipe( + switchMap(() => + this.getWarning$(organization, (response) => response.inactiveSubscription, true), + ), + ), + ).pipe( switchMap(async (warning) => { + if (!warning) { + return; + } + switch (warning.resolution) { case "contact_provider": { await this.dialogService.openSimpleDialog({ 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 4af5226e7ee..c0a9027388d 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 @@ -9,6 +9,7 @@ import { PopoverModule, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; import { BillingServicesModule, BraintreeService, StripeService } from "../../services"; import { + AccountCreditPaymentMethod, isTokenizablePaymentMethod, selectableCountries, TokenizablePaymentMethod, @@ -17,7 +18,7 @@ import { import { PaymentLabelComponent } from "./payment-label.component"; -type PaymentMethodOption = TokenizablePaymentMethod | "accountCredit"; +type PaymentMethodOption = TokenizablePaymentMethod | AccountCreditPaymentMethod; type PaymentMethodFormGroup = FormGroup<{ type: FormControl; @@ -183,14 +184,20 @@ type PaymentMethodFormGroup = FormGroup<{ } @case ("accountCredit") { - - {{ "makeSureEnoughCredit" | i18n }} - + @if (hasEnoughAccountCredit) { + + {{ "makeSureEnoughCredit" | i18n }} + + } @else { + + {{ "notEnoughAccountCredit" | i18n }} + + } } } @if (showBillingDetails) { -
{{ "billingAddress" | i18n }}
+
{{ "billingAddress" | i18n }}
@@ -230,6 +237,7 @@ export class EnterPaymentMethodComponent implements OnInit { @Input() private showBankAccount = true; @Input() showPayPal = true; @Input() showAccountCredit = false; + @Input() hasEnoughAccountCredit = true; @Input() includeBillingAddress = false; protected showBankAccount$!: Observable; diff --git a/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts b/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts index 5d212a9cb68..9b867329e66 100644 --- a/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts +++ b/apps/web/src/app/billing/payment/types/tokenized-payment-method.ts @@ -6,9 +6,14 @@ export const TokenizablePaymentMethods = { payPal: "payPal", } as const; +export const NonTokenizablePaymentMethods = { + accountCredit: "accountCredit", +} as const; + export type BankAccountPaymentMethod = typeof TokenizablePaymentMethods.bankAccount; export type CardPaymentMethod = typeof TokenizablePaymentMethods.card; export type PayPalPaymentMethod = typeof TokenizablePaymentMethods.payPal; +export type AccountCreditPaymentMethod = typeof NonTokenizablePaymentMethods.accountCredit; export type TokenizablePaymentMethod = (typeof TokenizablePaymentMethods)[keyof typeof TokenizablePaymentMethods]; @@ -18,21 +23,6 @@ export const isTokenizablePaymentMethod = (value: string): value is TokenizableP return valid.includes(value); }; -export const tokenizablePaymentMethodFromLegacyEnum = ( - legacyEnum: PaymentMethodType, -): TokenizablePaymentMethod | null => { - switch (legacyEnum) { - case PaymentMethodType.BankAccount: - return "bankAccount"; - case PaymentMethodType.Card: - return "card"; - case PaymentMethodType.PayPal: - return "payPal"; - default: - return null; - } -}; - export const tokenizablePaymentMethodToLegacyEnum = ( paymentMethod: TokenizablePaymentMethod, ): PaymentMethodType => { diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.ts b/apps/web/src/app/billing/services/subscription-pricing.service.ts index fad797bed51..82ec9f180b9 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.ts +++ b/apps/web/src/app/billing/services/subscription-pricing.service.ts @@ -110,7 +110,7 @@ export class SubscriptionPricingService { ); private free$: Observable = this.plansResponse$.pipe( - map((plans) => { + map((plans): BusinessSubscriptionPricingTier => { const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!; return { @@ -215,20 +215,22 @@ export class SubscriptionPricingService { ); private custom$: Observable = this.plansResponse$.pipe( - map(() => ({ - id: BusinessSubscriptionPricingTierIds.Custom, - name: this.i18nService.t("planNameCustom"), - description: this.i18nService.t("planDescCustom"), - availableCadences: [], - passwordManager: { - type: "custom", - features: [ - this.featureTranslations.strengthenCybersecurity(), - this.featureTranslations.boostProductivity(), - this.featureTranslations.seamlessIntegration(), - ], - }, - })), + map( + (): BusinessSubscriptionPricingTier => ({ + id: BusinessSubscriptionPricingTierIds.Custom, + name: this.i18nService.t("planNameCustom"), + description: this.i18nService.t("planDescCustom"), + availableCadences: [], + passwordManager: { + type: "custom", + features: [ + this.featureTranslations.strengthenCybersecurity(), + this.featureTranslations.boostProductivity(), + this.featureTranslations.seamlessIntegration(), + ], + }, + }), + ), ); private showUnexpectedErrorToast() { diff --git a/apps/web/src/app/core/router.service.ts b/apps/web/src/app/core/router.service.ts index 603c171e95b..7a2e53a374e 100644 --- a/apps/web/src/app/core/router.service.ts +++ b/apps/web/src/app/core/router.service.ts @@ -75,9 +75,7 @@ export class RouterService { const titleId: string = child?.snapshot?.data?.titleId; const rawTitle: string = child?.snapshot?.data?.title; - // TODO: Eslint upgrade. Please resolve this since the ?? does nothing - // eslint-disable-next-line no-constant-binary-expression - const updateUrl = !child?.snapshot?.data?.doNotSaveUrl ?? true; + const updateUrl = !child?.snapshot?.data?.doNotSaveUrl; if (titleId != null || rawTitle != null) { const newTitle = rawTitle != null ? rawTitle : i18nService.t(titleId); diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts index 1a4141c4d68..bf2a528e723 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.ts @@ -89,6 +89,9 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple private async isPasswordExposed(cv: CipherView): Promise { const { login } = cv; + if (login.password == null) { + return null; + } return await this.auditService.passwordLeaked(login.password).then((exposedCount) => { if (exposedCount > 0) { return { ...cv, exposedXTimes: exposedCount } as ReportResult; diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html index 809ccbabcb7..2a03bf78dd4 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html @@ -1,4 +1,6 @@ -
+
e instanceof NavigationEnd || e instanceof NavigationStart || e === null), ), - ]).pipe(map(() => null)); + ]).pipe(map((): any => null)); constructor( private organizationService: OrganizationService, diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 9ac628752b6..7ffe69b7ee6 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -10,6 +10,7 @@ import { unauthGuardFn, activeAuthGuard, } from "@bitwarden/angular/auth/guards"; +import { LoginViaWebAuthnComponent } from "@bitwarden/angular/auth/login-via-webauthn/login-via-webauthn.component"; 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 { @@ -17,6 +18,7 @@ import { RegistrationUserAddIcon, TwoFactorTimeoutIcon, TwoFactorAuthEmailIcon, + TwoFactorAuthSecurityKeyIcon, UserLockIcon, VaultIcon, SsoKeyIcon, @@ -49,7 +51,6 @@ import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/ import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component"; import { CreateOrganizationComponent } from "./admin-console/settings/create-organization.component"; import { deepLinkGuard } from "./auth/guards/deep-link/deep-link.guard"; -import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component"; import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "./auth/recover-two-factor.component"; @@ -106,11 +107,6 @@ const routes: Routes = [ children: [], // Children lets us have an empty component. canActivate: [redirectGuard()], // Redirects either to vault, login, or lock page. }, - { - path: "login-with-passkey", - component: LoginViaWebAuthnComponent, - data: { titleId: "logInWithPasskey" } satisfies RouteDataProperties, - }, { path: "verify-email", component: VerifyEmailTokenComponent }, { path: "accept-organization", @@ -140,6 +136,28 @@ const routes: Routes = [ path: "", component: AnonLayoutWrapperComponent, children: [ + { + path: "login-with-passkey", + canActivate: [unauthGuardFn()], + data: { + pageIcon: TwoFactorAuthSecurityKeyIcon, + titleId: "logInWithPasskey", + pageTitle: { + key: "logInWithPasskey", + }, + pageSubtitle: { + key: "readingPasskeyLoadingInfo", + }, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { path: "", component: LoginViaWebAuthnComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, { path: "signup", canActivate: [unauthGuardFn()], diff --git a/apps/web/src/app/oss.module.ts b/apps/web/src/app/oss.module.ts index 4e04910246f..ce1b45a9e47 100644 --- a/apps/web/src/app/oss.module.ts +++ b/apps/web/src/app/oss.module.ts @@ -1,7 +1,6 @@ import { NgModule } from "@angular/core"; import { AuthModule } from "./auth"; -import { LoginModule } from "./auth/login/login.module"; import { TrialInitiationModule } from "./billing/trial-initiation/trial-initiation.module"; import { HeaderModule } from "./layouts/header/header.module"; import { SharedModule } from "./shared"; @@ -21,7 +20,6 @@ import "./shared/locales"; TrialInitiationModule, VaultFilterModule, OrganizationBadgeModule, - LoginModule, AuthModule, AccessComponent, ], @@ -31,7 +29,6 @@ import "./shared/locales"; TrialInitiationModule, VaultFilterModule, OrganizationBadgeModule, - LoginModule, AccessComponent, ], bootstrap: [], diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index a5bcb915713..82ddda66110 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -17,6 +17,7 @@ import { CipherViewLikeUtils, } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { SortDirection, TableDataSource } from "@bitwarden/components"; +import { OrganizationId } from "@bitwarden/sdk-internal"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -579,7 +580,7 @@ export class VaultItemsComponent { .every(({ cipher }) => cipher?.edit && cipher?.viewPassword); } - private getUniqueOrganizationIds(): Set { + private getUniqueOrganizationIds(): Set { return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? [])); } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html index 7722ba1ad86..5ae189f05ae 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html @@ -1,6 +1,6 @@
- +
implements OnInit, OnDestr private restrictedItemTypesService: RestrictedItemTypesService, private cipherArchiveService: CipherArchiveService, private organizationWarningsService: OrganizationWarningsService, + private unifiedUpgradePromptService: UnifiedUpgradePromptService, ) {} async ngOnInit() { @@ -606,6 +608,7 @@ export class VaultComponent implements OnInit, OnDestr this.changeDetectorRef.markForCheck(); }, ); + await this.unifiedUpgradePromptService.displayUpgradePromptConditionally(); } ngOnDestroy() { diff --git a/apps/web/src/app/vault/individual-vault/vault.module.ts b/apps/web/src/app/vault/individual-vault/vault.module.ts index 573eceef64a..156e73b439a 100644 --- a/apps/web/src/app/vault/individual-vault/vault.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault.module.ts @@ -10,7 +10,6 @@ import { OrganizationBadgeModule } from "./organization-badge/organization-badge import { PipesModule } from "./pipes/pipes.module"; import { VaultRoutingModule } from "./vault-routing.module"; import { VaultComponent } from "./vault.component"; -import { ViewComponent } from "./view.component"; @NgModule({ imports: [ @@ -23,7 +22,6 @@ import { ViewComponent } from "./view.component"; BulkDialogsModule, CollectionDialogComponent, VaultComponent, - ViewComponent, ], }) export class VaultModule {} diff --git a/apps/web/src/app/vault/individual-vault/view.component.html b/apps/web/src/app/vault/individual-vault/view.component.html deleted file mode 100644 index ac6db212362..00000000000 --- a/apps/web/src/app/vault/individual-vault/view.component.html +++ /dev/null @@ -1,31 +0,0 @@ - - - {{ cipherTypeString }} - - - - - - -
- -
-
-
diff --git a/apps/web/src/app/vault/individual-vault/view.component.spec.ts b/apps/web/src/app/vault/individual-vault/view.component.spec.ts deleted file mode 100644 index d60a6313c67..00000000000 --- a/apps/web/src/app/vault/individual-vault/view.component.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { mock } from "jest-mock-extended"; -import { of } from "rxjs"; - -import { CollectionService } 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"; -import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.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 { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; -import { UserId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; -import { TaskService } from "@bitwarden/common/vault/tasks"; -import { DIALOG_DATA, DialogRef, DialogService, ToastService } from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; -import { ChangeLoginPasswordService } from "@bitwarden/vault"; - -import { ViewCipherDialogParams, ViewCipherDialogResult, ViewComponent } from "./view.component"; - -describe("ViewComponent", () => { - let component: ViewComponent; - let fixture: ComponentFixture; - - const mockCipher: CipherView = { - id: "cipher-id", - type: 1, - organizationId: "org-id", - isDeleted: false, - } as CipherView; - - const mockOrganization: Organization = { - id: "org-id", - name: "Test Organization", - } as Organization; - - const mockParams: ViewCipherDialogParams = { - cipher: mockCipher, - }; - const userId = Utils.newGuid() as UserId; - const accountService: FakeAccountService = mockAccountServiceWith(userId); - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ViewComponent], - providers: [ - { provide: DIALOG_DATA, useValue: mockParams }, - { provide: DialogRef, useValue: mock() }, - { provide: I18nService, useValue: { t: jest.fn().mockReturnValue("login") } }, - { provide: DialogService, useValue: mock() }, - { provide: CipherService, useValue: mock() }, - { provide: ToastService, useValue: mock() }, - { provide: MessagingService, useValue: mock() }, - { - provide: AccountService, - useValue: accountService, - }, - { provide: LogService, useValue: mock() }, - { - provide: OrganizationService, - useValue: { organizations$: jest.fn().mockReturnValue(of([mockOrganization])) }, - }, - { provide: CollectionService, useValue: mock() }, - { provide: FolderService, useValue: mock() }, - { provide: KeyService, useValue: mock() }, - { - provide: BillingAccountProfileStateService, - useValue: mock(), - }, - { provide: ConfigService, useValue: mock() }, - { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, - { - provide: CipherAuthorizationService, - useValue: { - canDeleteCipher$: jest.fn().mockReturnValue(true), - }, - }, - { provide: TaskService, useValue: mock() }, - ], - }) - .overrideComponent(ViewComponent, { - remove: { - providers: [ - { provide: PlatformUtilsService, useValue: PlatformUtilsService }, - { - provide: ChangeLoginPasswordService, - useValue: ChangeLoginPasswordService, - }, - ], - }, - add: { - providers: [ - { provide: PlatformUtilsService, useValue: mock() }, - { - provide: ChangeLoginPasswordService, - useValue: mock(), - }, - ], - }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(ViewComponent); - component = fixture.componentInstance; - component.params = mockParams; - component.cipher = mockCipher; - }); - - describe("ngOnInit", () => { - it("initializes the component with cipher and organization", async () => { - await component.ngOnInit(); - - expect(component.cipher).toEqual(mockCipher); - expect(component.organization).toEqual(mockOrganization); - }); - }); - - describe("edit", () => { - it("closes the dialog with the proper arguments", async () => { - const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); - - await component.edit(); - - expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Edited }); - }); - }); - - describe("delete", () => { - it("calls the delete method on delete and closes the dialog with the proper arguments", async () => { - const deleteSpy = jest.spyOn(component, "delete"); - const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); - jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true); - - await component.delete(); - - expect(deleteSpy).toHaveBeenCalled(); - expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: ViewCipherDialogResult.Deleted }); - }); - }); -}); diff --git a/apps/web/src/app/vault/individual-vault/view.component.ts b/apps/web/src/app/vault/individual-vault/view.component.ts deleted file mode 100644 index 6de29f8e328..00000000000 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ /dev/null @@ -1,218 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { CommonModule } from "@angular/common"; -import { Component, EventEmitter, Inject, OnInit } from "@angular/core"; -import { firstValueFrom, map, Observable } from "rxjs"; - -import { CollectionView } from "@bitwarden/admin-console/common"; -import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.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 { 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 { CollectionId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; -import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; -import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; -import { - DIALOG_DATA, - DialogRef, - DialogConfig, - AsyncActionsModule, - DialogModule, - DialogService, - ToastService, -} from "@bitwarden/components"; -import { CipherViewComponent } from "@bitwarden/vault"; - -import { SharedModule } from "../../shared/shared.module"; - -export interface ViewCipherDialogParams { - cipher: CipherView; - - /** - * Optional list of collections the cipher is assigned to. If none are provided, they will be loaded using the - * `CipherService` and the `collectionIds` property of the cipher. - */ - collections?: CollectionView[]; - - /** - * Optional collection ID used to know the collection filter selected. - */ - activeCollectionId?: CollectionId; - - /** - * If true, the edit button will be disabled in the dialog. - */ - disableEdit?: boolean; -} - -export const ViewCipherDialogResult = { - Edited: "edited", - Deleted: "deleted", - PremiumUpgrade: "premiumUpgrade", -} as const; - -type ViewCipherDialogResult = UnionOfValues; - -export interface ViewCipherDialogCloseResult { - action: ViewCipherDialogResult; -} - -/** - * Component for viewing a cipher, presented in a dialog. - * @deprecated Use the VaultItemDialogComponent instead. - */ -@Component({ - selector: "app-vault-view", - templateUrl: "view.component.html", - imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule], - providers: [{ provide: ViewPasswordHistoryService, useClass: VaultViewPasswordHistoryService }], -}) -export class ViewComponent implements OnInit { - cipher: CipherView; - collections?: CollectionView[]; - onDeletedCipher = new EventEmitter(); - cipherTypeString: string; - organization: Organization; - - canDeleteCipher$: Observable; - - constructor( - @Inject(DIALOG_DATA) public params: ViewCipherDialogParams, - private dialogRef: DialogRef, - private i18nService: I18nService, - private dialogService: DialogService, - private messagingService: MessagingService, - private logService: LogService, - private cipherService: CipherService, - private toastService: ToastService, - private organizationService: OrganizationService, - private cipherAuthorizationService: CipherAuthorizationService, - private accountService: AccountService, - ) {} - - /** - * Lifecycle hook for component initialization. - */ - async ngOnInit() { - this.cipher = this.params.cipher; - this.collections = this.params.collections; - this.cipherTypeString = this.getCipherViewTypeString(); - - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - - if (this.cipher.organizationId) { - this.organization = await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe( - map((organizations) => organizations.find((o) => o.id === this.cipher.organizationId)), - ), - ); - } - - this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher); - } - - /** - * Method to handle cipher deletion. Called when a user clicks the delete button. - */ - delete = async () => { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "deleteItem" }, - content: { - key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation", - }, - type: "warning", - }); - - if (!confirmed) { - return; - } - - try { - await this.deleteCipher(); - this.toastService.showToast({ - variant: "success", - title: this.i18nService.t("success"), - message: this.i18nService.t( - this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem", - ), - }); - this.onDeletedCipher.emit(this.cipher); - this.messagingService.send( - this.cipher.isDeleted ? "permanentlyDeletedCipher" : "deletedCipher", - ); - } catch (e) { - this.logService.error(e); - } - - this.dialogRef.close({ action: ViewCipherDialogResult.Deleted }); - }; - - /** - * Helper method to delete cipher. - */ - protected async deleteCipher(): Promise { - const asAdmin = this.organization?.canEditAllCiphers; - const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - if (this.cipher.isDeleted) { - await this.cipherService.deleteWithServer(this.cipher.id, userId, asAdmin); - } else { - await this.cipherService.softDeleteWithServer(this.cipher.id, userId, asAdmin); - } - } - - /** - * Method to handle cipher editing. Called when a user clicks the edit button. - */ - async edit(): Promise { - this.dialogRef.close({ action: ViewCipherDialogResult.Edited }); - } - - /** - * Method to get cipher view type string, used for the dialog title. - * E.g. "View login" or "View note". - * @returns The localized string for the cipher type - */ - getCipherViewTypeString(): string { - if (!this.cipher) { - return null; - } - - switch (this.cipher.type) { - case CipherType.Login: - return this.i18nService.t("viewItemHeaderLogin"); - case CipherType.SecureNote: - return this.i18nService.t("viewItemHeaderCard"); - case CipherType.Card: - return this.i18nService.t("viewItemHeaderIdentity"); - case CipherType.Identity: - return this.i18nService.t("viewItemHeaderNote"); - case CipherType.SshKey: - return this.i18nService.t("viewItemHeaderSshKey"); - default: - return null; - } - } -} - -/** - * Strongly typed helper to open a cipher view dialog - * @param dialogService Instance of the dialog service that will be used to open the dialog - * @param config Configuration for the dialog - * @returns A reference to the opened dialog - */ -export function openViewCipherDialog( - dialogService: DialogService, - config: DialogConfig, -): DialogRef { - return dialogService.open(ViewComponent, config); -} diff --git a/apps/web/src/images/integrations/logo-datadog-color.svg b/apps/web/src/images/integrations/logo-datadog-color.svg new file mode 100644 index 00000000000..62e608cd544 --- /dev/null +++ b/apps/web/src/images/integrations/logo-datadog-color.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 11195502289..97650f48518 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "U gebruik ’n onondersteunde webblaaier. Die webkluis werk dalk nie soos normaal nie." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index 7dfe8fe7cee..035d87df5de 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "الأعضاء الذين تم إشعارهم" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "تعيين كتطبيق حساس" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "التطبيقات المعلَّمة كتطبيقات حرجة" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "تطبيق" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "كل التطبيقات" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "ابق هذه النافذة مفتوحة واتبع الطلبات من المتصفح الخاص بك." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "استخدم طريقة أخرى للولوج" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index e4a4149d9d6..e29faae93fc 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Hələ heç bir hesabat yaratmamısınız" + }, "notifiedMembers": { "message": "Məlumatlandırılan üzvlər" }, @@ -60,7 +63,7 @@ "message": "Yeni giriş elementi yarat" }, "percentageCompleted": { - "message": "$PERCENT$% complete", + "message": "$PERCENT$% tamamlandı", "placeholders": { "percent": { "content": "$1", @@ -69,7 +72,7 @@ } }, "securityTasksCompleted": { - "message": "$COUNT$ out of $TOTAL$ security tasks completed", + "message": "$COUNT$/$TOTAL$ təhlükəsizlik tapşırığı tamamlandı", "placeholders": { "count": { "content": "$1", @@ -82,28 +85,28 @@ } }, "passwordChangeProgress": { - "message": "Password change progress" + "message": "Parol dəyişmə irəliləyişi" }, "assignMembersTasksToMonitorProgress": { - "message": "Assign members tasks to monitor progress" + "message": "İrəliləyişi izləmək üçün üzvlərə tapşırıqlar təyin edin" }, "onceYouReviewApplications": { - "message": "Once you review applications and mark them as critical, they will display here." + "message": "Müraciətləri incələyib kritik olaraq işarələsəniz, onlar burada nümayiş olunacaq." }, "sendReminders": { - "message": "Send reminders" + "message": "Xatırlatma göndər" }, "onceYouMarkApplicationsCriticalTheyWillDisplayHere": { - "message": "Once you mark applications critical, they will display here." + "message": "Tətbiqləri kritik olaraq işarələsəniz, onlar burada nümayiş olunacaq." }, "viewAtRiskMembers": { - "message": "View at-risk members" + "message": "Riskli üzvlərə bax" }, "viewAtRiskApplications": { - "message": "View at-risk applications" + "message": "Riskli tətbiqlərə bax" }, "criticalApplicationsAreAtRisk": { - "message": "$COUNT$ out of $TOTAL$ critical applications are at-risk due to at-risk passwords", + "message": "$COUNT$/$TOTAL$ kritik tətbiq, riskli parollara görə risk altındadır", "placeholders": { "count": { "content": "$1", @@ -134,7 +137,7 @@ } }, "countOfApplicationsAtRisk": { - "message": "$COUNT$ applications at-risk", + "message": "$COUNT$ tətbiq risk altındadır", "placeholders": { "count": { "content": "$1", @@ -143,7 +146,7 @@ } }, "countOfAtRiskPasswords": { - "message": "$COUNT$ passwords at-risk", + "message": "$COUNT$ parol risk altındadır", "placeholders": { "count": { "content": "$1", @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Tətbiqi kritik olaraq işarələ" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Kritik olaraq işarələnmiş tətbiqlər" }, + "applicationsMarkedAsCriticalFail": { + "message": "Müraciətlər kritik olaraq işarələnmədi" + }, "application": { "message": "Tətbiq" }, @@ -206,10 +224,10 @@ "message": "Riskli üzvlər" }, "membersWithAccessToAtRiskItemsForCriticalApps": { - "message": "Members with access to at-risk items for critical applications" + "message": "Kritik tətbiqlər üçün risk altındakı elementlərə erişimi olan üzvlər" }, "membersAtRiskCount": { - "message": "$COUNT$ members at-risk", + "message": "$COUNT$ üzv risk altındadır", "placeholders": { "count": { "content": "$1", @@ -274,6 +292,33 @@ "totalApplications": { "message": "Cəmi tətbiq" }, + "applicationsNeedingReview": { + "message": "İncələmə gözləyən müraciətlər" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ yeni müraciət", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Yeni müraciətləri incələyib kritik olaraq işarələyin və təşkilatınızı güvəndə saxlayın" + }, + "reviewNow": { + "message": "İndi incələ" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Kritik olaraq işarələməni götür" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Bu pəncərəni açıq saxlayın və brauzerinizdən gələn sorğuları izləyin." }, + "passkeyAuthenticationFailed": { + "message": "Keçid açarı kimlik doğrulaması uğursuzdur. Lütfən yenidən sınayın." + }, "useADifferentLogInMethod": { "message": "Fərqli bir giriş üsulu istifadə edin" }, @@ -1543,7 +1591,7 @@ "message": "Yararsız ana parol" }, "invalidMasterPasswordConfirmEmailAndHost": { - "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "message": "Yararsız ana parol. E-poçtunuzun doğru olduğunu və hesabınızın $HOST$ üzərində yaradıldığını təsdiqləyin.", "placeholders": { "host": { "content": "$1", @@ -1561,28 +1609,28 @@ "message": "Sadalanacaq heç bir element yoxdur." }, "noItemsInTrash": { - "message": "No items in trash" + "message": "Tullantıda element yoxdur" }, "noItemsInTrashDesc": { - "message": "Items you delete will appear here and be permanently deleted after 30 days" + "message": "Sildiyiniz elementlər burada görünəcək və 30 gün sonra birdəfəlik silinəcək" }, "noItemsInVault": { - "message": "No items in the vault" + "message": "Seyfdə element yoxdur" }, "emptyVaultDescription": { - "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." + "message": "Seyf, yalnız parollarızı yox, həm də daha çoxunu qoruyur. Giriş məlumatlarınızı, kimlikləri, kartları və notları burada güvənli şəkildə saxlayın." }, "emptyFavorites": { - "message": "You haven't favorited any items" + "message": "Hələ sevimlilərinizdə heç nə yoxdur" }, "emptyFavoritesDesc": { - "message": "Add frequently used items to favorites for quick access." + "message": "Çox istifadə etdiyiniz elementləri cəld erişim üçün sevimlilərə əlavə edin." }, "noSearchResults": { - "message": "No search results returned" + "message": "Heç bir axtarış nəticəsi qayıtmadı" }, "clearFiltersOrTryAnother": { - "message": "Clear filters or try another search term" + "message": "Filtrləri təmizləyin və ya başqa bir axtarış terminini sınayın" }, "noPermissionToViewAllCollectionItems": { "message": "Bu kolleksiyadakı bütün elementlərə baxmaq üçün icazəniz yoxdur." @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Lütfən bu alış üçün hesabınızda yetərli qədər kredit olduğuna əmin olun. Hesabınızda yetərli kredit yoxdursa, fərq üçün fayldakı ilkin ödəniş metodunuz istifadə edilir. Hesabınıza, faktura səhifəsindən kredit əlavə edə bilərsiniz." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Hesabınızın krediti, satın alma üçün istifadə oluna bilər. Əlçatan istənilən kredit, bu hesab üçün yaradılan fakturalara avtomatik tətbiq ediləcək." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Risk Təhlilləriniz yaradılır..." }, + "riskInsightsRunReport": { + "message": "Hesabatı işə sal" + }, "updateBrowserDesc": { "message": "Dəstəklənməyən bir veb brauzer istifadə edirsiniz. Veb seyf düzgün işləməyə bilər." }, @@ -4877,10 +4931,10 @@ "message": "Təşkilat sıradan çıxarıldı." }, "organizationIsSuspended": { - "message": "Organization is suspended" + "message": "Təşkilatın fəaliyyəti dayandırılıb" }, "organizationIsSuspendedDesc": { - "message": "Items in suspended organizations cannot be accessed. Contact your organization owner for assistance." + "message": "Fəaliyyəti dayandırılmış təşkilatlardakı elementlərə erişilə bilməz. Kömək üçün təşkilatınızın sahibi ilə əlaqə saxlayın." }, "secretsAccessSuspended": { "message": "Fəaliyyəti dayandırılmış təşkilatlara erişilə bilməz. Lütfən kömək üçün təşkilatınızın sahibi ilə əlaqə saxlayın." @@ -5268,11 +5322,11 @@ "message": "SSO identifikatoru" }, "ssoIdentifierHint": { - "message": "Provide this ID to your members to login with SSO. Members can skip entering this identifier during SSO if a claimed domain is set up. ", + "message": "SSO ilə giriş etmələri üçün üzvlərinizə bu ID-ni təqdim edin. Əgər tələb olunan domen qurulubsa, üzvlər SSO zamanı bu identifikatora daxil olmağı ötürə bilər.", "description": "This will be used as part of a larger sentence, broken up to include a link. The full sentence will read 'Provide this ID to your members to login with SSO. Members can skip entering this identifier during SSO if a claimed domain is set up. Learn more'" }, "claimedDomainsLearnMore": { - "message": "Learn more", + "message": "Daha ətraflı", "description": "This will be used as part of a larger sentence, broken up to include a link. The full sentence will read 'Provide this ID to your members to login with SSO. Members can skip entering this identifier during SSO if a claimed domain is set up. Learn more'" }, "unlinkSso": { @@ -7087,7 +7141,7 @@ } }, "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat seyfi xaricə köçürüləcək.", "placeholders": { "organization": { "content": "$1", @@ -7096,7 +7150,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat seyfi xaricə köçürüləcək. Element kolleksiyalarım daxil edilməyəcək.", "placeholders": { "organization": { "content": "$1", @@ -7259,7 +7313,7 @@ "message": "Bilinməyən sirr, bu sirrə erişmək üçün icazə istəməli ola bilərsiniz." }, "unknownServiceAccount": { - "message": "Unknown machine account, you may need to request permission to access this machine account." + "message": "Bilinməyən maşın hesabı, bu maşın hesabına erişmək üçün icazə istəməli ola bilərsiniz." }, "unknownProject": { "message": "Bilinməyən layihə, bu layihəyə erişmək üçün icazə istəməli ola bilərsiniz." @@ -8612,7 +8666,7 @@ } }, "accessedProjectWithIdentifier": { - "message": "Accessed a project with identifier: $PROJECT_ID$.", + "message": "$PROJECT_ID$ identifikatoruna sahib bir layihəyə erişildi.", "placeholders": { "project_id": { "content": "$1", @@ -8639,7 +8693,7 @@ } }, "nameUnavailableServiceAccountDeleted": { - "message": "Deleted machine account Id: $SERVICE_ACCOUNT_ID$", + "message": "ID-si $SERVICE_ACCOUNT_ID$ olan maşın hesabı silindi", "placeholders": { "service_account_id": { "content": "$1", @@ -8657,7 +8711,7 @@ } }, "addedUserToServiceAccountWithId": { - "message": "Added user: $USER_ID$ to machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "$USER_ID$ istifadəçisi, $SERVICE_ACCOUNT_ID$ ID-sinə sahib maşın hesabına əlavə edildi", "placeholders": { "user_id": { "content": "$1", @@ -8670,7 +8724,7 @@ } }, "removedUserToServiceAccountWithId": { - "message": "Removed user: $USER_ID$ from machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "$USER_ID$ istifadəçisi, $SERVICE_ACCOUNT_ID$ ID-sinə sahib maşın hesabından çıxarıldı", "placeholders": { "user_id": { "content": "$1", @@ -8683,7 +8737,7 @@ } }, "removedGroupFromServiceAccountWithId": { - "message": "Removed group: $GROUP_ID$ from machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "$GROUP_ID$ qrupu, $SERVICE_ACCOUNT_ID$ ID-sinə sahib maşın hesabından çıxarıldı", "placeholders": { "group_id": { "content": "$1", @@ -8696,7 +8750,7 @@ } }, "serviceAccountCreatedWithId": { - "message": "Created machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "$SERVICE_ACCOUNT_ID$ ID-sinə sahib maşın hesabı yaradıldı", "placeholders": { "service_account_id": { "content": "$1", @@ -8705,7 +8759,7 @@ } }, "addedGroupToServiceAccountId": { - "message": "Added group: $GROUP_ID$ to machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "$GROUP_ID$ qrupu, $SERVICE_ACCOUNT_ID$ ID-sinə sahib maşın hesabına əlavə edildi", "placeholders": { "group_id": { "content": "$1", @@ -8718,7 +8772,7 @@ } }, "serviceAccountDeletedWithId": { - "message": "Deleted machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "$SERVICE_ACCOUNT_ID$ ID-sinə sahib maşın hesabı silindi", "placeholders": { "service_account_id": { "content": "$1", @@ -9563,7 +9617,7 @@ "message": "Təyin et" }, "assignTasks": { - "message": "Assign tasks" + "message": "Tapşırıq təyin et" }, "assignToCollections": { "message": "Kolleksiyalara təyin et" @@ -9960,11 +10014,14 @@ "crowdstrikeEventIntegrationDesc": { "message": "Event verilərini öz Logscale instance\"na göndər" }, + "datadogEventIntegrationDesc": { + "message": "Seyf event verilərini Datadog serverinizə göndərin" + }, "failedToSaveIntegration": { "message": "İnteqrasiya saxlanılmadı. Lütfən daha sonra yenidən sınayın." }, "mustBeOrgOwnerToPerformAction": { - "message": "You must be the organization owner to perform this action." + "message": "Bu əməliyyatı icra etmək üçün təşkilatın sahibi olmalısınız." }, "failedToDeleteIntegration": { "message": "İnteqrasiya silinmədi. Lütfən daha sonra yenidən sınayın." @@ -11230,11 +11287,11 @@ "message": "Arxivdə axtar" }, "archiveNoun": { - "message": "Archive", + "message": "Arxiv", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Arxivlə", "description": "Verb" }, "noItemsInArchive": { @@ -11351,10 +11408,10 @@ "message": "Bitwarden uzantısı quraşdırıldı!" }, "openTheBitwardenExtension": { - "message": "Open the Bitwarden extension" + "message": "Bitwarden uzantısını aç" }, "bitwardenExtensionInstalledOpenExtension": { - "message": "The Bitwarden extension is installed! Open the extension to log in and start autofilling." + "message": "Bitwarden uzantısı quraşdırılıb! Giriş etmək və avto-doldurmanı başlatmaq üçün uzantını aç." }, "openExtensionToAutofill": { "message": "Giriş etmək üçün uzantını aç və avto-doldurmağa başla." @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Problemsiz inteqrasiya" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index 8c6000c3f38..72337228225 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Апавешчаныя ўдзельнікі" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Пазначыць праграму як крытычную" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Праграма" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Усяго праграм" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Пераканайцеся, што на вашым рахунку дастаткова крэдытаў для ажыццяўлення гэтай пакупкі. Калі сродкаў на вашым рахунку не хапае, то для кампенсацыі нястачы будзе выкарыстаны ваш прадвызначаны спосаб аплаты. Вы можаце дадаць крэдыты на свой рахунак на старонцы аплаты." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Крэдыт вашага рахунку можа выкарыстоўвацца для здзяйснення купляў. Любыя даступныя крэдыты будуць аўтаматычна ўжыты для рахункаў, якія згенерыраваны для гэтага ўліковага запісу." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Ваш браўзер не падтрымліваецца. Вэб-сховішча можа працаваць няправільна." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index dd0c14b8f76..694ad4ac22d 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Все още не сте създали нито един доклад" + }, "notifiedMembers": { "message": "Известени членове" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Отбелязване на приложението като важно" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Приложенията не успяха да бъдат отбелязани като важни" + }, "application": { "message": "Приложение" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Общо приложения" }, + "applicationsNeedingReview": { + "message": "Приложения, които имат нужда от преглед" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ нови приложения", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Прегледайте новите приложения, за да ги отбележите като важни, ако е необходимо – така ще се затвърдите сигурността на организацията си" + }, + "reviewNow": { + "message": "Преглеждане сега" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Премахване от важните" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Дръжте този прозорец отворен и следвайте инструкциите в браузъра си." }, + "passkeyAuthenticationFailed": { + "message": "Удостоверяването чрез секретен ключ беше неуспешно. Моля, опитайте отново." + }, "useADifferentLogInMethod": { "message": "Използване на друг метод на вписване" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Проверете дали към абонамента си имате достатъчно средства за тази покупка. В противен случай разликата от наличното и необходимото се изисква от основния ви метод за плащане, който сте задали." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Може да използвате кредитите към абонамента си за покупки. Кредитите се използват за покриването на издадените фактури към абонамента." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Създаване на Вашата информация относно рисковете…" }, + "riskInsightsRunReport": { + "message": "Изпълнение на доклада" + }, "updateBrowserDesc": { "message": "Ползвате неподдържан браузър. Трезорът по уеб може да не сработи правилно." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Изпращане на данни за събитията до Вашата инстанция на Logscale" }, + "datadogEventIntegrationDesc": { + "message": "Изпращане на данните за събитията в трезора към Вашата инсталация на Datadog" + }, "failedToSaveIntegration": { "message": "Интеграцията не беше запазена. Опитайте отново по-късно." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Безпроблемна интеграция" + }, + "families": { + "message": "Семейства" + }, + "upgradeToFamilies": { + "message": "Надградете до Семейния план" + }, + "upgradeToPremium": { + "message": "Надградете до Платения план" + }, + "familiesUpdated": { + "message": "Вече ползвате Семейния абонамент!" + }, + "taxCalculationError": { + "message": "Възникна грешка при изчисляването на данъка за Вашето местоположение. Моля, опитайте отново." + }, + "individualUpgradeWelcomeMessage": { + "message": "Добре дошли в Битуорден" + }, + "individualUpgradeDescriptionMessage": { + "message": "Отключете още функционалности свързани със сигурността, с Платения план; или започнете да споделяте данните в трезора си със Семейния план" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Цените не включват данък и се заплащат веднъж годишно." + }, + "organizationNameDescription": { + "message": "Името на организацията Ви ще се вижда в поканите за присъединяване, които изпращате." + }, + "continueWithoutUpgrading": { + "message": "Продължаване без надграждане" + }, + "upgradeErrorMessage": { + "message": "Възникна грешка при надграждането. Моля, опитайте отново." } } diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index a0d38221ef2..2dbe8104be8 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index 3f723a74e80..01594127641 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index 90d69ca33ad..a8b97f65e17 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Membres notificats" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Aplicació" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Utilitzeu un mètode d'inici de sessió diferent" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Assegureu-vos que el compte tinga suficient crèdit disponible per a aquesta compra. Si no té suficient crèdit, el mètode de pagament predeterminat en el fitxer s'utilitzarà per la diferència. Podeu afegir crèdit al vostre compte des de la pàgina de facturació." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "El crèdit del vostre compte es pot utilitzar per fer compres. Qualsevol crèdit disponible s'aplicarà automàticament a les factures generades." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Esteu utilitzant un navegador web no compatible. La caixa forta web pot no funcionar correctament." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 62af2581a57..262ab520326 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Zatím jste nevytvořili hlášení" + }, "notifiedMembers": { "message": "Obeznámení členové" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Označit aplikaci jako kritickou" }, + "markAsCritical": { + "message": "Označit jako kritické" + }, + "applicationsSelected": { + "message": "vybraných aplikací" + }, + "selectApplication": { + "message": "Vybrat aplikaci" + }, + "unselectApplication": { + "message": "Zrušit výběr aplikace" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Aplikace označené jako kritické" }, + "applicationsMarkedAsCriticalFail": { + "message": "Nepodařilo se označit aplikace jako kritické" + }, "application": { "message": "Aplikace" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Celkem aplikací" }, + "applicationsNeedingReview": { + "message": "Aplikace, které vyžadují kontrolu" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ nových aplikací", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Zkontrolujte nové aplikace a označte je jako kritické a udržujte v bezpečí Vaši organizaci" + }, + "reviewNow": { + "message": "Zkontrolovat nyní" + }, + "prioritizeCriticalApplications": { + "message": "Upřednostnit kritické aplikace" + }, + "atRiskItems": { + "message": "Položky v ohrožení" + }, + "markAsCriticalPlaceholder": { + "message": "Funkce označení jako kritické bude implementována v budoucí aktualizaci" + }, "unmarkAsCritical": { "message": "Zrušit označení jako kritické" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Ponechte toto okno otevřené a postupujte podle dotazů z Vašeho prohlížeče." }, + "passkeyAuthenticationFailed": { + "message": "Ověření přístupového klíče se nezdařilo. Zkuste to znovu." + }, "useADifferentLogInMethod": { "message": "Použít jinou metodu přihlášení" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Ujistěte se, že je na Vašem účtu dostatečný kredit pro provedení tohoto nákupu. Nemáte-li na účtu dostatečný kredit, bude pro doplacení rozdílu použita Vaše výchozí platební metoda. Kredit si můžete navýšit prostřednictvím stránky Fakturace." }, + "notEnoughAccountCredit": { + "message": "Pro tento nákup nemáte na účtu dostatek prostředků. Prostředky na účet můžete přidat na stránce Fakturace." + }, "creditAppliedDesc": { "message": "Pro nákupy bude použit kredit z Vašeho účtu. Jakýkoli dostupný kredit bude automaticky použit pro faktury vystavené pro tento účet." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generování poznatků o rizicích..." }, + "riskInsightsRunReport": { + "message": "Spustit hlášení" + }, "updateBrowserDesc": { "message": "Používáte nepodporovaný webový prohlížeč. Webový trezor nemusí pracovat správně." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Odeslat data události do Vaší instance Logscale" }, + "datadogEventIntegrationDesc": { + "message": "Odeslat data o trezoru do Vaší instance Datadog" + }, "failedToSaveIntegration": { "message": "Nepodařilo se uložit integraci. Opakujte akci později." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Hladká integrace" + }, + "families": { + "message": "Rodiny" + }, + "upgradeToFamilies": { + "message": "Aktualizovat na Rodiny" + }, + "upgradeToPremium": { + "message": "Aktualizovat na Premium" + }, + "familiesUpdated": { + "message": "Aktualizovali jste na Rodiny!" + }, + "taxCalculationError": { + "message": "Došlo k chybě při výpočtu daně pro Vaši polohu. Zkuste to znovu." + }, + "individualUpgradeWelcomeMessage": { + "message": "Vítejte v Bitwardenu" + }, + "individualUpgradeDescriptionMessage": { + "message": "Odemkněte více bezpečnostních funkcí s lidencí Premium, nebo začněte sdílet položky s Rodinami" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Ceny nezahrnují daň a účtují se každoročně." + }, + "organizationNameDescription": { + "message": "Název Vaší organizace se zobrazí v pozvánkách, které posíláte členům." + }, + "continueWithoutUpgrading": { + "message": "Pokračovat bez aktualizace" + }, + "upgradeErrorMessage": { + "message": "Při zpracování aktualizace došlo k chybě. Zkuste to znovu." } } diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index 59dd63da969..9a8754f8ae8 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 2ffab8a80c9..165d08ab3ff 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Underrettede medlemmer" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Markér app som kritisk" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Applikation" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Applikationer i alt" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Hold dette vindue åbent, og følg prompterne fra webbrowseren." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Benyt en anden login-metode" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Sørg for, at din konto har tilstrækkelig indestående til rådighed for dette køb. Hvis din konto ikke har tilstrækkelig indestående til rådighed, bruges din normale betalingsmetode til at dække forskellen. Du kan indbetale til din konto på faktureringssiden." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Din kontos indestående kan bruges til at foretage køb. Ledig indestående vil automatisk blive anvendt til fakturaer for denne konto." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Du bruger en ikke-understøttet webbrowser. Web-boksen fungerer muligvis ikke korrekt." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index d45bf25a138..918efb73db9 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Du hast bis jetzt noch keinen Bericht erstellt" + }, "notifiedMembers": { "message": "Benachrichtigte Mitglieder" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Anwendung als kritisch markieren" }, + "markAsCritical": { + "message": "Als kritisch markieren" + }, + "applicationsSelected": { + "message": "ausgewählte Anwendungen" + }, + "selectApplication": { + "message": "Anwendung auswählen" + }, + "unselectApplication": { + "message": "Anwendung abwählen" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Als kritisch markierte Anwendungen" }, + "applicationsMarkedAsCriticalFail": { + "message": "Anwendungen konnten nicht als kritisch markiert werden" + }, "application": { "message": "Anwendung" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Anwendungen insgesamt" }, + "applicationsNeedingReview": { + "message": "Anwendungen, die geprüft werden müssen" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ neue Anwendungen", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Überprüfe neue Anwendungen, um sie als kritisch zu markieren und deine Organisation sicher zu halten" + }, + "reviewNow": { + "message": "Jetzt prüfen" + }, + "prioritizeCriticalApplications": { + "message": "Kritische Anwendungen priorisieren" + }, + "atRiskItems": { + "message": "Gefährdete Einträge" + }, + "markAsCriticalPlaceholder": { + "message": "Die Funktion \"Als kritisch markieren\" wird in einer zukünftigen Aktualisierung implementiert" + }, "unmarkAsCritical": { "message": "Markierung als kritisch aufheben" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Behalte dieses Fenster geöffnet und folge den Anweisungen deines Browsers." }, + "passkeyAuthenticationFailed": { + "message": "Passkey-Authentifizierung fehlgeschlagen. Bitte versuche es erneut." + }, "useADifferentLogInMethod": { "message": "Eine andere Anmeldemethode verwenden" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Bitte vergewissere dich, dass auf deinem Konto genügend Guthaben für diesen Kauf vorhanden ist. Wenn dein Konto nicht über genügend Guthaben verfügt, wird die Differenz mit deiner Standardzahlungsmethode beglichen. Du kannst deinem Konto auf der Seite \"Abrechnung\" Guthaben hinzufügen." }, + "notEnoughAccountCredit": { + "message": "Du hast nicht genug Guthaben für diesen Kauf. Du kannst auf der Seite „Abrechnung“ Guthaben auf dein Konto laden." + }, "creditAppliedDesc": { "message": "Das Guthaben deines Kontos kann für Einkäufe verwendet werden. Das verfügbare Guthaben wird automatisch auf die für dieses Konto erstellten Rechnungen angerechnet." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Dein Risiko-Überblick wird generiert..." }, + "riskInsightsRunReport": { + "message": "Bericht ausführen" + }, "updateBrowserDesc": { "message": "Du verwendest einen nicht unterstützten Webbrowser. Der Web-Tresor funktioniert möglicherweise nicht richtig." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Ereignisdaten an deine Logscale-Instanz senden" }, + "datadogEventIntegrationDesc": { + "message": "Tresor-Ereignisdaten an deine Datadog-Instanz senden" + }, "failedToSaveIntegration": { "message": "Fehler beim Speichern der Integration. Bitte versuche es später erneut." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Nahtlose Integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade auf Families" + }, + "upgradeToPremium": { + "message": "Upgrade auf Premium" + }, + "familiesUpdated": { + "message": "Du hast ein Upgrade auf Families durchgeführt!" + }, + "taxCalculationError": { + "message": "Es gab einen Fehler bei der Berechnung der Steuer für deinen Standort. Bitte versuche es erneut." + }, + "individualUpgradeWelcomeMessage": { + "message": "Willkommen bei Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Schalte mehr Sicherheitsfunktionen mit Premium frei oder teile Einträge mit Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Die Preise enthalten keine Steuern und werden jährlich in Rechnung gestellt." + }, + "organizationNameDescription": { + "message": "Dein Organisationsname wird in Einladungen angezeigt, die du an Mitglieder sendest." + }, + "continueWithoutUpgrading": { + "message": "Fortfahren, ohne ein Upgrade durchzuführen" + }, + "upgradeErrorMessage": { + "message": "Bei der Verarbeitung deines Upgrades ist ein Fehler aufgetreten. Bitte versuche es erneut." } } diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index 436f2176efb..d5b8867fa21 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Ειδοποιημένα μέλη" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Εφαρμογή" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Σύνολο εφαρμογών" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Κρατήστε αυτό το παράθυρο ανοιχτό και ακολουθήστε τις οδηγίες απ' τον περιηγητή σας." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Χρήση διαφορετικής μεθόδου σύνδεσης" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Βεβαιωθείτε ότι ο λογαριασμός σας έχει αρκετές μονάδες για αυτήν την αγορά. Αν ο λογαριασμός σας δεν διαθέτει αρκετές μονάδες, θα χρησιμοποιηθεί η προκαθορισμένη μέθοδος πληρωμής για τη διαφορά. Μπορείτε να προσθέσετε μονάδες στο λογαριασμό σας από τη σελίδα Χρέωσης." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Οι μονάδες του λογαριασμού σας, μπορούν να χρησιμοποιηθούν για να κάνετε αγορές. Κάθε διαθέσιμη μονάδα θα εφαρμοστεί αυτόματα στα παραστατικά που δημιουργούνται για αυτόν το λογαριασμό." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Χρησιμοποιείτε ένα μη υποστηριζόμενο browser. Το web vault ενδέχεται να μην λειτουργεί σωστά." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5ed393c0295..fd237992d6b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2903,6 +2951,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4314,6 +4365,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9964,6 +10018,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11717,5 +11774,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index e5e5d8f972c..ef664d7d1a4 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organisation secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritise critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organisation name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index c00b02f1e6f..e4c535d4349 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organisation secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritise critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organisation name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 4634635f277..1962ac40d36 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Sciigitaj membroj" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Aplikaĵo" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Uzi diferentan motodon salutan" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Bonvolu certigi, ke via konto havas sufiĉe da kredito havebla por ĉi tiu aĉeto. Se via konto ne havas sufiĉe da kredito havebla, via defaŭlta pagmaniero uzata por la diferenco. Vi povas aldoni krediton al via konto de la Faktura paĝo. " }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "La kredito de via konto povas esti uzata por aĉeti. Ĉiu havebla kredito aŭtomate aplikiĝos al fakturoj generitaj por ĉi tiu konto." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Vi uzas nesubtenatan tTT-legilon. La ttt-volbo eble ne funkcias ĝuste." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 85305249a91..d30509f861a 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Miembros notificados" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Aplicación" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total de aplicaciones" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Mantenga esta ventana abierta y siga las instrucciones de su navegador." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Utilizar otro método de inicio de sesión" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Por favor, asegúrese de que su cuenta tiene suficiente crédito disponible para esta compra. Si su cuenta no tiene suficiente crédito disponible, su método de pago por defecto se utilizará para la diferencia. Puede agregar crédito a su cuenta desde la página de facturación." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "El crédito de su cuenta puede utilizarse para realizar compras. Cualquier crédito disponible se aplicará automáticamente a las facturas generadas para esta cuenta." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Está utilizando un navegador web no compatible. Es posible que la caja fuerte web no funcione correctamente." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index 23c1a1312eb..46be79e90ff 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Teavitatud liikmed" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Hoia see aken lahti ja järgi brauseri juhiseid." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Kasuta teist logimismeetodit" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Veendu, et kontol on selle ostu tegemiseks piisavalt krediiti. Ebapiisava krediidi puhul võetakse puuduolev summa teiselt (vaike) maksemeetodilt. Kontole saab krediiti lisada \"Maksmine\" menüü alt." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Konto krediiti saab kasutada maksete tegemiseks. Uue arve laekumisel kasutatakse selle tasumiseks esmajärjekorras kontol olevat krediiti." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Kasutad brauserit, mida ei toetata. Veebihoidla ei pruugi hästi töötada." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index 8e86873841d..3728bfc656d 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Ziurtatu zure kontuak nahikoa kreditu duela erosketa honetarako. Zure kontuak behar adina kreditu erabilgarri ez badu, fitxategian lehenespenez ezarrita dagoen ordainketa modua ezberdintasuna ordaintzeko erabiliko da. Zure kontuan kreditua gehitu dezakezu fakturazio orritik." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Zure kontuko kreditua erosketak egiteko erabil daiteke. Erabilgarri dagoen edozein kreditu automatikoki aplikatuko zaie kontu honetarako sortutako fakturei." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Euskarririk gabeko web nabigatzailea erabiltzen ari zara. Baliteke webguneko kutxa gotorrak behar bezala ez funtzionatzea." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index 10b51babfc1..60bad3f3f79 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "اعضای مطلع شده" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "برنامه را به عنوان حیاتی علامت‌گذاری کنید" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "برنامه‌های علامت گذاری شده به عنوان حیاتی" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "برنامه" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "کل برنامه‌ها" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "این پنجره را باز نگه دارید و دستورهای مرورگر خود را دنبال کنید." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "از روش ورود متفاوتی استفاده کنید" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "لطفاً مطمئن شوید که اعتبار حساب شما برای این خرید کافی است. اگر حساب شما اعتبار کافی ندارد، از روش پرداخت پیش‌فرض موجود در پرونده برای ما به‌ تفاوت استفاده می‌شود. می‌توانید از صفحه صورتحساب اعتبار به حساب خود اضافه کنید." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "اعتبار حساب شما می‌تواند برای خرید استفاده شود. هر اعتبار موجود به طور خودکار برای فاکتورهای ایجاد شده برای این حساب اعمال می‌شود." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "در حال تولید تحلیل‌های ریسک شما..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "شما از یک مرورگر وب پشتیبانی نشده استفاده می‌کنید. گاوصندوق وب ممکن است به درستی کار نکند." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index 54662ea2274..67932e8724c 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Ilmoitetut jäsenet" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Merkitse sovellus kriittiseksi" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Sovellus" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Sovelluksia yhteensä" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Pidä tämä ikkuna avoinna ja seuraa selaimesi opasteita." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Käytä vaihtoehtoista kirjautumistapaa" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Varmista, että tililläsi on riittävästi saldoa tälle ostolle. Jos tililläsi ei ole tarpeeksi saldoa, voidaan erotus veloittaa tallennettua oletusmaksutapaa käyttäen. Voit lisätä tilillesi saldoa \"Laskutus\" -sivulta." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Tilisi saldoa voidaan käyttää ostoksiin. Kaikki käytettävissä oleva saldo kohdistetaan automaattisesti tälle tilille luotuihin laskuihin." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Käytät selainta, jota ei tueta. Verkkoholvi ei välttämättä toimi oikein." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index 96c97dcc6d5..d4cc52ceda7 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Siguraduhing may sapat na credit ang account mo para sa pagbiling ito. Kung walang sapat na credit ang account mo, gagamitin ang default na paraan sa pagbabayad ng account mo at gagamiting pantapal sa kulang. Makakapagdagdag ka ng credit sa account mo sa pahina ng Pagsingil." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Magagamit ang credit ng account mo para mamili. Mailalapat sa anumang bayarin sa account na ito ang available na credit." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Gumagamit ka ng isang hindi suportado na web browser. Ang web vault ay maaaring hindi gumana nang maayos." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index 62fdc6741b2..f2afdfa0cb8 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Vous n'avez pas encore créé de rapport" + }, "notifiedMembers": { "message": "Membres notifiés" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Marquer l'application comme critique" }, + "markAsCritical": { + "message": "Marquer comme critique" + }, + "applicationsSelected": { + "message": "applications sélectionnées" + }, + "selectApplication": { + "message": "Sélectionner l'application" + }, + "unselectApplication": { + "message": "Désélectionner l'application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marquées comme critiques" }, + "applicationsMarkedAsCriticalFail": { + "message": "Échec du marquage de l'application comme étant critique" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total des applications" }, + "applicationsNeedingReview": { + "message": "Applications nécessitant un examen" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ nouvelles applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Examiner les nouvelles applications à marquer comme critiques et garder votre organisation sécuritaire" + }, + "reviewNow": { + "message": "Examiner maintenant" + }, + "prioritizeCriticalApplications": { + "message": "Prioriser les applications critiques" + }, + "atRiskItems": { + "message": "Éléments à risque" + }, + "markAsCriticalPlaceholder": { + "message": "Marquer comme fonctionnalité critique sera implémentée dans une mise à jour future" + }, "unmarkAsCritical": { "message": "Retirer la cote critique" }, @@ -1243,11 +1288,14 @@ "message": "Se connecter avec le mot de passe principal" }, "readingPasskeyLoading": { - "message": "Lecture de la Passkey..." + "message": "Lecture de la clé d'accès..." }, "readingPasskeyLoadingInfo": { "message": "Gardez cette fenêtre ouverte et suivez les instructions de votre navigateur." }, + "passkeyAuthenticationFailed": { + "message": "Échec de l'authentification par clé d'accès. Veuillez réessayer." + }, "useADifferentLogInMethod": { "message": "Utiliser une méthode de connexion différente" }, @@ -1261,10 +1309,10 @@ "message": "Content de vous revoir" }, "invalidPasskeyPleaseTryAgain": { - "message": "Passkey invalide. Veuillez réessayer de nouveau." + "message": "Clé d'accès invalide. Veuillez réessayer." }, "twoFactorForPasskeysNotSupportedOnClientUpdateToLogIn": { - "message": "La 2FA pour les Passkey n'est pas pris en charge. Mettez à jour l'application pour vous connecter." + "message": "La 2FA pour les clé d'accès n'est pas pris en charge. Mettez à jour l'application pour vous connecter." }, "loginWithPasskeyInfo": { "message": "Utilisez une clé d'accès générée qui vous connectera automatiquement sans mot de passe. La biométrie, comme la reconnaissance faciale ou les empreintes digitales, ou une autre méthode de sécurité FIDO2 vérifiera votre identité." @@ -1724,7 +1772,7 @@ "message": "Clé de sécurité FIDO U2F" }, "webAuthnTitle": { - "message": "WebAuthn FIDO2" + "message": "Clé d'accès" }, "webAuthnDesc": { "message": "Utilisez n'importe quelle clé de sécurité compatible avec WebAuthn pour accéder à votre compte." @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Veuillez vous assurer que votre compte dispose d'un crédit suffisant pour cet achat. Si le crédit de votre compte n'est pas suffisant, votre mode de paiement par défaut sera utilisé pour régler la différence. Vous pouvez ajouter du crédit à votre compte à partir de la page Facturation." }, + "notEnoughAccountCredit": { + "message": "Vous n'avez pas assez de crédit pour cet achat. Vous pouvez ajouter du crédit à votre compte à partir de la page de facturation." + }, "creditAppliedDesc": { "message": "Le crédit de votre compte peut être utilisé pour régler vos achats. Tout crédit disponible sera automatiquement appliqué aux factures générées pour ce compte." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Génération de vos Aperçus de Risque..." }, + "riskInsightsRunReport": { + "message": "Exécuter le rapport" + }, "updateBrowserDesc": { "message": "Vous utilisez un navigateur non supporté. Le coffre web pourrait ne pas fonctionner correctement." }, @@ -9314,10 +9368,10 @@ "message": "Passkey" }, "passkeyNotCopied": { - "message": "La Passkey ne sera pas copiée" + "message": "La clé d'accès ne sera pas copiée" }, "passkeyNotCopiedAlert": { - "message": "La passkey ne sera pas copiée à l'item cloné. Voulez-vous continuez à cloner cet élément?" + "message": "La clé d'accès ne sera pas copiée à l'item cloné. Voulez-vous continuez à cloner cet élément?" }, "modifiedCollectionManagement": { "message": "Paramètre de gestion de la collection modifiée $ID$.", @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Envoyer les données de l'événement à votre instance Logscale" }, + "datadogEventIntegrationDesc": { + "message": "Envoyer les données de l'événement du coffre à votre instance Datadog" + }, "failedToSaveIntegration": { "message": "Impossible d'enregistrer l'intégration. Veuillez réessayer plus tard." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Intégrations transparentes" + }, + "families": { + "message": "Familles" + }, + "upgradeToFamilies": { + "message": "Mettre à niveau vers les Familles" + }, + "upgradeToPremium": { + "message": "Mettre à niveau vers Premium" + }, + "familiesUpdated": { + "message": "Vous avez mis à niveau vers Familles!" + }, + "taxCalculationError": { + "message": "Une erreur s'est produite lors du calcul de la taxe pour votre emplacement. Veuillez réessayer." + }, + "individualUpgradeWelcomeMessage": { + "message": "Bienvenue sur Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Déverrouillez plus de fonctionnalités de sécurité avec Premium, ou commencez à partager des éléments avec Familles" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Les prix excluent les taxes et sont facturés annuellement." + }, + "organizationNameDescription": { + "message": "Le nom de votre organisation apparaîtra dans les invitations que vous envoyez aux membres." + }, + "continueWithoutUpgrading": { + "message": "Continuer sans mettre à niveau" + }, + "upgradeErrorMessage": { + "message": "Nous avons rencontré une erreur lors du traitement de votre mise à niveau. Veuillez réessayer." } } diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index c4c35278f4b..31f5f928bd5 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 915145eebc5..43142a33ea3 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "חברים שהודיעו להם" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "סמן יישום כקריטי" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "יישומים המסומנים כקריטיים" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "יישום" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "סה\"כ יישומים" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "בטל סימון כקריטי" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "השאר חלון זה פתוח ועקוב אחר ההנחיות מהדפדפן שלך." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "השתמש בשיטת כניסה אחרת" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "אנא ודא כי יש בחשבונך מספיק קרדיט עבור רכישה זו. אם בחשבונך אין די קרדיט, נשתמש בשיטת התשלום המועדפת בחשבונך כדי לגבות את הפער. באפשרותך להוסיף קרדיט לחשבונך דרך עמוד החיוב." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "ניתן להשתמש בקרדיט שבחשבונך כדי לבצע רכישות. נשתמש בקרדיט הראשון הזמין עבור חשבוניות בחשבון זה." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "מייצר את תובנות הסיכון שלך..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "אתה משתמש בדפדפן אינטרנט שאיננו נתמך. כספת הרשת עלולה שלא לפעול כראוי." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "שלח נתוני אירועים אל מופע ה־Logscale שלך" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "שמירת האינטגרציה נכשלה. נא לנסות שוב מאוחר יותר." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index f7d116162ff..73714cce120 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index 8f49041638a..a98b4d684d3 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Nema stvorenih izvješčća" + }, "notifiedMembers": { "message": "Obaviješteni članovi" }, @@ -60,7 +63,7 @@ "message": "Stvori novu stavku prijave" }, "percentageCompleted": { - "message": "$PERCENT$% complete", + "message": "dovršeno $PERCENT$ %", "placeholders": { "percent": { "content": "$1", @@ -69,7 +72,7 @@ } }, "securityTasksCompleted": { - "message": "$COUNT$ out of $TOTAL$ security tasks completed", + "message": "završeni sigurnosni zadaci $COUNT$ od $TOTAL$", "placeholders": { "count": { "content": "$1", @@ -82,19 +85,19 @@ } }, "passwordChangeProgress": { - "message": "Password change progress" + "message": "Napredak promjene lozinke" }, "assignMembersTasksToMonitorProgress": { - "message": "Assign members tasks to monitor progress" + "message": "Dodijeli članovima zadatke za praćenje napretka" }, "onceYouReviewApplications": { - "message": "Once you review applications and mark them as critical, they will display here." + "message": "Ako nakon pregleda aplikacije neku označiš kao kritičnu, biti će prikazane ovdje." }, "sendReminders": { - "message": "Send reminders" + "message": "Pošalji podsjetnike" }, "onceYouMarkApplicationsCriticalTheyWillDisplayHere": { - "message": "Once you mark applications critical, they will display here." + "message": "Aplikacije označene kao kritične će biti prikazane ovdje." }, "viewAtRiskMembers": { "message": "Rizični korisnici" @@ -143,7 +146,7 @@ } }, "countOfAtRiskPasswords": { - "message": "$COUNT$ passwords at-risk", + "message": "Rizičnih lozinki: $COUNT$", "placeholders": { "count": { "content": "$1", @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Označi aplikacije kao kritične" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Aplikacije označene kao kritične" }, + "applicationsMarkedAsCriticalFail": { + "message": "Nije uspjelo označavanje aplikacija kao kritičnih" + }, "application": { "message": "Aplikacija" }, @@ -206,7 +224,7 @@ "message": "Rizični korisnici" }, "membersWithAccessToAtRiskItemsForCriticalApps": { - "message": "Members with access to at-risk items for critical applications" + "message": "Članovi koji imaju pristup stavkama za aplikacije označene kao kritične" }, "membersAtRiskCount": { "message": "Rizičnih članova: $COUNT$", @@ -274,6 +292,33 @@ "totalApplications": { "message": "Ukupno aplikacija" }, + "applicationsNeedingReview": { + "message": "Aplikacije koje je potrebno pregledati" + }, + "newApplicationsWithCount": { + "message": "Novih aplikacija: $COUNT$", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Pregledaj nove aplikacije i po potrebi označi kritične za zaštitu svoje organizacije" + }, + "reviewNow": { + "message": "Pregledaj sada" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Odznači kao kritično" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Ostavi ovaj prozor otvorenim i slijedi upute preglednika." }, + "passkeyAuthenticationFailed": { + "message": "Autentifikacija pristupnim ključem nije uspjela. Pokušaj ponovno." + }, "useADifferentLogInMethod": { "message": "Koristi drugi način prijave" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Osiguraj da tvoj račun ima dovoljno raspoloživih sredstava za kupnju. Ako tvoj račun nema dovoljno sredstava za kupnju, sredstva će biti dopunjena iz tvojeg zadanog spremljenog načina plaćanja. Sredstva svojem računu možeš dodati na stranici Naplata." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Sredstva na tvojem računu mogu se koristiti za kupnju. Sva raspoloživa sredstva će automatski biti upotrijebljena za plaćanje kupnji napravljenih na tvojem računu." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Stvaranje tvojih uvida u rizik..." }, + "riskInsightsRunReport": { + "message": "Pokreni izvješće" + }, "updateBrowserDesc": { "message": "Koristiš nepodržani preglednik. Web trezor možda neće ispravno raditi." }, @@ -9563,7 +9617,7 @@ "message": "Dodijeli" }, "assignTasks": { - "message": "Assign tasks" + "message": "Dodijeli zadatke" }, "assignToCollections": { "message": "Dodijeli zbirkama" @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Pošaljite podatke o događajima svojoj Logscale instanci" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Spremanje integracije nije uspjelo. Pokušaj ponovno kasnije." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Jednostavna integracija" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index cfc6e9e0735..504151297b2 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Még nem készült jelentés." + }, "notifiedMembers": { "message": "Értesített tagok" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Alkalmazások megjelölése kritikusként" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Kritikusként megjelölt alkalmazások" }, + "applicationsMarkedAsCriticalFail": { + "message": "Nem sikerült kritikusként megjelölni a kérelmeket." + }, "application": { "message": "Alkalmazás" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Összes alkalmazás" }, + "applicationsNeedingReview": { + "message": "Felülvizsgálatot igénylő kérelmek" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ új alkalmazás", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Tekintsük át az új alkalmazásokat a kritikusként megjelöléshez és tartsuk biztonságban szervezetet." + }, + "reviewNow": { + "message": "Áttekintés most" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Ktritkus jelölés eltávolítása" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Tartsuk nyitva ezt az ablakot és kövessük a böngésző utasításait." }, + "passkeyAuthenticationFailed": { + "message": "A hozzáférési kulcs hitelesítés sikertelen volt. Próbáljuk újra." + }, "useADifferentLogInMethod": { "message": "Más bejelentkezési mód használata" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Ellenőrizzük, hogy a fióknak elég hitelkerete van a vásárláshoz. Ha nincs elég keret, akkor az alapértelmezett fizetési mód kerül használatba a különbségnél. Hitelkeretet a Számlázás oldalon adhatunk hozzá." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "A fiók hitelkeret vásárlásra használható. A rendelkezésre álló keret automatikusan ehhez a fiókhoz generálódó számlákra kerül alkalmazásra." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "A kockázati betekintések generálása..." }, + "riskInsightsRunReport": { + "message": "Jelentés futtatása" + }, "updateBrowserDesc": { "message": "Nem támogatott böngészőt használunk. Előfordulhat, hogy a webes széf nem működik megfelelően." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Eseményadatok küldése a Logscale példánynak" }, + "datadogEventIntegrationDesc": { + "message": "Széf eseményadatok küldése a Datadog példánynak" + }, "failedToSaveIntegration": { "message": "Nem sikerült menteni az integrációt. Próbáljuk újra később." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Zökkenőmentes integráció" + }, + "families": { + "message": "Családok" + }, + "upgradeToFamilies": { + "message": "Áttérés Családok csomagra" + }, + "upgradeToPremium": { + "message": "Áttérés Prémium csomagra" + }, + "familiesUpdated": { + "message": "Megtörtént az áttérés a Családok csomagra." + }, + "taxCalculationError": { + "message": "Hiba történt a tartózkodási helyre vonatkozó adó kiszámításakor. Próbáljuk újra." + }, + "individualUpgradeWelcomeMessage": { + "message": "Üdvözlet a Bitwardenben" + }, + "individualUpgradeDescriptionMessage": { + "message": "Szabadítsunk fel meg több biztonsági funkciót a Prémium segítségével vagy kezdjük el megosztani az elemeket a Családok csomaggal." + }, + "individualUpgradeTaxInformationMessage": { + "message": "Az árak nem tartalmazzák az adót és évente kerülnek számlázásra." + }, + "organizationNameDescription": { + "message": "A szervezet neve megjelenik a tagoknak küldött meghívókban." + }, + "continueWithoutUpgrading": { + "message": "Folytatás áttérés nélkül" + }, + "upgradeErrorMessage": { + "message": "Hiba történt az áttérés feldolgozása közben. Próbáljuk újra." } } diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index 3a628b0889b..fd8bc0ee6c1 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Anggota yang diberitahukan" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Aplikasi" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Semua aplikasi" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Harap pastikan bahwa akun Anda memiliki cukup kredit yang tersedia untuk pembelian ini. Jika akun Anda tidak memiliki cukup kredit yang tersedia, metode pembayaran default Anda yang tercatat akan digunakan untuk selisihnya. Anda dapat menambahkan kredit ke akun Anda dari halaman Penagihan." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Saldo akun Anda dapat digunakan untuk melakukan pembelian. Semua saldo yang tersedia akan secara otomatis ditagihkan ke faktur yang dibuat untuk akun ini." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Anda menggunakan browser web yang tidak didukung. Kubah web mungkin tidak berfungsi dengan baik." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index 53eece391a5..bf7b0203fae 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Membri notificati" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Contrassegna l'applicazione come critica" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applicazioni contrassegnate come critiche" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Applicazione" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Applicazioni totali" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Contrassegna l'elemento come non critico" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Mantieni questa finestra aperta e segui le istruzioni del tuo browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Usa un altro metodo di accesso" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Assicurati che il tuo account abbia abbastanza credito disponibile per questo acquisto. Se il tuo account non ha abbastanza credito disponibile, il tuo metodo di pagamento predefinito sarà utilizzato per la differenza. Puoi aggiungere credito al tuo account dalla sezione Fatturazione." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Il credito del tuo account può essere usato per effettuare acquisti. Ogni credito disponibile sarà automaticamente applicato alle fatture generate per questo account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generazione delle tue informazioni sui rischi..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Stai utilizzando un browser non supportato. La cassaforte web potrebbe non funzionare correttamente." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Invia i dati dell'evento all'istanza Logscale" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Impossibile salvare l'integrazione. Riprova più tardi." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index bd8a9465b7b..b70e7fa6e9f 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "通知済みメンバー" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "重要なアプリとしてマーク" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "アプリ" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "合計アプリ数" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "このウィンドウを開いたままにして、ブラウザの指示に従ってください。" }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "別のログイン方法を使用する" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "購入に使用できるだけのクレジットが登録されているか確認してください。使用可能なクレジットが不足している場合、差額にはデフォルトのお支払い方法が利用されます。「料金」ページから悪ントにクレジットを追加できます。" }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "アカウントに登録されたクレジットは購入に使用できます。使用可能なクレジットは、このアカウントへの請求に自動的に適用されます。" }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "サポートされていないブラウザを使用しています。ウェブ保管庫が正しく動作しないかもしれません。" }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index a57142dc31f..41ac992cd75 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 173520dab61..7d4d047617d 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index d625ab92f07..a44f4763d27 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "ಈ ವಿಂಡೋವನ್ನು ತೆರೆದಿಡಿ ಮತ್ತು ನಿಮ್ಮ ಬ್ರೌಸರ್‌ನಿಂದ ಪ್ರಾಂಪ್ಟ್‌ಗಳನ್ನು ಅನುಸರಿಸಿ." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "ಈ ಖರೀದಿಗೆ ನಿಮ್ಮ ಖಾತೆಗೆ ಸಾಕಷ್ಟು ಕ್ರೆಡಿಟ್ ಲಭ್ಯವಿದೆ ಎಂದು ಖಚಿತಪಡಿಸಿಕೊಳ್ಳಿ. ನಿಮ್ಮ ಖಾತೆಗೆ ಸಾಕಷ್ಟು ಕ್ರೆಡಿಟ್ ಲಭ್ಯವಿಲ್ಲದಿದ್ದರೆ, ಫೈಲ್‌ನಲ್ಲಿ ನಿಮ್ಮ ಡೀಫಾಲ್ಟ್ ಪಾವತಿ ವಿಧಾನವನ್ನು ವ್ಯತ್ಯಾಸಕ್ಕಾಗಿ ಬಳಸಲಾಗುತ್ತದೆ. ಬಿಲ್ಲಿಂಗ್ ಪುಟದಿಂದ ನಿಮ್ಮ ಖಾತೆಗೆ ನೀವು ಕ್ರೆಡಿಟ್ ಸೇರಿಸಬಹುದು." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "ನಿಮ್ಮ ಖಾತೆಯ ಕ್ರೆಡಿಟ್ ಅನ್ನು ಖರೀದಿ ಮಾಡಲು ಬಳಸಬಹುದು. ಲಭ್ಯವಿರುವ ಯಾವುದೇ ಕ್ರೆಡಿಟ್ ಅನ್ನು ಈ ಖಾತೆಗಾಗಿ ರಚಿಸಲಾದ ಇನ್‌ವಾಯ್ಸ್‌ಗಳಿಗೆ ಸ್ವಯಂಚಾಲಿತವಾಗಿ ಅನ್ವಯಿಸಲಾಗುತ್ತದೆ." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "ನೀವು ಬೆಂಬಲಿಸದ ವೆಬ್ ಬ್ರೌಸರ್ ಅನ್ನು ಬಳಸುತ್ತಿರುವಿರಿ. ವೆಬ್ ವಾಲ್ಟ್ ಸರಿಯಾಗಿ ಕಾರ್ಯನಿರ್ವಹಿಸದೆ ಇರಬಹುದು." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index 2ebd08b1733..c0929501c00 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "이 구매에 사용할 수 있는 크레딧이 충분한지 확인하십시오. 만약 계정에 충분한 크레딧이 없다면, 그 차액만큼 기본 결제 방식에서 지불될 것입니다. 청구 페이지를 통해 계정에 크레딧을 추가할 수 있습니다." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "계정의 크레딧을 구매에 사용할 수 있습니다. 사용 가능한 모든 크레딧이 이 계정에 대해 생성된 청구서에 자동으로 적용됩니다." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "지원하지 않는 웹 브라우저를 사용하고 있습니다. 웹 보관함 기능이 제대로 동작하지 않을 수 있습니다." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 28bca33cdd5..d9175466f1f 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Vēl nav izveidota neviena atskaite" + }, "notifiedMembers": { "message": "Apziņotie dalībnieki" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Atzīmēt lietotni kā kritisku" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Lietotnes, kas atzīmētas kā kritiskas" }, + "applicationsMarkedAsCriticalFail": { + "message": "Neizdevās lietotnes atzīmēt kā būtiskas" + }, "application": { "message": "Lietotne" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Kopējais lietotņu skaits" }, + "applicationsNeedingReview": { + "message": "Lietotnes, kuras ir nepieciešams izskatīt" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ jaunas lietotnes", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Jaunu lietotņu izskatīšana, lai atzīmētu tās kā būtiskas un uzturētu apvienību drošu" + }, + "reviewNow": { + "message": "Izskatīt tagad" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Noņemt kritiskuma atzīmi" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Šis logs ir jāpatur atvērts un jāseko uzvednēm pārlūkā." }, + "passkeyAuthenticationFailed": { + "message": "Autentificēšanās ar piekļuves atslēgu neizdevās. Lūgums mēģināt vēlreiz." + }, "useADifferentLogInMethod": { "message": "Jāizmanto cits pieteikšanās veids" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Lūgums pārliecināties, ka kontā ir pieejams pietiekami daudz kredīta šim pirkumam. Ja kontā nav pieejams pietiekami daudz kredīta, tiks izmantots noklusējuma norēķinu veids, lai segtu starpību. Kredītu kontam var pievienot norēķinu sadaļā." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Konta kredīts var tikt izmantots, lai veiktu pirkumus. Viss pieejamais kredīts tiks automātiski izmantots kontam veidotajiem rēķiniem." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Tiek veidots ieskats par riskiem..." }, + "riskInsightsRunReport": { + "message": "Izveidot atskaiti" + }, "updateBrowserDesc": { "message": "Tiek izmantots neatbalstīts tīmekļa pārlūks. Tīmekļa glabātava var nedarboties pareizi." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Nosūtīt notikum udatus uz savu Logscale instanci" }, + "datadogEventIntegrationDesc": { + "message": "Nosūtīt glabātavas notikumu datus uz savu Datadog serveri" + }, "failedToSaveIntegration": { "message": "Neizdevās saglabāt iekļaušanu. Lūgums vēlāk mēģināt vēlreiz." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Plūdena iekļaušana" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index cbb5aeecf49..65c0ff94b29 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index 2ea56d367ff..446cb859574 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "अ‍ॅपला गंभीर म्हणून चिन्हांकित करा" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 173520dab61..7d4d047617d 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index 778d3adf74c..22a2d8f4834 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Program" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Hold dette vinduet åpent og følg anvisningene fra nettleseren." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Bruk en annen innloggingsmetode" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Vær sikker på at kontoen din har nok kreditt tilgjengelig for dette kjøpet. Hvis kontoen din ikke har nok kreditt tilgjengelig, vil standard betalingsmåten din bli brukt til forskjellen. Du kan legge til kreditt på kontoen din på Faktureringssiden." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Kontoens kreditt kan brukes til kjøp. Eventuell tilgjengelig kreditt vil automatisk bli brukt mot fakturaer generert for denne kontoen." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Du bruker en ustøttet nettleser. Netthvelvet vil kanskje ikke fungere ordentlig." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 6575f687031..edb18ba019d 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index 31d648fb94f..b51b563d8ef 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Je hebt nog geen rapport aangemaakt" + }, "notifiedMembers": { "message": "Geînformeerde leden" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "App aanwijzen als belangrijk" }, + "markAsCritical": { + "message": "App aanwijzen als belangrijk" + }, + "applicationsSelected": { + "message": "applicaties geselecteerd" + }, + "selectApplication": { + "message": "Applicatie selecteren" + }, + "unselectApplication": { + "message": "Applicatie deselecteren" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Als belangrijk gemarkeerde applicaties" }, + "applicationsMarkedAsCriticalFail": { + "message": "Kon applicaties niet als kritiek markeren" + }, "application": { "message": "Applicatie" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Totaal applicaties" }, + "applicationsNeedingReview": { + "message": "Aanvragen die herzien moeten worden" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ nieuwe applicaties", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Controleer nieuwe applicaties om ze als kritiek te markeren en je organisatie veilig te houden" + }, + "reviewNow": { + "message": "Nu beoordelen" + }, + "prioritizeCriticalApplications": { + "message": "Belangrijke applicaties prioriteren" + }, + "atRiskItems": { + "message": "Items in gevaar" + }, + "markAsCriticalPlaceholder": { + "message": "Markeren als kritieke functionaliteit wordt geïmplementeerd in een toekomstige update" + }, "unmarkAsCritical": { "message": "Markeren als belangrijk ongedaan maken" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Houd dit venster open en volg de aanwijzingen van je browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey-authenticatie mislukt. Probeer het opnieuw." + }, "useADifferentLogInMethod": { "message": "Gebruik een andere loginmethode" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Verzeker jezelf ervan dat je account voldoende krediet voor deze aankoop beschikbaar heeft. Als je account niet genoeg krediet heeft, zal je standaard betaalmethode worden gebruikt voor het verschil. Je kunt krediet aan je account toevoegen vanaf de factuurpagina." }, + "notEnoughAccountCredit": { + "message": "Je hebt niet genoeg accountkrediet voor deze aankoop. Je kunt krediet aan je account toevoegen vanaf de factuurpagina." + }, "creditAppliedDesc": { "message": "Je kunt het krediet van je account voor aankopen gebruiken. Elk beschikbaar krediet zal automatisch worden toegepast op facturen die gegenereerd zijn voor dit account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Je risico-inzichten genereren..." }, + "riskInsightsRunReport": { + "message": "Rapport uitvoeren" + }, "updateBrowserDesc": { "message": "Je maakt gebruik van webbrowser die we niet ondersteunen. De webkluis werkt mogelijk niet goed." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Stuur eventgegevens naar je Logscale-instantie" }, + "datadogEventIntegrationDesc": { + "message": "Stuur gebeurtenisgegevens van je kluis naar je Datadog-instance" + }, "failedToSaveIntegration": { "message": "Opslaan van integratie mislukt. Probeer het later opnieuw." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Naadloze integratie" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Opwaarderen naar Families" + }, + "upgradeToPremium": { + "message": "Opwaarderen naar Premium" + }, + "familiesUpdated": { + "message": "Je hebt opgewaardeerd naar Families!" + }, + "taxCalculationError": { + "message": "Er is een fout opgetreden bij het berekenen van de belasting voor je locatie. Probeer het opnieuw." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welkom bij Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Ontgrendel meer beveiligingsfuncties met Premium, of begin met het delen van items met Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prijzen exclusief btw en worden jaarlijks gefactureerd." + }, + "organizationNameDescription": { + "message": "De naam van je organisatie verschijnt in uitnodigingen die je aan leden verstuurt." + }, + "continueWithoutUpgrading": { + "message": "Doorgaan zonder opwaarderen" + }, + "upgradeErrorMessage": { + "message": "Er is een fout opgetreden tijdens het verwerken van het opwaarderen. Probeer het opnieuw." } } diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 92bfe3c925c..ce9f12c6190 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 173520dab61..7d4d047617d 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 808981f5fb0..e254083efd9 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Powiadomieni członkowie" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Oznacz aplikację jako krytyczną" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Aplikacje oznaczone jako krytyczne" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Aplikacja" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Wszystkie aplikacje" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Pozostaw to okno otwarte i postępuj zgodnie z instrukcjami z przeglądarki." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Użyj innej metody logowania" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Upewnij się, że na koncie posiadasz wystarczająca ilość środków do dokonania tego zakupu. Jeśli na koncie nie ma wystarczających środków, do opłacenia brakującej różnicy zostanie użyta domyślna metoda płatności. Możesz też dodać środki do swojego konta na stronie Płatności." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Środki dodane do konta mogą zostać użyte do dokonywania płatności. Dostępne środki będą automatycznie wykorzystane do opłacenia faktur wygenerowanych dla tego konta." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generowanie informacji o ryzyku..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Używasz nieobsługiwanej przeglądarki. Sejf internetowy może działać niewłaściwie." }, @@ -4870,7 +4924,7 @@ "description": "ex. Date this item was created" }, "datePasswordUpdated": { - "message": "Hasło zostało zaktualizowane", + "message": "Ostatnia aktualizacja hasła", "description": "ex. Date this password was updated" }, "organizationIsDisabled": { @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 5df0689d9de..f24a136d5e8 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Membros notificados" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Marcar aplicativo como crítico" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Aplicações marcadas como críticas" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Aplicativo" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Todos os aplicativos" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Desmarcar como crítico" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Mantenha esta janela aberta e siga as instruções do seu navegador." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use um método de login diferente" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Por favor, certifique-se de que sua conta tenha crédito suficiente para esta compra. Se sua conta não tiver crédito suficiente disponível, seu método de pagamento padrão será usado para completar a diferença. Você pode adicionar crédito à sua conta na página de Cobrança." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "O crédito da sua conta pode ser usado para efetuar compras. Qualquer crédito disponível será automaticamente usado em faturas geradas nesta conta." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Você está usando um navegador da Web não suportado. O cofre web pode não funcionar corretamente." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index a7be84317e3..cf323ead519 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Ainda não criou um relatório" + }, "notifiedMembers": { "message": "Membros notificados" }, @@ -60,7 +63,7 @@ "message": "Criar nova credencial" }, "percentageCompleted": { - "message": "$PERCENT$% complete", + "message": "$PERCENT$% concluído", "placeholders": { "percent": { "content": "$1", @@ -69,7 +72,7 @@ } }, "securityTasksCompleted": { - "message": "$COUNT$ out of $TOTAL$ security tasks completed", + "message": "$COUNT$ de $TOTAL$ tarefas de segurança concluídas", "placeholders": { "count": { "content": "$1", @@ -82,19 +85,19 @@ } }, "passwordChangeProgress": { - "message": "Password change progress" + "message": "Progresso da alteração da palavra-passe" }, "assignMembersTasksToMonitorProgress": { - "message": "Assign members tasks to monitor progress" + "message": "Atribuir tarefas aos membros para monitorizar o progresso" }, "onceYouReviewApplications": { - "message": "Once you review applications and mark them as critical, they will display here." + "message": "Depois de analisar as aplicações e marcá-las como críticas, estas serão apresentadas aqui." }, "sendReminders": { - "message": "Send reminders" + "message": "Enviar lembretes" }, "onceYouMarkApplicationsCriticalTheyWillDisplayHere": { - "message": "Once you mark applications critical, they will display here." + "message": "Depois de marcar as aplicações como críticas, estas serão apresentadas aqui." }, "viewAtRiskMembers": { "message": "Ver membros em risco" @@ -143,7 +146,7 @@ } }, "countOfAtRiskPasswords": { - "message": "$COUNT$ passwords at-risk", + "message": "$COUNT$ palavras-passe em risco", "placeholders": { "count": { "content": "$1", @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Marcar a app como crítica" }, + "markAsCritical": { + "message": "Marcar como crítica" + }, + "applicationsSelected": { + "message": "aplicações selecionadas" + }, + "selectApplication": { + "message": "Selecionar aplicação" + }, + "unselectApplication": { + "message": "Desmarcar aplicação" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Aplicações marcadas como críticas" }, + "applicationsMarkedAsCriticalFail": { + "message": "Falha ao marcar aplicações como críticas" + }, "application": { "message": "Aplicação" }, @@ -206,7 +224,7 @@ "message": "Membros em risco" }, "membersWithAccessToAtRiskItemsForCriticalApps": { - "message": "Members with access to at-risk items for critical applications" + "message": "Membros com acesso a itens em risco para aplicações críticas" }, "membersAtRiskCount": { "message": "$COUNT$ membros em risco", @@ -274,6 +292,33 @@ "totalApplications": { "message": "Todas de aplicações" }, + "applicationsNeedingReview": { + "message": "Aplicações que necessitam de análise" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ novas aplicações", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Analise novas aplicações para marcar como críticas e manter a sua organização segura" + }, + "reviewNow": { + "message": "Analisar agora" + }, + "prioritizeCriticalApplications": { + "message": "Dê prioridade a aplicações críticas" + }, + "atRiskItems": { + "message": "Itens em risco" + }, + "markAsCriticalPlaceholder": { + "message": "A funcionalidade marcada como crítica será implementada numa atualização futura" + }, "unmarkAsCritical": { "message": "Desmarcar como crítica" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Mantenha esta janela aberta e siga as indicações do seu navegador." }, + "passkeyAuthenticationFailed": { + "message": "Falha na autenticação da chave de acesso. Por favor, tente novamente." + }, "useADifferentLogInMethod": { "message": "Utilizar um método de início de sessão diferente" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Por favor, certifique-se de que a sua conta tem crédito suficiente disponível para esta compra. Se a sua conta não tiver crédito suficiente disponível, será utilizado o seu método de pagamento predefinido registado para cobrir a diferença. Pode adicionar crédito à sua conta a partir da página de faturação." }, + "notEnoughAccountCredit": { + "message": "Não tem crédito suficiente na sua conta para esta compra. Pode adicionar crédito à sua conta na página Faturação." + }, "creditAppliedDesc": { "message": "O crédito da sua conta pode ser utilizado para efetuar compras. Qualquer crédito disponível será automaticamente aplicado às faturas geradas para esta conta." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "A gerar as suas perceções de riscos..." }, + "riskInsightsRunReport": { + "message": "Executar relatório" + }, "updateBrowserDesc": { "message": "Está a utilizar um navegador web não suportado. O cofre web pode não funcionar corretamente." }, @@ -9563,7 +9617,7 @@ "message": "Atribuir" }, "assignTasks": { - "message": "Assign tasks" + "message": "Atribuir tarefas" }, "assignToCollections": { "message": "Atribuir às coleções" @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Enviar dados de eventos para a sua instância Logscale" }, + "datadogEventIntegrationDesc": { + "message": "Envie dados de eventos do cofre para a sua instância da Datadog" + }, "failedToSaveIntegration": { "message": "Falha ao guardar a integração. Por favor, tente novamente mais tarde." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Integração perfeita" + }, + "families": { + "message": "Familiar" + }, + "upgradeToFamilies": { + "message": "Atualizar para o Familiar" + }, + "upgradeToPremium": { + "message": "Atualizar para o Premium" + }, + "familiesUpdated": { + "message": "Atualizou para o Familiar!" + }, + "taxCalculationError": { + "message": "Ocorreu um erro ao calcular o imposto para a sua localização. Por favor, tente novamente." + }, + "individualUpgradeWelcomeMessage": { + "message": "Bem-vindo ao Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Desbloqueie mais funcionalidades de segurança com o Premium ou comece a partilhar itens com o Familiar" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Os preços não incluem impostos e são cobrados anualmente." + }, + "organizationNameDescription": { + "message": "O nome da sua organização aparecerá nos convites que enviar aos membros." + }, + "continueWithoutUpgrading": { + "message": "Continuar sem atualizar" + }, + "upgradeErrorMessage": { + "message": "Ocorreu um erro durante o processamento da sua atualização. Por favor, tente novamente." } } diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index 3296b1a3a0a..2bd6f6e5497 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Membri notificați" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Marchează aplicația drept critică" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Aplicații marcate drept critice" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Aplicație" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Toate aplicațiile" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Păstrează această fereastră deschisă și urmează instrucțiunile din browser-ul tău." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Folosiți o metodă diferită de autentificare" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Vă rugăm să vă asigurați că aveți suficient credit disponibil în cont pentru această achiziție. Dacă nu aveți suficient credit în cont, metoda dvs. implicită de plată înregistrată va fi folosită pentru diferență. Puteți adăuga credit în contul dvs. din pagina Facturare." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Creditul contului dvs. se poate utiliza pentru a face cumpărături. Orice credit disponibil va fi aplicat automat pentru facturile generate pentru acest cont." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Utilizați un browser nesuportat. Seiful web ar putea să nu funcționeze corect." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index 8db4da0c6ca..0bca6612f7a 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Вы еще не создали отчет" + }, "notifiedMembers": { "message": "Уведомленные участники" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Пометить приложение как критическое" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Приложения помечены как критические" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Приложение" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Всего приложений" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Снять отметку критического" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Не закрывайте это окно и следуйте запросам браузера." }, + "passkeyAuthenticationFailed": { + "message": "Не удалось выполнить аутентификацию с помощью passkey. Попробуйте еще раз." + }, "useADifferentLogInMethod": { "message": "Использовать другой способ авторизации" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Убедитесь, что на вашем счету достаточно средств для этой покупки. Если на вашем счете недостаточно средств, то для покрытия разницы будет использован ваш метод оплаты по умолчанию. Вы можете добавить денежные средства в свой аккаунт на странице оплаты." }, + "notEnoughAccountCredit": { + "message": "У вас недостаточно средств для этой покупки. Вы можете сделать пополнение на странице выставления счета." + }, "creditAppliedDesc": { "message": "Средства на вашем счете могут быть использованы для совершения платежей. Любой доступный остаток будет автоматически использован для оплаты счетов, выставленных этому аккаунту." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Генерация информации о рисках..." }, + "riskInsightsRunReport": { + "message": "Запустить отчет" + }, "updateBrowserDesc": { "message": "Вы используете неподдерживаемый браузер. Веб-хранилище может работать некорректно." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Отправлять данные о событиях в ваш инстанс Logscale" }, + "datadogEventIntegrationDesc": { + "message": "Отправляйте данные о событиях хранилища в ваш экземпляр Datadog" + }, "failedToSaveIntegration": { "message": "Не удалось сохранить интеграцию. Пожалуйста, повторите попытку позже." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Простая интеграция" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index 8db4f703b7e..5891fdc50a2 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 5bea697ab57..f8c5591fb60 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -60,7 +63,7 @@ "message": "Pridať novu položku s prihlásením" }, "percentageCompleted": { - "message": "$PERCENT$% complete", + "message": "$PERCENT$% splnených", "placeholders": { "percent": { "content": "$1", @@ -69,7 +72,7 @@ } }, "securityTasksCompleted": { - "message": "$COUNT$ out of $TOTAL$ security tasks completed", + "message": "$COUNT$ z $TOTAL$ bezpečnostných úloh splnených", "placeholders": { "count": { "content": "$1", @@ -82,19 +85,19 @@ } }, "passwordChangeProgress": { - "message": "Password change progress" + "message": "Progres pri zmene hesla" }, "assignMembersTasksToMonitorProgress": { - "message": "Assign members tasks to monitor progress" + "message": "Pre sledovanie progresu, priraďte členom úlohy" }, "onceYouReviewApplications": { - "message": "Once you review applications and mark them as critical, they will display here." + "message": "Tu sa zobrazia aplikácie, ktoré pri kontrole označíte za kritické." }, "sendReminders": { - "message": "Send reminders" + "message": "Poslať upomienky" }, "onceYouMarkApplicationsCriticalTheyWillDisplayHere": { - "message": "Once you mark applications critical, they will display here." + "message": "Tu sa zobrazia aplikácie, ktoré označíte za kritické." }, "viewAtRiskMembers": { "message": "Zobraziť ohrozených členov" @@ -143,7 +146,7 @@ } }, "countOfAtRiskPasswords": { - "message": "$COUNT$ passwords at-risk", + "message": "$COUNT$ ohrozených hesiel", "placeholders": { "count": { "content": "$1", @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Označiť aplikáciu ako kritickú" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Aplikácie označené ako kritické" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Aplikácia" }, @@ -206,7 +224,7 @@ "message": "Ohrozených členov" }, "membersWithAccessToAtRiskItemsForCriticalApps": { - "message": "Members with access to at-risk items for critical applications" + "message": "Členovia s prístupom k ohrozeným položkám kritických aplikácii" }, "membersAtRiskCount": { "message": "$COUNT$ ohrozených členov", @@ -274,6 +292,33 @@ "totalApplications": { "message": "Všetkých aplikácii" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Zrušiť označenie za kritické" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Ponechajte toto okno otvorené a nasledujte pokyny vášho prehliadača." }, + "passkeyAuthenticationFailed": { + "message": "Overenie prístupovým kľúčom zlyhalo. Skúste to znova." + }, "useADifferentLogInMethod": { "message": "Použiť iný spôsob prihlásenia" }, @@ -1543,7 +1591,7 @@ "message": "Neplatné hlavné heslo" }, "invalidMasterPasswordConfirmEmailAndHost": { - "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "message": "Neplatné hlavné heslo. Overte, že váš e-mail je správny a účet bol vytvorený na $HOST$.", "placeholders": { "host": { "content": "$1", @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Prosím uistite sa, že váš účet má k dispozícii dostatok kreditu pre tento nákup. Ak konto nemá k dispozícii dostatok kreditu, rozdiel sa zaplatí vašou predvolenou platobnou metódou. Kredit si môžete pridať na stránke fakturácie." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Kredit na vašom účte sa dá použiť na nákupy. Akýkoľvek dostupný kredit bude automaticky použitý na zaplatenie faktúr pre tento účet." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generuje sa váš prehľad o rizikách..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Používate nepodporovaný prehliadač. Webový trezor nemusí úplne fungovať." }, @@ -5268,7 +5322,7 @@ "message": "SSO identifikátor" }, "ssoIdentifierHint": { - "message": "Provide this ID to your members to login with SSO. Members can skip entering this identifier during SSO if a claimed domain is set up. ", + "message": "Zdieľajte toto ID s vašimi členmi pre prihlásenie prostredníctvom SSO. Ak nastavíte privlastnenú doménu, členovia sa môžu prihlásiť prostredníctvom SSO bez zadávania tohto identifikátora. ", "description": "This will be used as part of a larger sentence, broken up to include a link. The full sentence will read 'Provide this ID to your members to login with SSO. Members can skip entering this identifier during SSO if a claimed domain is set up. Learn more'" }, "claimedDomainsLearnMore": { @@ -7087,7 +7141,7 @@ } }, "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "message": "Exportuje sa len trezor organizácie spojený s $ORGANIZATION$.", "placeholders": { "organization": { "content": "$1", @@ -7096,7 +7150,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "message": "Exportuje sa len trezor organizácie spojený s $ORGANIZATION$. Moje zbierky položiek nebudú zahrnuté.", "placeholders": { "organization": { "content": "$1", @@ -7259,7 +7313,7 @@ "message": "Neznáma položka možno potrebujete požiadať o prístup k tejto položke." }, "unknownServiceAccount": { - "message": "Unknown machine account, you may need to request permission to access this machine account." + "message": "Neznáme strojové konto, možno potrebujete požiadať o prístup k tomuto kontu." }, "unknownProject": { "message": "Neznámy projekt, možno potrebujete požiadať o prístup k tomuto projektu." @@ -8612,7 +8666,7 @@ } }, "accessedProjectWithIdentifier": { - "message": "Accessed a project with identifier: $PROJECT_ID$.", + "message": "Pristúp k projektu s identifikátorom: $PROJECT_ID$.", "placeholders": { "project_id": { "content": "$1", @@ -8639,7 +8693,7 @@ } }, "nameUnavailableServiceAccountDeleted": { - "message": "Deleted machine account Id: $SERVICE_ACCOUNT_ID$", + "message": "Vymazané strojové konto s identifikátorom: $SERVICE_ACCOUNT_ID$", "placeholders": { "service_account_id": { "content": "$1", @@ -8657,7 +8711,7 @@ } }, "addedUserToServiceAccountWithId": { - "message": "Added user: $USER_ID$ to machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "Pridaný používateľ: $USER_ID$ k strojovému účtu s identifikátorom: $SERVICE_ACCOUNT_ID$", "placeholders": { "user_id": { "content": "$1", @@ -8670,7 +8724,7 @@ } }, "removedUserToServiceAccountWithId": { - "message": "Removed user: $USER_ID$ from machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "Odstránený používateľ: $USER_ID$ zo strojového účtu s identifikátorom: $SERVICE_ACCOUNT_ID$", "placeholders": { "user_id": { "content": "$1", @@ -8683,7 +8737,7 @@ } }, "removedGroupFromServiceAccountWithId": { - "message": "Removed group: $GROUP_ID$ from machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "Odstránená skupina: $GROUP_ID$ zo strojového účtu s identifikátorom: $SERVICE_ACCOUNT_ID$", "placeholders": { "group_id": { "content": "$1", @@ -8696,7 +8750,7 @@ } }, "serviceAccountCreatedWithId": { - "message": "Created machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "Vytvorený strojový účet s identifikátorom: $SERVICE_ACCOUNT_ID$", "placeholders": { "service_account_id": { "content": "$1", @@ -8705,7 +8759,7 @@ } }, "addedGroupToServiceAccountId": { - "message": "Added group: $GROUP_ID$ to machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "Pridaná skupina: $GROUP_ID$ do strojového účtu s identifikátorom: $SERVICE_ACCOUNT_ID$", "placeholders": { "group_id": { "content": "$1", @@ -8718,7 +8772,7 @@ } }, "serviceAccountDeletedWithId": { - "message": "Deleted machine account with identifier: $SERVICE_ACCOUNT_ID$", + "message": "Odstránený strojový účet s identifikátorom: $SERVICE_ACCOUNT_ID$", "placeholders": { "service_account_id": { "content": "$1", @@ -9563,7 +9617,7 @@ "message": "Prideliť" }, "assignTasks": { - "message": "Assign tasks" + "message": "Priradiť úlohy" }, "assignToCollections": { "message": "Prideliť k zbierkam" @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Pošlite dáta z denníka udalostí do vašej inštancie Logscale" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Nepodarilo sa uložiť integráciu. Prosím skúste to neskôr." }, @@ -11230,11 +11287,11 @@ "message": "Prehľadať archív" }, "archiveNoun": { - "message": "Archive", + "message": "Archív", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Archivovať", "description": "Verb" }, "noItemsInArchive": { @@ -11351,10 +11408,10 @@ "message": "Rozšírenie Bitwarden nainštalované!" }, "openTheBitwardenExtension": { - "message": "Open the Bitwarden extension" + "message": "Otvoriť rozšírenie Bitwarden" }, "bitwardenExtensionInstalledOpenExtension": { - "message": "The Bitwarden extension is installed! Open the extension to log in and start autofilling." + "message": "Rozšírenie Bitwarden je nainštalované! Otvorte rozšírenie, prihláste sa a začnite automaticky vypĺňať." }, "openExtensionToAutofill": { "message": "Otvorte rozšírenie, prihláste sa a začnite automatické vypĺňanie." @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index f0acd2ca34b..8bf7f77a67a 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index 1eaf8f25274..60ca583aae5 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Obavešteni članovi" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/sr_CY/messages.json b/apps/web/src/locales/sr_CY/messages.json index e31c138121f..9d8cf01f128 100644 --- a/apps/web/src/locales/sr_CY/messages.json +++ b/apps/web/src/locales/sr_CY/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Обавештени чланови" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Означите апликацију као критичну" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Апликације означене као критичне" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Апликација" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Укупно апликација" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Уклони као критично" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Држите овај прозор отворен и пратите упутства из прегледача." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Користи други начин пријављивања" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Уверите се да је на вашем рачуну доступно довољно кредита за ову куповину. Ако на вашем рачуну нема довољно кредита, за разлику ће се користити ваш подразумевани начин плаћања у евиденцији. На свој рачун можете да додате кредит на страници Обрачун." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Кредит вашег рачуна може се користити за куповину. Сав расположиви кредит аутоматски ће се применити на фактуре генерисане за овај рачун." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Генерисање прегледа вашег ризика..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Користите неподржани веб прегледач. Веб сеф можда неће правилно функционисати." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Пошаљите податке о догађају на вашу инстанцу Logscale-а" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Није успело сачувавање интеграције. Покушајте поново касније." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Бешавна интеграција" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index 9523343079c..622c09587a8 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "Du har inte skapat en rapport än" + }, "notifiedMembers": { "message": "Meddelade medlemmar" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Markera app som kritisk" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applikationer markerade som kritiska" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Applikation" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Totalt antal applikationer" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Avmarkera som kritisk" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Håll det här fönstret öppet och följ anvisningarna från din webbläsare." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Använd en annan inloggningsmetod" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Se till att ditt konto har tillräckligt mycket tillgänglig kredit för detta köp. Om ditt konto inte har tillräckligt med tillgänglig kredit, kommer din sparade standardbetalningsmetod användas för skillnaden. Du kan lägga till kredit till ditt konto från faktureringssidan." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Kontots kredit kan användas för att göra köp. Tillgänglig kredit kommer automatiskt tillämpas mot fakturor som genereras för detta konto." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Skapa din riskinsikt..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Du använder en webbläsare som inte stöds. Webbvalvet kanske inte fungerar som det ska." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Skicka händelsedata till din Logscale-instans" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Misslyckades med att spara integration. Försök igen senare." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Sömlös integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/ta/messages.json b/apps/web/src/locales/ta/messages.json index e2426c2d69b..f59295b7342 100644 --- a/apps/web/src/locales/ta/messages.json +++ b/apps/web/src/locales/ta/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "அறிவிக்கப்பட்ட உறுப்பினர்கள்" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "பயன்பாட்டை முக்கியமானதாகக் குறியிடு" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "பயன்பாடுகள் முக்கியமானதாகக் குறியிடப்பட்டன" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "பயன்பாடு" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "மொத்த பயன்பாடுகள்" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "முக்கியமானதாக இருந்து குறியீட்டை நீக்கு" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "இந்த சாளரத்தை திறந்து வைத்து, உங்கள் உலாவியில் கேட்கப்படும் வழிமுறைகளைப் பின்பற்றவும்." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "வேறு உள்நுழைவு முறையைப் பயன்படுத்து" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "இந்த வாங்குதலுக்கு உங்கள் கணக்கில் போதுமான கடன் இருப்பதை உறுதிசெய்யவும். உங்கள் கணக்கில் போதுமான கடன் இல்லையென்றால், வேறுபாட்டிற்கு கோப்பில் உள்ள உங்கள் இயல்புநிலை கட்டண முறை பயன்படுத்தப்படும். பில்லிங் பக்கத்திலிருந்து உங்கள் கணக்கில் நீங்கள் கடனைச் சேர்க்கலாம்." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "உங்கள் கணக்கின் கடனை வாங்குதல்களுக்குப் பயன்படுத்தலாம். கிடைக்கக்கூடிய எந்தக் கடனும் இந்த கணக்கிற்காக உருவாக்கப்பட்ட பில்களுக்கு தானாகவே பயன்படுத்தப்படும்." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "உங்கள் இடர் நுண்ணறிவுகளை உருவாக்குகிறது..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "ஆதரிக்கப்படாத இணைய உலாவியைப் பயன்படுத்துகிறீர்கள். இணையப் பெட்டகம் சரியாகச் செயல்படாமல் போகலாம்." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "உங்கள் Logscale இன்ஸ்டன்ஸுக்கு நிகழ்வுத் தரவை அனுப்பவும்" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "ஒருங்கிணைப்பைச் சேமிக்கத் தவறிவிட்டது. பின்னர் மீண்டும் முயற்சிக்கவும்." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 173520dab61..7d4d047617d 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index 9a8958651a2..0f3ed45b728 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Notified members" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Mark app as critical" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Application" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Total applications" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Keep this window open and follow prompts from your browser." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Use a different log in method" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Please make sure that your account has enough credit available for this purchase. If your account does not have enough credit available, your default payment method on file will be used for the difference. You can add credit to your account from the Billing page." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Your account's credit can be used to make purchases. Any available credit will be automatically applied towards invoices generated for this account." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 6b19e0b5fe7..106ed3fadd3 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Bildirilen üyeler" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Uygulamayı kritik olarak işaretle" }, + "markAsCritical": { + "message": "Kritik olarak işaretle" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Kritik olarak işaretlenmiş uygulamalar" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Uygulama" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Toplam uygulama" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Kritik olarak işaretlemeyi kaldır" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Bu pencereyi açık tutun ve tarayıcınızdan gelen talimatları uygulayın." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Başka bir giriş yöntemi kullan" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Lütfen hesabınızda bu ödemeyi yapabilecek kadar kredi olduğundan emin olun. Eğer hesabınızda yeteri kadar kredi yoksa, aradaki fark varsayılan ödeme yöntemizinden karşılanacaktır. Faturalandırma sayfasından hesabınıza kredi ekleyebilirsiniz." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Hesabınızdaki krediyi satın alımlarda kullanabilirsiniz. Mevcut krediniz bu hesap için oluşturulan faturalardan otomatik olarak düşülecektir." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Risk içgörüleriniz oluşturuluyor..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Desteklenmeyen bir web tarayıcısı kullanıyorsunuz. Web kasası düzgün çalışmayabilir." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Etkinlik verilerini Logscale'e gönderin" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Entegrasyon kaydedilemedi. Lütfen daha sonra tekrar deneyin." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Sorunsuz entegrasyon" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index db32311e2ae..afd38eb74d1 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Сповіщення учасників" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Позначити програму критичною" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Позначені критичні програми" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Програма" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Всього програм" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Не закривайте це вікно та дотримуйтесь підказок у браузері." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Використати інший спосіб входу" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Будь ласка, переконайтеся, що на вашому рахунку достатньо коштів для цієї покупки. Якщо на вашому рахунку недостатньо коштів, то різниця спишеться з використанням вашого типового способу оплати. Ви можете додати кошти до свого рахунку на сторінці Оплата." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Кредит вашого рахунку можна використовувати для покупок. Будь-який наявний кредит автоматично використовуватиметься для рахунків, згенерованих для цього облікового запису." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Генерується інформація щодо ризиків..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Ви використовуєте непідтримуваний браузер. Вебсховище може працювати неправильно." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index f5e3ee95bb6..52a25a8a1bb 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "Các thành viên được thông báo" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "Đánh dấu các ứng dụng quan trọng" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Các ứng dụng được đánh dấu là quan trọng" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "Ứng dụng" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "Tổng số ứng dụng" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Bỏ đánh dấu là nghiêm trọng" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "Giữ cửa sổ này mở và làm theo lời nhắc từ trình duyệt của bạn." }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "Dùng phương thức đăng nhập khác" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "Vui lòng đảm bảo tài khoản của bạn có đủ số dư để thực hiện giao dịch này. Nếu tài khoản của bạn không đủ số dư, phương thức thanh toán mặc định đã đăng ký sẽ được sử dụng để thanh toán phần chênh lệch. Bạn có thể nạp tiền vào tài khoản từ trang Thanh toán." }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "Số dư trong tài khoản của bạn có thể được sử dụng để thực hiện các giao dịch mua hàng. Số dư khả dụng sẽ được tự động áp dụng cho các hóa đơn được tạo ra cho tài khoản này." }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Đang tạo báo cáo phân tích rủi ro của bạn..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "Bạn đang sử dụng trình duyệt web không được hỗ trợ. Kho lưu trữ web có thể không hoạt động đúng cách." }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Gửi dữ liệu sự kiện đến thực thể Logscale của bạn" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Không thể lưu tích hợp. Vui lòng thử lại sau." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Tích hợp liền mạch" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 12afbc9a33c..ab1cf570462 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "您还没有创建报告" + }, "notifiedMembers": { "message": "已通知的成员" }, @@ -82,7 +85,7 @@ } }, "passwordChangeProgress": { - "message": "密码修改进度" + "message": "密码更改进度" }, "assignMembersTasksToMonitorProgress": { "message": "分配成员任务以监测进度" @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "标记应用程序为关键" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "标记为关键的应用程序" }, + "applicationsMarkedAsCriticalFail": { + "message": "无法将应用程序标记为关键" + }, "application": { "message": "应用程序" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "总的应用程序" }, + "applicationsNeedingReview": { + "message": "应用程序需要审查" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ 个新应用程序", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "审查新应用程序并将其标记为关键,以确保您的组织安全" + }, + "reviewNow": { + "message": "立即审查" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "取消标记为关键" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "保持此窗口打开然后按照浏览器的提示操作。" }, + "passkeyAuthenticationFailed": { + "message": "通行密钥验证失败。请重试。" + }, "useADifferentLogInMethod": { "message": "使用其他登录方式" }, @@ -1375,7 +1423,7 @@ "message": "身份验证超时" }, "authenticationSessionTimedOut": { - "message": "身份验证会话超时。请重新启动登录过程。" + "message": "身份验证会话超时。请重新开始登录过程。" }, "verifyYourIdentity": { "message": "验证您的身份" @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "请确保您的账户有足够的信用额度来用于此购买。如果您的账户信用额度不足,您的默认付款方式将用于补足差额。您可以从「计费」页面向您的账户添加信用额度。" }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "您账户的信用额度可用于进行消费。任何可用的信用额度将用于自动抵扣此账户的账单。" }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "正在生成风险洞察..." }, + "riskInsightsRunReport": { + "message": "运行报告" + }, "updateBrowserDesc": { "message": "您使用的是不受支持的网页浏览器。网页密码库可能无法正常运行。" }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "将事件数据发送到您的 Logscale 实例" }, + "datadogEventIntegrationDesc": { + "message": "将密码库事件数据发送到您的 Datadog 实例" + }, "failedToSaveIntegration": { "message": "保存集成失败。请稍后再试。" }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "无缝集成" + }, + "families": { + "message": "家庭版" + }, + "upgradeToFamilies": { + "message": "升级为家庭版" + }, + "upgradeToPremium": { + "message": "升级为高级版" + }, + "familiesUpdated": { + "message": "您已升级为家庭版!" + }, + "taxCalculationError": { + "message": "计算您所在位置的税费时出错。请重试。" + }, + "individualUpgradeWelcomeMessage": { + "message": "欢迎使用 Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "使用高级版解锁更多安全功能,或使用家庭版开始分享项目" + }, + "individualUpgradeTaxInformationMessage": { + "message": "价格不含税,按年计费。" + }, + "organizationNameDescription": { + "message": "您的组织名称将出现在您发送给成员的邀请中。" + }, + "continueWithoutUpgrading": { + "message": "继续但不升级" + }, + "upgradeErrorMessage": { + "message": "我们在处理您的升级时遇到错误。请重试。" } } diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 15c05a9598c..dee1bf7196e 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -35,6 +35,9 @@ } } }, + "noReportRan": { + "message": "You have not created a report yet" + }, "notifiedMembers": { "message": "已被通知的成員" }, @@ -184,9 +187,24 @@ "markAppAsCritical": { "message": "標註應用程式為重要" }, + "markAsCritical": { + "message": "Mark as critical" + }, + "applicationsSelected": { + "message": "applications selected" + }, + "selectApplication": { + "message": "Select application" + }, + "unselectApplication": { + "message": "Unselect application" + }, "applicationsMarkedAsCriticalSuccess": { "message": "Applications marked as critical" }, + "applicationsMarkedAsCriticalFail": { + "message": "Failed to mark applications as critical" + }, "application": { "message": "應用程式" }, @@ -274,6 +292,33 @@ "totalApplications": { "message": "應用程式總數" }, + "applicationsNeedingReview": { + "message": "Applications needing review" + }, + "newApplicationsWithCount": { + "message": "$COUNT$ new applications", + "placeholders": { + "count": { + "content": "$1", + "example": "13" + } + } + }, + "newApplicationsDescription": { + "message": "Review new applications to mark as critical and keep your organization secure" + }, + "reviewNow": { + "message": "Review now" + }, + "prioritizeCriticalApplications": { + "message": "Prioritize critical applications" + }, + "atRiskItems": { + "message": "At-risk items" + }, + "markAsCriticalPlaceholder": { + "message": "Mark as critical functionality will be implemented in a future update" + }, "unmarkAsCritical": { "message": "Unmark as critical" }, @@ -1248,6 +1293,9 @@ "readingPasskeyLoadingInfo": { "message": "保持此視窗打開,然後按照瀏覽器的提示進行操作。" }, + "passkeyAuthenticationFailed": { + "message": "Passkey authentication failed. Please try again." + }, "useADifferentLogInMethod": { "message": "改用不同的登入方式" }, @@ -2902,6 +2950,9 @@ "makeSureEnoughCredit": { "message": "請確保您的帳戶有足夠的餘額用於此次購買,若您的帳戶餘額不足,則會以您預設的付款方式補足差額。您可以透過計費頁面對您的帳戶儲值餘額。" }, + "notEnoughAccountCredit": { + "message": "You do not have enough account credit for this purchase. You can add credit to your account from the Billing page." + }, "creditAppliedDesc": { "message": "您帳戶的餘額可用於消費。任何可用的餘額將用於自動繳納此帳戶的帳單。" }, @@ -4310,6 +4361,9 @@ "generatingYourRiskInsights": { "message": "Generating your Risk Insights..." }, + "riskInsightsRunReport": { + "message": "Run report" + }, "updateBrowserDesc": { "message": "未支援您使用的瀏覽器。網頁版密碼庫可能無法正常運作。" }, @@ -9960,6 +10014,9 @@ "crowdstrikeEventIntegrationDesc": { "message": "Send event data to your Logscale instance" }, + "datadogEventIntegrationDesc": { + "message": "Send vault event data to your Datadog instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -11713,5 +11770,38 @@ }, "seamlessIntegration": { "message": "Seamless integration" + }, + "families": { + "message": "Families" + }, + "upgradeToFamilies": { + "message": "Upgrade to Families" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, + "familiesUpdated": { + "message": "You've upgraded to Families!" + }, + "taxCalculationError": { + "message": "There was an error calculating tax for your location. Please try again." + }, + "individualUpgradeWelcomeMessage": { + "message": "Welcome to Bitwarden" + }, + "individualUpgradeDescriptionMessage": { + "message": "Unlock more security features with Premium, or start sharing items with Families" + }, + "individualUpgradeTaxInformationMessage": { + "message": "Prices exclude tax and are billed annually." + }, + "organizationNameDescription": { + "message": "Your organization name will appear in invitations you send to members." + }, + "continueWithoutUpgrading": { + "message": "Continue without upgrading" + }, + "upgradeErrorMessage": { + "message": "We encountered an error while processing your upgrade. Please try again." } } diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 0e1ae28abd1..58812f4c6b7 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -9,6 +9,7 @@ config.content = [ "../../libs/key-management-ui/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", + "../../libs/tools/generator/components/src/**/*.{html,ts}", "../../bitwarden_license/bit-web/src/**/*.{html,ts}", ]; config.corePlugins.preflight = true; diff --git a/apps/web/webpack.base.js b/apps/web/webpack.base.js new file mode 100644 index 00000000000..2bfe0e27553 --- /dev/null +++ b/apps/web/webpack.base.js @@ -0,0 +1,431 @@ +const fs = require("fs"); +const path = require("path"); + +const { AngularWebpackPlugin } = require("@ngtools/webpack"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); +const HtmlWebpackInjector = require("html-webpack-injector"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const TerserPlugin = require("terser-webpack-plugin"); +const webpack = require("webpack"); + +const config = require("./config.js"); +const pjson = require("./package.json"); + +module.exports.getEnv = function getEnv() { + const ENV = process.env.ENV == null ? "development" : process.env.ENV; + const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; + const LOGGING = process.env.LOGGING != "false"; + + return { ENV, NODE_ENV, LOGGING }; +}; + +/** + * + * @param {{ + * configName: string; + * app: { + * entry: string; + * entryModule: string; + * }; + * tsConfig: string; + * }} params + */ +module.exports.buildConfig = function buildConfig(params) { + const { ENV, NODE_ENV, LOGGING } = module.exports.getEnv(); + + const envConfig = config.load(ENV); + if (LOGGING) { + config.log(`Building web - ${params.configName} version`); + config.log(envConfig); + } + + const moduleRules = [ + { + test: /\.(html)$/, + loader: "html-loader", + }, + { + test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, + exclude: /loading(|-white).svg/, + generator: { + filename: "fonts/[name].[contenthash][ext]", + }, + type: "asset/resource", + }, + { + test: /\.(jpe?g|png|gif|svg|webp|avif)$/i, + exclude: /.*(bwi-font)\.svg/, + generator: { + filename: "images/[name][ext]", + }, + type: "asset/resource", + }, + { + test: /\.scss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + "css-loader", + "resolve-url-loader", + { + loader: "sass-loader", + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.css$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + "css-loader", + "resolve-url-loader", + { + loader: "postcss-loader", + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.[cm]?js$/, + use: [ + { + loader: "babel-loader", + options: { + configFile: "../../babel.config.json", + cacheDirectory: NODE_ENV !== "production", + }, + }, + ], + }, + { + test: /\.[jt]sx?$/, + loader: "@ngtools/webpack", + }, + ]; + + const plugins = [ + new HtmlWebpackPlugin({ + template: "./src/index.html", + filename: "index.html", + chunks: ["theme_head", "app/polyfills", "app/vendor", "app/main", "styles"], + }), + new HtmlWebpackInjector(), + new HtmlWebpackPlugin({ + template: "./src/connectors/webauthn.html", + filename: "webauthn-connector.html", + chunks: ["connectors/webauthn", "styles"], + }), + new HtmlWebpackPlugin({ + template: "./src/connectors/webauthn-mobile.html", + filename: "webauthn-mobile-connector.html", + chunks: ["connectors/webauthn", "styles"], + }), + new HtmlWebpackPlugin({ + template: "./src/connectors/webauthn-fallback.html", + filename: "webauthn-fallback-connector.html", + chunks: ["connectors/webauthn-fallback", "styles"], + }), + new HtmlWebpackPlugin({ + template: "./src/connectors/sso.html", + filename: "sso-connector.html", + chunks: ["connectors/sso", "styles"], + }), + new HtmlWebpackPlugin({ + template: "./src/connectors/redirect.html", + filename: "redirect-connector.html", + chunks: ["connectors/redirect", "styles"], + }), + new HtmlWebpackPlugin({ + template: "./src/connectors/duo-redirect.html", + filename: "duo-redirect-connector.html", + chunks: ["connectors/duo-redirect", "styles"], + }), + new HtmlWebpackPlugin({ + template: "./src/404.html", + filename: "404.html", + chunks: ["styles"], + // 404 page is a wildcard, this ensures it uses absolute paths. + publicPath: "/", + }), + new CopyWebpackPlugin({ + patterns: [ + { from: "./src/.nojekyll" }, + { from: "./src/manifest.json" }, + { from: "./src/favicon.ico" }, + { from: "./src/browserconfig.xml" }, + { from: "./src/app-id.json" }, + { from: "./src/images", to: "images" }, + { from: "./src/videos", to: "videos" }, + { from: "./src/locales", to: "locales" }, + { from: "../../node_modules/qrious/dist/qrious.min.js", to: "scripts" }, + { from: "../../node_modules/braintree-web-drop-in/dist/browser/dropin.js", to: "scripts" }, + { + from: "./src/version.json", + transform(content, path) { + return content.toString().replace("process.env.APPLICATION_VERSION", pjson.version); + }, + }, + ], + }), + new MiniCssExtractPlugin({ + filename: "[name].[contenthash].css", + chunkFilename: "[id].[contenthash].css", + }), + new webpack.ProvidePlugin({ + process: "process/browser.js", + }), + new webpack.EnvironmentPlugin({ + ENV: ENV, + NODE_ENV: NODE_ENV === "production" ? "production" : "development", + APPLICATION_VERSION: pjson.version, + CACHE_TAG: Math.random().toString(36).substring(7), + URLS: envConfig["urls"] ?? {}, + STRIPE_KEY: envConfig["stripeKey"] ?? "", + BRAINTREE_KEY: envConfig["braintreeKey"] ?? "", + PAYPAL_CONFIG: envConfig["paypal"] ?? {}, + FLAGS: envConfig["flags"] ?? {}, + DEV_FLAGS: NODE_ENV === "development" ? envConfig["devFlags"] : {}, + ADDITIONAL_REGIONS: envConfig["additionalRegions"] ?? [], + }), + new AngularWebpackPlugin({ + tsconfig: params.tsConfig, + entryModule: params.app.entryModule, + sourceMap: true, + }), + ]; + + // ref: https://webpack.js.org/configuration/dev-server/#devserver + let certSuffix = fs.existsSync("dev-server.local.pem") ? ".local" : ".shared"; + const devServer = + NODE_ENV !== "development" + ? {} + : { + server: { + type: "https", + options: { + key: fs.readFileSync("dev-server" + certSuffix + ".pem"), + cert: fs.readFileSync("dev-server" + certSuffix + ".pem"), + }, + }, + // host: '192.168.1.9', + proxy: [ + { + context: ["/api"], + target: envConfig.dev?.proxyApi, + pathRewrite: { "^/api": "" }, + secure: false, + changeOrigin: true, + }, + { + context: ["/identity"], + target: envConfig.dev?.proxyIdentity, + pathRewrite: { "^/identity": "" }, + secure: false, + changeOrigin: true, + }, + { + context: ["/events"], + target: envConfig.dev?.proxyEvents, + pathRewrite: { "^/events": "" }, + secure: false, + changeOrigin: true, + }, + { + context: ["/notifications"], + target: envConfig.dev?.proxyNotifications, + pathRewrite: { "^/notifications": "" }, + secure: false, + changeOrigin: true, + ws: true, + }, + { + context: ["/icons"], + target: envConfig.dev?.proxyIcons, + pathRewrite: { "^/icons": "" }, + secure: false, + changeOrigin: true, + }, + ], + headers: (req) => { + if (!req.originalUrl.includes("connector.html")) { + return { + "Content-Security-Policy": ` + default-src 'self' + ;script-src + 'self' + 'wasm-unsafe-eval' + 'sha256-ryoU+5+IUZTuUyTElqkrQGBJXr1brEv6r2CA62WUw8w=' + https://js.stripe.com + https://js.braintreegateway.com + https://www.paypalobjects.com + ;style-src + 'self' + https://assets.braintreegateway.com + https://*.paypal.com + ${"'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='" /* date input polyfill */} + ${"'sha256-JVRXyYPueLWdwGwY9m/7u4QlZ1xeQdqUj2t8OVIzZE4='" /* date input polyfill */} + ${"'sha256-EnIJNDxVnh0++RytXJOkU0sqtLDFt1nYUDOfeJ5SKxg='" /* ng-select */} + ${"'sha256-dbBsIsz2pJ5loaLjhE6xWlmhYdjl6ghbwnGSCr4YObs='" /* cdk-virtual-scroll */} + ${"'sha256-S+uMh1G1SNQDAMG3seBmknQ26Wh+KSEoKdsNiy0joEE='" /* cdk-visually-hidden */} + ;img-src + 'self' + data: + https://icons.bitwarden.net + https://*.paypal.com + https://www.paypalobjects.com + https://q.stripe.com + https://haveibeenpwned.com + ;media-src + 'self' + https://assets.bitwarden.com + ;child-src + 'self' + https://js.stripe.com + https://assets.braintreegateway.com + https://*.paypal.com + https://*.duosecurity.com + ;frame-src + 'self' + https://js.stripe.com + https://assets.braintreegateway.com + https://*.paypal.com + https://*.duosecurity.com + ;connect-src + 'self' + ${envConfig.dev.wsConnectSrc ?? ""} + wss://notifications.bitwarden.com + https://notifications.bitwarden.com + https://cdn.bitwarden.net + https://api.pwnedpasswords.com + https://api.2fa.directory/v3/totp.json + https://api.stripe.com + https://www.paypal.com + https://api.sandbox.braintreegateway.com + https://api.braintreegateway.com + https://client-analytics.braintreegateway.com + https://*.braintree-api.com + https://*.blob.core.windows.net + http://127.0.0.1:10000 + https://app.simplelogin.io/api/alias/random/new + https://quack.duckduckgo.com/api/email/addresses + https://app.addy.io/api/v1/aliases + https://api.fastmail.com + https://api.forwardemail.net + http://localhost:5000 + ;object-src + 'self' + blob: + ;` + .replace(/\n/g, " ") + .replace(/ +(?= )/g, ""), + }; + } + }, + hot: false, + port: envConfig.dev?.port ?? 8080, + allowedHosts: envConfig.dev?.allowedHosts ?? "auto", + client: { + overlay: { + errors: true, + warnings: false, + runtimeErrors: false, + }, + }, + }; + + const webpackConfig = { + mode: NODE_ENV, + devtool: "source-map", + devServer: devServer, + target: "web", + entry: { + "app/polyfills": "./src/polyfills.ts", + "app/main": params.app.entry, + "connectors/webauthn": "./src/connectors/webauthn.ts", + "connectors/webauthn-fallback": "./src/connectors/webauthn-fallback.ts", + "connectors/sso": "./src/connectors/sso.ts", + "connectors/duo-redirect": "./src/connectors/duo-redirect.ts", + "connectors/redirect": "./src/connectors/redirect.ts", + styles: ["./src/scss/styles.scss", "./src/scss/tailwind.css"], + theme_head: "./src/theme.ts", + }, + cache: + NODE_ENV === "production" + ? false + : { + type: "filesystem", + allowCollectingMemory: true, + cacheDirectory: path.resolve(__dirname, "../../node_modules/.cache/webpack"), + buildDependencies: { + config: [__filename], + }, + }, + snapshot: { + unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], + }, + optimization: { + splitChunks: { + cacheGroups: { + commons: { + test: /[\\/]node_modules[\\/]/, + name: "app/vendor", + chunks: (chunk) => { + return chunk.name === "app/main"; + }, + }, + }, + }, + minimize: NODE_ENV === "production", + minimizer: [ + new TerserPlugin({ + terserOptions: { + safari10: true, + // Replicate Angular CLI behaviour + compress: { + global_defs: { + ngDevMode: false, + ngI18nClosureMode: false, + }, + }, + }, + }), + ], + }, + resolve: { + extensions: [".ts", ".js"], + symlinks: false, + modules: [path.resolve("../../node_modules")], + fallback: { + buffer: false, + util: require.resolve("util/"), + assert: false, + url: false, + fs: false, + process: false, + path: require.resolve("path-browserify"), + }, + }, + output: { + filename: "[name].[contenthash].js", + path: path.resolve(__dirname, "build"), + clean: true, + }, + module: { + rules: moduleRules, + }, + experiments: { + asyncWebAssembly: true, + }, + plugins: plugins, + }; + + return webpackConfig; +}; diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index b311499dd55..e9d7bd46002 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -1,411 +1,10 @@ -const fs = require("fs"); -const path = require("path"); +const { buildConfig } = require("./webpack.base"); -const { AngularWebpackPlugin } = require("@ngtools/webpack"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const HtmlWebpackInjector = require("html-webpack-injector"); -const HtmlWebpackPlugin = require("html-webpack-plugin"); -const MiniCssExtractPlugin = require("mini-css-extract-plugin"); -const TerserPlugin = require("terser-webpack-plugin"); -const webpack = require("webpack"); - -const config = require("./config.js"); -const pjson = require("./package.json"); - -const ENV = process.env.ENV == null ? "development" : process.env.ENV; -const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV; -const LOGGING = process.env.LOGGING != "false"; - -const envConfig = config.load(ENV); -if (LOGGING) { - config.log(envConfig); -} - -const moduleRules = [ - { - test: /\.(html)$/, - loader: "html-loader", - }, - { - test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, - exclude: /loading(|-white).svg/, - generator: { - filename: "fonts/[name].[contenthash][ext]", - }, - type: "asset/resource", - }, - { - test: /\.(jpe?g|png|gif|svg|webp|avif)$/i, - exclude: /.*(bwi-font)\.svg/, - generator: { - filename: "images/[name][ext]", - }, - type: "asset/resource", - }, - { - test: /\.scss$/, - use: [ - { - loader: MiniCssExtractPlugin.loader, - }, - "css-loader", - "resolve-url-loader", - { - loader: "sass-loader", - options: { - sourceMap: true, - }, - }, - ], - }, - { - test: /\.css$/, - use: [ - { - loader: MiniCssExtractPlugin.loader, - }, - "css-loader", - "resolve-url-loader", - { - loader: "postcss-loader", - options: { - sourceMap: true, - }, - }, - ], - }, - { - test: /\.[cm]?js$/, - use: [ - { - loader: "babel-loader", - options: { - configFile: "../../babel.config.json", - cacheDirectory: NODE_ENV !== "production", - }, - }, - ], - }, - { - test: /\.[jt]sx?$/, - loader: "@ngtools/webpack", - }, -]; - -const plugins = [ - new HtmlWebpackPlugin({ - template: "./src/index.html", - filename: "index.html", - chunks: ["theme_head", "app/polyfills", "app/vendor", "app/main", "styles"], - }), - new HtmlWebpackInjector(), - new HtmlWebpackPlugin({ - template: "./src/connectors/webauthn.html", - filename: "webauthn-connector.html", - chunks: ["connectors/webauthn", "styles"], - }), - new HtmlWebpackPlugin({ - template: "./src/connectors/webauthn-mobile.html", - filename: "webauthn-mobile-connector.html", - chunks: ["connectors/webauthn", "styles"], - }), - new HtmlWebpackPlugin({ - template: "./src/connectors/webauthn-fallback.html", - filename: "webauthn-fallback-connector.html", - chunks: ["connectors/webauthn-fallback", "styles"], - }), - new HtmlWebpackPlugin({ - template: "./src/connectors/sso.html", - filename: "sso-connector.html", - chunks: ["connectors/sso", "styles"], - }), - new HtmlWebpackPlugin({ - template: "./src/connectors/redirect.html", - filename: "redirect-connector.html", - chunks: ["connectors/redirect", "styles"], - }), - new HtmlWebpackPlugin({ - template: "./src/connectors/duo-redirect.html", - filename: "duo-redirect-connector.html", - chunks: ["connectors/duo-redirect", "styles"], - }), - new HtmlWebpackPlugin({ - template: "./src/404.html", - filename: "404.html", - chunks: ["styles"], - // 404 page is a wildcard, this ensures it uses absolute paths. - publicPath: "/", - }), - new CopyWebpackPlugin({ - patterns: [ - { from: "./src/.nojekyll" }, - { from: "./src/manifest.json" }, - { from: "./src/favicon.ico" }, - { from: "./src/browserconfig.xml" }, - { from: "./src/app-id.json" }, - { from: "./src/images", to: "images" }, - { from: "./src/videos", to: "videos" }, - { from: "./src/locales", to: "locales" }, - { from: "../../node_modules/qrious/dist/qrious.min.js", to: "scripts" }, - { from: "../../node_modules/braintree-web-drop-in/dist/browser/dropin.js", to: "scripts" }, - { - from: "./src/version.json", - transform(content, path) { - return content.toString().replace("process.env.APPLICATION_VERSION", pjson.version); - }, - }, - ], - }), - new MiniCssExtractPlugin({ - filename: "[name].[contenthash].css", - chunkFilename: "[id].[contenthash].css", - }), - new webpack.ProvidePlugin({ - process: "process/browser.js", - }), - new webpack.EnvironmentPlugin({ - ENV: ENV, - NODE_ENV: NODE_ENV === "production" ? "production" : "development", - APPLICATION_VERSION: pjson.version, - CACHE_TAG: Math.random().toString(36).substring(7), - URLS: envConfig["urls"] ?? {}, - STRIPE_KEY: envConfig["stripeKey"] ?? "", - BRAINTREE_KEY: envConfig["braintreeKey"] ?? "", - PAYPAL_CONFIG: envConfig["paypal"] ?? {}, - FLAGS: envConfig["flags"] ?? {}, - DEV_FLAGS: NODE_ENV === "development" ? envConfig["devFlags"] : {}, - ADDITIONAL_REGIONS: envConfig["additionalRegions"] ?? [], - }), - new AngularWebpackPlugin({ - tsconfig: "tsconfig.build.json", +module.exports = buildConfig({ + configName: "OSS", + app: { + entry: "./src/main.ts", entryModule: "src/app/app.module#AppModule", - sourceMap: true, - }), -]; - -// ref: https://webpack.js.org/configuration/dev-server/#devserver -let certSuffix = fs.existsSync("dev-server.local.pem") ? ".local" : ".shared"; -const devServer = - NODE_ENV !== "development" - ? {} - : { - server: { - type: "https", - options: { - key: fs.readFileSync("dev-server" + certSuffix + ".pem"), - cert: fs.readFileSync("dev-server" + certSuffix + ".pem"), - }, - }, - // host: '192.168.1.9', - proxy: [ - { - context: ["/api"], - target: envConfig.dev?.proxyApi, - pathRewrite: { "^/api": "" }, - secure: false, - changeOrigin: true, - }, - { - context: ["/identity"], - target: envConfig.dev?.proxyIdentity, - pathRewrite: { "^/identity": "" }, - secure: false, - changeOrigin: true, - }, - { - context: ["/events"], - target: envConfig.dev?.proxyEvents, - pathRewrite: { "^/events": "" }, - secure: false, - changeOrigin: true, - }, - { - context: ["/notifications"], - target: envConfig.dev?.proxyNotifications, - pathRewrite: { "^/notifications": "" }, - secure: false, - changeOrigin: true, - ws: true, - }, - { - context: ["/icons"], - target: envConfig.dev?.proxyIcons, - pathRewrite: { "^/icons": "" }, - secure: false, - changeOrigin: true, - }, - ], - headers: (req) => { - if (!req.originalUrl.includes("connector.html")) { - return { - "Content-Security-Policy": ` - default-src 'self' - ;script-src - 'self' - 'wasm-unsafe-eval' - 'sha256-ryoU+5+IUZTuUyTElqkrQGBJXr1brEv6r2CA62WUw8w=' - https://js.stripe.com - https://js.braintreegateway.com - https://www.paypalobjects.com - ;style-src - 'self' - https://assets.braintreegateway.com - https://*.paypal.com - ${"'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='" /* date input polyfill */} - ${"'sha256-JVRXyYPueLWdwGwY9m/7u4QlZ1xeQdqUj2t8OVIzZE4='" /* date input polyfill */} - ${"'sha256-EnIJNDxVnh0++RytXJOkU0sqtLDFt1nYUDOfeJ5SKxg='" /* ng-select */} - ${"'sha256-dbBsIsz2pJ5loaLjhE6xWlmhYdjl6ghbwnGSCr4YObs='" /* cdk-virtual-scroll */} - ${"'sha256-S+uMh1G1SNQDAMG3seBmknQ26Wh+KSEoKdsNiy0joEE='" /* cdk-visually-hidden */} - ;img-src - 'self' - data: - https://icons.bitwarden.net - https://*.paypal.com - https://www.paypalobjects.com - https://q.stripe.com - https://haveibeenpwned.com - ;media-src - 'self' - https://assets.bitwarden.com - ;child-src - 'self' - https://js.stripe.com - https://assets.braintreegateway.com - https://*.paypal.com - https://*.duosecurity.com - ;frame-src - 'self' - https://js.stripe.com - https://assets.braintreegateway.com - https://*.paypal.com - https://*.duosecurity.com - ;connect-src - 'self' - ${envConfig.dev.wsConnectSrc ?? ""} - wss://notifications.bitwarden.com - https://notifications.bitwarden.com - https://cdn.bitwarden.net - https://api.pwnedpasswords.com - https://api.2fa.directory/v3/totp.json - https://api.stripe.com - https://www.paypal.com - https://api.sandbox.braintreegateway.com - https://api.braintreegateway.com - https://client-analytics.braintreegateway.com - https://*.braintree-api.com - https://*.blob.core.windows.net - http://127.0.0.1:10000 - https://app.simplelogin.io/api/alias/random/new - https://quack.duckduckgo.com/api/email/addresses - https://app.addy.io/api/v1/aliases - https://api.fastmail.com - https://api.forwardemail.net - http://localhost:5000 - ;object-src - 'self' - blob: - ;` - .replace(/\n/g, " ") - .replace(/ +(?= )/g, ""), - }; - } - }, - hot: false, - port: envConfig.dev?.port ?? 8080, - allowedHosts: envConfig.dev?.allowedHosts ?? "auto", - client: { - overlay: { - errors: true, - warnings: false, - runtimeErrors: false, - }, - }, - }; - -const webpackConfig = { - mode: NODE_ENV, - devtool: "source-map", - devServer: devServer, - target: "web", - entry: { - "app/polyfills": "./src/polyfills.ts", - "app/main": "./src/main.ts", - "connectors/webauthn": "./src/connectors/webauthn.ts", - "connectors/webauthn-fallback": "./src/connectors/webauthn-fallback.ts", - "connectors/sso": "./src/connectors/sso.ts", - "connectors/duo-redirect": "./src/connectors/duo-redirect.ts", - "connectors/redirect": "./src/connectors/redirect.ts", - styles: ["./src/scss/styles.scss", "./src/scss/tailwind.css"], - theme_head: "./src/theme.ts", }, - cache: - NODE_ENV === "production" - ? false - : { - type: "filesystem", - allowCollectingMemory: true, - cacheDirectory: path.resolve(__dirname, "../../node_modules/.cache/webpack"), - buildDependencies: { - config: [__filename], - }, - }, - snapshot: { - unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], - }, - optimization: { - splitChunks: { - cacheGroups: { - commons: { - test: /[\\/]node_modules[\\/]/, - name: "app/vendor", - chunks: (chunk) => { - return chunk.name === "app/main"; - }, - }, - }, - }, - minimize: NODE_ENV === "production", - minimizer: [ - new TerserPlugin({ - terserOptions: { - safari10: true, - // Replicate Angular CLI behaviour - compress: { - global_defs: { - ngDevMode: false, - ngI18nClosureMode: false, - }, - }, - }, - }), - ], - }, - resolve: { - extensions: [".ts", ".js"], - symlinks: false, - modules: [path.resolve("../../node_modules")], - fallback: { - buffer: false, - util: require.resolve("util/"), - assert: false, - url: false, - fs: false, - process: false, - path: require.resolve("path-browserify"), - }, - }, - output: { - filename: "[name].[contenthash].js", - path: path.resolve(__dirname, "build"), - clean: true, - }, - module: { - rules: moduleRules, - }, - experiments: { - asyncWebAssembly: true, - }, - plugins: plugins, -}; - -module.exports = webpackConfig; + tsConfig: "tsconfig.build.json", +}); diff --git a/bitwarden_license/bit-cli/webpack.config.js b/bitwarden_license/bit-cli/webpack.config.js index 3e991f7971e..f746da40761 100644 --- a/bitwarden_license/bit-cli/webpack.config.js +++ b/bitwarden_license/bit-cli/webpack.config.js @@ -1,12 +1,48 @@ -const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); +const path = require("path"); +const { buildConfig } = require("../../apps/cli/webpack.base"); -// Re-use the OSS CLI webpack config -const webpackConfig = require("../../apps/cli/webpack.config"); +module.exports = (webpackConfig, context) => { + // Detect if called by Nx (context parameter exists) + const isNxBuild = context && context.options; -// Update paths to use the bit-cli entrypoint and tsconfig -webpackConfig.entry = { bw: "../../bitwarden_license/bit-cli/src/bw.ts" }; -webpackConfig.resolve.plugins = [ - new TsconfigPathsPlugin({ configFile: "../../bitwarden_license/bit-cli/tsconfig.json" }), -]; + if (isNxBuild) { + // Nx build configuration + const mode = context.options.mode || "development"; + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = mode; + } + const ENV = (process.env.ENV = process.env.NODE_ENV); -module.exports = webpackConfig; + return buildConfig({ + configName: "Commercial", + entry: context.options.main || "bitwarden_license/bit-cli/src/bw.ts", + tsConfig: "tsconfig.base.json", + outputPath: path.resolve(context.context.root, context.options.outputPath), + mode: mode, + env: ENV, + modulesPath: [path.resolve("node_modules")], + localesPath: "apps/cli/src/locales", + externalsModulesDir: "node_modules", + watch: context.options.watch || false, + }); + } else { + // npm build configuration + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = "development"; + } + const ENV = (process.env.ENV = process.env.NODE_ENV); + const mode = ENV; + + return buildConfig({ + configName: "Commercial", + entry: "../../bitwarden_license/bit-cli/src/bw.ts", + tsConfig: "../../bitwarden_license/bit-cli/tsconfig.json", + outputPath: path.resolve(__dirname, "../../apps/cli/build"), + mode: mode, + env: ENV, + modulesPath: [path.resolve("../../node_modules")], + localesPath: "../../apps/cli/src/locales", + externalsModulesDir: "../../node_modules", + }); + } +}; diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts new file mode 100644 index 00000000000..e788ebba7f2 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/datadog-configuration.ts @@ -0,0 +1,17 @@ +import { OrganizationIntegrationServiceType } from "../organization-integration-service-type"; + +export class DatadogConfiguration { + uri: string; + apiKey: string; + service: OrganizationIntegrationServiceType; + + constructor(uri: string, apiKey: string, service: string) { + this.uri = uri; + this.apiKey = apiKey; + this.service = service as OrganizationIntegrationServiceType; + } + + toString(): string { + return JSON.stringify(this); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts new file mode 100644 index 00000000000..9aa6e34f478 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/datadog-template.ts @@ -0,0 +1,17 @@ +import { OrganizationIntegrationServiceType } from "../../organization-integration-service-type"; + +export class DatadogTemplate { + source_type_name = "Bitwarden"; + title: string = "#Title#"; + text: string = + "ActingUser: #ActingUserId#\nUser: #UserId#\nEvent: #Type#\nOrganization: #OrganizationId#\nPolicyId: #PolicyId#\nIpAddress: #IpAddress#\nDomainName: #DomainName#\nCipherId: #CipherId#\n"; + service: OrganizationIntegrationServiceType; + + constructor(service: string) { + this.service = service as OrganizationIntegrationServiceType; + } + + toString(): string { + return JSON.stringify(this); + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts index abd1861caa9..2167694b720 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration.ts @@ -1,6 +1,7 @@ import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum"; import { OrganizationIntegration } from "./organization-integration"; +import { OrganizationIntegrationType } from "./organization-integration-type"; /** Integration or SDK */ export type Integration = { @@ -23,6 +24,7 @@ export type Integration = { canSetupConnection?: boolean; configuration?: string; template?: string; + integrationType?: OrganizationIntegrationType | null; // OrganizationIntegration organizationIntegration?: OrganizationIntegration | null; diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts index d4bbd30055f..0209460b630 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-configuration.ts @@ -4,6 +4,7 @@ import { OrganizationIntegrationId, } from "@bitwarden/common/types/guid"; +import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template"; import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template"; import { WebhookTemplate } from "./integration-configuration-config/configuration-template/webhook-template"; import { WebhookIntegrationConfigurationConfig } from "./integration-configuration-config/webhook-integration-configuration-config"; @@ -14,7 +15,7 @@ export class OrganizationIntegrationConfiguration { eventType?: EventType | null; configuration?: WebhookIntegrationConfigurationConfig | null; filters?: string; - template?: HecTemplate | WebhookTemplate | null; + template?: HecTemplate | WebhookTemplate | DatadogTemplate | null; constructor( id: OrganizationIntegrationConfigurationId, @@ -22,7 +23,7 @@ export class OrganizationIntegrationConfiguration { eventType?: EventType | null, configuration?: WebhookIntegrationConfigurationConfig | null, filters?: string, - template?: HecTemplate | WebhookTemplate | null, + template?: HecTemplate | WebhookTemplate | DatadogTemplate | null, ) { this.id = id; this.integrationId = integrationId; diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts index dd1b4fb3f6c..e9e93adc0ff 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts @@ -1,5 +1,6 @@ export const OrganizationIntegrationServiceType = Object.freeze({ CrowdStrike: "CrowdStrike", + Datadog: "Datadog", } as const); export type OrganizationIntegrationServiceType = diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-type.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-type.ts index 1c98e174836..3cf68ee9b1d 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-type.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-type.ts @@ -4,6 +4,7 @@ export const OrganizationIntegrationType = Object.freeze({ Slack: 3, Webhook: 4, Hec: 5, + Datadog: 6, } as const); export type OrganizationIntegrationType = diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts index abbe2271b30..d32c92a460a 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration.ts @@ -1,5 +1,6 @@ import { OrganizationIntegrationId } from "@bitwarden/common/types/guid"; +import { DatadogConfiguration } from "./configuration/datadog-configuration"; import { HecConfiguration } from "./configuration/hec-configuration"; import { WebhookConfiguration } from "./configuration/webhook-configuration"; import { OrganizationIntegrationConfiguration } from "./organization-integration-configuration"; @@ -10,14 +11,14 @@ export class OrganizationIntegration { id: OrganizationIntegrationId; type: OrganizationIntegrationType; serviceType: OrganizationIntegrationServiceType; - configuration: HecConfiguration | WebhookConfiguration | null; + configuration: HecConfiguration | WebhookConfiguration | DatadogConfiguration | null; integrationConfiguration: OrganizationIntegrationConfiguration[] = []; constructor( id: OrganizationIntegrationId, type: OrganizationIntegrationType, serviceType: OrganizationIntegrationServiceType, - configuration: HecConfiguration | WebhookConfiguration | null, + configuration: HecConfiguration | WebhookConfiguration | DatadogConfiguration | null, integrationConfiguration: OrganizationIntegrationConfiguration[] = [], ) { this.id = id; diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts new file mode 100644 index 00000000000..0545f95cb83 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.spec.ts @@ -0,0 +1,184 @@ +import { mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { + OrganizationId, + OrganizationIntegrationConfigurationId, + OrganizationIntegrationId, +} from "@bitwarden/common/types/guid"; + +import { DatadogConfiguration } from "../models/configuration/datadog-configuration"; +import { DatadogTemplate } from "../models/integration-configuration-config/configuration-template/datadog-template"; +import { OrganizationIntegration } from "../models/organization-integration"; +import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration"; +import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; +import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; +import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type"; +import { OrganizationIntegrationType } from "../models/organization-integration-type"; + +import { DatadogOrganizationIntegrationService } from "./datadog-organization-integration-service"; +import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; +import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; + +describe("DatadogOrganizationIntegrationService", () => { + let service: DatadogOrganizationIntegrationService; + const mockIntegrationApiService = mock(); + const mockIntegrationConfigurationApiService = + mock(); + const organizationId = "org-1" as OrganizationId; + const integrationId = "int-1" as OrganizationIntegrationId; + const configId = "conf-1" as OrganizationIntegrationConfigurationId; + const serviceType = OrganizationIntegrationServiceType.CrowdStrike; + const url = "https://example.com"; + const apiKey = "token"; + + beforeEach(() => { + service = new DatadogOrganizationIntegrationService( + mockIntegrationApiService, + mockIntegrationConfigurationApiService, + ); + + jest.resetAllMocks(); + }); + + it("should set organization integrations", (done) => { + mockIntegrationApiService.getOrganizationIntegrations.mockResolvedValue([]); + service.setOrganizationIntegrations(organizationId); + const subscription = service.integrations$.subscribe((integrations) => { + expect(integrations).toEqual([]); + subscription.unsubscribe(); + done(); + }); + }); + + it("should save a new Datadog integration", async () => { + service.setOrganizationIntegrations(organizationId); + + const integrationResponse = { + id: integrationId, + type: OrganizationIntegrationType.Datadog, + configuration: JSON.stringify({ url, apiKey, service: serviceType }), + } as OrganizationIntegrationResponse; + + const configResponse = { + id: configId, + template: JSON.stringify({ service: serviceType }), + } as OrganizationIntegrationConfigurationResponse; + + mockIntegrationApiService.createOrganizationIntegration.mockResolvedValue(integrationResponse); + mockIntegrationConfigurationApiService.createOrganizationIntegrationConfiguration.mockResolvedValue( + configResponse, + ); + + await service.saveDatadog(organizationId, serviceType, url, apiKey); + + const integrations = await firstValueFrom(service.integrations$); + expect(integrations.length).toBe(1); + expect(integrations[0].id).toBe(integrationId); + expect(integrations[0].serviceType).toBe(serviceType); + }); + + it("should throw error on organization ID mismatch in saveDatadog", async () => { + service.setOrganizationIntegrations("other-org" as OrganizationId); + await expect(service.saveDatadog(organizationId, serviceType, url, apiKey)).rejects.toThrow( + Error("Organization ID mismatch"), + ); + }); + + it("should update an existing Datadog integration", async () => { + service.setOrganizationIntegrations(organizationId); + + const integrationResponse = { + id: integrationId, + type: OrganizationIntegrationType.Datadog, + configuration: JSON.stringify({ url, apiKey, service: serviceType }), + } as OrganizationIntegrationResponse; + + const configResponse = { + id: configId, + template: JSON.stringify({ service: serviceType }), + } as OrganizationIntegrationConfigurationResponse; + + mockIntegrationApiService.updateOrganizationIntegration.mockResolvedValue(integrationResponse); + mockIntegrationConfigurationApiService.updateOrganizationIntegrationConfiguration.mockResolvedValue( + configResponse, + ); + + await service.updateDatadog(organizationId, integrationId, configId, serviceType, url, apiKey); + + const integrations = await firstValueFrom(service.integrations$); + expect(integrations.length).toBe(1); + expect(integrations[0].id).toBe(integrationId); + }); + + it("should throw error on organization ID mismatch in updateDatadog", async () => { + service.setOrganizationIntegrations("other-org" as OrganizationId); + await expect( + service.updateDatadog(organizationId, integrationId, configId, serviceType, url, apiKey), + ).rejects.toThrow(Error("Organization ID mismatch")); + }); + + it("should get integration by id", async () => { + service["_integrations$"].next([ + new OrganizationIntegration( + integrationId, + OrganizationIntegrationType.Datadog, + serviceType, + {} as DatadogConfiguration, + [], + ), + ]); + const integration = await service.getIntegrationById(integrationId); + expect(integration).not.toBeNull(); + expect(integration!.id).toBe(integrationId); + }); + + it("should get integration by service type", async () => { + service["_integrations$"].next([ + new OrganizationIntegration( + integrationId, + OrganizationIntegrationType.Datadog, + serviceType, + {} as DatadogConfiguration, + [], + ), + ]); + const integration = await service.getIntegrationByServiceType(serviceType); + expect(integration).not.toBeNull(); + expect(integration!.serviceType).toBe(serviceType); + }); + + it("should get integration configurations", async () => { + const config = new OrganizationIntegrationConfiguration( + configId, + integrationId, + null, + null, + "", + {} as DatadogTemplate, + ); + + service["_integrations$"].next([ + new OrganizationIntegration( + integrationId, + OrganizationIntegrationType.Datadog, + serviceType, + {} as DatadogConfiguration, + [config], + ), + ]); + const configs = await service.getIntegrationConfigurations(integrationId); + expect(configs).not.toBeNull(); + expect(configs![0].id).toBe(configId); + }); + + it("convertToJson should parse valid JSON", () => { + const obj = service.convertToJson<{ a: number }>('{"a":1}'); + expect(obj).toEqual({ a: 1 }); + }); + + it("convertToJson should return null for invalid JSON", () => { + const obj = service.convertToJson<{ a: number }>("invalid"); + expect(obj).toBeNull(); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts new file mode 100644 index 00000000000..1fd5e9f8c06 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/datadog-organization-integration-service.ts @@ -0,0 +1,350 @@ +import { BehaviorSubject, firstValueFrom, map, Subject, switchMap, takeUntil, zip } from "rxjs"; + +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { + OrganizationId, + OrganizationIntegrationId, + OrganizationIntegrationConfigurationId, +} from "@bitwarden/common/types/guid"; + +import { DatadogConfiguration } from "../models/configuration/datadog-configuration"; +import { DatadogTemplate } from "../models/integration-configuration-config/configuration-template/datadog-template"; +import { OrganizationIntegration } from "../models/organization-integration"; +import { OrganizationIntegrationConfiguration } from "../models/organization-integration-configuration"; +import { OrganizationIntegrationConfigurationRequest } from "../models/organization-integration-configuration-request"; +import { OrganizationIntegrationConfigurationResponse } from "../models/organization-integration-configuration-response"; +import { OrganizationIntegrationRequest } from "../models/organization-integration-request"; +import { OrganizationIntegrationResponse } from "../models/organization-integration-response"; +import { OrganizationIntegrationServiceType } from "../models/organization-integration-service-type"; +import { OrganizationIntegrationType } from "../models/organization-integration-type"; + +import { OrganizationIntegrationApiService } from "./organization-integration-api.service"; +import { OrganizationIntegrationConfigurationApiService } from "./organization-integration-configuration-api.service"; + +export type DatadogModificationFailureReason = { + mustBeOwner: boolean; + success: boolean; +}; + +export class DatadogOrganizationIntegrationService { + private organizationId$ = new BehaviorSubject(null); + private _integrations$ = new BehaviorSubject([]); + private destroy$ = new Subject(); + + integrations$ = this._integrations$.asObservable(); + + private fetch$ = this.organizationId$ + .pipe( + switchMap(async (orgId) => { + if (orgId) { + const data$ = await this.setIntegrations(orgId); + return await firstValueFrom(data$); + } else { + return this._integrations$.getValue(); + } + }), + takeUntil(this.destroy$), + ) + .subscribe({ + next: (integrations) => { + this._integrations$.next(integrations); + }, + }); + + constructor( + private integrationApiService: OrganizationIntegrationApiService, + private integrationConfigurationApiService: OrganizationIntegrationConfigurationApiService, + ) {} + + /** + * Sets the organization Id and will trigger the retrieval of the + * integrations for a given org. + * @param orgId + */ + setOrganizationIntegrations(orgId: OrganizationId) { + this.organizationId$.next(orgId); + } + + /** + * Saves a new organization integration and updates the integrations$ observable + * @param organizationId id of the organization + * @param service service type of the integration + * @param url url of the service + * @param apiKey api token + */ + async saveDatadog( + organizationId: OrganizationId, + service: OrganizationIntegrationServiceType, + url: string, + apiKey: string, + ): Promise { + if (organizationId != this.organizationId$.getValue()) { + throw new Error("Organization ID mismatch"); + } + + try { + const datadogConfig = new DatadogConfiguration(url, apiKey, service); + const newIntegrationResponse = await this.integrationApiService.createOrganizationIntegration( + organizationId, + new OrganizationIntegrationRequest( + OrganizationIntegrationType.Datadog, + datadogConfig.toString(), + ), + ); + + const newTemplate = new DatadogTemplate(service); + const newIntegrationConfigResponse = + await this.integrationConfigurationApiService.createOrganizationIntegrationConfiguration( + organizationId, + newIntegrationResponse.id, + new OrganizationIntegrationConfigurationRequest(null, null, null, newTemplate.toString()), + ); + + const newIntegration = this.mapResponsesToOrganizationIntegration( + newIntegrationResponse, + newIntegrationConfigResponse, + ); + if (newIntegration !== null) { + this._integrations$.next([...this._integrations$.getValue(), newIntegration]); + } + return { mustBeOwner: false, success: true }; + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return { mustBeOwner: true, success: false }; + } + throw error; + } + } + + /** + * Updates an existing organization integration and updates the integrations$ observable + * @param organizationId id of the organization + * @param OrganizationIntegrationId id of the organization integration + * @param OrganizationIntegrationConfigurationId id of the organization integration configuration + * @param service service type of the integration + * @param url url of the service + * @param apiKey api token + */ + async updateDatadog( + organizationId: OrganizationId, + OrganizationIntegrationId: OrganizationIntegrationId, + OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId, + service: OrganizationIntegrationServiceType, + url: string, + apiKey: string, + ): Promise { + if (organizationId != this.organizationId$.getValue()) { + throw new Error("Organization ID mismatch"); + } + + try { + const datadogConfig = new DatadogConfiguration(url, apiKey, service); + const updatedIntegrationResponse = + await this.integrationApiService.updateOrganizationIntegration( + organizationId, + OrganizationIntegrationId, + new OrganizationIntegrationRequest( + OrganizationIntegrationType.Datadog, + datadogConfig.toString(), + ), + ); + + const updatedTemplate = new DatadogTemplate(service); + const updatedIntegrationConfigResponse = + await this.integrationConfigurationApiService.updateOrganizationIntegrationConfiguration( + organizationId, + OrganizationIntegrationId, + OrganizationIntegrationConfigurationId, + new OrganizationIntegrationConfigurationRequest( + null, + null, + null, + updatedTemplate.toString(), + ), + ); + + const updatedIntegration = this.mapResponsesToOrganizationIntegration( + updatedIntegrationResponse, + updatedIntegrationConfigResponse, + ); + + if (updatedIntegration !== null) { + this._integrations$.next([...this._integrations$.getValue(), updatedIntegration]); + } + return { mustBeOwner: false, success: true }; + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return { mustBeOwner: true, success: false }; + } + throw error; + } + } + + async deleteDatadog( + organizationId: OrganizationId, + OrganizationIntegrationId: OrganizationIntegrationId, + OrganizationIntegrationConfigurationId: OrganizationIntegrationConfigurationId, + ): Promise { + if (organizationId != this.organizationId$.getValue()) { + throw new Error("Organization ID mismatch"); + } + + try { + // delete the configuration first due to foreign key constraint + await this.integrationConfigurationApiService.deleteOrganizationIntegrationConfiguration( + organizationId, + OrganizationIntegrationId, + OrganizationIntegrationConfigurationId, + ); + + // delete the integration + await this.integrationApiService.deleteOrganizationIntegration( + organizationId, + OrganizationIntegrationId, + ); + + // update the local observable + const updatedIntegrations = this._integrations$ + .getValue() + .filter((i) => i.id !== OrganizationIntegrationId); + this._integrations$.next(updatedIntegrations); + + return { mustBeOwner: false, success: true }; + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === 404) { + return { mustBeOwner: true, success: false }; + } + throw error; + } + } + + /** + * Gets a OrganizationIntegration for an OrganizationIntegrationId + * @param integrationId id of the integration + * @returns OrganizationIntegration or null + */ + // TODO: Move to base class when another service integration type is implemented + async getIntegrationById( + integrationId: OrganizationIntegrationId, + ): Promise { + return await firstValueFrom( + this.integrations$.pipe( + map((integrations) => integrations.find((i) => i.id === integrationId) || null), + ), + ); + } + + /** + * Gets a OrganizationIntegration for a service type + * @param serviceType type of the service + * @returns OrganizationIntegration or null + */ + // TODO: Move to base class when another service integration type is implemented + async getIntegrationByServiceType( + serviceType: OrganizationIntegrationServiceType, + ): Promise { + return await firstValueFrom( + this.integrations$.pipe( + map((integrations) => integrations.find((i) => i.serviceType === serviceType) || null), + ), + ); + } + + /** + * Gets a OrganizationIntegrationConfigurations for an integration ID + * @param integrationId id of the integration + * @returns OrganizationIntegration array or null + */ + // TODO: Move to base class when another service integration type is implemented + async getIntegrationConfigurations( + integrationId: OrganizationIntegrationId, + ): Promise { + return await firstValueFrom( + this.integrations$.pipe( + map((integrations) => { + const integration = integrations.find((i) => i.id === integrationId); + return integration ? integration.integrationConfiguration : null; + }), + ), + ); + } + + // TODO: Move to data models to be more explicit for future services + private mapResponsesToOrganizationIntegration( + integrationResponse: OrganizationIntegrationResponse, + configurationResponse: OrganizationIntegrationConfigurationResponse, + ): OrganizationIntegration | null { + const datadogConfig = this.convertToJson( + integrationResponse.configuration, + ); + const template = this.convertToJson(configurationResponse.template); + + if (!datadogConfig || !template) { + return null; + } + + const integrationConfig = new OrganizationIntegrationConfiguration( + configurationResponse.id, + integrationResponse.id, + null, + null, + "", + template, + ); + + return new OrganizationIntegration( + integrationResponse.id, + integrationResponse.type, + datadogConfig.service, + datadogConfig, + [integrationConfig], + ); + } + + // Could possibly be moved to a base service. All services would then assume that the + // integration configuration would always be an array and this datadog specific service + // would just assume a single entry. + private setIntegrations(orgId: OrganizationId) { + const results$ = zip(this.integrationApiService.getOrganizationIntegrations(orgId)).pipe( + switchMap(([responses]) => { + const integrations: OrganizationIntegration[] = []; + const promises: Promise[] = []; + + responses.forEach((integration) => { + if (integration.type === OrganizationIntegrationType.Datadog) { + const promise = this.integrationConfigurationApiService + .getOrganizationIntegrationConfigurations(orgId, integration.id) + .then((response) => { + // datadog events will only have one OrganizationIntegrationConfiguration + const config = response[0]; + + const orgIntegration = this.mapResponsesToOrganizationIntegration( + integration, + config, + ); + + if (orgIntegration !== null) { + integrations.push(orgIntegration); + } + }); + promises.push(promise); + } + }); + return Promise.all(promises).then(() => { + return integrations; + }); + }), + ); + + return results$; + } + + // TODO: Move to base service when necessary + convertToJson(jsonString?: string): T | null { + try { + return JSON.parse(jsonString || "") as T; + } catch { + return null; + } + } +} diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts index 6c6a086e0f5..ad9854c4b25 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/hec-organization-integration-service.ts @@ -311,22 +311,24 @@ export class HecOrganizationIntegrationService { const promises: Promise[] = []; responses.forEach((integration) => { - const promise = this.integrationConfigurationApiService - .getOrganizationIntegrationConfigurations(orgId, integration.id) - .then((response) => { - // Hec events will only have one OrganizationIntegrationConfiguration - const config = response[0]; + if (integration.type === OrganizationIntegrationType.Hec) { + const promise = this.integrationConfigurationApiService + .getOrganizationIntegrationConfigurations(orgId, integration.id) + .then((response) => { + // Hec events will only have one OrganizationIntegrationConfiguration + const config = response[0]; - const orgIntegration = this.mapResponsesToOrganizationIntegration( - integration, - config, - ); + const orgIntegration = this.mapResponsesToOrganizationIntegration( + integration, + config, + ); - if (orgIntegration !== null) { - integrations.push(orgIntegration); - } - }); - promises.push(promise); + if (orgIntegration !== null) { + integrations.push(orgIntegration); + } + }); + promises.push(promise); + } }); return Promise.all(promises).then(() => { return integrations; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts index 3f679924df9..6afb0ee6815 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/helpers/risk-insights-data-mappers.ts @@ -9,7 +9,7 @@ import { import { ApplicationHealthReportDetail, OrganizationReportSummary, - RiskInsightsReportData, + RiskInsightsData, } from "../models/report-models"; import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; @@ -40,7 +40,7 @@ export function getTrimmedCipherUris(cipher: CipherView): string[] { const uniqueDomains = new Set(); - uris.forEach((u: { uri: string }) => { + uris.forEach((u: { uri: string | undefined }) => { const domain = Utils.getDomain(u.uri) ?? u.uri; uniqueDomains.add(domain); }); @@ -154,10 +154,12 @@ export function getApplicationReportDetail( * * @returns An empty report */ -export function createNewReportData(): RiskInsightsReportData { +export function createNewReportData(): RiskInsightsData { return { - data: [], - summary: createNewSummaryData(), + creationDate: new Date(), + reportData: [], + summaryData: createNewSummaryData(), + applicationData: [], }; } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts index 89293651a23..871db2b68ac 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/api-models.types.ts @@ -37,8 +37,10 @@ export interface PasswordHealthReportApplicationsRequest { export interface SaveRiskInsightsReportRequest { data: { organizationId: OrganizationId; - date: string; + creationDate: string; reportData: string; + summaryData: string; + applicationData: string; contentEncryptionKey: string; }; } @@ -58,9 +60,10 @@ export function isSaveRiskInsightsReportResponse(obj: any): obj is SaveRiskInsig export class GetRiskInsightsReportResponse extends BaseResponse { id: string; organizationId: OrganizationId; - // TODO Update to use creationDate from server - date: string; + creationDate: Date; reportData: EncString; + summaryData: EncString; + applicationData: EncString; contentEncryptionKey: EncString; constructor(response: any) { @@ -68,8 +71,10 @@ export class GetRiskInsightsReportResponse extends BaseResponse { this.id = this.getResponseProperty("organizationId"); this.organizationId = this.getResponseProperty("organizationId"); - this.date = this.getResponseProperty("date"); + this.creationDate = new Date(this.getResponseProperty("creationDate")); this.reportData = new EncString(this.getResponseProperty("reportData")); + this.summaryData = new EncString(this.getResponseProperty("summaryData")); + this.applicationData = new EncString(this.getResponseProperty("applicationData")); this.contentEncryptionKey = new EncString(this.getResponseProperty("contentEncryptionKey")); } } @@ -77,7 +82,7 @@ export class GetRiskInsightsReportResponse extends BaseResponse { export class GetRiskInsightsSummaryResponse extends BaseResponse { id: string; organizationId: OrganizationId; - encryptedData: EncString; // Decrypted as OrganizationReportSummary + encryptedSummary: EncString; // Decrypted as OrganizationReportSummary contentEncryptionKey: EncString; constructor(response: any) { @@ -85,7 +90,7 @@ export class GetRiskInsightsSummaryResponse extends BaseResponse { // TODO Handle taking array of summary data and converting to array this.id = this.getResponseProperty("id"); this.organizationId = this.getResponseProperty("organizationId"); - this.encryptedData = this.getResponseProperty("encryptedData"); + this.encryptedSummary = this.getResponseProperty("encryptedData"); this.contentEncryptionKey = this.getResponseProperty("contentEncryptionKey"); } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts index b8fcfe251ff..abe1f7200dc 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/index.ts @@ -1,3 +1,5 @@ export * from "./api-models.types"; export * from "./password-health"; +export * from "./report-data-service.types"; +export * from "./report-encryption.types"; export * from "./report-models"; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts new file mode 100644 index 00000000000..c790fc327a9 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/mock-data.ts @@ -0,0 +1,140 @@ +import { mock } from "jest-mock-extended"; + +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; + +import { ApplicationHealthReportDetailEnriched } from "./report-data-service.types"; +import { + ApplicationHealthReportDetail, + OrganizationReportApplication, + OrganizationReportSummary, +} from "./report-models"; + +const mockApplication1: ApplicationHealthReportDetail = { + applicationName: "application1.com", + passwordCount: 2, + atRiskPasswordCount: 1, + atRiskCipherIds: ["cipher-1"], + memberCount: 2, + atRiskMemberCount: 1, + memberDetails: [ + { + userGuid: "user-id-1", + userName: "tom", + email: "tom@application1.com", + cipherId: "cipher-1", + }, + ], + atRiskMemberDetails: [ + { + userGuid: "user-id-2", + userName: "tom", + email: "tom2@application1.com", + cipherId: "cipher-2", + }, + ], + cipherIds: ["cipher-1", "cipher-2"], +}; + +const mockApplication2: ApplicationHealthReportDetail = { + applicationName: "site2.application1.com", + passwordCount: 0, + atRiskPasswordCount: 0, + atRiskCipherIds: [], + memberCount: 0, + atRiskMemberCount: 0, + memberDetails: [], + atRiskMemberDetails: [], + cipherIds: [], +}; +const mockApplication3: ApplicationHealthReportDetail = { + applicationName: "application2.com", + passwordCount: 0, + atRiskPasswordCount: 0, + atRiskCipherIds: [], + memberCount: 0, + atRiskMemberCount: 0, + memberDetails: [], + atRiskMemberDetails: [], + cipherIds: [], +}; + +export const mockReportData: ApplicationHealthReportDetail[] = [ + mockApplication1, + mockApplication2, + mockApplication3, +]; + +export const mockSummaryData: OrganizationReportSummary = { + totalMemberCount: 5, + totalAtRiskMemberCount: 2, + totalApplicationCount: 3, + totalAtRiskApplicationCount: 1, + totalCriticalMemberCount: 1, + totalCriticalAtRiskMemberCount: 1, + totalCriticalApplicationCount: 1, + totalCriticalAtRiskApplicationCount: 1, + newApplications: [], +}; +export const mockApplicationData: OrganizationReportApplication[] = [ + { + applicationName: "application1.com", + isCritical: true, + }, + { + applicationName: "application2.com", + isCritical: false, + }, +]; + +export const mockEnrichedReportData: ApplicationHealthReportDetailEnriched[] = [ + { ...mockApplication1, isMarkedAsCritical: true, ciphers: [] }, + { ...mockApplication2, isMarkedAsCritical: false, ciphers: [] }, +]; + +export const mockCipherViews: CipherView[] = [ + mock({ + id: "cipher-1", + type: CipherType.Login, + login: { password: "pass1", username: "user1", uris: [{ uri: "https://app.com/login" }] }, + isDeleted: false, + viewPassword: true, + }), + mock({ + id: "cipher-2", + type: CipherType.Login, + login: { password: "pass2", username: "user2", uris: [{ uri: "app.com/home" }] }, + isDeleted: false, + viewPassword: true, + }), + mock({ + id: "cipher-3", + type: CipherType.Login, + login: { password: "pass3", username: "user3", uris: [{ uri: "https://other.com" }] }, + isDeleted: false, + viewPassword: true, + }), +]; + +export const mockMemberDetails = [ + mock({ + cipherIds: ["cipher-1"], + userGuid: "user1", + userName: "User 1", + email: "user1@app.com", + }), + mock({ + cipherIds: ["cipher-2"], + userGuid: "user2", + userName: "User 2", + email: "user2@app.com", + }), + mock({ + cipherIds: ["cipher-3"], + userGuid: "user3", + userName: "User 3", + email: "user3@other.com", + }), +]; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts index e026a4475b7..8127ea41085 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/password-health.ts @@ -1,7 +1,5 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BadgeVariant } from "@bitwarden/components"; @@ -33,16 +31,6 @@ export type ExposedPasswordDetail = { exposedXTimes: number; } | null; -/* - * After data is encrypted, it is returned with the - * encryption key used to encrypt the data. - */ -export interface EncryptedDataWithKey { - organizationId: OrganizationId; - encryptedData: EncString; - contentEncryptionKey: EncString; -} - export type LEGACY_MemberDetailsFlat = { userGuid: string; userName: string; diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-data-service.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-data-service.types.ts new file mode 100644 index 00000000000..6196c788ecd --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-data-service.types.ts @@ -0,0 +1,18 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { + ApplicationHealthReportDetail, + OrganizationReportApplication, + OrganizationReportSummary, +} from "./report-models"; + +export type ApplicationHealthReportDetailEnriched = ApplicationHealthReportDetail & { + isMarkedAsCritical: boolean; + ciphers: CipherView[]; +}; +export interface RiskInsightsEnrichedData { + reportData: ApplicationHealthReportDetailEnriched[]; + summaryData: OrganizationReportSummary; + applicationData: OrganizationReportApplication[]; + creationDate: Date; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-encryption.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-encryption.types.ts new file mode 100644 index 00000000000..d5f2726d7ca --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-encryption.types.ts @@ -0,0 +1,32 @@ +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { + ApplicationHealthReportDetail, + OrganizationReportApplication, + OrganizationReportSummary, +} from "./report-models"; + +/* + * After data is encrypted, it is returned with the + * encryption key used to encrypt the data. + */ +export interface EncryptedDataWithKey { + organizationId: OrganizationId; + encryptedReportData: EncString; + encryptedSummaryData: EncString; + encryptedApplicationData: EncString; + contentEncryptionKey: EncString; +} + +export interface DecryptedReportData { + reportData: ApplicationHealthReportDetail[]; + summaryData: OrganizationReportSummary; + applicationData: OrganizationReportApplication[]; +} + +export interface EncryptedReportData { + encryptedReportData: EncString; + encryptedSummaryData: EncString; + encryptedApplicationData: EncString; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts index 1758bb41b1b..564f483813a 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/report-models.ts @@ -131,11 +131,6 @@ export type ApplicationHealthReportDetail = { cipherIds: string[]; }; -export type ApplicationHealthReportDetailEnriched = ApplicationHealthReportDetail & { - isMarkedAsCritical: boolean; - ciphers: CipherView[]; -}; - /* * A list of applications and the count of * at risk passwords for each application @@ -148,12 +143,6 @@ export type AtRiskApplicationDetail = { // -------------------- Password Health Report Models -------------------- export type PasswordHealthReportApplicationId = Opaque; -// -------------------- Risk Insights Report Models -------------------- -export interface RiskInsightsReportData { - data: ApplicationHealthReportDetailEnriched[]; - summary: OrganizationReportSummary; -} - export type ReportScore = { label: string; badgeVariant: BadgeVariant; sortOrder: number }; export type ReportResult = CipherView & { @@ -162,8 +151,9 @@ export type ReportResult = CipherView & { scoreKey: number; }; -export type ReportDetailsAndSummary = { - data: ApplicationHealthReportDetailEnriched[]; - summary: OrganizationReportSummary; - dateCreated: Date; -}; +export interface RiskInsightsData { + creationDate: Date; + reportData: ApplicationHealthReportDetail[]; + summaryData: OrganizationReportSummary; + applicationData: OrganizationReportApplication[]; +} diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts index 3ea67d8f7c9..ee7f0fae56a 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/all-activities.service.ts @@ -1,8 +1,10 @@ import { BehaviorSubject } from "rxjs"; -import { LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher } from "../models"; +import { ApplicationHealthReportDetailEnriched } from "../models"; import { OrganizationReportSummary } from "../models/report-models"; +import { RiskInsightsDataService } from "./risk-insights-data.service"; + export class AllActivitiesService { /// This class is used to manage the summary of all applications /// and critical applications. @@ -20,12 +22,10 @@ export class AllActivitiesService { totalCriticalAtRiskApplicationCount: 0, newApplications: [], }); - reportSummary$ = this.reportSummarySubject$.asObservable(); - private allApplicationsDetailsSubject$: BehaviorSubject< - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[] - > = new BehaviorSubject([]); + private allApplicationsDetailsSubject$: BehaviorSubject = + new BehaviorSubject([]); allApplicationsDetails$ = this.allApplicationsDetailsSubject$.asObservable(); private atRiskPasswordsCountSubject$ = new BehaviorSubject(0); @@ -35,6 +35,25 @@ export class AllActivitiesService { passwordChangeProgressMetricHasProgressBar$ = this.passwordChangeProgressMetricHasProgressBarSubject$.asObservable(); + private taskCreatedCountSubject$ = new BehaviorSubject(0); + taskCreatedCount$ = this.taskCreatedCountSubject$.asObservable(); + + constructor(private dataService: RiskInsightsDataService) { + // All application summary changes + this.dataService.reportResults$.subscribe((report) => { + if (report) { + this.setAllAppsReportSummary(report.summaryData); + this.setAllAppsReportDetails(report.reportData); + } + }); + // Critical application summary changes + this.dataService.criticalReportResults$.subscribe((report) => { + if (report) { + this.setCriticalAppsReportSummary(report.summaryData); + } + }); + } + setCriticalAppsReportSummary(summary: OrganizationReportSummary) { this.reportSummarySubject$.next({ ...this.reportSummarySubject$.getValue(), @@ -52,12 +71,11 @@ export class AllActivitiesService { totalAtRiskMemberCount: summary.totalAtRiskMemberCount, totalApplicationCount: summary.totalApplicationCount, totalAtRiskApplicationCount: summary.totalAtRiskApplicationCount, + newApplications: summary.newApplications, }); } - setAllAppsReportDetails( - applications: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], - ) { + setAllAppsReportDetails(applications: ApplicationHealthReportDetailEnriched[]) { const totalAtRiskPasswords = applications.reduce( (sum, app) => sum + app.atRiskPasswordCount, 0, @@ -70,4 +88,8 @@ export class AllActivitiesService { setPasswordChangeProgressMetricHasProgressBar(hasProgressBar: boolean) { this.passwordChangeProgressMetricHasProgressBarSubject$.next(hasProgressBar); } + + setTaskCreatedCount(count: number) { + this.taskCreatedCountSubject$.next(count); + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts index 72d7e88fcab..28d670f226d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.spec.ts @@ -82,9 +82,10 @@ describe("CriticalAppsService", () => { }); it("should exclude records that already exist", async () => { + const privateCriticalAppsSubject = service["criticalAppsListSubject$"]; // arrange // one record already exists - service.setAppsInListForOrg([ + privateCriticalAppsSubject.next([ { id: randomUUID() as PasswordHealthReportApplicationId, organizationId: SomeOrganization, @@ -145,6 +146,7 @@ describe("CriticalAppsService", () => { it("should get by org id", () => { const orgId = "some organization" as OrganizationId; + const privateCriticalAppsSubject = service["criticalAppsListSubject$"]; const response = [ { id: "id1", organizationId: "some organization", uri: "https://example.com" }, { id: "id2", organizationId: "some organization", uri: "https://example.org" }, @@ -155,13 +157,14 @@ describe("CriticalAppsService", () => { const orgKey$ = new BehaviorSubject(OrgRecords); keyService.orgKeys$.mockReturnValue(orgKey$); service.loadOrganizationContext(SomeOrganization, SomeUser); - service.setAppsInListForOrg(response); + privateCriticalAppsSubject.next(response); service.getAppsListForOrg(orgId as OrganizationId).subscribe((res) => { expect(res).toHaveLength(2); }); }); it("should drop a critical app", async () => { + const privateCriticalAppsSubject = service["criticalAppsListSubject$"]; // arrange const selectedUrl = "https://example.com"; @@ -175,7 +178,7 @@ describe("CriticalAppsService", () => { service.loadOrganizationContext(SomeOrganization, SomeUser); - service.setAppsInListForOrg(initialList); + privateCriticalAppsSubject.next(initialList); // act await service.dropCriticalApp(SomeOrganization, selectedUrl); @@ -193,6 +196,7 @@ describe("CriticalAppsService", () => { }); it("should not drop a critical app if it does not exist", async () => { + const privateCriticalAppsSubject = service["criticalAppsListSubject$"]; // arrange const selectedUrl = "https://nonexistent.com"; @@ -206,7 +210,7 @@ describe("CriticalAppsService", () => { service.loadOrganizationContext(SomeOrganization, SomeUser); - service.setAppsInListForOrg(initialList); + privateCriticalAppsSubject.next(initialList); // act await service.dropCriticalApp(SomeOrganization, selectedUrl); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts index 82001387bbd..b3b2f7c44e8 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/critical-apps.service.ts @@ -83,11 +83,6 @@ export class CriticalAppsService { .pipe(map((apps) => apps.filter((app) => app.organizationId === orgId))); } - // Reset the critical apps list - setAppsInListForOrg(apps: PasswordHealthReportApplicationsResponse[]) { - this.criticalAppsListSubject$.next(apps); - } - // Save the selected critical apps for a given organization async setCriticalApps(orgId: OrganizationId, selectedUrls: string[]) { if (orgId != this.organizationId.value) { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts index 3904c4c3865..2ad9f1c7cfd 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/password-health.service.ts @@ -29,7 +29,7 @@ export class PasswordHealthService { filter((cipher) => this.isValidCipher(cipher)), mergeMap((cipher) => this.auditService - .passwordLeaked(cipher.login.password) + .passwordLeaked(cipher.login.password!) .then((exposedCount) => ({ cipher, exposedCount })), ), // [FIXME] ExposedDetails is can still return a null @@ -74,11 +74,11 @@ export class PasswordHealthService { // Check the username const userInput = this.isUserNameNotEmpty(cipher) - ? this.extractUsernameParts(cipher.login.username) + ? this.extractUsernameParts(cipher.login.username!) : undefined; const { score } = this.passwordStrengthService.getPasswordStrength( - cipher.login.password, + cipher.login.password!, undefined, // No email available in this context userInput, ); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts index 4eda92f0eb3..56246f3c3b6 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.spec.ts @@ -7,6 +7,7 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response" import { makeEncString } from "@bitwarden/common/spec"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; +import { EncryptedDataWithKey } from "../models"; import { GetRiskInsightsApplicationDataResponse, GetRiskInsightsReportResponse, @@ -14,7 +15,7 @@ import { SaveRiskInsightsReportRequest, SaveRiskInsightsReportResponse, } from "../models/api-models.types"; -import { EncryptedDataWithKey } from "../models/password-health"; +import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data"; import { RiskInsightsApiService } from "./risk-insights-api.service"; @@ -26,17 +27,21 @@ describe("RiskInsightsApiService", () => { const orgId = "org1" as OrganizationId; const mockReportId = "report-1"; const mockKey = "encryption-key-1"; - const mockData = "encrypted-data"; - const reportData = makeEncString("test").encryptedString?.toString() ?? ""; - const reportKey = makeEncString("test-key").encryptedString?.toString() ?? ""; + const mockReportKey = makeEncString("test-key"); - const saveRiskInsightsReportRequest: SaveRiskInsightsReportRequest = { + const mockReportEnc = makeEncString(JSON.stringify(mockReportData)); + const mockSummaryEnc = makeEncString(JSON.stringify(mockSummaryData)); + const mockApplicationsEnc = makeEncString(JSON.stringify(mockApplicationData)); + + const mockSaveRiskInsightsReportRequest: SaveRiskInsightsReportRequest = { data: { organizationId: orgId, - date: new Date().toISOString(), - reportData: reportData, - contentEncryptionKey: reportKey, + creationDate: new Date().toISOString(), + reportData: mockReportEnc.decryptedValue ?? "", + summaryData: mockReportEnc.decryptedValue ?? "", + applicationData: mockReportEnc.decryptedValue ?? "", + contentEncryptionKey: mockReportKey.decryptedValue ?? "", }, }; @@ -53,7 +58,9 @@ describe("RiskInsightsApiService", () => { id: mockId, organizationId: orgId, date: new Date().toISOString(), - reportData: mockData, + reportData: mockReportEnc, + summaryData: mockSummaryEnc, + applicationData: mockApplicationsEnc, contentEncryptionKey: mockKey, }; @@ -96,17 +103,17 @@ describe("RiskInsightsApiService", () => { }); it("saveRiskInsightsReport$ should call apiService.send with correct parameters", async () => { - mockApiService.send.mockReturnValue(Promise.resolve(saveRiskInsightsReportRequest)); + mockApiService.send.mockReturnValue(Promise.resolve(mockSaveRiskInsightsReportRequest)); const result = await firstValueFrom( - service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId), + service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId), ); - expect(result).toEqual(new SaveRiskInsightsReportResponse(saveRiskInsightsReportRequest)); + expect(result).toEqual(new SaveRiskInsightsReportResponse(mockSaveRiskInsightsReportRequest)); expect(mockApiService.send).toHaveBeenCalledWith( "POST", `/reports/organizations/${orgId.toString()}`, - saveRiskInsightsReportRequest.data, + mockSaveRiskInsightsReportRequest.data, true, true, ); @@ -117,13 +124,13 @@ describe("RiskInsightsApiService", () => { mockApiService.send.mockReturnValue(Promise.reject(error)); await expect( - firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)), + firstValueFrom(service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId)), ).rejects.toEqual(error); expect(mockApiService.send).toHaveBeenCalledWith( "POST", `/reports/organizations/${orgId.toString()}`, - saveRiskInsightsReportRequest.data, + mockSaveRiskInsightsReportRequest.data, true, true, ); @@ -134,13 +141,13 @@ describe("RiskInsightsApiService", () => { mockApiService.send.mockReturnValue(Promise.reject(error)); await expect( - firstValueFrom(service.saveRiskInsightsReport$(saveRiskInsightsReportRequest, orgId)), + firstValueFrom(service.saveRiskInsightsReport$(mockSaveRiskInsightsReportRequest, orgId)), ).rejects.toEqual(error); expect(mockApiService.send).toHaveBeenCalledWith( "POST", `/reports/organizations/${orgId.toString()}`, - saveRiskInsightsReportRequest.data, + mockSaveRiskInsightsReportRequest.data, true, true, ); @@ -153,7 +160,7 @@ describe("RiskInsightsApiService", () => { { reportId: mockReportId, organizationId: orgId, - encryptedData: mockData, + encryptedData: mockReportData, contentEncryptionKey: mockKey, }, ]; @@ -175,8 +182,10 @@ describe("RiskInsightsApiService", () => { it("updateRiskInsightsSummary$ should call apiService.send with correct parameters and return an Observable", async () => { const data: EncryptedDataWithKey = { organizationId: orgId, - encryptedData: new EncString(mockData), contentEncryptionKey: new EncString(mockKey), + encryptedReportData: new EncString(JSON.stringify(mockReportData)), + encryptedSummaryData: new EncString(JSON.stringify(mockSummaryData)), + encryptedApplicationData: new EncString(JSON.stringify(mockApplicationData)), }; const reportId = "report123" as OrganizationReportId; @@ -199,7 +208,9 @@ describe("RiskInsightsApiService", () => { const reportId = "report123" as OrganizationReportId; const mockResponse: EncryptedDataWithKey | null = { organizationId: orgId, - encryptedData: new EncString(mockData), + encryptedReportData: new EncString(JSON.stringify(mockReportData)), + encryptedSummaryData: new EncString(JSON.stringify(mockSummaryData)), + encryptedApplicationData: new EncString(JSON.stringify(mockApplicationData)), contentEncryptionKey: new EncString(mockKey), }; @@ -217,21 +228,17 @@ describe("RiskInsightsApiService", () => { }); it("updateRiskInsightsApplicationData$ should call apiService.send with correct parameters and return an Observable", async () => { - const applicationData: EncryptedDataWithKey = { - organizationId: orgId, - encryptedData: new EncString(mockData), - contentEncryptionKey: new EncString(mockKey), - }; const reportId = "report123" as OrganizationReportId; + const mockApplication = mockApplicationData[0]; mockApiService.send.mockResolvedValueOnce(undefined); const result = await firstValueFrom( - service.updateRiskInsightsApplicationData$(applicationData, orgId, reportId), + service.updateRiskInsightsApplicationData$(mockApplication, orgId, reportId), ); expect(mockApiService.send).toHaveBeenCalledWith( "PATCH", `/reports/organizations/${orgId.toString()}/data/application/${reportId.toString()}`, - applicationData, + mockApplication, true, true, ); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts index 8f40ae91b47..99bf27506be 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-api.service.ts @@ -4,6 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { OrganizationId, OrganizationReportId } from "@bitwarden/common/types/guid"; +import { EncryptedDataWithKey, OrganizationReportApplication } from "../models"; import { GetRiskInsightsApplicationDataResponse, GetRiskInsightsReportResponse, @@ -11,7 +12,6 @@ import { SaveRiskInsightsReportRequest, SaveRiskInsightsReportResponse, } from "../models/api-models.types"; -import { EncryptedDataWithKey } from "../models/password-health"; export class RiskInsightsApiService { constructor(private apiService: ApiService) {} @@ -102,7 +102,7 @@ export class RiskInsightsApiService { } updateRiskInsightsApplicationData$( - applicationData: EncryptedDataWithKey, + applicationData: OrganizationReportApplication, orgId: OrganizationId, reportId: OrganizationReportId, ): Observable { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts index 7038844998d..6b775f8432e 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-data.service.ts @@ -1,10 +1,12 @@ -import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of } from "rxjs"; +import { BehaviorSubject, EMPTY, firstValueFrom, Observable, of, throwError } from "rxjs"; import { + catchError, distinctUntilChanged, exhaustMap, filter, finalize, map, + shareReplay, switchMap, tap, withLatestFrom, @@ -18,19 +20,13 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { - AppAtRiskMembersDialogParams, - AtRiskApplicationDetail, - AtRiskMemberDetail, - DrawerType, - DrawerDetails, - ApplicationHealthReportDetail, - ApplicationHealthReportDetailEnriched, - ReportDetailsAndSummary, -} from "../models/report-models"; +import { ApplicationHealthReportDetailEnriched } from "../models"; +import { RiskInsightsEnrichedData } from "../models/report-data-service.types"; +import { DrawerType, DrawerDetails, ApplicationHealthReportDetail } from "../models/report-models"; import { CriticalAppsService } from "./critical-apps.service"; import { RiskInsightsReportService } from "./risk-insights-report.service"; + export class RiskInsightsDataService { // -------------------------- Context state -------------------------- // Current user viewing risk insights @@ -45,16 +41,17 @@ export class RiskInsightsDataService { organizationDetails$ = this.organizationDetailsSubject.asObservable(); // -------------------------- Data ------------------------------------ - private applicationsSubject = new BehaviorSubject(null); - applications$ = this.applicationsSubject.asObservable(); + // TODO: Remove. Will use report results + private LEGACY_applicationsSubject = new BehaviorSubject( + null, + ); + LEGACY_applications$ = this.LEGACY_applicationsSubject.asObservable(); - private dataLastUpdatedSubject = new BehaviorSubject(null); - dataLastUpdated$ = this.dataLastUpdatedSubject.asObservable(); - - criticalApps$ = this.criticalAppsService.criticalAppsList$; + // TODO: Remove. Will use date from report results + private LEGACY_dataLastUpdatedSubject = new BehaviorSubject(null); + dataLastUpdated$ = this.LEGACY_dataLastUpdatedSubject.asObservable(); // --------------------------- UI State ------------------------------------ - private isLoadingSubject = new BehaviorSubject(false); isLoading$ = this.isLoadingSubject.asObservable(); @@ -78,21 +75,52 @@ export class RiskInsightsDataService { // ------------------------- Report Variables ---------------- // The last run report details - private reportResultsSubject = new BehaviorSubject(null); + private reportResultsSubject = new BehaviorSubject(null); reportResults$ = this.reportResultsSubject.asObservable(); // Is a report being generated private isRunningReportSubject = new BehaviorSubject(false); isRunningReport$ = this.isRunningReportSubject.asObservable(); - // The error from report generation if there was an error + + // --------------------------- Critical Application data --------------------- + criticalReportResults$: Observable = of(null); constructor( private accountService: AccountService, private criticalAppsService: CriticalAppsService, private organizationService: OrganizationService, private reportService: RiskInsightsReportService, - ) {} + ) { + // Reload report if critical applications change + // This also handles the original report load + this.criticalAppsService.criticalAppsList$ + .pipe(withLatestFrom(this.organizationDetails$, this.userId$)) + .subscribe({ + next: ([_criticalApps, organizationDetails, userId]) => { + if (organizationDetails?.organizationId && userId) { + this.fetchLastReport(organizationDetails?.organizationId, userId); + } + }, + }); + + // Setup critical application data and summary generation for live critical application usage + this.criticalReportResults$ = this.reportResults$.pipe( + filter((report) => !!report), + map((r) => { + const criticalApplications = r.reportData.filter( + (application) => application.isMarkedAsCritical, + ); + const summary = this.reportService.generateApplicationsSummary(criticalApplications); + + return { + ...r, + summaryData: summary, + reportData: criticalApplications, + }; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + } - // [FIXME] PM-25612 - Call Initialization in RiskInsightsComponent instead of child components async initializeForOrganization(organizationId: OrganizationId) { // Fetch current user const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); @@ -115,9 +143,6 @@ export class RiskInsightsDataService { // Load critical applications for organization await this.criticalAppsService.loadOrganizationContext(organizationId, userId); - // Load existing report - this.fetchLastReport(organizationId, userId); - // Setup new report generation this._runApplicationsReport().subscribe({ next: (result) => { @@ -133,7 +158,7 @@ export class RiskInsightsDataService { * Fetches the applications report and updates the applicationsSubject. * @param organizationId The ID of the organization. */ - fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void { + LEGACY_fetchApplicationsReport(organizationId: OrganizationId, isRefresh?: boolean): void { if (isRefresh) { this.isRefreshingSubject.next(true); } else { @@ -145,24 +170,20 @@ export class RiskInsightsDataService { finalize(() => { this.isLoadingSubject.next(false); this.isRefreshingSubject.next(false); - this.dataLastUpdatedSubject.next(new Date()); + this.LEGACY_dataLastUpdatedSubject.next(new Date()); }), ) .subscribe({ next: (reports: ApplicationHealthReportDetail[]) => { - this.applicationsSubject.next(reports); + this.LEGACY_applicationsSubject.next(reports); this.errorSubject.next(null); }, error: () => { - this.applicationsSubject.next([]); + this.LEGACY_applicationsSubject.next([]); }, }); } - refreshApplicationsReport(organizationId: OrganizationId): void { - this.fetchApplicationsReport(organizationId, true); - } - // ------------------------------- Enrichment methods ------------------------------- /** * Takes the basic application health report details and enriches them to include @@ -174,8 +195,10 @@ export class RiskInsightsDataService { enrichReportData$( applications: ApplicationHealthReportDetail[], ): Observable { + // TODO Compare applications on report to updated critical applications + // TODO Compare applications on report to any new applications return of(applications).pipe( - withLatestFrom(this.organizationDetails$, this.criticalApps$), + withLatestFrom(this.organizationDetails$, this.criticalAppsService.criticalAppsList$), switchMap(async ([apps, orgDetails, criticalApps]) => { if (!orgDetails) { return []; @@ -200,19 +223,11 @@ export class RiskInsightsDataService { ); } - // ------------------------------- Drawer management methods ------------------------------- // ------------------------- Drawer functions ----------------------------- - - isActiveDrawerType$ = (drawerType: DrawerType): Observable => { - return this.drawerDetails$.pipe(map((details) => details.activeDrawerType === drawerType)); - }; isActiveDrawerType = (drawerType: DrawerType): boolean => { return this.drawerDetailsSubject.value.activeDrawerType === drawerType; }; - isDrawerOpenForInvoker$ = (applicationName: string) => { - return this.drawerDetails$.pipe(map((details) => details.invokerId === applicationName)); - }; isDrawerOpenForInvoker = (applicationName: string): boolean => { return this.drawerDetailsSubject.value.invokerId === applicationName; }; @@ -228,10 +243,7 @@ export class RiskInsightsDataService { }); }; - setDrawerForOrgAtRiskMembers = ( - atRiskMemberDetails: AtRiskMemberDetail[], - invokerId: string = "", - ): void => { + setDrawerForOrgAtRiskMembers = async (invokerId: string = ""): Promise => { const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; const shouldClose = open && activeDrawerType === DrawerType.OrgAtRiskMembers && currentInvokerId === invokerId; @@ -239,6 +251,15 @@ export class RiskInsightsDataService { if (shouldClose) { this.closeDrawer(); } else { + const reportResults = await firstValueFrom(this.reportResults$); + if (!reportResults) { + return; + } + + const atRiskMemberDetails = this.reportService.generateAtRiskMemberList( + reportResults.reportData, + ); + this.drawerDetailsSubject.next({ open: true, invokerId, @@ -250,10 +271,7 @@ export class RiskInsightsDataService { } }; - setDrawerForAppAtRiskMembers = ( - atRiskMembersDialogParams: AppAtRiskMembersDialogParams, - invokerId: string = "", - ): void => { + setDrawerForAppAtRiskMembers = async (invokerId: string = ""): Promise => { const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; const shouldClose = open && activeDrawerType === DrawerType.AppAtRiskMembers && currentInvokerId === invokerId; @@ -261,21 +279,29 @@ export class RiskInsightsDataService { if (shouldClose) { this.closeDrawer(); } else { + const reportResults = await firstValueFrom(this.reportResults$); + if (!reportResults) { + return; + } + + const atRiskMembers = { + members: + reportResults.reportData.find((app) => app.applicationName === invokerId) + ?.atRiskMemberDetails ?? [], + applicationName: invokerId, + }; this.drawerDetailsSubject.next({ open: true, invokerId, activeDrawerType: DrawerType.AppAtRiskMembers, atRiskMemberDetails: [], - appAtRiskMembers: atRiskMembersDialogParams, + appAtRiskMembers: atRiskMembers, atRiskAppDetails: null, }); } }; - setDrawerForOrgAtRiskApps = ( - atRiskApps: AtRiskApplicationDetail[], - invokerId: string = "", - ): void => { + setDrawerForOrgAtRiskApps = async (invokerId: string = ""): Promise => { const { open, activeDrawerType, invokerId: currentInvokerId } = this.drawerDetailsSubject.value; const shouldClose = open && activeDrawerType === DrawerType.OrgAtRiskApps && currentInvokerId === invokerId; @@ -283,13 +309,21 @@ export class RiskInsightsDataService { if (shouldClose) { this.closeDrawer(); } else { + const reportResults = await firstValueFrom(this.reportResults$); + if (!reportResults) { + return; + } + const atRiskAppDetails = this.reportService.generateAtRiskApplicationList( + reportResults.reportData, + ); + this.drawerDetailsSubject.next({ open: true, invokerId, activeDrawerType: DrawerType.OrgAtRiskApps, atRiskMemberDetails: [], appAtRiskMembers: null, - atRiskAppDetails: atRiskApps, + atRiskAppDetails, }); } }; @@ -311,23 +345,31 @@ export class RiskInsightsDataService { .getRiskInsightsReport$(organizationId, userId) .pipe( switchMap((report) => { - return this.enrichReportData$(report.data).pipe( + // Take fetched report data and merge with critical applications + return this.enrichReportData$(report.reportData).pipe( map((enrichedReport) => ({ - data: enrichedReport, - summary: report.summary, + report: enrichedReport, + summary: report.summaryData, + applications: report.applicationData, + creationDate: report.creationDate, })), ); }), + catchError((error: unknown) => { + // console.error("An error occurred when fetching the last report", error); + return EMPTY; + }), finalize(() => { this.isLoadingSubject.next(false); }), ) .subscribe({ - next: ({ data, summary }) => { + next: ({ report, summary, applications, creationDate }) => { this.reportResultsSubject.next({ - data, - summary, - dateCreated: new Date(), + reportData: report, + summaryData: summary, + applicationData: applications, + creationDate: creationDate, }); this.errorSubject.next(null); this.isLoadingSubject.next(false); @@ -343,6 +385,7 @@ export class RiskInsightsDataService { private _runApplicationsReport() { return this.isRunningReport$.pipe( distinctUntilChanged(), + // Only run this report if the flag for running is true filter((isRunning) => isRunning), withLatestFrom(this.organizationDetails$, this.userId$), exhaustMap(([_, organizationDetails, userId]) => { @@ -353,22 +396,30 @@ export class RiskInsightsDataService { // Generate the report return this.reportService.generateApplicationsReport$(organizationId).pipe( - map((data) => ({ - data, - summary: this.reportService.generateApplicationsSummary(data), + map((report) => ({ + report, + summary: this.reportService.generateApplicationsSummary(report), + applications: this.reportService.generateOrganizationApplications(report), })), - switchMap(({ data, summary }) => - this.enrichReportData$(data).pipe( - map((enrichedData) => ({ data: enrichedData, summary })), + // Enrich report with critical markings + switchMap(({ report, summary, applications }) => + this.enrichReportData$(report).pipe( + map((enrichedReport) => ({ report: enrichedReport, summary, applications })), ), ), - tap(({ data, summary }) => { - this.reportResultsSubject.next({ data, summary, dateCreated: new Date() }); + // Load the updated data into the UI + tap(({ report, summary, applications }) => { + this.reportResultsSubject.next({ + reportData: report, + summaryData: summary, + applicationData: applications, + creationDate: new Date(), + }); this.errorSubject.next(null); }), - switchMap(({ data, summary }) => { - // Just returns ID - return this.reportService.saveRiskInsightsReport$(data, summary, { + switchMap(({ report, summary, applications }) => { + // Save the generated data + return this.reportService.saveRiskInsightsReport$(report, summary, applications, { organizationId, userId, }); @@ -377,4 +428,42 @@ export class RiskInsightsDataService { }), ); } + + // ------------------------------ Critical application methods -------------- + + saveCriticalApplications(selectedUrls: string[]) { + return this.organizationDetails$.pipe( + exhaustMap((organizationDetails) => { + if (!organizationDetails?.organizationId) { + return EMPTY; + } + return this.criticalAppsService.setCriticalApps( + organizationDetails?.organizationId, + selectedUrls, + ); + }), + catchError((error: unknown) => { + this.errorSubject.next("Failed to save critical applications"); + return throwError(() => error); + }), + ); + } + + removeCriticalApplication(hostname: string) { + return this.organizationDetails$.pipe( + exhaustMap((organizationDetails) => { + if (!organizationDetails?.organizationId) { + return EMPTY; + } + return this.criticalAppsService.dropCriticalApp( + organizationDetails?.organizationId, + hostname, + ); + }), + catchError((error: unknown) => { + this.errorSubject.next("Failed to remove critical application"); + return throwError(() => error); + }), + ); + } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts index 9b7bb3b7258..e2c92ad4b9b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.spec.ts @@ -10,6 +10,9 @@ import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; +import { EncryptedReportData, DecryptedReportData } from "../models"; +import { mockApplicationData, mockReportData, mockSummaryData } from "../models/mock-data"; + import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; describe("RiskInsightsEncryptionService", () => { @@ -31,6 +34,10 @@ describe("RiskInsightsEncryptionService", () => { }; const orgKey$ = new BehaviorSubject(OrgRecords); + let mockDecryptedData: DecryptedReportData; + let mockEncryptedData: EncryptedReportData; + let mockKey: EncString; + beforeEach(() => { service = new RiskInsightsEncryptionService( mockKeyService, @@ -47,6 +54,18 @@ describe("RiskInsightsEncryptionService", () => { mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData)); mockKeyService.orgKeys$.mockReturnValue(orgKey$); + + mockKey = new EncString("wrapped-key"); + mockEncryptedData = { + encryptedReportData: new EncString(JSON.stringify(mockReportData)), + encryptedSummaryData: new EncString(JSON.stringify(mockSummaryData)), + encryptedApplicationData: new EncString(JSON.stringify(mockApplicationData)), + }; + mockDecryptedData = { + reportData: mockReportData, + summaryData: mockSummaryData, + applicationData: mockApplicationData, + }; }); describe("encryptRiskInsightsReport", () => { @@ -55,22 +74,40 @@ describe("RiskInsightsEncryptionService", () => { mockKeyService.orgKeys$.mockReturnValue(orgKey$); // Act: call the method under test - const result = await service.encryptRiskInsightsReport(orgId, userId, testData); + const result = await service.encryptRiskInsightsReport( + { organizationId: orgId, userId }, + mockDecryptedData, + ); // Assert: ensure that the methods were called with the expected parameters expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId); expect(mockKeyGenerationService.createKey).toHaveBeenCalledWith(512); + + // Assert all variables were encrypted expect(mockEncryptService.encryptString).toHaveBeenCalledWith( - JSON.stringify(testData), + JSON.stringify(mockDecryptedData.reportData), contentEncryptionKey, ); + expect(mockEncryptService.encryptString).toHaveBeenCalledWith( + JSON.stringify(mockDecryptedData.summaryData), + contentEncryptionKey, + ); + expect(mockEncryptService.encryptString).toHaveBeenCalledWith( + JSON.stringify(mockDecryptedData.applicationData), + contentEncryptionKey, + ); + expect(mockEncryptService.wrapSymmetricKey).toHaveBeenCalledWith( contentEncryptionKey, orgKey, ); + + // Mocked encrypt returns ENCRYPTED_TEXT expect(result).toEqual({ organizationId: orgId, - encryptedData: new EncString(ENCRYPTED_TEXT), + encryptedReportData: new EncString(ENCRYPTED_TEXT), + encryptedSummaryData: new EncString(ENCRYPTED_TEXT), + encryptedApplicationData: new EncString(ENCRYPTED_TEXT), contentEncryptionKey: new EncString(ENCRYPTED_KEY), }); }); @@ -82,9 +119,9 @@ describe("RiskInsightsEncryptionService", () => { mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString(ENCRYPTED_KEY)); // Act & Assert: call the method under test and expect rejection - await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow( - "Encryption failed, encrypted strings are null", - ); + await expect( + service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData), + ).rejects.toThrow("Encryption failed, encrypted strings are null"); }); it("should throw an error when encrypted key is null or empty", async () => { @@ -94,18 +131,18 @@ describe("RiskInsightsEncryptionService", () => { mockEncryptService.wrapSymmetricKey.mockResolvedValue(new EncString("")); // Act & Assert: call the method under test and expect rejection - await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow( - "Encryption failed, encrypted strings are null", - ); + await expect( + service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData), + ).rejects.toThrow("Encryption failed, encrypted strings are null"); }); it("should throw if org key is not found", async () => { // when we cannot get an organization key, we should throw an error mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({})); - await expect(service.encryptRiskInsightsReport(orgId, userId, testData)).rejects.toThrow( - "Organization key not found", - ); + await expect( + service.encryptRiskInsightsReport({ organizationId: orgId, userId }, mockDecryptedData), + ).rejects.toThrow("Organization key not found"); }); }); @@ -120,23 +157,21 @@ describe("RiskInsightsEncryptionService", () => { // actual decryption does not happen here, // we just want to ensure the method calls are correct const result = await service.decryptRiskInsightsReport( - orgId, - userId, - new EncString("encrypted-data"), - new EncString("wrapped-key"), - (data) => data as typeof testData, + { organizationId: orgId, userId }, + mockEncryptedData, + mockKey, ); expect(mockKeyService.orgKeys$).toHaveBeenCalledWith(userId); - expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith( - new EncString("wrapped-key"), - orgKey, - ); - expect(mockEncryptService.decryptString).toHaveBeenCalledWith( - new EncString("encrypted-data"), - contentEncryptionKey, - ); - expect(result).toEqual(testData); + expect(mockEncryptService.unwrapSymmetricKey).toHaveBeenCalledWith(mockKey, orgKey); + expect(mockEncryptService.decryptString).toHaveBeenCalledTimes(3); + + // Mock decrypt returns JSON.stringify(testData) + expect(result).toEqual({ + reportData: testData, + summaryData: testData, + applicationData: testData, + }); }); it("should invoke data type validation method during decryption", async () => { @@ -144,77 +179,47 @@ describe("RiskInsightsEncryptionService", () => { mockKeyService.orgKeys$.mockReturnValue(orgKey$); mockEncryptService.unwrapSymmetricKey.mockResolvedValue(contentEncryptionKey); mockEncryptService.decryptString.mockResolvedValue(JSON.stringify(testData)); - const mockParseFn = jest.fn((data) => data as typeof testData); // act: call the decrypt method - with any params // actual decryption does not happen here, // we just want to ensure the method calls are correct const result = await service.decryptRiskInsightsReport( - orgId, - userId, - new EncString("encrypted-data"), - new EncString("wrapped-key"), - mockParseFn, + { organizationId: orgId, userId }, + mockEncryptedData, + mockKey, ); - expect(mockParseFn).toHaveBeenCalledWith(JSON.parse(JSON.stringify(testData))); - expect(result).toEqual(testData); + expect(result).toEqual({ + reportData: testData, + summaryData: testData, + applicationData: testData, + }); }); it("should return null if org key is not found", async () => { mockKeyService.orgKeys$.mockReturnValue(new BehaviorSubject({})); + await expect( + service.decryptRiskInsightsReport( + { organizationId: orgId, userId }, - const result = await service.decryptRiskInsightsReport( - orgId, - userId, - new EncString("encrypted-data"), - new EncString("wrapped-key"), - (data) => data as typeof testData, - ); - - expect(result).toBeNull(); + mockEncryptedData, + mockKey, + ), + ).rejects.toEqual(Error("Organization key not found")); }); it("should return null if decrypt throws", async () => { mockKeyService.orgKeys$.mockReturnValue(orgKey$); mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail")); - const result = await service.decryptRiskInsightsReport( - orgId, - userId, - new EncString("encrypted-data"), - new EncString("wrapped-key"), - (data) => data as typeof testData, - ); - expect(result).toBeNull(); - }); + await expect( + service.decryptRiskInsightsReport( + { organizationId: orgId, userId }, - it("should return null if decrypt throws", async () => { - mockKeyService.orgKeys$.mockReturnValue(orgKey$); - mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail")); - - const result = await service.decryptRiskInsightsReport( - orgId, - userId, - new EncString("encrypted-data"), - new EncString("wrapped-key"), - (data) => data as typeof testData, - ); - expect(result).toBeNull(); - }); - - it("should return null if decrypt throws", async () => { - mockKeyService.orgKeys$.mockReturnValue(orgKey$); - mockEncryptService.unwrapSymmetricKey.mockRejectedValue(new Error("fail")); - - const result = await service.decryptRiskInsightsReport( - orgId, - userId, - new EncString("encrypted-data"), - new EncString("wrapped-key"), - (data) => data as typeof testData, - ); - expect(result).toBeNull(); + mockEncryptedData, + mockKey, + ), + ).rejects.toEqual(Error("fail")); }); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts index 7bf01b04a63..04811f9cfcd 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-encryption.service.ts @@ -1,13 +1,13 @@ import { firstValueFrom, map } from "rxjs"; -import { Jsonify } from "type-fest"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; -import { EncryptedDataWithKey } from "../models/password-health"; +import { DecryptedReportData, EncryptedReportData, EncryptedDataWithKey } from "../models"; export class RiskInsightsEncryptionService { constructor( @@ -16,11 +16,15 @@ export class RiskInsightsEncryptionService { private keyGeneratorService: KeyGenerationService, ) {} - async encryptRiskInsightsReport( - organizationId: OrganizationId, - userId: UserId, - data: T, + async encryptRiskInsightsReport( + context: { + organizationId: OrganizationId; + userId: UserId; + }, + data: DecryptedReportData, + wrappedKey?: EncString, ): Promise { + const { userId, organizationId } = context; const orgKey = await firstValueFrom( this.keyService .orgKeys$(userId) @@ -35,10 +39,28 @@ export class RiskInsightsEncryptionService { throw new Error("Organization key not found"); } - const contentEncryptionKey = await this.keyGeneratorService.createKey(512); + let contentEncryptionKey: SymmetricCryptoKey; + if (!wrappedKey) { + // Generate a new key + contentEncryptionKey = await this.keyGeneratorService.createKey(512); + } else { + // Unwrap the existing key + contentEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey); + } - const dataEncrypted = await this.encryptService.encryptString( - JSON.stringify(data), + const { reportData, summaryData, applicationData } = data; + + // Encrypt the data + const encryptedReportData = await this.encryptService.encryptString( + JSON.stringify(reportData), + contentEncryptionKey, + ); + const encryptedSummaryData = await this.encryptService.encryptString( + JSON.stringify(summaryData), + contentEncryptionKey, + ); + const encryptedApplicationData = await this.encryptService.encryptString( + JSON.stringify(applicationData), contentEncryptionKey, ); @@ -47,59 +69,87 @@ export class RiskInsightsEncryptionService { orgKey, ); - if (!dataEncrypted.encryptedString || !wrappedEncryptionKey.encryptedString) { + if ( + !encryptedReportData.encryptedString || + !encryptedSummaryData.encryptedString || + !encryptedApplicationData.encryptedString || + !wrappedEncryptionKey.encryptedString + ) { throw new Error("Encryption failed, encrypted strings are null"); } - const encryptedData = dataEncrypted; - const contentEncryptionKeyString = wrappedEncryptionKey; - const encryptedDataPacket: EncryptedDataWithKey = { organizationId, - encryptedData, - contentEncryptionKey: contentEncryptionKeyString, + encryptedReportData: encryptedReportData, + encryptedSummaryData: encryptedSummaryData, + encryptedApplicationData: encryptedApplicationData, + contentEncryptionKey: wrappedEncryptionKey, }; return encryptedDataPacket; } - async decryptRiskInsightsReport( - organizationId: OrganizationId, - userId: UserId, - encryptedData: EncString, + async decryptRiskInsightsReport( + context: { + organizationId: OrganizationId; + userId: UserId; + }, + encryptedData: EncryptedReportData, wrappedKey: EncString, - parser: (data: Jsonify) => T, - ): Promise { - try { - const orgKey = await firstValueFrom( - this.keyService - .orgKeys$(userId) - .pipe( - map((organizationKeysById) => - organizationKeysById ? organizationKeysById[organizationId] : null, - ), + ): Promise { + const { userId, organizationId } = context; + const orgKey = await firstValueFrom( + this.keyService + .orgKeys$(userId) + .pipe( + map((organizationKeysById) => + organizationKeysById ? organizationKeysById[organizationId] : null, ), - ); + ), + ); - if (!orgKey) { - throw new Error("Organization key not found"); - } - - const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey( - wrappedKey, - orgKey, - ); - - const dataUnencrypted = await this.encryptService.decryptString( - encryptedData, - unwrappedEncryptionKey, - ); - - const dataUnencryptedJson = parser(JSON.parse(dataUnencrypted)); - - return dataUnencryptedJson as T; - } catch { - return null; + if (!orgKey) { + throw new Error("Organization key not found"); } + + const unwrappedEncryptionKey = await this.encryptService.unwrapSymmetricKey(wrappedKey, orgKey); + if (!unwrappedEncryptionKey) { + throw Error("Encryption key not found"); + } + + const { encryptedReportData, encryptedSummaryData, encryptedApplicationData } = encryptedData; + if (!encryptedReportData || !encryptedSummaryData || !encryptedApplicationData) { + throw new Error("Missing data"); + } + + // Decrypt the data + const decryptedReportData = await this.encryptService.decryptString( + encryptedReportData, + unwrappedEncryptionKey, + ); + const decryptedSummaryData = await this.encryptService.decryptString( + encryptedSummaryData, + unwrappedEncryptionKey, + ); + const decryptedApplicationData = await this.encryptService.decryptString( + encryptedApplicationData, + unwrappedEncryptionKey, + ); + + if (!decryptedReportData || !decryptedSummaryData || !decryptedApplicationData) { + throw new Error("Decryption failed, decrypted strings are null"); + } + + const decryptedReportDataJson = JSON.parse(decryptedReportData); + const decryptedSummaryDataJson = JSON.parse(decryptedSummaryData); + const decryptedApplicationDataJson = JSON.parse(decryptedApplicationData); + + const decryptedFullReport = { + reportData: decryptedReportDataJson, + summaryData: decryptedSummaryDataJson, + applicationData: decryptedApplicationDataJson, + }; + + return decryptedFullReport; } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts index 18836fb1319..5f8fdaa244a 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.spec.ts @@ -1,25 +1,23 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { makeEncString } from "@bitwarden/common/spec"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherType } from "@bitwarden/common/vault/enums"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { createNewSummaryData } from "../helpers"; +import { DecryptedReportData, EncryptedDataWithKey } from "../models"; import { GetRiskInsightsReportResponse, SaveRiskInsightsReportResponse, } from "../models/api-models.types"; -import { EncryptedDataWithKey } from "../models/password-health"; import { - ApplicationHealthReportDetail, - OrganizationReportSummary, - RiskInsightsReportData, -} from "../models/report-models"; -import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response"; + mockApplicationData, + mockCipherViews, + mockMemberDetails, + mockReportData, + mockSummaryData, +} from "../models/mock-data"; import { mockCiphers } from "./ciphers.mock"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; @@ -45,17 +43,13 @@ describe("RiskInsightsReportService", () => { // Non changing mock data const mockOrganizationId = "orgId" as OrganizationId; const mockUserId = "userId" as UserId; - const ENCRYPTED_TEXT = "This data has been encrypted"; - const ENCRYPTED_KEY = "Re-encrypted Cipher Key"; - const mockEncryptedText = new EncString(ENCRYPTED_TEXT); - const mockEncryptedKey = new EncString(ENCRYPTED_KEY); + const mockEncryptedKey = makeEncString("test-key"); // Changing mock data - let mockCipherViews: CipherView[]; - let mockMemberDetails: MemberCipherDetailsResponse[]; - let mockReport: ApplicationHealthReportDetail[]; - let mockSummary: OrganizationReportSummary; - let mockEncryptedReport: EncryptedDataWithKey; + let mockDecryptedData: DecryptedReportData; + const mockReportEnc = makeEncString(JSON.stringify(mockReportData)); + const mockSummaryEnc = makeEncString(JSON.stringify(mockSummaryData)); + const mockApplicationsEnc = makeEncString(JSON.stringify(mockApplicationData)); beforeEach(() => { cipherService.getAllFromApiForOrganization.mockResolvedValue(mockCiphers); @@ -87,75 +81,15 @@ describe("RiskInsightsReportService", () => { service = new RiskInsightsReportService( cipherService, memberCipherDetailsService, + mockPasswordHealthService, mockRiskInsightsApiService, mockRiskInsightsEncryptionService, - mockPasswordHealthService, ); - // Reset mock ciphers before each test - mockCipherViews = [ - mock({ - id: "cipher-1", - type: CipherType.Login, - login: { password: "pass1", username: "user1", uris: [{ uri: "https://app.com/login" }] }, - isDeleted: false, - viewPassword: true, - }), - mock({ - id: "cipher-2", - type: CipherType.Login, - login: { password: "pass2", username: "user2", uris: [{ uri: "app.com/home" }] }, - isDeleted: false, - viewPassword: true, - }), - mock({ - id: "cipher-3", - type: CipherType.Login, - login: { password: "pass3", username: "user3", uris: [{ uri: "https://other.com" }] }, - isDeleted: false, - viewPassword: true, - }), - ]; - mockMemberDetails = [ - mock({ - cipherIds: ["cipher-1"], - userGuid: "user1", - userName: "User 1", - email: "user1@app.com", - }), - mock({ - cipherIds: ["cipher-2"], - userGuid: "user2", - userName: "User 2", - email: "user2@app.com", - }), - mock({ - cipherIds: ["cipher-3"], - userGuid: "user3", - userName: "User 3", - email: "user3@other.com", - }), - ]; - - mockReport = [ - { - applicationName: "app1", - passwordCount: 0, - atRiskPasswordCount: 0, - atRiskCipherIds: [], - memberCount: 0, - atRiskMemberCount: 0, - memberDetails: [], - atRiskMemberDetails: [], - cipherIds: [], - }, - ]; - mockSummary = createNewSummaryData(); - - mockEncryptedReport = { - organizationId: mockOrganizationId, - encryptedData: mockEncryptedText, - contentEncryptionKey: mockEncryptedKey, + mockDecryptedData = { + reportData: mockReportData, + summaryData: mockSummaryData, + applicationData: mockApplicationData, }; }); @@ -284,15 +218,22 @@ describe("RiskInsightsReportService", () => { describe("saveRiskInsightsReport$", () => { it("should not update subjects if save response does not have id", (done) => { + const mockEncryptedOutput: EncryptedDataWithKey = { + organizationId: mockOrganizationId, + encryptedReportData: mockReportEnc, + encryptedSummaryData: mockSummaryEnc, + encryptedApplicationData: mockApplicationsEnc, + contentEncryptionKey: mockEncryptedKey, + }; mockRiskInsightsEncryptionService.encryptRiskInsightsReport.mockResolvedValue( - mockEncryptedReport, + mockEncryptedOutput, ); const saveResponse = new SaveRiskInsightsReportResponse({ id: "" }); // Simulating no ID in response mockRiskInsightsApiService.saveRiskInsightsReport$.mockReturnValue(of(saveResponse)); service - .saveRiskInsightsReport$(mockReport, mockSummary, { + .saveRiskInsightsReport$(mockReportData, mockSummaryData, mockApplicationData, { organizationId: mockOrganizationId, userId: mockUserId, }) @@ -321,17 +262,19 @@ describe("RiskInsightsReportService", () => { it("should call with the correct organizationId", async () => { // we need to ensure that the api is invoked with the specified organizationId // here it doesn't matter what the Api returns - const apiResponse = { + const apiResponse = new GetRiskInsightsReportResponse({ id: "reportId", - date: new Date().toISOString(), + date: new Date(), organizationId: mockOrganizationId, - reportData: mockEncryptedReport.encryptedData, - contentEncryptionKey: mockEncryptedReport.contentEncryptionKey, - } as GetRiskInsightsReportResponse; + reportData: mockReportEnc.encryptedString, + summaryData: mockSummaryEnc.encryptedString, + applicationData: mockApplicationsEnc.encryptedString, + contentEncryptionKey: mockEncryptedKey.encryptedString, + }); - const decryptedResponse: RiskInsightsReportData = { - data: [], - summary: { + const decryptedResponse: DecryptedReportData = { + reportData: [], + summaryData: { totalMemberCount: 1, totalAtRiskMemberCount: 1, totalApplicationCount: 1, @@ -342,9 +285,9 @@ describe("RiskInsightsReportService", () => { totalCriticalAtRiskApplicationCount: 1, newApplications: [], }, + applicationData: [], }; - const organizationId = "orgId" as OrganizationId; const userId = "userId" as UserId; // Mock api returned encrypted data @@ -355,17 +298,15 @@ describe("RiskInsightsReportService", () => { Promise.resolve(decryptedResponse), ); - await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId)); + await firstValueFrom(service.getRiskInsightsReport$(mockOrganizationId, userId)); expect(mockRiskInsightsApiService.getRiskInsightsReport$).toHaveBeenCalledWith( - organizationId, + mockOrganizationId, ); expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( - organizationId, - userId, - expect.anything(), // encryptedData - expect.anything(), // wrappedKey - expect.any(Function), // parser + { organizationId: mockOrganizationId, userId }, + expect.anything(), + expect.anything(), ); }); @@ -375,32 +316,29 @@ describe("RiskInsightsReportService", () => { const organizationId = "orgId" as OrganizationId; const userId = "userId" as UserId; - const mockResponse = { + const mockResponse = new GetRiskInsightsReportResponse({ id: "reportId", - date: new Date().toISOString(), + creationDate: new Date(), organizationId: organizationId as OrganizationId, - reportData: mockEncryptedReport.encryptedData, - contentEncryptionKey: mockEncryptedReport.contentEncryptionKey, - } as GetRiskInsightsReportResponse; + reportData: mockReportEnc.encryptedString, + summaryData: mockSummaryEnc.encryptedString, + applicationData: mockApplicationsEnc.encryptedString, + contentEncryptionKey: mockEncryptedKey.encryptedString, + }); - const decryptedReport = { - data: [{ foo: "bar" }], - }; mockRiskInsightsApiService.getRiskInsightsReport$.mockReturnValue(of(mockResponse)); mockRiskInsightsEncryptionService.decryptRiskInsightsReport.mockResolvedValue( - decryptedReport, + mockDecryptedData, ); const result = await firstValueFrom(service.getRiskInsightsReport$(organizationId, userId)); expect(mockRiskInsightsEncryptionService.decryptRiskInsightsReport).toHaveBeenCalledWith( - organizationId, - userId, + { organizationId: mockOrganizationId, userId }, expect.anything(), expect.anything(), - expect.any(Function), ); - expect(result).toEqual(decryptedReport); + expect(result).toEqual({ ...mockDecryptedData, creationDate: mockResponse.creationDate }); }); }); }); diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts index d82366c0154..fcfc7a255df 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/risk-insights-report.service.ts @@ -1,6 +1,7 @@ import { - BehaviorSubject, + catchError, concatMap, + EMPTY, first, firstValueFrom, forkJoin, @@ -19,7 +20,6 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { createNewReportData, - createNewSummaryData, flattenMemberDetails, getApplicationReportDetail, getFlattenedCipherDetails, @@ -45,7 +45,8 @@ import { CipherHealthReport, MemberDetails, PasswordHealthData, - RiskInsightsReportData, + OrganizationReportApplication, + RiskInsightsData, } from "../models/report-models"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; @@ -54,14 +55,6 @@ import { RiskInsightsApiService } from "./risk-insights-api.service"; import { RiskInsightsEncryptionService } from "./risk-insights-encryption.service"; export class RiskInsightsReportService { - private riskInsightsReportSubject = new BehaviorSubject([]); - riskInsightsReport$ = this.riskInsightsReportSubject.asObservable(); - - private riskInsightsSummarySubject = new BehaviorSubject( - createNewSummaryData(), - ); - riskInsightsSummary$ = this.riskInsightsSummarySubject.asObservable(); - // [FIXME] CipherData // Cipher data // private _ciphersSubject = new BehaviorSubject(null); @@ -70,9 +63,9 @@ export class RiskInsightsReportService { constructor( private cipherService: CipherService, private memberCipherDetailsApiService: MemberCipherDetailsApiService, + private passwordHealthService: PasswordHealthService, private riskInsightsApiService: RiskInsightsApiService, private riskInsightsEncryptionService: RiskInsightsEncryptionService, - private passwordHealthService: PasswordHealthService, ) {} // [FIXME] CipherData @@ -152,6 +145,7 @@ export class RiskInsightsReportService { /** * Report data for the aggregation of uris to like uris and getting password/member counts, * members, and at risk statuses. + * * @param organizationId Id of the organization * @returns The all applications health report data */ @@ -232,20 +226,51 @@ export class RiskInsightsReportService { const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails); const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers); - // TODO: totalCriticalMemberCount, totalCriticalAtRiskMemberCount, totalCriticalApplicationCount, totalCriticalAtRiskApplicationCount, and newApplications will be handled with future logic implementation + // TODO: Replace with actual new applications detection logic (PM-26185) + const dummyNewApplications = [ + "github.com", + "google.com", + "stackoverflow.com", + "gitlab.com", + "bitbucket.org", + "npmjs.com", + "docker.com", + "aws.amazon.com", + "azure.microsoft.com", + "jenkins.io", + "terraform.io", + "kubernetes.io", + "atlassian.net", + ]; + return { totalMemberCount: uniqueMembers.length, - totalCriticalMemberCount: 0, totalAtRiskMemberCount: uniqueAtRiskMembers.length, - totalCriticalAtRiskMemberCount: 0, totalApplicationCount: reports.length, - totalCriticalApplicationCount: 0, totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length, + totalCriticalMemberCount: 0, + totalCriticalAtRiskMemberCount: 0, + totalCriticalApplicationCount: 0, totalCriticalAtRiskApplicationCount: 0, - newApplications: [], + newApplications: dummyNewApplications, }; } + /** + * Generate a snapshot of applications and related data associated to this report + * + * @param reports + * @returns A list of applications with a critical marking flag + */ + generateOrganizationApplications( + reports: ApplicationHealthReportDetail[], + ): OrganizationReportApplication[] { + return reports.map((report) => ({ + applicationName: report.applicationName, + isCritical: false, + })); + } + async identifyCiphers( data: ApplicationHealthReportDetail[], organizationId: OrganizationId, @@ -272,12 +297,12 @@ export class RiskInsightsReportService { getRiskInsightsReport$( organizationId: OrganizationId, userId: UserId, - ): Observable { + ): Observable { return this.riskInsightsApiService.getRiskInsightsReport$(organizationId).pipe( - switchMap((response): Observable => { + switchMap((response) => { if (!response) { // Return an empty report and summary if response is falsy - return of(createNewReportData()); + return of(createNewReportData()); } if (!response.contentEncryptionKey || response.contentEncryptionKey.data == "") { return throwError(() => new Error("Report key not found")); @@ -285,15 +310,43 @@ export class RiskInsightsReportService { if (!response.reportData) { return throwError(() => new Error("Report data not found")); } + if (!response.summaryData) { + return throwError(() => new Error("Summary data not found")); + } + if (!response.applicationData) { + return throwError(() => new Error("Application data not found")); + } + return from( - this.riskInsightsEncryptionService.decryptRiskInsightsReport( - organizationId, - userId, - response.reportData, + this.riskInsightsEncryptionService.decryptRiskInsightsReport( + { + organizationId, + userId, + }, + { + encryptedReportData: response.reportData, + encryptedSummaryData: response.summaryData, + encryptedApplicationData: response.applicationData, + }, response.contentEncryptionKey, - (data) => data as RiskInsightsReportData, ), - ).pipe(map((decryptedReport) => decryptedReport ?? createNewReportData())); + ).pipe( + map((decryptedData) => ({ + reportData: decryptedData.reportData, + summaryData: decryptedData.summaryData, + applicationData: decryptedData.applicationData, + creationDate: response.creationDate, + })), + catchError((error: unknown) => { + // TODO Handle errors appropriately + // console.error("An error occurred when decrypting report", error); + return EMPTY; + }), + ); + }), + catchError((error: unknown) => { + // console.error("An error occurred when fetching the last report", error); + return EMPTY; }), ); } @@ -308,6 +361,7 @@ export class RiskInsightsReportService { saveRiskInsightsReport$( report: ApplicationHealthReportDetail[], summary: OrganizationReportSummary, + applications: OrganizationReportApplication[], encryptionParameters: { organizationId: OrganizationId; userId: UserId; @@ -315,28 +369,43 @@ export class RiskInsightsReportService { ): Observable { return from( this.riskInsightsEncryptionService.encryptRiskInsightsReport( - encryptionParameters.organizationId, - encryptionParameters.userId, { - data: report, - summary: summary, + organizationId: encryptionParameters.organizationId, + userId: encryptionParameters.userId, + }, + { + reportData: report, + summaryData: summary, + applicationData: applications, }, ), ).pipe( - map(({ encryptedData, contentEncryptionKey }) => ({ - data: { - organizationId: encryptionParameters.organizationId, - date: new Date().toISOString(), - reportData: encryptedData.toSdk(), - contentEncryptionKey: contentEncryptionKey.toSdk(), - }, - })), + map( + ({ + encryptedReportData, + encryptedSummaryData, + encryptedApplicationData, + contentEncryptionKey, + }) => ({ + data: { + organizationId: encryptionParameters.organizationId, + creationDate: new Date().toISOString(), + reportData: encryptedReportData.toSdk(), + summaryData: encryptedSummaryData.toSdk(), + applicationData: encryptedApplicationData.toSdk(), + contentEncryptionKey: contentEncryptionKey.toSdk(), + }, + }), + ), switchMap((encryptedReport) => this.riskInsightsApiService.saveRiskInsightsReport$( encryptedReport, encryptionParameters.organizationId, ), ), + catchError((error: unknown) => { + return EMPTY; + }), map((response) => { if (!isSaveRiskInsightsReportResponse(response)) { throw new Error("Invalid response from API"); @@ -367,13 +436,13 @@ export class RiskInsightsReportService { const weakPassword = this.passwordHealthService.findWeakPasswordDetails(cipher); // Looping over all ciphers needs to happen first to determine reused passwords over all ciphers. // Store in the set and evaluate later - if (passwordUseMap.has(cipher.login.password)) { + if (passwordUseMap.has(cipher.login.password!)) { passwordUseMap.set( - cipher.login.password, - (passwordUseMap.get(cipher.login.password) || 0) + 1, + cipher.login.password!, + (passwordUseMap.get(cipher.login.password!) || 0) + 1, ); } else { - passwordUseMap.set(cipher.login.password, 1); + passwordUseMap.set(cipher.login.password!, 1); } const exposedPassword = exposedDetails.find((x) => x?.cipherId === cipher.id); @@ -397,7 +466,7 @@ export class RiskInsightsReportService { // loop for reused passwords cipherHealthReports.forEach((detail) => { - detail.reusedPasswordCount = passwordUseMap.get(detail.login.password) ?? 0; + detail.reusedPasswordCount = passwordUseMap.get(detail.login.password!) ?? 0; }); return cipherHealthReports; } @@ -445,7 +514,7 @@ export class RiskInsightsReportService { private _buildPasswordUseMap(ciphers: CipherView[]): Map { const passwordUseMap = new Map(); ciphers.forEach((cipher) => { - const password = cipher.login.password; + const password = cipher.login.password!; passwordUseMap.set(password, (passwordUseMap.get(password) || 0) + 1); }); return passwordUseMap; @@ -457,6 +526,13 @@ export class RiskInsightsReportService { const applicationMap = new Map(); cipherHealthData.forEach((cipher: CipherHealthReport) => { + // Warning: Currently does not show ciphers with NO Application + // if (cipher.applications.length === 0) { + // const existingApplication = applicationMap.get("None") || []; + // existingApplication.push(cipher); + // applicationMap.set("None", existingApplication); + // } + cipher.applications.forEach((application) => { const existingApplication = applicationMap.get(application) || []; existingApplication.push(cipher); @@ -610,7 +686,7 @@ export class RiskInsightsReportService { healthData: { weakPasswordDetail: this.passwordHealthService.findWeakPasswordDetails(cipher), exposedPasswordDetail: exposedPassword, - reusedPasswordCount: passwordUseMap.get(cipher.login.password) ?? 0, + reusedPasswordCount: passwordUseMap.get(cipher.login.password!) ?? 0, }, applications: getTrimmedCipherUris(cipher), } as CipherHealthReport; 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 f68e35bf240..b0f3af4d108 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 @@ -9,7 +9,7 @@ import { Validators, } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs"; +import { concatMap, firstValueFrom, Subject, switchMap, takeUntil } from "rxjs"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -34,6 +34,7 @@ import { OrganizationSsoRequest } from "@bitwarden/common/auth/models/request/or import { OrganizationSsoResponse } from "@bitwarden/common/auth/models/response/organization-sso.response"; import { SsoConfigView } from "@bitwarden/common/auth/models/view/sso-config.view"; import { getUserId } from "@bitwarden/common/auth/services/account.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 { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -203,6 +204,7 @@ export class SsoComponent implements OnInit, OnDestroy { private accountService: AccountService, private organizationApiService: OrganizationApiServiceAbstraction, private toastService: ToastService, + private environmentService: EnvironmentService, ) {} async ngOnInit() { @@ -253,6 +255,32 @@ export class SsoComponent implements OnInit, OnDestroy { .subscribe(); this.showKeyConnectorOptions = this.platformUtilsService.isSelfHost(); + + // Only setup listener if key connector is a possible selection + if (this.showKeyConnectorOptions) { + this.listenForKeyConnectorSelection(); + } + } + + listenForKeyConnectorSelection() { + this.ssoConfigForm?.controls?.memberDecryptionType.valueChanges + .pipe( + switchMap(async (memberDecryptionType) => { + if (memberDecryptionType === MemberDecryptionType.KeyConnector) { + // Pre-populate a default key connector URL (user can still change it) + const env = await firstValueFrom(this.environmentService.environment$); + const webVaultUrl = env.getWebVaultUrl(); + const defaultKeyConnectorUrl = webVaultUrl + "/key-connector/"; + + this.ssoConfigForm.controls.keyConnectorUrl.setValue(defaultKeyConnectorUrl); + } else { + // Otherwise clear the key connector URL + this.ssoConfigForm.controls.keyConnectorUrl.setValue(""); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); } ngOnDestroy(): void { 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 01bf19f30a4..9e9a695cd1e 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 @@ -19,38 +19,47 @@ import { OrganizationService } from "@bitwarden/common/admin-console/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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength/password-strength.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service"; + import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module"; import { RiskInsightsComponent } from "./risk-insights.component"; +import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service"; @NgModule({ imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule], providers: [ - { + safeProvider({ provide: MemberCipherDetailsApiService, + useClass: MemberCipherDetailsApiService, deps: [ApiService], - }, - { + }), + safeProvider({ provide: PasswordHealthService, + useClass: PasswordHealthService, deps: [PasswordStrengthServiceAbstraction, AuditService], - }, - { + }), + safeProvider({ provide: RiskInsightsApiService, + useClass: RiskInsightsApiService, deps: [ApiService], - }, - { + }), + safeProvider({ provide: RiskInsightsReportService, + useClass: RiskInsightsReportService, deps: [ CipherService, MemberCipherDetailsApiService, + PasswordHealthService, RiskInsightsApiService, RiskInsightsEncryptionService, - PasswordHealthService, ], - }, + }), safeProvider({ provide: RiskInsightsDataService, deps: [ @@ -78,13 +87,18 @@ import { RiskInsightsComponent } from "./risk-insights.component"; safeProvider({ provide: AllActivitiesService, useClass: AllActivitiesService, - deps: [], + deps: [RiskInsightsDataService], }), safeProvider({ provide: SecurityTasksApiService, useClass: SecurityTasksApiService, deps: [ApiService], }), + safeProvider({ + provide: AccessIntelligenceSecurityTasksService, + useClass: AccessIntelligenceSecurityTasksService, + deps: [AllActivitiesService, DefaultAdminTaskService, ToastService, I18nService], + }), ], }) 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 17ae964dbed..0eb9b30367c 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,12 +1,29 @@
{{ title }}
+ @if (iconClass) { + + } {{ cardMetrics }}
{{ metricDescription }}
- @if (showNavigationLink) { + @if (buttonClick.observed && buttonText) { +
+ +
+ } + @if (showNavigationLink && !buttonText) {

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 2dc7c6a9c79..c8c73cd0e5a 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,9 +1,9 @@ import { CommonModule } from "@angular/common"; -import { Component, Input } from "@angular/core"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ButtonModule, LinkModule, TypographyModule } from "@bitwarden/components"; +import { ButtonModule, ButtonType, LinkModule, TypographyModule } from "@bitwarden/components"; @Component({ selector: "dirt-activity-card", @@ -43,9 +43,34 @@ export class ActivityCardComponent { */ @Input() showNavigationLink: boolean = false; + /** + * Icon class to display next to metrics (e.g., "bwi-exclamation-triangle"). + * If null, no icon is displayed. + */ + @Input() iconClass: string | null = null; + + /** + * Button text. If provided, a button will be displayed instead of a navigation link. + */ + @Input() buttonText: string = ""; + + /** + * Button type (e.g., "primary", "secondary") + */ + @Input() buttonType: ButtonType = "primary"; + + /** + * Event emitted when button is clicked + */ + @Output() buttonClick = new EventEmitter(); + constructor(private router: Router) {} navigateToLink = async (navigationLink: string) => { await this.router.navigateByUrl(navigationLink); }; + + onButtonClick = () => { + this.buttonClick.emit(); + }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts index b7a36a79988..3b8475ed5cf 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity-cards/password-change-metric.component.ts @@ -10,18 +10,11 @@ import { SecurityTasksApiService, TaskMetrics, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; -import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; -import { - ButtonModule, - ProgressModule, - ToastService, - TypographyModule, -} from "@bitwarden/components"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components"; -import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction"; import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; +import { AccessIntelligenceSecurityTasksService } from "../shared/security-tasks.service"; export const RenderMode = { noCriticalApps: "noCriticalApps", @@ -34,7 +27,7 @@ export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode]; selector: "dirt-password-change-metric", imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule], templateUrl: "./password-change-metric.component.html", - providers: [DefaultAdminTaskService], + providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], }) export class PasswordChangeMetricComponent implements OnInit { protected taskMetrics$ = new BehaviorSubject({ totalTasks: 0, completedTasks: 0 }); @@ -50,10 +43,10 @@ export class PasswordChangeMetricComponent implements OnInit { renderMode: RenderMode = "noCriticalApps"; async ngOnInit(): Promise { - this.activatedRoute.paramMap + combineLatest([this.activatedRoute.paramMap, this.allActivitiesService.taskCreatedCount$]) .pipe( - switchMap((paramMap) => { - const orgId = paramMap.get("organizationId"); + switchMap(([params, _]) => { + const orgId = params.get("organizationId"); if (orgId) { this.organizationId = orgId as OrganizationId; return this.securityTasksApiService.getTaskMetrics(this.organizationId); @@ -110,9 +103,7 @@ export class PasswordChangeMetricComponent implements OnInit { private activatedRoute: ActivatedRoute, private securityTasksApiService: SecurityTasksApiService, private allActivitiesService: AllActivitiesService, - private adminTaskService: DefaultAdminTaskService, - protected toastService: ToastService, - protected i18nService: I18nService, + protected accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, ) {} get completedPercent(): number { @@ -161,44 +152,9 @@ export class PasswordChangeMetricComponent implements OnInit { } async assignTasks() { - const taskCount = await this.requestPasswordChange(); - this.taskMetrics$.next({ - totalTasks: this.totalTasks + taskCount, - completedTasks: this.completedTasks, - }); - } - - // TODO: this method is shared between here and critical-applications.component.ts - async requestPasswordChange() { - const apps = this.allApplicationsDetails; - const cipherIds = apps - .filter((_) => _.atRiskPasswordCount > 0) - .flatMap((app) => app.atRiskCipherIds); - - const distinctCipherIds = Array.from(new Set(cipherIds)); - - const tasks: CreateTasksRequest[] = distinctCipherIds.map((cipherId) => ({ - cipherId: cipherId as CipherId, - type: SecurityTaskType.UpdateAtRiskCredential, - })); - - try { - await this.adminTaskService.bulkCreateTasks(this.organizationId as OrganizationId, tasks); - this.toastService.showToast({ - message: this.i18nService.t("notifiedMembers"), - variant: "success", - title: this.i18nService.t("success"), - }); - - return tasks.length; - } catch { - this.toastService.showToast({ - message: this.i18nService.t("unexpectedError"), - variant: "error", - title: this.i18nService.t("error"), - }); - } - - return 0; + await this.accessIntelligenceSecurityTasksService.assignTasks( + this.organizationId, + this.allApplicationsDetails, + ); } } 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 a1b5611ff14..844b2f92bb3 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 @@ -1,10 +1,6 @@ -@if (isLoading$ | async) { -

-} - -@if (!(isLoading$ | async)) { +@if (dataService.isLoading$ | async) { + +} @else {
    @@ -45,5 +41,18 @@ > + +
  • + + +
} 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 1691e35c819..e4942344b0e 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 @@ -11,16 +11,19 @@ import { OrganizationService } from "@bitwarden/common/admin-console/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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { getById } from "@bitwarden/common/platform/misc"; +import { ToastService, DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { ActivityCardComponent } from "./activity-card.component"; import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component"; +import { NewApplicationsDialogComponent } from "./new-applications-dialog.component"; import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; import { RiskInsightsTabType } from "./risk-insights.component"; @Component({ - selector: "tools-all-activity", + selector: "dirt-all-activity", imports: [ ApplicationsLoadingComponent, SharedModule, @@ -30,11 +33,12 @@ import { RiskInsightsTabType } from "./risk-insights.component"; templateUrl: "./all-activity.component.html", }) export class AllActivityComponent implements OnInit { - protected isLoading$ = this.dataService.isLoading$; organization: Organization | null = null; totalCriticalAppsAtRiskMemberCount = 0; totalCriticalAppsCount = 0; totalCriticalAppsAtRiskCount = 0; + newApplicationsCount = 0; + newApplications: string[] = []; passwordChangeMetricHasProgressBar = false; destroyRef = inject(DestroyRef); @@ -55,6 +59,8 @@ export class AllActivityComponent implements OnInit { this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount; this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount; + this.newApplications = summary.newApplications; + this.newApplicationsCount = summary.newApplications.length; }); this.allActivitiesService.passwordChangeProgressMetricHasProgressBar$ @@ -71,6 +77,9 @@ export class AllActivityComponent implements OnInit { protected organizationService: OrganizationService, protected dataService: RiskInsightsDataService, protected allActivitiesService: AllActivitiesService, + private toastService: ToastService, + private i18nService: I18nService, + private dialogService: DialogService, ) {} get RiskInsightsTabType() { @@ -81,4 +90,16 @@ export class AllActivityComponent implements OnInit { const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); return `/organizations/${organizationId}/access-intelligence/risk-insights?tabIndex=${tabIndex}`; } + + /** + * Handles the review new applications button click. + * Opens a dialog showing the list of new applications that can be marked as critical. + */ + onReviewNewApplications = async () => { + const dialogRef = NewApplicationsDialogComponent.open(this.dialogService, { + newApplications: this.newApplications, + }); + + await firstValueFrom(dialogRef.closed); + }; } 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 febdb5fa0de..1971b61d516 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 @@ -1,96 +1,102 @@ -
- -
-
- - -

- {{ "noAppsInOrgTitle" | i18n: organization?.name }} -

-
- -
- - {{ "noAppsInOrgDescription" | i18n }} - - {{ "learnMore" | i18n }} +@if (dataService.isLoading$ | async) { + +} @else { + @let drawerDetails = dataService.drawerDetails$ | async; + @if (!dataSource.data.length) { +
+ + +

+ {{ + "noAppsInOrgTitle" + | i18n: (dataService.organizationDetails$ | async)?.organizationName || "" + }} +

+
+ +
+ + {{ "noAppsInOrgDescription" | i18n }} + + {{ "learnMore" | i18n }} +
+
+ + + +
+
+ } @else { +
+

{{ "allApplications" | i18n }}

+
+ +
- - - - - -
-
-

{{ "allApplications" | i18n }}

- @if (dataService.drawerDetails$ | async; as drawerDetails) { -
- - -
-
- - -
+ + {{ "markAppAsCritical" | 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 3b7490dbc19..bc04884c799 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 @@ -2,33 +2,17 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { combineLatest, debounceTime, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; +import { debounceTime } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { - AllActivitiesService, - CriticalAppsService, + ApplicationHealthReportDetailEnriched, 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 { 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, - 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 { 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { IconButtonModule, NoItemsModule, @@ -45,7 +29,7 @@ import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.compo import { ApplicationsLoadingComponent } from "./risk-insights-loading.component"; @Component({ - selector: "tools-all-applications", + selector: "dirt-all-applications", templateUrl: "./all-applications.component.html", imports: [ ApplicationsLoadingComponent, @@ -60,97 +44,44 @@ import { ApplicationsLoadingComponent } from "./risk-insights-loading.component" ], }) export class AllApplicationsComponent implements OnInit { - protected dataSource = - new TableDataSource(); + protected dataSource = new TableDataSource(); protected selectedUrls: Set = new Set(); protected searchControl = new FormControl("", { nonNullable: true }); - protected loading = true; protected organization = new Organization(); noItemsIcon = Security; protected markingAsCritical = false; protected applicationSummary: OrganizationReportSummary = createNewSummaryData(); destroyRef = inject(DestroyRef); - isLoading$: Observable = of(false); - - async ngOnInit() { - const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - - if (organizationId) { - const organization$ = this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(organizationId)); - - combineLatest([ - this.dataService.applications$, - this.criticalAppsService.getAppsListForOrg(organizationId as OrganizationId), - organization$, - ]) - .pipe( - takeUntilDestroyed(this.destroyRef), - map(([applications, criticalApps, organization]) => { - if (applications && applications.length === 0 && criticalApps && criticalApps) { - const criticalUrls = criticalApps.map((ca) => ca.uri); - const data = applications?.map((app) => ({ - ...app, - isMarkedAsCritical: criticalUrls.includes(app.applicationName), - })) as LEGACY_ApplicationHealthReportDetailWithCriticalFlag[]; - return { data, organization }; - } - - return { data: applications, organization }; - }), - switchMap(async ({ data, organization }) => { - if (data && organization) { - const dataWithCiphers = await this.reportService.identifyCiphers( - data, - organization.id as OrganizationId, - ); - - return { - data: dataWithCiphers, - organization, - }; - } - - return { data: [], organization }; - }), - ) - .subscribe(({ data, organization }) => { - if (data) { - this.dataSource.data = data; - this.applicationSummary = this.reportService.generateApplicationsSummary(data); - this.allActivitiesService.setAllAppsReportSummary(this.applicationSummary); - } - if (organization) { - this.organization = organization; - } - }); - - this.isLoading$ = this.dataService.isLoading$; - } - } constructor( - protected cipherService: CipherService, protected i18nService: I18nService, protected activatedRoute: ActivatedRoute, protected toastService: ToastService, - protected configService: ConfigService, protected dataService: RiskInsightsDataService, - protected organizationService: OrganizationService, - protected reportService: RiskInsightsReportService, - private accountService: AccountService, - protected criticalAppsService: CriticalAppsService, - protected riskInsightsEncryptionService: RiskInsightsEncryptionService, - protected allActivitiesService: AllActivitiesService, + // protected allActivitiesService: AllActivitiesService, ) { this.searchControl.valueChanges .pipe(debounceTime(200), takeUntilDestroyed()) .subscribe((v) => (this.dataSource.filter = v)); } + async ngOnInit() { + this.dataService.reportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (report) => { + this.applicationSummary = report?.summaryData ?? createNewSummaryData(); + this.dataSource.data = report?.reportData ?? []; + }, + error: () => { + this.dataSource.data = []; + }, + }); + + // TODO + // this.applicationSummary = this.reportService.generateApplicationsSummary(data); + // this.allActivitiesService.setAllAppsReportSummary(this.applicationSummary); + } + goToCreateNewLoginItem = async () => { // TODO: implement this.toastService.showToast({ @@ -167,41 +98,31 @@ export class AllApplicationsComponent implements OnInit { markAppsAsCritical = async () => { this.markingAsCritical = true; - try { - await this.criticalAppsService.setCriticalApps( - this.organization.id as OrganizationId, - Array.from(this.selectedUrls), - ); - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"), + this.dataService + .saveCriticalApplications(Array.from(this.selectedUrls)) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"), + }); + this.selectedUrls.clear(); + this.markingAsCritical = false; + }, + error: () => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("applicationsMarkedAsCriticalFail"), + }); + }, }); - } finally { - this.selectedUrls.clear(); - this.markingAsCritical = false; - } }; showAppAtRiskMembers = async (applicationName: string) => { - const info = { - members: - this.dataSource.data.find((app) => app.applicationName === applicationName) - ?.atRiskMemberDetails ?? [], - applicationName, - }; - this.dataService.setDrawerForAppAtRiskMembers(info, applicationName); - }; - - showOrgAtRiskMembers = async (invokerId: string) => { - const dialogData = this.reportService.generateAtRiskMemberList(this.dataSource.data); - this.dataService.setDrawerForOrgAtRiskMembers(dialogData, invokerId); - }; - - showOrgAtRiskApps = async (invokerId: string) => { - const data = this.reportService.generateAtRiskApplicationList(this.dataSource.data); - this.dataService.setDrawerForOrgAtRiskApps(data, invokerId); + await this.dataService.setDrawerForAppAtRiskMembers(applicationName); }; onCheckboxChange = (applicationName: string, event: Event) => { 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 01f3b8fb494..e34b13176ee 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 @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; +import { ApplicationHealthReportDetailEnriched } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { MenuModule, TableDataSource, TableModule } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; @@ -14,7 +14,7 @@ import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pip }) export class AppTableRowScrollableComponent { @Input() - dataSource!: TableDataSource; + dataSource!: TableDataSource; @Input() showRowMenuForCriticalApps: boolean = false; @Input() showRowCheckBox: boolean = false; @Input() selectedUrls: Set = new Set(); 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 ed2a6b96524..cfcdf3a1841 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 @@ -49,7 +49,7 @@ type="button" class="tw-flex-1" tabindex="0" - (click)="showOrgAtRiskMembers('criticalAppsAtRiskMembers')" + (click)="dataService.setDrawerForOrgAtRiskMembers('criticalAppsAtRiskMembers')" > }
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 7848d37ea94..0ea273546b5 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 @@ -4,40 +4,33 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, debounceTime, firstValueFrom, map, switchMap } from "rxjs"; +import { debounceTime, EMPTY, map, switchMap } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; import { - AllActivitiesService, + ApplicationHealthReportDetailEnriched, CriticalAppsService, RiskInsightsDataService, RiskInsightsReportService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { - LEGACY_ApplicationHealthReportDetailWithCriticalFlag, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, -} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/password-health"; +import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; import { OrganizationReportSummary } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; -import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { NoItemsModule, SearchModule, TableDataSource, ToastService } from "@bitwarden/components"; import { CardComponent } from "@bitwarden/dirt-card"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; -import { CreateTasksRequest } from "../../vault/services/abstractions/admin-task.abstraction"; import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service"; import { AppTableRowScrollableComponent } from "./app-table-row-scrollable.component"; import { RiskInsightsTabType } from "./risk-insights.component"; +import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service"; @Component({ - selector: "tools-critical-applications", + selector: "dirt-critical-applications", templateUrl: "./critical-applications.component.html", imports: [ CardComponent, @@ -48,63 +41,62 @@ import { RiskInsightsTabType } from "./risk-insights.component"; SharedModule, AppTableRowScrollableComponent, ], - providers: [DefaultAdminTaskService], + providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], }) export class CriticalApplicationsComponent implements OnInit { - protected dataSource = - new TableDataSource(); - protected selectedIds: Set = new Set(); - protected searchControl = new FormControl("", { nonNullable: true }); private destroyRef = inject(DestroyRef); protected loading = false; + protected enableRequestPasswordChange = false; protected organizationId: OrganizationId; - protected applicationSummary = {} as OrganizationReportSummary; noItemsIcon = Security; - enableRequestPasswordChange = false; + + protected dataSource = new TableDataSource(); + protected applicationSummary = {} as OrganizationReportSummary; + + protected selectedIds: Set = new Set(); + protected searchControl = new FormControl("", { nonNullable: true }); + + constructor( + protected activatedRoute: ActivatedRoute, + protected router: Router, + protected toastService: ToastService, + protected dataService: RiskInsightsDataService, + protected criticalAppsService: CriticalAppsService, + protected reportService: RiskInsightsReportService, + protected i18nService: I18nService, + private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + ) { + this.searchControl.valueChanges + .pipe(debounceTime(200), takeUntilDestroyed()) + .subscribe((v) => (this.dataSource.filter = v)); + } async ngOnInit() { - this.organizationId = this.activatedRoute.snapshot.paramMap.get( - "organizationId", - ) as OrganizationId; - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - 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); - this.allActivitiesService.setAllAppsReportDetails(applications); + this.dataService.criticalReportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (criticalReport) => { + this.dataSource.data = criticalReport?.reportData ?? []; + this.applicationSummary = criticalReport?.summaryData ?? createNewSummaryData(); + this.enableRequestPasswordChange = criticalReport?.summaryData?.totalAtRiskMemberCount > 0; + }, + error: () => { + this.dataSource.data = []; + this.applicationSummary = createNewSummaryData(); + this.enableRequestPasswordChange = false; + }, + }); + this.activatedRoute.paramMap + .pipe( + takeUntilDestroyed(this.destroyRef), + map((params) => params.get("organizationId")), + switchMap(async (orgId) => { + if (orgId) { + this.organizationId = orgId as OrganizationId; + } else { + return EMPTY; } - }); - } + }), + ) + .subscribe(); } goToAllAppsTab = async () => { @@ -117,92 +109,35 @@ export class CriticalApplicationsComponent implements OnInit { ); }; - unmarkAsCritical = async (hostname: string) => { - try { - await this.criticalAppsService.dropCriticalApp( - this.organizationId as OrganizationId, - hostname, - ); - } catch { - this.toastService.showToast({ - message: this.i18nService.t("unexpectedError"), - variant: "error", - title: this.i18nService.t("error"), + removeCriticalApplication = async (hostname: string) => { + this.dataService + .removeCriticalApplication(hostname) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + message: this.i18nService.t("criticalApplicationUnmarkedSuccessfully"), + variant: "success", + }); + }, + error: () => { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + }, }); - return; - } - - this.toastService.showToast({ - message: this.i18nService.t("criticalApplicationUnmarkedSuccessfully"), - variant: "success", - }); - this.dataSource.data = this.dataSource.data.filter((app) => app.applicationName !== hostname); }; async requestPasswordChange() { - const apps = this.dataSource.data; - const cipherIds = apps - .filter((_) => _.atRiskPasswordCount > 0) - .flatMap((app) => app.atRiskCipherIds); - - const distinctCipherIds = Array.from(new Set(cipherIds)); - - const tasks: CreateTasksRequest[] = distinctCipherIds.map((cipherId) => ({ - cipherId: cipherId as CipherId, - type: SecurityTaskType.UpdateAtRiskCredential, - })); - - try { - await this.adminTaskService.bulkCreateTasks(this.organizationId as OrganizationId, tasks); - this.toastService.showToast({ - message: this.i18nService.t("notifiedMembers"), - variant: "success", - title: this.i18nService.t("success"), - }); - } catch { - this.toastService.showToast({ - message: this.i18nService.t("unexpectedError"), - variant: "error", - title: this.i18nService.t("error"), - }); - } - } - - constructor( - protected activatedRoute: ActivatedRoute, - protected router: Router, - protected toastService: ToastService, - protected dataService: RiskInsightsDataService, - protected criticalAppsService: CriticalAppsService, - protected reportService: RiskInsightsReportService, - protected i18nService: I18nService, - private configService: ConfigService, - private adminTaskService: DefaultAdminTaskService, - private accountService: AccountService, - private allActivitiesService: AllActivitiesService, - ) { - this.searchControl.valueChanges - .pipe(debounceTime(200), takeUntilDestroyed()) - .subscribe((v) => (this.dataSource.filter = v)); + await this.accessIntelligenceSecurityTasksService.assignTasks( + this.organizationId, + this.dataSource.data, + ); } showAppAtRiskMembers = async (applicationName: string) => { - const data = { - members: - this.dataSource.data.find((app) => app.applicationName === applicationName) - ?.atRiskMemberDetails ?? [], - applicationName, - }; - this.dataService.setDrawerForAppAtRiskMembers(data, applicationName); - }; - - showOrgAtRiskMembers = async (invokerId: string) => { - const data = this.reportService.generateAtRiskMemberList(this.dataSource.data); - this.dataService.setDrawerForOrgAtRiskMembers(data, invokerId); - }; - - showOrgAtRiskApps = async (invokerId: string) => { - const data = this.reportService.generateAtRiskApplicationList(this.dataSource.data); - this.dataService.setDrawerForOrgAtRiskApps(data, invokerId); + await this.dataService.setDrawerForAppAtRiskMembers(applicationName); }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.html new file mode 100644 index 00000000000..f7a5441030e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.html @@ -0,0 +1,71 @@ + + {{ "prioritizeCriticalApplications" | i18n }} +
+
+ + + + + + + + + + @for (app of newApplications; track app) { + + + + + + } + +
+ {{ "application" | i18n }} + + {{ "atRiskItems" | i18n }} +
+ + +
+ + {{ app }} +
+
+
+
+ + + + +
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.ts new file mode 100644 index 00000000000..e06d889c59e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/new-applications-dialog.component.ts @@ -0,0 +1,86 @@ +import { CommonModule } from "@angular/common"; +import { Component, inject } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + DialogModule, + DialogService, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export interface NewApplicationsDialogData { + newApplications: string[]; +} + +@Component({ + templateUrl: "./new-applications-dialog.component.html", + imports: [CommonModule, ButtonModule, DialogModule, TypographyModule, I18nPipe], +}) +export class NewApplicationsDialogComponent { + protected newApplications: string[] = []; + protected selectedApplications: Set = new Set(); + + private toastService = inject(ToastService); + private i18nService = inject(I18nService); + + /** + * Opens the new applications dialog + * @param dialogService The dialog service instance + * @param data Dialog data containing the list of new applications + * @returns Dialog reference + */ + static open(dialogService: DialogService, data: NewApplicationsDialogData) { + const ref = dialogService.open( + NewApplicationsDialogComponent, + { + data, + }, + ); + + // Set the component's data after opening + const instance = ref.componentInstance as NewApplicationsDialogComponent; + if (instance) { + instance.newApplications = data.newApplications; + } + + return ref; + } + + /** + * Toggles the selection state of an application. + * @param applicationName The application to toggle + */ + toggleSelection = (applicationName: string) => { + if (this.selectedApplications.has(applicationName)) { + this.selectedApplications.delete(applicationName); + } else { + this.selectedApplications.add(applicationName); + } + }; + + /** + * Checks if an application is currently selected. + * @param applicationName The application to check + * @returns True if selected, false otherwise + */ + isSelected = (applicationName: string): boolean => { + return this.selectedApplications.has(applicationName); + }; + + /** + * Placeholder handler for mark as critical functionality. + * Shows a toast notification with count of selected applications. + * TODO: Implement actual mark as critical functionality (PM-26203 follow-up) + */ + onMarkAsCritical = () => { + const selectedCount = this.selectedApplications.size; + this.toastService.showToast({ + variant: "info", + title: this.i18nService.t("markAsCritical"), + message: `${selectedCount} ${this.i18nService.t("applicationsSelected")}`, + }); + }; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts index af61c9a35c8..1d18ca3a030 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts @@ -4,7 +4,7 @@ import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @Component({ - selector: "tools-risk-insights-loading", + selector: "dirt-risk-insights-loading", imports: [CommonModule, JslibModule], templateUrl: "./risk-insights-loading.component.html", }) 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 50af2c9e9a7..49ccfb73c5d 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 @@ -4,19 +4,23 @@ {{ "reviewAtRiskPasswords" | i18n }}
- {{ - "dataLastUpdated" | i18n: (dataLastUpdated$ | async | date: "MMMM d, y 'at' h:mm a") - }} - + @if (dataLastUpdated) { + {{ + "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") + }} + } @else { + {{ "noReportRan" | i18n }} + } + @let isRunningReport = dataService.isRunningReport$ | async; + @@ -38,18 +42,21 @@ @if (isRiskInsightsActivityTabFeatureEnabled) { - + } - + - {{ "criticalApplicationsWithCount" | i18n: (criticalApps$ | async)?.length ?? 0 }} + {{ + "criticalApplicationsWithCount" + | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 + }} - + @@ -69,7 +76,9 @@ }}
-
{{ "email" | i18n }}
+
+ {{ "email" | i18n }} +
{{ "atRiskPasswords" | i18n }}
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 208ba59fb9d..308cc351dc3 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 @@ -2,21 +2,12 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { EMPTY, firstValueFrom, Observable } from "rxjs"; +import { EMPTY } from "rxjs"; import { map, switchMap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { - CriticalAppsService, - RiskInsightsDataService, -} from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { PasswordHealthReportApplicationsResponse } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/api-models.types"; -import { - ApplicationHealthReportDetail, - DrawerType, -} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -67,19 +58,13 @@ export class RiskInsightsComponent implements OnInit { tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps; isRiskInsightsActivityTabFeatureEnabled: boolean = false; - dataLastUpdated: Date = new Date(); - - criticalApps$: Observable = new Observable(); - appsCount: number = 0; - criticalAppsCount: number = 0; - notifiedMembersCount: number = 0; + // Leaving this commented because it's not used but seems important + // notifiedMembersCount: number = 0; private organizationId: OrganizationId = "" as OrganizationId; - isLoading$: Observable = new Observable(); - isRefreshing$: Observable = new Observable(); - dataLastUpdated$: Observable = new Observable(); + dataLastUpdated: Date | null = null; refetching: boolean = false; constructor( @@ -87,8 +72,6 @@ export class RiskInsightsComponent implements OnInit { private router: Router, private configService: ConfigService, protected dataService: RiskInsightsDataService, - private criticalAppsService: CriticalAppsService, - private accountService: AccountService, ) { this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; @@ -104,39 +87,29 @@ export class RiskInsightsComponent implements OnInit { } async ngOnInit() { - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - this.route.paramMap .pipe( takeUntilDestroyed(this.destroyRef), map((params) => params.get("organizationId")), - switchMap((orgId) => { + switchMap(async (orgId) => { if (orgId) { + // Initialize Data Service + await this.dataService.initializeForOrganization(orgId as OrganizationId); + this.organizationId = orgId as OrganizationId; - this.dataService.fetchApplicationsReport(this.organizationId); - this.isLoading$ = this.dataService.isLoading$; - this.isRefreshing$ = this.dataService.isRefreshing$; - this.dataLastUpdated$ = this.dataService.dataLastUpdated$; - return this.dataService.applications$; } else { return EMPTY; } }), ) - .subscribe({ - next: (applications: ApplicationHealthReportDetail[] | null) => { - if (applications) { - this.appsCount = applications.length; - } + .subscribe(); - this.criticalAppsService.loadOrganizationContext( - this.organizationId as OrganizationId, - userId, - ); - this.criticalApps$ = this.criticalAppsService.getAppsListForOrg( - this.organizationId as OrganizationId, - ); - }, + // Subscribe to report result details + this.dataService.reportResults$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((report) => { + this.appsCount = report?.reportData.length ?? 0; + this.dataLastUpdated = report?.creationDate ?? null; }); // Subscribe to drawer state changes @@ -156,7 +129,7 @@ export class RiskInsightsComponent implements OnInit { */ refreshData(): void { if (this.organizationId) { - this.dataService.refreshApplicationsReport(this.organizationId); + this.dataService.triggerReport(); } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts new file mode 100644 index 00000000000..520164c80e7 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts @@ -0,0 +1,119 @@ +import { TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; + +import { + AllActivitiesService, + LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; +import { ToastService } from "@bitwarden/components"; + +import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; + +import { AccessIntelligenceSecurityTasksService } from "./security-tasks.service"; + +describe("AccessIntelligenceSecurityTasksService", () => { + let service: AccessIntelligenceSecurityTasksService; + const defaultAdminTaskServiceSpy = mock(); + const allActivitiesServiceSpy = mock(); + const toastServiceSpy = mock(); + const i18nServiceSpy = mock(); + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = new AccessIntelligenceSecurityTasksService( + allActivitiesServiceSpy, + defaultAdminTaskServiceSpy, + toastServiceSpy, + i18nServiceSpy, + ); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + describe("assignTasks", () => { + it("should call requestPasswordChange and setTaskCreatedCount", async () => { + const organizationId = "org-1" as OrganizationId; + const apps = [ + { + atRiskPasswordCount: 1, + atRiskCipherIds: ["cid1"], + } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ]; + const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2); + await service.assignTasks(organizationId, apps); + expect(spy).toHaveBeenCalledWith(organizationId, apps); + expect(allActivitiesServiceSpy.setTaskCreatedCount).toHaveBeenCalledWith(2); + }); + }); + + describe("requestPasswordChange", () => { + it("should create tasks for distinct cipher ids and show success toast", async () => { + const organizationId = "org-2" as OrganizationId; + const apps = [ + { + atRiskPasswordCount: 2, + atRiskCipherIds: ["cid1", "cid2"], + } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + { + atRiskPasswordCount: 1, + atRiskCipherIds: ["cid2"], + } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ]; + defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined); + i18nServiceSpy.t.mockImplementation((key) => key); + + const result = await service.requestPasswordChange(organizationId, apps); + + expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, [ + { cipherId: "cid1", type: SecurityTaskType.UpdateAtRiskCredential }, + { cipherId: "cid2", type: SecurityTaskType.UpdateAtRiskCredential }, + ]); + expect(toastServiceSpy.showToast).toHaveBeenCalledWith({ + message: "notifiedMembers", + variant: "success", + title: "success", + }); + expect(result).toBe(2); + }); + + it("should show error toast and return 0 if bulkCreateTasks throws", async () => { + const organizationId = "org-3" as OrganizationId; + const apps = [ + { + atRiskPasswordCount: 1, + atRiskCipherIds: ["cid3"], + } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ]; + defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail")); + i18nServiceSpy.t.mockImplementation((key) => key); + + const result = await service.requestPasswordChange(organizationId, apps); + + expect(toastServiceSpy.showToast).toHaveBeenCalledWith({ + message: "unexpectedError", + variant: "error", + title: "error", + }); + expect(result).toBe(0); + }); + + it("should not create any tasks if no apps have atRiskPasswordCount > 0", async () => { + const organizationId = "org-4" as OrganizationId; + const apps = [ + { + atRiskPasswordCount: 0, + atRiskCipherIds: ["cid4"], + } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ]; + const result = await service.requestPasswordChange(organizationId, apps); + + expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []); + expect(result).toBe(0); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts new file mode 100644 index 00000000000..c5610c638b0 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from "@angular/core"; + +import { + AllActivitiesService, + LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { SecurityTaskType } from "@bitwarden/common/vault/tasks"; +import { ToastService } from "@bitwarden/components"; + +import { CreateTasksRequest } from "../../../vault/services/abstractions/admin-task.abstraction"; +import { DefaultAdminTaskService } from "../../../vault/services/default-admin-task.service"; + +@Injectable() +export class AccessIntelligenceSecurityTasksService { + constructor( + private allActivitiesService: AllActivitiesService, + private adminTaskService: DefaultAdminTaskService, + private toastService: ToastService, + private i18nService: I18nService, + ) {} + async assignTasks( + organizationId: OrganizationId, + apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], + ) { + const taskCount = await this.requestPasswordChange(organizationId, apps); + this.allActivitiesService.setTaskCreatedCount(taskCount); + } + + // TODO: this method is shared between here and critical-applications.component.ts + async requestPasswordChange( + organizationId: OrganizationId, + apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], + ): Promise { + const cipherIds = apps + .filter((_) => _.atRiskPasswordCount > 0) + .flatMap((app) => app.atRiskCipherIds); + + const distinctCipherIds = Array.from(new Set(cipherIds)); + + const tasks: CreateTasksRequest[] = distinctCipherIds.map((cipherId) => ({ + cipherId: cipherId as CipherId, + type: SecurityTaskType.UpdateAtRiskCredential, + })); + + try { + await this.adminTaskService.bulkCreateTasks(organizationId, tasks); + this.toastService.showToast({ + message: this.i18nService.t("notifiedMembers"), + variant: "success", + title: this.i18nService.t("success"), + }); + + return tasks.length; + } catch { + this.toastService.showToast({ + message: this.i18nService.t("unexpectedError"), + variant: "error", + title: this.i18nService.t("error"), + }); + } + + return 0; + } +} 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 74c39613502..8beaae7f10a 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 @@ -5,6 +5,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 { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; 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"; @@ -29,6 +30,7 @@ describe("IntegrationCardComponent", () => { const mockI18nService = mock(); const activatedRoute = mock(); const mockIntegrationService = mock(); + const mockDatadogIntegrationService = mock(); const dialogService = mock(); const toastService = mock(); @@ -53,6 +55,7 @@ describe("IntegrationCardComponent", () => { { provide: I18nService, useValue: mockI18nService }, { provide: ActivatedRoute, useValue: activatedRoute }, { provide: HecOrganizationIntegrationService, useValue: mockIntegrationService }, + { provide: DatadogOrganizationIntegrationService, useValue: mockDatadogIntegrationService }, { provide: ToastService, useValue: toastService }, { provide: DialogService, useValue: dialogService }, ], 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 091de63d7a1..3a243f8eb91 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 @@ -13,17 +13,22 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; 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 { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; +import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { HecConnectDialogResult, + DatadogConnectDialogResult, HecConnectDialogResultStatus, + DatadogConnectDialogResultStatus, + openDatadogConnectDialog, openHecConnectDialog, } from "../integration-dialog/index"; @@ -64,6 +69,7 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { private dialogService: DialogService, private activatedRoute: ActivatedRoute, private hecOrganizationIntegrationService: HecOrganizationIntegrationService, + private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService, private toastService: ToastService, private i18nService: I18nService, ) { @@ -131,42 +137,87 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async setupConnection() { - // invoke the dialog to connect the integration - const dialog = openHecConnectDialog(this.dialogService, { - data: { - settings: this.integrationSettings, - }, - }); + let dialog: DialogRef; - const result = await lastValueFrom(dialog.closed); - - // the dialog was cancelled - if (!result || !result.success) { + if (this.integrationSettings?.integrationType === null) { return; } - try { - if (result.success === HecConnectDialogResultStatus.Delete) { - await this.deleteHec(); - } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("failedToDeleteIntegration"), + if (this.integrationSettings?.integrationType === OrganizationIntegrationType.Datadog) { + dialog = openDatadogConnectDialog(this.dialogService, { + data: { + settings: this.integrationSettings, + }, }); - } - try { - if (result.success === HecConnectDialogResultStatus.Edited) { - await this.saveHec(result); + const result = await lastValueFrom(dialog.closed); + + // the dialog was cancelled + if (!result || !result.success) { + return; } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("failedToSaveIntegration"), + + try { + if (result.success === HecConnectDialogResultStatus.Delete) { + await this.deleteDatadog(); + } + } catch { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("failedToDeleteIntegration"), + }); + } + + try { + if (result.success === DatadogConnectDialogResultStatus.Edited) { + await this.saveDatadog(result as DatadogConnectDialogResult); + } + } catch { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("failedToSaveIntegration"), + }); + } + } else { + // invoke the dialog to connect the integration + dialog = openHecConnectDialog(this.dialogService, { + data: { + settings: this.integrationSettings, + }, }); + + const result = await lastValueFrom(dialog.closed); + + // the dialog was cancelled + if (!result || !result.success) { + return; + } + + try { + if (result.success === HecConnectDialogResultStatus.Delete) { + await this.deleteHec(); + } + } catch { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("failedToDeleteIntegration"), + }); + } + + try { + if (result.success === HecConnectDialogResultStatus.Edited) { + await this.saveHec(result as HecConnectDialogResult); + } + } catch { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("failedToSaveIntegration"), + }); + } } } @@ -242,6 +293,69 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { }); } + async saveDatadog(result: DatadogConnectDialogResult) { + if (this.isUpdateAvailable) { + // retrieve org integration and configuration ids + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); + } + + // update existing integration and configuration + await this.datadogOrganizationIntegrationService.updateDatadog( + this.organizationId, + orgIntegrationId, + orgIntegrationConfigurationId, + this.integrationSettings.name as OrganizationIntegrationServiceType, + result.url, + result.apiKey, + ); + } else { + // create new integration and configuration + await this.datadogOrganizationIntegrationService.saveDatadog( + this.organizationId, + this.integrationSettings.name as OrganizationIntegrationServiceType, + result.url, + result.apiKey, + ); + } + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("success"), + }); + } + + async deleteDatadog() { + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); + } + + const response = await this.datadogOrganizationIntegrationService.deleteDatadog( + 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", diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html new file mode 100644 index 00000000000..c129216b694 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html @@ -0,0 +1,58 @@ +
+ + + {{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }} + +
+ @if (loading) { + + + + } + @if (!loading) { + + + {{ "url" | i18n }} + + + + + {{ "apiKey" | i18n }} + + {{ "apiKey" | i18n }} + + + } +
+ + + + + @if (canDelete) { +
+ +
+ } +
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts new file mode 100644 index 00000000000..7298087e7e4 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts @@ -0,0 +1,171 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { IntegrationType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { + ConnectDatadogDialogComponent, + DatadogConnectDialogParams, + DatadogConnectDialogResult, + DatadogConnectDialogResultStatus, + openDatadogConnectDialog, +} from "./connect-dialog-datadog.component"; + +beforeAll(() => { + // Mock element.animate for jsdom + // the animate function is not available in jsdom, so we provide a mock implementation + // This is necessary for tests that rely on animations + // This mock does not perform any actual animations, it just provides a structure that allows tests + // to run without throwing errors related to missing animate function + if (!HTMLElement.prototype.animate) { + HTMLElement.prototype.animate = function () { + return { + play: () => {}, + pause: () => {}, + finish: () => {}, + cancel: () => {}, + reverse: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + onfinish: null, + oncancel: null, + startTime: 0, + currentTime: 0, + playbackRate: 1, + playState: "idle", + replaceState: "active", + effect: null, + finished: Promise.resolve(), + id: "", + remove: () => {}, + timeline: null, + ready: Promise.resolve(), + } as unknown as Animation; + }; + } +}); + +describe("ConnectDialogDatadogComponent", () => { + let component: ConnectDatadogDialogComponent; + let fixture: ComponentFixture; + let dialogRefMock = mock>(); + const mockI18nService = mock(); + + const integrationMock: Integration = { + name: "Test Integration", + image: "test-image.png", + linkURL: "https://example.com", + imageDarkMode: "test-image-dark.png", + newBadgeExpiration: "2024-12-31", + description: "Test Description", + canSetupConnection: true, + type: IntegrationType.EVENT, + } as Integration; + const connectInfo: DatadogConnectDialogParams = { + settings: integrationMock, // Provide appropriate mock template if needed + }; + + beforeEach(async () => { + dialogRefMock = mock>(); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: connectInfo }, + { provide: DialogRef, useValue: dialogRefMock }, + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConnectDatadogDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + mockI18nService.t.mockImplementation((key) => key); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize form with empty values", () => { + expect(component.formGroup.value).toEqual({ + url: "", + apiKey: "", + service: "Test Integration", + }); + }); + + it("should have required validators for all fields", () => { + component.formGroup.setValue({ url: "", apiKey: "", service: "" }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://test.com", + apiKey: "token", + service: "Test Service", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should test url is at least 7 characters long", () => { + component.formGroup.setValue({ + url: "test", + apiKey: "token", + service: "Test Service", + }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://test.com", + apiKey: "token", + service: "Test Service", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should call dialogRef.close with correct result on submit", async () => { + component.formGroup.setValue({ + url: "https://test.com", + apiKey: "token", + service: "Test Service", + }); + + await component.submit(); + + expect(dialogRefMock.close).toHaveBeenCalledWith({ + integrationSettings: integrationMock, + url: "https://test.com", + apiKey: "token", + service: "Test Service", + success: DatadogConnectDialogResultStatus.Edited, + }); + }); +}); + +describe("openDatadogConnectDialog", () => { + it("should call dialogService.open with correct params", () => { + const dialogServiceMock = mock(); + const config: DialogConfig< + DatadogConnectDialogParams, + DialogRef + > = { + data: { settings: { name: "Test" } as Integration }, + } as any; + + openDatadogConnectDialog(dialogServiceMock, config); + + expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectDatadogDialogComponent, config); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts new file mode 100644 index 00000000000..d186910d2f7 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts @@ -0,0 +1,121 @@ +import { Component, Inject, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; + +import { DatadogConfiguration } from "@bitwarden/bit-common/dirt/organization-integrations/models/configuration/datadog-configuration"; +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +export type DatadogConnectDialogParams = { + settings: Integration; +}; + +export interface DatadogConnectDialogResult { + integrationSettings: Integration; + url: string; + apiKey: string; + service: string; + success: DatadogConnectDialogResultStatusType | null; +} + +export const DatadogConnectDialogResultStatus = { + Edited: "edit", + Delete: "delete", +} as const; + +export type DatadogConnectDialogResultStatusType = + (typeof DatadogConnectDialogResultStatus)[keyof typeof DatadogConnectDialogResultStatus]; + +@Component({ + templateUrl: "./connect-dialog-datadog.component.html", + imports: [SharedModule], +}) +export class ConnectDatadogDialogComponent implements OnInit { + loading = false; + datadogConfig: DatadogConfiguration | null = null; + hecTemplate: HecTemplate | null = null; + formGroup = this.formBuilder.group({ + url: ["", [Validators.required, Validators.minLength(7)]], + apiKey: ["", Validators.required], + service: ["", Validators.required], + }); + + constructor( + @Inject(DIALOG_DATA) protected connectInfo: DatadogConnectDialogParams, + protected formBuilder: FormBuilder, + private dialogRef: DialogRef, + private dialogService: DialogService, + ) {} + + ngOnInit(): void { + this.datadogConfig = + this.connectInfo.settings.organizationIntegration?.getConfiguration() ?? + null; + this.hecTemplate = + this.connectInfo.settings.organizationIntegration?.integrationConfiguration?.[0]?.getTemplate() ?? + null; + + this.formGroup.patchValue({ + url: this.datadogConfig?.uri || "", + apiKey: this.datadogConfig?.apiKey || "", + service: this.connectInfo.settings.name, + }); + } + + get isUpdateAvailable(): boolean { + return !!this.datadogConfig; + } + + get canDelete(): boolean { + return !!this.datadogConfig; + } + + submit = async (): Promise => { + if (this.formGroup.invalid) { + this.formGroup.markAllAsTouched(); + return; + } + const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Edited); + + this.dialogRef.close(result); + + return; + }; + + delete = async (): Promise => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: "deleteItemConfirmation", + }, + type: "warning", + }); + + if (confirmed) { + const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Delete); + this.dialogRef.close(result); + } + }; + + private getDatadogConnectDialogResult( + status: DatadogConnectDialogResultStatusType, + ): DatadogConnectDialogResult { + const formJson = this.formGroup.getRawValue(); + + return { + integrationSettings: this.connectInfo.settings, + url: formJson.url || "", + apiKey: formJson.apiKey || "", + service: formJson.service || "", + success: status, + }; + } +} + +export function openDatadogConnectDialog( + dialogService: DialogService, + config: DialogConfig>, +) { + return dialogService.open(ConnectDatadogDialogComponent, config); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts index 8c4891b9aa8..9852f3fe5c8 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts @@ -1 +1,2 @@ export * from "./connect-dialog/connect-dialog-hec.component"; +export * from "./connect-dialog/connect-dialog-datadog.component"; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts index f0b371703f6..2908fe0c089 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.spec.ts @@ -6,6 +6,7 @@ import { of } from "rxjs"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; import { IntegrationType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -24,6 +25,7 @@ describe("IntegrationGridComponent", () => { let fixture: ComponentFixture; const mockActivatedRoute = mock(); const mockIntegrationService = mock(); + const mockDatadogIntegrationService = mock(); const integrations: Integration[] = [ { name: "Integration 1", @@ -70,6 +72,7 @@ describe("IntegrationGridComponent", () => { useValue: mockActivatedRoute, }, { provide: HecOrganizationIntegrationService, useValue: mockIntegrationService }, + { provide: DatadogOrganizationIntegrationService, useValue: mockDatadogIntegrationService }, { provide: ToastService, useValue: mock(), 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 c249bf42282..539da9b31b1 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 @@ -4,6 +4,8 @@ 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 { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; +import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -226,6 +228,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { // Sets the organization ID which also loads the integrations$ this.organization$.pipe(takeUntil(this.destroy$)).subscribe((org) => { this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id); + this.datadogOrganizationIntegrationService.setOrganizationIntegrations(org.id); }); // For all existing event based configurations loop through and assign the @@ -253,6 +256,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { private accountService: AccountService, private configService: ConfigService, private hecOrganizationIntegrationService: HecOrganizationIntegrationService, + private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService, ) { this.configService .getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations) @@ -270,10 +274,62 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { type: IntegrationType.EVENT, description: "crowdstrikeEventIntegrationDesc", canSetupConnection: true, + integrationType: 5, // Assuming 5 corresponds to CrowdStrike in OrganizationIntegrationType }; this.integrationsList.push(crowdstrikeIntegration); + + const datadogIntegration: Integration = { + name: OrganizationIntegrationServiceType.Datadog, + // TODO: Update link when help article is published + linkURL: "", + image: "../../../../../../../images/integrations/logo-datadog-color.svg", + type: IntegrationType.EVENT, + description: "datadogEventIntegrationDesc", + canSetupConnection: true, + integrationType: 6, // Assuming 6 corresponds to Datadog in OrganizationIntegrationType + }; + + this.integrationsList.push(datadogIntegration); } + + // 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) => { + if (i.integrationType === OrganizationIntegrationType.Hec) { + i.organizationIntegration = null; + } + }); + + integrations.map((integration) => { + const item = this.integrationsList.find((i) => i.name === integration.serviceType); + if (item) { + item.organizationIntegration = integration; + } + }); + }); + + this.datadogOrganizationIntegrationService.integrations$ + .pipe(takeUntil(this.destroy$)) + .subscribe((integrations) => { + // reset all integrations to null first - in case one was deleted + this.integrationsList.forEach((i) => { + if (i.integrationType === OrganizationIntegrationType.Datadog) { + 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/dirt/organization-integrations/organization-integrations.module.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts index a8e0899f26d..e3c37b4a42b 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/organization-integrations.module.ts @@ -1,5 +1,6 @@ import { NgModule } from "@angular/core"; +import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service"; import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service"; @@ -12,6 +13,11 @@ import { OrganizationIntegrationsRoutingModule } from "./organization-integratio @NgModule({ imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule], providers: [ + safeProvider({ + provide: DatadogOrganizationIntegrationService, + useClass: DatadogOrganizationIntegrationService, + deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService], + }), safeProvider({ provide: HecOrganizationIntegrationService, useClass: HecOrganizationIntegrationService, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts index bd105fc21e2..43d512439f0 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.spec.ts @@ -9,6 +9,7 @@ import {} from "@bitwarden/web-vault/app/shared"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; +import { DatadogOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/datadog-organization-integration-service"; import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ThemeType } from "@bitwarden/common/platform/enums"; @@ -37,6 +38,7 @@ class MockNewMenuComponent {} describe("IntegrationsComponent", () => { let fixture: ComponentFixture; const hecOrgIntegrationSvc = mock(); + const datadogOrgIntegrationSvc = mock(); const activatedRouteMock = { snapshot: { paramMap: { get: jest.fn() } }, @@ -55,6 +57,7 @@ describe("IntegrationsComponent", () => { { provide: I18nPipe, useValue: mock() }, { provide: I18nService, useValue: mockI18nService }, { provide: HecOrganizationIntegrationService, useValue: hecOrgIntegrationSvc }, + { provide: DatadogOrganizationIntegrationService, useValue: datadogOrgIntegrationSvc }, ], }).compileComponents(); fixture = TestBed.createComponent(IntegrationsComponent); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts index 13f80920558..ec7397a22a8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts @@ -41,7 +41,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy { return convertToAccessPolicyItemViews(policies); }), ), - catchError(async () => { + catchError(async (): Promise => { this.logService.info("Error fetching project people access policies."); await this.router.navigate(["/sm", this.organizationId, "projects"]); return undefined; diff --git a/bitwarden_license/bit-web/webpack.config.js b/bitwarden_license/bit-web/webpack.config.js index ce5f0075afc..37e0a0c5e03 100644 --- a/bitwarden_license/bit-web/webpack.config.js +++ b/bitwarden_license/bit-web/webpack.config.js @@ -1,12 +1,10 @@ -const { AngularWebpackPlugin } = require("@ngtools/webpack"); +const { buildConfig } = require("../../apps/web/webpack.base"); -const webpackConfig = require("../../apps/web/webpack.config"); - -webpackConfig.entry["app/main"] = "../../bitwarden_license/bit-web/src/main.ts"; -webpackConfig.plugins[webpackConfig.plugins.length - 1] = new AngularWebpackPlugin({ - tsconfig: "../../bitwarden_license/bit-web/tsconfig.build.json", - entryModule: "bitwarden_license/src/app/app.module#AppModule", - sourceMap: true, +module.exports = buildConfig({ + configName: "Commercial", + app: { + entry: "../../bitwarden_license/bit-web/src/main.ts", + entryModule: "../../bitwarden_license/bit-web/src/app/app.module#AppModule", + }, + tsConfig: "../../bitwarden_license/bit-web/tsconfig.build.json", }); - -module.exports = webpackConfig; diff --git a/docs/using-nx-to-build-projects.md b/docs/using-nx-to-build-projects.md new file mode 100644 index 00000000000..f1fd54e1c20 --- /dev/null +++ b/docs/using-nx-to-build-projects.md @@ -0,0 +1,208 @@ +# Using Nx to Build Projects + +Bitwarden uses [Nx](https://nx.dev/) to make building projects from the monorepo easier. To build, lint, or test a project you'll want to reference the project's `project.json` file for availible commands and their names. Then you'll run `npx nx [your_command] [your_project] [your_options]`. Run `npx nx --help` to see availible options, there are many. + +Please note: the Nx implementation is a work in progress. Not all apps support Nx yet, CI still uses the old npm builds, and we have many "legacy" libraries that use hacks to get them into the Nx project graph. + +## Quick Start + +### Basic Commands + +```bash +# Build a project +npx nx build cli +npx nx build state # Modern libs and apps have simple, all lowercase target names +npx nx build @bitwarden/common # Legacy libs have a special naming convention and include the @bitwarden prefix + +# Test a project +npx nx test cli + +# Lint a project +npx nx lint cli + +# Serve/watch a project (for projects with serve targets) +npx nx serve cli + +# Build all projects that differ from origin/main +nx affected --target=build --base=origin/main + +# Build, lint, and test every project at once +npx nx run-many --target=build,test,lint --all + +# Most projects default to the "oss-dev" build, so if you need the bitwarden license build add a --configuration +npx nx build cli --configuration=commercial-dev + +# If you need a production build drop the "dev" suffix +npx nx build cli --configuration=oss # or "commercial" + +# Configurations can also be passed to run-many +# For example: to run all Bitwarden licensed builds +npx nx run-many --target=build,test,lint --all --configuration=commercial + +# Outputs are distrubuted in a root level /dist/ folder + +# Run a locally built CLI +node dist/apps/cli/oss-dev/bw.js +``` + +### Global Commands + +```bash +# See all projects +npx nx show projects + +# Run affected projects only (great for local dev and CI) +npx nx affected:build +npx nx affected:test +npx nx affected:lint + +# Show dependency graph +npx nx dep-graph +``` + +## Library Projects + +Our libraries use two different Nx integration patterns depending on their migration status. + +### Legacy Libraries + +Most existing libraries use a facade pattern where `project.json` delegates to existing npm scripts. This approach maintains backward compatibility with the build methods we used before introducing Nx. These libraries are considered tech debt and Platform has a focus on updating them. For an example reference `libs/common/project.json`. + +These libraries use `nx:run-script` executor to call existing npm scripts: + +```json +{ + "targets": { + "build": { + "executor": "nx:run-script", + "options": { + "script": "build" + } + } + } +} +``` + +#### Available Commands for Legacy Libraries + +All legacy libraries support these standardized commands: + +- **`nx build `** - Build the library +- **`nx build:watch `** - Build and watch for changes +- **`nx clean `** - Clean build artifacts +- **`nx test `** - Run tests +- **`nx lint `** - Run linting + +### Modern Libraries + +Newer libraries like `libs/state` use native Nx executors for better performance and caching. + +```json +{ + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/state" + } + } + } +} +``` + +## What Happens When You Run An Nx Command + +```mermaid +flowchart TD + Start([You just ran an nx command]) --> ParseCmd[Nx parses command args] + ParseCmd --> ReadWorkspace[Nx reads nx.json, workspace configuration, cache settings, and plugins] + ReadWorkspace --> ReadProject[Nx reads project.json, finds the target configuration, and checks executor to use] + ReadProject --> CheckCache{Nx checks the cache: has this exact build been done before?} + + CheckCache -->|Cache hit| UseCached[Nx uses cached outputs, copies from .nx/cache, and skips execution] + UseCached --> Done([Your command is done]) + + CheckCache -->|Cache miss| DetermineExecutor{Which executor is configured?} + + DetermineExecutor -->|nx:run-script| FacadePattern[Legacy Facade Pattern] + DetermineExecutor -->|nx/webpack:webpack| WebpackExecutor[Webpack Executor] + DetermineExecutor -->|nx/js:tsc| TypeScriptExecutor[TypeScript Executor] + DetermineExecutor -->|nx/jest:jest| JestExecutor[Jest Executor] + DetermineExecutor -->|nx/eslint:lint| ESLintExecutor[ESLint Executor] + + %% Facade Pattern Flow + FacadePattern --> ReadPackageJson[The run-script executor finds npm script to run in package.json] + ReadPackageJson --> RunNpmScript[Npm script is executed] + RunNpmScript --> NpmDelegates{What does the npm script do?} + + NpmDelegates -->|TypeScript| TSCompile[TypeScript compiles to JavaScript using tsconfig.json] + NpmDelegates -->|Webpack| WebpackBuild[Webpack bundles and optimizes code] + NpmDelegates -->|Jest| JestTest[Jest executes unit tests] + + TSCompile --> FacadeOutput[Outputs written to libs/LIB/dist/] + WebpackBuild --> FacadeOutput + JestTest --> FacadeOutput + FacadeOutput --> CacheResults1[Nx caches results in .nx/cache/] + + %% Webpack Executor Flow + WebpackExecutor --> ReadWebpackConfig[Webpack config read from apps/cli/webpack.config.js or bit-cli/webpack.config.js] + ReadWebpackConfig --> ConfigureWebpack[Webpack configured with entry points, TypeScript paths, and plugins] + ConfigureWebpack --> WebpackProcess[Webpack resolves paths, compiles TypeScript, bundles dependencies, and applies optimizations] + WebpackProcess --> WebpackOutput[Single executable bundle written to dist/apps/cli/] + WebpackOutput --> CacheResults2[Nx caches results in .nx/cache/] + + %% TypeScript Executor Flow + TypeScriptExecutor --> ReadTSConfig[TypeScript reads tsconfig.lib.json compilation options] + ReadTSConfig --> TSProcess[TypeScript performs type checking, emits declarations, and compiles to JavaScript] + TSProcess --> TSOutput[Outputs written to dist/libs/LIB/] + TSOutput --> CacheResults3[Nx caches results in .nx/cache/] + + %% Jest Executor Flow + JestExecutor --> ReadJestConfig[Jest reads jest.config.js test configuration] + ReadJestConfig --> JestProcess[Jest finds test files, runs suites, and generates coverage] + JestProcess --> JestOutput[Test results and coverage reports output] + JestOutput --> CacheResults4[Nx caches results in .nx/cache/] + + %% ESLint Executor Flow + ESLintExecutor --> ReadESLintConfig[ESLint reads .eslintrc.json rules and configuration] + ReadESLintConfig --> ESLintProcess[ESLint checks code style, finds issues, and applies auto-fixes] + ESLintProcess --> ESLintOutput[Lint results with errors and warnings output] + ESLintOutput --> CacheResults5[Nx caches results in .nx/cache/] + + %% All paths converge + CacheResults1 --> UpdateGraph[Dependency graph updated to track project relationships] + CacheResults2 --> UpdateGraph + CacheResults3 --> UpdateGraph + CacheResults4 --> UpdateGraph + CacheResults5 --> UpdateGraph + + UpdateGraph --> Done +``` + +## Caching and Performance + +### Nx Caching + +Nx automatically caches build outputs and only rebuilds what changed: + +```bash +# First run builds everything +npx nx build cli + +# Second run uses cache (much faster) +npx nx build cli +``` + +### Clearing Cache + +```bash +# Clear all caches +npx nx reset +``` + +## Additional Resources + +- [Nx Documentation](https://nx.dev/getting-started/intro) +- [Nx CLI Reference](https://nx.dev/packages/nx/documents/cli) +- [Nx Workspace Configuration](https://nx.dev/reference/project-configuration) diff --git a/eslint.config.mjs b/eslint.config.mjs index 0a7b8e59c75..2750c3e11d0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -336,6 +336,7 @@ export default tseslint.config( "mfaType.*", "filter.*", // Temporary until filters are migrated "tw-app-region*", // Custom utility for native passkey modals + "tw-@container", ], }, ], diff --git a/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.html b/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.html new file mode 100644 index 00000000000..1fe0f18ceb7 --- /dev/null +++ b/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.html @@ -0,0 +1,20 @@ +
+ +

{{ "readingPasskeyLoading" | i18n }}

+ +
+ + +

{{ "passkeyAuthenticationFailed" | i18n }}

+ +
+ +

+ {{ "troubleLoggingIn" | i18n }}
+ {{ "useADifferentLogInMethod" | i18n }} +

+
diff --git a/libs/angular/src/auth/components/base-login-via-webauthn.component.ts b/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts similarity index 62% rename from libs/angular/src/auth/components/base-login-via-webauthn.component.ts rename to libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts index 53e29d4d940..f795b66d916 100644 --- a/libs/angular/src/auth/components/base-login-via-webauthn.component.ts +++ b/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts @@ -1,27 +1,69 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Directive, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { Router, RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + TwoFactorAuthSecurityKeyIcon, + TwoFactorAuthSecurityKeyFailedIcon, +} from "@bitwarden/assets/svg"; // 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 { LoginSuccessHandlerService } from "@bitwarden/auth/common"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view"; +import { ClientType } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { + AnonLayoutWrapperDataService, + ButtonModule, + IconModule, + LinkModule, + TypographyModule, +} from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; export type State = "assert" | "assertFailed"; - -@Directive() -export class BaseLoginViaWebAuthnComponent implements OnInit { +@Component({ + selector: "app-login-via-webauthn", + templateUrl: "login-via-webauthn.component.html", + standalone: true, + imports: [ + CommonModule, + RouterModule, + JslibModule, + ButtonModule, + IconModule, + LinkModule, + TypographyModule, + ], +}) +export class LoginViaWebAuthnComponent implements OnInit { protected currentState: State = "assert"; - protected successRoute = "/vault"; + protected readonly Icons = { + TwoFactorAuthSecurityKeyIcon, + TwoFactorAuthSecurityKeyFailedIcon, + }; + + private readonly successRoutes: Record = { + [ClientType.Web]: "/vault", + [ClientType.Browser]: "/tabs/vault", + [ClientType.Desktop]: "/vault", + [ClientType.Cli]: "/vault", + }; + + protected get successRoute(): string { + const clientType = this.platformUtilsService.getClientType(); + return this.successRoutes[clientType] || "/vault"; + } constructor( private webAuthnLoginService: WebAuthnLoginServiceAbstraction, @@ -31,6 +73,8 @@ export class BaseLoginViaWebAuthnComponent implements OnInit { private i18nService: I18nService, private loginSuccessHandlerService: LoginSuccessHandlerService, private keyService: KeyService, + private platformUtilsService: PlatformUtilsService, + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, ) {} ngOnInit(): void { @@ -41,6 +85,8 @@ export class BaseLoginViaWebAuthnComponent implements OnInit { protected retry() { this.currentState = "assert"; + // Reset to default icon on retry + this.setDefaultIcon(); // 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.authenticate(); @@ -54,6 +100,7 @@ export class BaseLoginViaWebAuthnComponent implements OnInit { } catch (error) { this.validationService.showError(error); this.currentState = "assertFailed"; + this.setFailureIcon(); return; } try { @@ -64,6 +111,7 @@ export class BaseLoginViaWebAuthnComponent implements OnInit { this.i18nService.t("twoFactorForPasskeysNotSupportedOnClientUpdateToLogIn"), ); this.currentState = "assertFailed"; + this.setFailureIcon(); return; } @@ -80,6 +128,19 @@ export class BaseLoginViaWebAuthnComponent implements OnInit { } this.logService.error(error); this.currentState = "assertFailed"; + this.setFailureIcon(); } } + + private setDefaultIcon(): void { + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageIcon: this.Icons.TwoFactorAuthSecurityKeyIcon, + }); + } + + private setFailureIcon(): void { + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageIcon: this.Icons.TwoFactorAuthSecurityKeyFailedIcon, + }); + } } diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.svg b/libs/angular/src/scss/bwicons/fonts/bwi-font.svg index f6ea5d09372..cbbc8e04a37 100644 --- a/libs/angular/src/scss/bwicons/fonts/bwi-font.svg +++ b/libs/angular/src/scss/bwicons/fonts/bwi-font.svg @@ -23,9 +23,9 @@ - + - + @@ -39,6 +39,7 @@ + @@ -49,6 +50,7 @@ + diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.ttf b/libs/angular/src/scss/bwicons/fonts/bwi-font.ttf index dd6a52c1a02..0d0c408c53a 100644 Binary files a/libs/angular/src/scss/bwicons/fonts/bwi-font.ttf and b/libs/angular/src/scss/bwicons/fonts/bwi-font.ttf differ diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.woff b/libs/angular/src/scss/bwicons/fonts/bwi-font.woff index a20ec5903b3..0d9f0a549dc 100644 Binary files a/libs/angular/src/scss/bwicons/fonts/bwi-font.woff and b/libs/angular/src/scss/bwicons/fonts/bwi-font.woff differ diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.woff2 b/libs/angular/src/scss/bwicons/fonts/bwi-font.woff2 index dc92f85cd76..75c7ba9b430 100644 Binary files a/libs/angular/src/scss/bwicons/fonts/bwi-font.woff2 and b/libs/angular/src/scss/bwicons/fonts/bwi-font.woff2 differ diff --git a/libs/angular/src/scss/bwicons/styles/style.scss b/libs/angular/src/scss/bwicons/styles/style.scss index 92c6434d28d..755088a92a0 100644 --- a/libs/angular/src/scss/bwicons/styles/style.scss +++ b/libs/angular/src/scss/bwicons/styles/style.scss @@ -168,6 +168,7 @@ $icons: ( "plus-circle": "\e942", "plus": "\e943", "popout": "\e944", + "premium": "\e90d", "provider": "\e945", "puzzle": "\e946", "question-circle": "\e947", @@ -198,6 +199,7 @@ $icons: ( "credit-card": "\e9a2", "desktop": "\e9a3", "archive": "\e9c1", + "unarchive": "\e918", ); @each $name, $glyph in $icons { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index df135dcc0ef..15f52d0e65c 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1523,6 +1523,7 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, KdfConfigService, KeyService, + ApiServiceAbstraction, StateProvider, ConfigService, ], diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index f5ba2d0be23..25ae8a31ef6 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -75,7 +75,7 @@ describe("WebAuthnLoginStrategy", () => { // We must do this to make the mocked classes available for all the // assertCredential(...) tests. - global.PublicKeyCredential = MockPublicKeyCredential; + global.PublicKeyCredential = MockPublicKeyCredential as any; global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse; }); @@ -397,4 +397,8 @@ export class MockPublicKeyCredential implements PublicKeyCredential { static isUserVerifyingPlatformAuthenticatorAvailable(): Promise { return Promise.resolve(false); } + + toJSON() { + throw new Error("Method not implemented."); + } } diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index aea52d7310d..1cab48148e9 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -163,7 +163,7 @@ export abstract class ApiService { ): Promise< IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse >; - abstract refreshIdentityToken(): Promise; + abstract refreshIdentityToken(userId?: UserId): Promise; abstract getProfile(): Promise; abstract getUserSubscription(): Promise; diff --git a/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts index 56aa1139cda..b848cb2f902 100644 --- a/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts @@ -38,7 +38,7 @@ describe("WebAuthnLoginService", () => { // We must do this to make the mocked classes available for all the // assertCredential(...) tests. - global.PublicKeyCredential = MockPublicKeyCredential; + global.PublicKeyCredential = MockPublicKeyCredential as any; global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse; // Save the original navigator @@ -316,6 +316,10 @@ class MockPublicKeyCredential implements PublicKeyCredential { static isUserVerifyingPlatformAuthenticatorAvailable(): Promise { return Promise.resolve(false); } + + toJSON() { + throw new Error("Method not implemented."); + } } function buildCredentialAssertionOptions(): WebAuthnLoginCredentialAssertionOptionsView { diff --git a/libs/common/src/autofill/services/autofill-settings.service.ts b/libs/common/src/autofill/services/autofill-settings.service.ts index c56f852d3de..f39c6e8de30 100644 --- a/libs/common/src/autofill/services/autofill-settings.service.ts +++ b/libs/common/src/autofill/services/autofill-settings.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { combineLatest, map, Observable, startWith, switchMap } from "rxjs"; +import { combineLatest, map, Observable, switchMap } from "rxjs"; import { CipherType } from "@bitwarden/common/vault/enums"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; @@ -205,7 +205,7 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti this.showInlineMenuCardsState = this.stateProvider.getActive(SHOW_INLINE_MENU_CARDS); this.showInlineMenuCards$ = combineLatest([ this.showInlineMenuCardsState.state$.pipe(map((x) => x ?? true)), - this.restrictedItemTypesService.restricted$.pipe(startWith([])), + this.restrictedItemTypesService.restricted$, ]).pipe( map( ([enabled, restrictions]) => diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 578d09c9aea..0897aab33c9 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -25,6 +25,7 @@ export enum FeatureFlag { PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", + PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -101,6 +102,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, + [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/common/src/platform/misc/safe-urls.ts b/libs/common/src/platform/misc/safe-urls.ts index d7223a344e4..f958f92aa19 100644 --- a/libs/common/src/platform/misc/safe-urls.ts +++ b/libs/common/src/platform/misc/safe-urls.ts @@ -17,13 +17,13 @@ const CanLaunchWhitelist = [ ]; export class SafeUrls { - static canLaunch(uri: string): boolean { + static canLaunch(uri: string | null | undefined): boolean { if (Utils.isNullOrWhitespace(uri)) { return false; } for (let i = 0; i < CanLaunchWhitelist.length; i++) { - if (uri.indexOf(CanLaunchWhitelist[i]) === 0) { + if (uri!.indexOf(CanLaunchWhitelist[i]) === 0) { return true; } } diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index c771bee5463..5f977da3979 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -375,7 +375,7 @@ export class Utils { } } - static getDomain(uriString: string): string { + static getDomain(uriString: string | null | undefined): string { if (Utils.isNullOrWhitespace(uriString)) { return null; } @@ -457,7 +457,7 @@ export class Utils { return str == null || typeof str !== "string" || str.trim() === ""; } - static isNullOrEmpty(str: string | null): boolean { + static isNullOrEmpty(str: string | null | undefined): boolean { return str == null || typeof str !== "string" || str == ""; } @@ -479,7 +479,7 @@ export class Utils { return (Object.keys(obj).filter((k) => Number.isNaN(+k)) as K[]).map((k) => obj[k]); } - static getUrl(uriString: string): URL { + static getUrl(uriString: string | undefined | null): URL { if (this.isNullOrWhitespace(uriString)) { return null; } diff --git a/libs/common/src/platform/services/fido2/fido2-autofill-utils.ts b/libs/common/src/platform/services/fido2/fido2-autofill-utils.ts index 31f6ce10e01..a58b2d470e6 100644 --- a/libs/common/src/platform/services/fido2/fido2-autofill-utils.ts +++ b/libs/common/src/platform/services/fido2/fido2-autofill-utils.ts @@ -27,8 +27,8 @@ export async function getCredentialsForAutofill( cipherId: cipher.id, credentialId: credId, rpId: credential.rpId, - userHandle: credential.userHandle, - userName: credential.userName, - }; + userHandle: credential.userHandle!, + userName: credential.userName!, + } satisfies Fido2CredentialAutofillView; }); } diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index 2f6c32aa78d..7165e845885 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -8,11 +8,12 @@ import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-ma import { BitwardenClient } from "@bitwarden/sdk-internal"; import { + ObservableTracker, FakeAccountService, FakeStateProvider, mockAccountServiceWith, - ObservableTracker, } from "../../../../spec"; +import { ApiService } from "../../../abstractions/api.service"; import { AccountInfo } from "../../../auth/abstractions/account.service"; import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; import { UserId } from "../../../types/guid"; @@ -46,6 +47,7 @@ describe("DefaultSdkService", () => { let service!: DefaultSdkService; let accountService!: FakeAccountService; let fakeStateProvider!: FakeStateProvider; + let apiService!: MockProxy; beforeEach(async () => { await new TestSdkLoadService().loadAndInit(); @@ -55,6 +57,7 @@ describe("DefaultSdkService", () => { platformUtilsService = mock(); kdfConfigService = mock(); keyService = mock(); + apiService = mock(); const mockUserId = Utils.newGuid() as UserId; accountService = mockAccountServiceWith(mockUserId); fakeStateProvider = new FakeStateProvider(accountService); @@ -72,6 +75,7 @@ describe("DefaultSdkService", () => { accountService, kdfConfigService, keyService, + apiService, fakeStateProvider, configService, ); diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index 2713aaf8f4b..ec57783e02f 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -27,6 +27,7 @@ import { UnsignedSharedKey, } from "@bitwarden/sdk-internal"; +import { ApiService } from "../../../abstractions/api.service"; import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { DeviceType } from "../../../enums/device-type.enum"; import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; @@ -43,7 +44,7 @@ import { StateProvider } from "../../state"; import { initializeState } from "./client-managed-state"; -// A symbol that represents an overriden client that is explicitly set to undefined, +// A symbol that represents an overridden client that is explicitly set to undefined, // blocking the creation of an internal client for that user. const UnsetClient = Symbol("UnsetClient"); @@ -51,10 +52,17 @@ const UnsetClient = Symbol("UnsetClient"); * A token provider that exposes the access token to the SDK. */ class JsTokenProvider implements TokenProvider { - constructor() {} + constructor( + private apiService: ApiService, + private userId?: UserId, + ) {} async get_access_token(): Promise { - return undefined; + if (this.userId == null) { + return undefined; + } + + return await this.apiService.getActiveBearerToken(this.userId); } } @@ -68,7 +76,10 @@ export class DefaultSdkService implements SdkService { concatMap(async (env) => { await SdkLoadService.Ready; const settings = this.toSettings(env); - const client = await this.sdkClientFactory.createSdkClient(new JsTokenProvider(), settings); + const client = await this.sdkClientFactory.createSdkClient( + new JsTokenProvider(this.apiService), + settings, + ); await this.loadFeatureFlags(client); return client; }), @@ -87,6 +98,7 @@ export class DefaultSdkService implements SdkService { private accountService: AccountService, private kdfConfigService: KdfConfigService, private keyService: KeyService, + private apiService: ApiService, private stateProvider: StateProvider, private configService: ConfigService, private userAgent: string | null = null, @@ -173,7 +185,7 @@ export class DefaultSdkService implements SdkService { const settings = this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( - new JsTokenProvider(), + new JsTokenProvider(this.apiService, userId), settings, ); 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 352e45b88b1..193a5a2d2dd 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -329,10 +329,8 @@ describe("DefaultSyncService", () => { // 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(); + jest.useFakeTimers({ now: Date.now() }); - // send it! await sut.fullSync(true, defaultSyncOptions); expectUpdateCallCount(mockUserState, 1); @@ -340,9 +338,10 @@ describe("DefaultSyncService", () => { 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; + const actualDate = dateCallback() as Date; - expect(Math.abs(actualTime.getTime() - beforeSync)).toBeLessThan(1); + expect(actualDate.getTime()).toEqual(jest.now()); + jest.useRealTimers(); }); it("updates last sync time when no sync is necessary", async () => { diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index 5fff6b32aac..4ace8ce0e77 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -47,6 +47,7 @@ export class Attachment extends Domain { ): Promise { const view = await this.decryptObj( this, + // @ts-expect-error ViewEncryptableKeys type should be fixed to allow for optional values, but is out of scope for now. new AttachmentView(this), ["fileName"], orgId, diff --git a/libs/common/src/vault/models/domain/card.spec.ts b/libs/common/src/vault/models/domain/card.spec.ts index 5a134651e32..4da62c631d6 100644 --- a/libs/common/src/vault/models/domain/card.spec.ts +++ b/libs/common/src/vault/models/domain/card.spec.ts @@ -63,7 +63,6 @@ describe("Card", () => { expect(view).toEqual({ _brand: "brand", _number: "number", - _subTitle: null, cardholderName: "cardHolder", code: "code", expMonth: "expMonth", diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index c4ee35b2b8f..8ba81c7bbd3 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -161,6 +161,8 @@ export class Cipher extends Domain implements Decryptable { await this.decryptObj( this, + // @ts-expect-error Ciphers have optional Ids which are getting swallowed by the ViewEncryptableKeys type + // The ViewEncryptableKeys type should be fixed to allow for optional Ids, but is out of scope for now. model, ["name", "notes"], this.organizationId, diff --git a/libs/common/src/vault/models/domain/fido2-credential.ts b/libs/common/src/vault/models/domain/fido2-credential.ts index a74afc2336d..bdfac9a85ad 100644 --- a/libs/common/src/vault/models/domain/fido2-credential.ts +++ b/libs/common/src/vault/models/domain/fido2-credential.ts @@ -56,6 +56,7 @@ export class Fido2Credential extends Domain { async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { const view = await this.decryptObj( this, + // @ts-expect-error ViewEncryptableKeys type should be fixed to allow for optional values, but is out of scope for now. new Fido2CredentialView(), [ "credentialId", diff --git a/libs/common/src/vault/models/domain/field.ts b/libs/common/src/vault/models/domain/field.ts index f652a2820d4..130d1cc56d5 100644 --- a/libs/common/src/vault/models/domain/field.ts +++ b/libs/common/src/vault/models/domain/field.ts @@ -39,6 +39,7 @@ export class Field extends Domain { decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { return this.decryptObj( this, + // @ts-expect-error ViewEncryptableKeys type should be fixed to allow for optional values, but is out of scope for now. new FieldView(this), ["name", "value"], orgId, diff --git a/libs/common/src/vault/models/domain/identity.spec.ts b/libs/common/src/vault/models/domain/identity.spec.ts index c122c90371f..9fbcb92e4ae 100644 --- a/libs/common/src/vault/models/domain/identity.spec.ts +++ b/libs/common/src/vault/models/domain/identity.spec.ts @@ -112,7 +112,6 @@ describe("Identity", () => { expect(view).toEqual({ _firstName: "mockFirstName", _lastName: "mockLastName", - _subTitle: null, address1: "mockAddress1", address2: "mockAddress2", address3: "mockAddress3", diff --git a/libs/common/src/vault/models/domain/login-uri.spec.ts b/libs/common/src/vault/models/domain/login-uri.spec.ts index cbab41f1472..e67ba771412 100644 --- a/libs/common/src/vault/models/domain/login-uri.spec.ts +++ b/libs/common/src/vault/models/domain/login-uri.spec.ts @@ -56,10 +56,6 @@ describe("LoginUri", () => { const view = await loginUri.decrypt(null); expect(view).toEqual({ - _canLaunch: null, - _domain: null, - _host: null, - _hostname: null, _uri: "uri", match: 3, }); diff --git a/libs/common/src/vault/models/domain/login.spec.ts b/libs/common/src/vault/models/domain/login.spec.ts index dc3cc71fda8..99ceb2b0a3d 100644 --- a/libs/common/src/vault/models/domain/login.spec.ts +++ b/libs/common/src/vault/models/domain/login.spec.ts @@ -2,7 +2,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { mockEnc, mockFromJson } from "../../../../spec"; import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; -import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domain/domain-service"; +import { UriMatchStrategy } from "../../../models/domain/domain-service"; import { LoginData } from "../../models/data/login.data"; import { Login } from "../../models/domain/login"; import { LoginUri } from "../../models/domain/login-uri"; @@ -82,12 +82,7 @@ describe("Login DTO", () => { totp: "encrypted totp", uris: [ { - match: null as UriMatchStrategySetting, _uri: "decrypted uri", - _domain: null as string, - _hostname: null as string, - _host: null as string, - _canLaunch: null as boolean, }, ], autofillOnPageLoad: true, diff --git a/libs/common/src/vault/models/view/attachment.view.ts b/libs/common/src/vault/models/view/attachment.view.ts index 1c796c8f275..ef4a9ed8b27 100644 --- a/libs/common/src/vault/models/view/attachment.view.ts +++ b/libs/common/src/vault/models/view/attachment.view.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { AttachmentView as SdkAttachmentView } from "@bitwarden/sdk-internal"; @@ -10,12 +8,12 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { Attachment } from "../domain/attachment"; export class AttachmentView implements View { - id: string = null; - url: string = null; - size: string = null; - sizeName: string = null; - fileName: string = null; - key: SymmetricCryptoKey = null; + id?: string; + url?: string; + size?: string; + sizeName?: string; + fileName?: string; + key?: SymmetricCryptoKey; /** * The SDK returns an encrypted key for the attachment. */ @@ -35,7 +33,7 @@ export class AttachmentView implements View { get fileSize(): number { try { if (this.size != null) { - return parseInt(this.size, null); + return parseInt(this.size); } } catch { // Invalid file size. @@ -71,7 +69,7 @@ export class AttachmentView implements View { fileName: this.fileName, key: this.encryptedKey?.toSdk(), // TODO: PM-23005 - Temporary field, should be removed when encrypted migration is complete - decryptedKey: this.key ? this.key.toBase64() : null, + decryptedKey: this.key ? this.key.toBase64() : undefined, }; } @@ -84,13 +82,13 @@ export class AttachmentView implements View { } const view = new AttachmentView(); - view.id = obj.id ?? null; - view.url = obj.url ?? null; - view.size = obj.size ?? null; - view.sizeName = obj.sizeName ?? null; - view.fileName = obj.fileName ?? null; + view.id = obj.id; + view.url = obj.url; + view.size = obj.size; + view.sizeName = obj.sizeName; + view.fileName = obj.fileName; // TODO: PM-23005 - Temporary field, should be removed when encrypted migration is complete - view.key = obj.decryptedKey ? SymmetricCryptoKey.fromString(obj.decryptedKey) : null; + view.key = obj.decryptedKey ? SymmetricCryptoKey.fromString(obj.decryptedKey) : undefined; view.encryptedKey = obj.key ? new EncString(obj.key) : undefined; return view; diff --git a/libs/common/src/vault/models/view/card.view.ts b/libs/common/src/vault/models/view/card.view.ts index ed02fa68365..9b78ad384c6 100644 --- a/libs/common/src/vault/models/view/card.view.ts +++ b/libs/common/src/vault/models/view/card.view.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { CardView as SdkCardView } from "@bitwarden/sdk-internal"; @@ -12,45 +10,45 @@ import { ItemView } from "./item.view"; export class CardView extends ItemView implements SdkCardView { @linkedFieldOption(LinkedId.CardholderName, { sortPosition: 0 }) - cardholderName: string = null; + cardholderName: string | undefined; @linkedFieldOption(LinkedId.ExpMonth, { sortPosition: 3, i18nKey: "expirationMonth" }) - expMonth: string = null; + expMonth: string | undefined; @linkedFieldOption(LinkedId.ExpYear, { sortPosition: 4, i18nKey: "expirationYear" }) - expYear: string = null; + expYear: string | undefined; @linkedFieldOption(LinkedId.Code, { sortPosition: 5, i18nKey: "securityCode" }) - code: string = null; + code: string | undefined; - private _brand: string = null; - private _number: string = null; - private _subTitle: string = null; + private _brand?: string; + private _number?: string; + private _subTitle?: string; - get maskedCode(): string { - return this.code != null ? "•".repeat(this.code.length) : null; + get maskedCode(): string | undefined { + return this.code != null ? "•".repeat(this.code.length) : undefined; } - get maskedNumber(): string { - return this.number != null ? "•".repeat(this.number.length) : null; + get maskedNumber(): string | undefined { + return this.number != null ? "•".repeat(this.number.length) : undefined; } @linkedFieldOption(LinkedId.Brand, { sortPosition: 2 }) - get brand(): string { + get brand(): string | undefined { return this._brand; } - set brand(value: string) { + set brand(value: string | undefined) { this._brand = value; - this._subTitle = null; + this._subTitle = undefined; } @linkedFieldOption(LinkedId.Number, { sortPosition: 1 }) - get number(): string { + get number(): string | undefined { return this._number; } - set number(value: string) { + set number(value: string | undefined) { this._number = value; - this._subTitle = null; + this._subTitle = undefined; } - get subTitle(): string { + get subTitle(): string | undefined { if (this._subTitle == null) { this._subTitle = this.brand; if (this.number != null && this.number.length >= 4) { @@ -69,11 +67,11 @@ export class CardView extends ItemView implements SdkCardView { return this._subTitle; } - get expiration(): string { - const normalizedYear = normalizeExpiryYearFormat(this.expYear); + get expiration(): string | undefined { + const normalizedYear = this.expYear ? normalizeExpiryYearFormat(this.expYear) : undefined; if (!this.expMonth && !normalizedYear) { - return null; + return undefined; } let exp = this.expMonth != null ? ("0" + this.expMonth).slice(-2) : "__"; @@ -82,14 +80,14 @@ export class CardView extends ItemView implements SdkCardView { return exp; } - static fromJSON(obj: Partial>): CardView { + static fromJSON(obj: Partial> | undefined): CardView { return Object.assign(new CardView(), obj); } // ref https://stackoverflow.com/a/5911300 - static getCardBrandByPatterns(cardNum: string): string { + static getCardBrandByPatterns(cardNum: string | undefined | null): string | undefined { if (cardNum == null || typeof cardNum !== "string" || cardNum.trim() === "") { - return null; + return undefined; } // Visa @@ -146,25 +144,21 @@ export class CardView extends ItemView implements SdkCardView { return "Visa"; } - return null; + return undefined; } /** * Converts an SDK CardView to a CardView. */ - static fromSdkCardView(obj: SdkCardView): CardView | undefined { - if (obj == null) { - return undefined; - } - + static fromSdkCardView(obj: SdkCardView): CardView { const cardView = new CardView(); - cardView.cardholderName = obj.cardholderName ?? null; - cardView.brand = obj.brand ?? null; - cardView.number = obj.number ?? null; - cardView.expMonth = obj.expMonth ?? null; - cardView.expYear = obj.expYear ?? null; - cardView.code = obj.code ?? null; + cardView.cardholderName = obj.cardholderName; + cardView.brand = obj.brand; + cardView.number = obj.number; + cardView.expMonth = obj.expMonth; + cardView.expYear = obj.expYear; + cardView.code = obj.code; return cardView; } diff --git a/libs/common/src/vault/models/view/cipher.view.spec.ts b/libs/common/src/vault/models/view/cipher.view.spec.ts index e9614db6858..475fe9e23f3 100644 --- a/libs/common/src/vault/models/view/cipher.view.spec.ts +++ b/libs/common/src/vault/models/view/cipher.view.spec.ts @@ -109,6 +109,72 @@ describe("CipherView", () => { expect(actual.key).toBeInstanceOf(EncString); expect(actual.key?.toJSON()).toBe(cipherKeyObject.toJSON()); }); + + it("fromJSON should always restore top-level CipherView properties", () => { + jest.spyOn(LoginView, "fromJSON").mockImplementation(mockFromJson); + // Create a fully populated CipherView instance + const original = new CipherView(); + original.id = "test-id"; + original.organizationId = "org-id"; + original.folderId = "folder-id"; + original.name = "test-name"; + original.notes = "test-notes"; + original.type = CipherType.Login; + original.favorite = true; + original.organizationUseTotp = true; + original.permissions = new CipherPermissionsApi(); + original.edit = true; + original.viewPassword = false; + original.localData = { lastUsedDate: Date.now() }; + original.login = new LoginView(); + original.identity = new IdentityView(); + original.card = new CardView(); + original.secureNote = new SecureNoteView(); + original.sshKey = new SshKeyView(); + original.attachments = []; + original.fields = []; + original.passwordHistory = []; + original.collectionIds = ["collection-1"]; + original.revisionDate = new Date("2022-01-01"); + original.creationDate = new Date("2022-01-02"); + original.deletedDate = new Date("2022-01-03"); + original.archivedDate = new Date("2022-01-04"); + original.reprompt = CipherRepromptType.Password; + original.key = new EncString("test-key"); + original.decryptionFailure = true; + + // Serialize and deserialize + const json = original.toJSON(); + const restored = CipherView.fromJSON(json as any); + + // Get all enumerable properties from the original instance + const originalProps = Object.keys(original); + + // Check that all properties exist on the restored instance + for (const prop of originalProps) { + try { + expect(restored).toHaveProperty(prop); + } catch { + throw new Error(`Property '${prop}' is missing from restored instance`); + } + + // For non-function, non-getter properties, verify the value is defined + const descriptor = Object.getOwnPropertyDescriptor(CipherView.prototype, prop); + if (!descriptor?.get && typeof (original as any)[prop] !== "function") { + try { + expect((restored as any)[prop]).toBeDefined(); + } catch { + throw new Error(`Property '${prop}' is undefined in restored instance`); + } + } + } + + // Verify restored instance has the same properties as original + const restoredProps = Object.keys(restored!).sort(); + const sortedOriginalProps = originalProps.sort(); + + expect(restoredProps).toEqual(sortedOriginalProps); + }); }); describe("fromSdkCipherView", () => { @@ -180,15 +246,12 @@ describe("CipherView", () => { folderId: "folderId", collectionIds: ["collectionId"], name: "name", - notes: null, type: CipherType.Login, favorite: true, edit: true, reprompt: CipherRepromptType.None, organizationUseTotp: false, viewPassword: true, - localData: undefined, - permissions: undefined, attachments: [ { id: "attachmentId", @@ -224,7 +287,6 @@ describe("CipherView", () => { passwordHistory: [], creationDate: new Date("2022-01-01T12:00:00.000Z"), revisionDate: new Date("2022-01-02T12:00:00.000Z"), - deletedDate: null, }); }); }); @@ -283,18 +345,12 @@ describe("CipherView", () => { restore: true, delete: true, }, - deletedDate: undefined, creationDate: "2022-01-02T12:00:00.000Z", revisionDate: "2022-01-02T12:00:00.000Z", attachments: [], passwordHistory: [], - login: undefined, - identity: undefined, - card: undefined, - secureNote: undefined, - sshKey: undefined, fields: [], - } as SdkCipherView); + }); }); }); }); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index b9f717b3a7f..c586297d6a5 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -1,7 +1,6 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { asUuid, uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { ItemView } from "@bitwarden/common/vault/models/view/item.view"; import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; import { View } from "../../../models/view/view"; @@ -26,18 +25,18 @@ import { SshKeyView } from "./ssh-key.view"; export class CipherView implements View, InitializerMetadata { readonly initializerKey = InitializerKey.CipherView; - id: string = null; - organizationId: string | undefined = null; - folderId: string = null; - name: string = null; - notes: string = null; - type: CipherType = null; + id: string = ""; + organizationId?: string; + folderId?: string; + name: string = ""; + notes?: string; + type: CipherType = CipherType.Login; favorite = false; organizationUseTotp = false; - permissions: CipherPermissionsApi = new CipherPermissionsApi(); + permissions?: CipherPermissionsApi = new CipherPermissionsApi(); edit = false; viewPassword = true; - localData: LocalData; + localData?: LocalData; login = new LoginView(); identity = new IdentityView(); card = new CardView(); @@ -46,11 +45,11 @@ export class CipherView implements View, InitializerMetadata { attachments: AttachmentView[] = []; fields: FieldView[] = []; passwordHistory: PasswordHistoryView[] = []; - collectionIds: string[] = null; - revisionDate: Date = null; - creationDate: Date = null; - deletedDate: Date | null = null; - archivedDate: Date | null = null; + collectionIds: string[] = []; + revisionDate: Date; + creationDate: Date; + deletedDate?: Date; + archivedDate?: Date; reprompt: CipherRepromptType = CipherRepromptType.None; // We need a copy of the encrypted key so we can pass it to // the SdkCipherView during encryption @@ -63,6 +62,7 @@ export class CipherView implements View, InitializerMetadata { constructor(c?: Cipher) { if (!c) { + this.creationDate = this.revisionDate = new Date(); return; } @@ -86,7 +86,7 @@ export class CipherView implements View, InitializerMetadata { this.key = c.key; } - private get item() { + private get item(): ItemView | undefined { switch (this.type) { case CipherType.Login: return this.login; @@ -102,10 +102,10 @@ export class CipherView implements View, InitializerMetadata { break; } - return null; + return undefined; } - get subTitle(): string { + get subTitle(): string | undefined { return this.item?.subTitle; } @@ -114,7 +114,7 @@ export class CipherView implements View, InitializerMetadata { } get hasAttachments(): boolean { - return this.attachments && this.attachments.length > 0; + return !!this.attachments && this.attachments.length > 0; } get hasOldAttachments(): boolean { @@ -132,11 +132,11 @@ export class CipherView implements View, InitializerMetadata { return this.fields && this.fields.length > 0; } - get passwordRevisionDisplayDate(): Date { + get passwordRevisionDisplayDate(): Date | undefined { if (this.type !== CipherType.Login || this.login == null) { - return null; + return undefined; } else if (this.login.password == null || this.login.password === "") { - return null; + return undefined; } return this.login.passwordRevisionDate; } @@ -170,23 +170,17 @@ export class CipherView implements View, InitializerMetadata { * Determines if the cipher can be launched in a new browser tab. */ get canLaunch(): boolean { - return this.type === CipherType.Login && this.login.canLaunch; + return this.type === CipherType.Login && this.login!.canLaunch; } linkedFieldValue(id: LinkedIdType) { const linkedFieldOption = this.linkedFieldOptions?.get(id); - if (linkedFieldOption == null) { - return null; + const item = this.item; + if (linkedFieldOption == null || item == null) { + return undefined; } - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const item = this.item; - return this.item[linkedFieldOption.propertyKey as keyof typeof item]; - } - - linkedFieldI18nKey(id: LinkedIdType): string { - return this.linkedFieldOptions.get(id)?.i18nKey; + return item[linkedFieldOption.propertyKey as keyof typeof item]; } // This is used as a marker to indicate that the cipher view object still has its prototype @@ -194,23 +188,42 @@ export class CipherView implements View, InitializerMetadata { return this; } - static fromJSON(obj: Partial>): CipherView { + static fromJSON(obj: Partial>): CipherView | null { if (obj == null) { return null; } const view = new CipherView(); - const creationDate = obj.creationDate == null ? null : new Date(obj.creationDate); - const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); - const deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate); - const archivedDate = obj.archivedDate == null ? null : new Date(obj.archivedDate); - const attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a)); - const fields = obj.fields?.map((f: any) => FieldView.fromJSON(f)); - const passwordHistory = obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph)); - const permissions = CipherPermissionsApi.fromJSON(obj.permissions); - let key: EncString | undefined; + view.type = obj.type ?? CipherType.Login; + view.id = obj.id ?? ""; + view.organizationId = obj.organizationId ?? undefined; + view.folderId = obj.folderId ?? undefined; + view.collectionIds = obj.collectionIds ?? []; + view.name = obj.name ?? ""; + view.notes = obj.notes; + view.edit = obj.edit ?? false; + view.viewPassword = obj.viewPassword ?? true; + view.favorite = obj.favorite ?? false; + view.organizationUseTotp = obj.organizationUseTotp ?? false; + view.localData = obj.localData ? obj.localData : undefined; + view.permissions = obj.permissions ? CipherPermissionsApi.fromJSON(obj.permissions) : undefined; + view.reprompt = obj.reprompt ?? CipherRepromptType.None; + view.decryptionFailure = obj.decryptionFailure ?? false; + if (obj.creationDate) { + view.creationDate = new Date(obj.creationDate); + } + if (obj.revisionDate) { + view.revisionDate = new Date(obj.revisionDate); + } + view.deletedDate = obj.deletedDate == null ? undefined : new Date(obj.deletedDate); + view.archivedDate = obj.archivedDate == null ? undefined : new Date(obj.archivedDate); + view.attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a)) ?? []; + view.fields = obj.fields?.map((f: any) => FieldView.fromJSON(f)) ?? []; + view.passwordHistory = + obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph)) ?? []; if (obj.key != null) { + let key: EncString | undefined; if (typeof obj.key === "string") { // If the key is a string, we need to parse it as EncString key = EncString.fromJSON(obj.key); @@ -218,20 +231,9 @@ export class CipherView implements View, InitializerMetadata { // If the key is already an EncString instance, we can use it directly key = obj.key; } + view.key = key; } - Object.assign(view, obj, { - creationDate: creationDate, - revisionDate: revisionDate, - deletedDate: deletedDate, - archivedDate: archivedDate, - attachments: attachments, - fields: fields, - passwordHistory: passwordHistory, - permissions: permissions, - key: key, - }); - switch (obj.type) { case CipherType.Card: view.card = CardView.fromJSON(obj.card); @@ -264,46 +266,54 @@ export class CipherView implements View, InitializerMetadata { } const cipherView = new CipherView(); - cipherView.id = uuidAsString(obj.id) ?? null; - cipherView.organizationId = uuidAsString(obj.organizationId) ?? null; - cipherView.folderId = uuidAsString(obj.folderId) ?? null; + cipherView.id = uuidAsString(obj.id); + cipherView.organizationId = uuidAsString(obj.organizationId); + cipherView.folderId = uuidAsString(obj.folderId); cipherView.name = obj.name; - cipherView.notes = obj.notes ?? null; + cipherView.notes = obj.notes; cipherView.type = obj.type; cipherView.favorite = obj.favorite; cipherView.organizationUseTotp = obj.organizationUseTotp; - cipherView.permissions = CipherPermissionsApi.fromSdkCipherPermissions(obj.permissions); + cipherView.permissions = obj.permissions + ? CipherPermissionsApi.fromSdkCipherPermissions(obj.permissions) + : undefined; cipherView.edit = obj.edit; cipherView.viewPassword = obj.viewPassword; cipherView.localData = fromSdkLocalData(obj.localData); cipherView.attachments = - obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)) ?? []; - cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)) ?? []; + obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)!) ?? []; + cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)!) ?? []; cipherView.passwordHistory = - obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)) ?? []; + obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)!) ?? []; cipherView.collectionIds = obj.collectionIds?.map((i) => uuidAsString(i)) ?? []; - cipherView.revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate); - cipherView.creationDate = obj.creationDate == null ? null : new Date(obj.creationDate); - cipherView.deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate); - cipherView.archivedDate = obj.archivedDate == null ? null : new Date(obj.archivedDate); + cipherView.revisionDate = new Date(obj.revisionDate); + cipherView.creationDate = new Date(obj.creationDate); + cipherView.deletedDate = obj.deletedDate == null ? undefined : new Date(obj.deletedDate); + cipherView.archivedDate = obj.archivedDate == null ? undefined : new Date(obj.archivedDate); cipherView.reprompt = obj.reprompt ?? CipherRepromptType.None; - cipherView.key = EncString.fromJSON(obj.key); + cipherView.key = obj.key ? EncString.fromJSON(obj.key) : undefined; switch (obj.type) { case CipherType.Card: - cipherView.card = CardView.fromSdkCardView(obj.card); + cipherView.card = obj.card ? CardView.fromSdkCardView(obj.card) : new CardView(); break; case CipherType.Identity: - cipherView.identity = IdentityView.fromSdkIdentityView(obj.identity); + cipherView.identity = obj.identity + ? IdentityView.fromSdkIdentityView(obj.identity) + : new IdentityView(); break; case CipherType.Login: - cipherView.login = LoginView.fromSdkLoginView(obj.login); + cipherView.login = obj.login ? LoginView.fromSdkLoginView(obj.login) : new LoginView(); break; case CipherType.SecureNote: - cipherView.secureNote = SecureNoteView.fromSdkSecureNoteView(obj.secureNote); + cipherView.secureNote = obj.secureNote + ? SecureNoteView.fromSdkSecureNoteView(obj.secureNote) + : new SecureNoteView(); break; case CipherType.SshKey: - cipherView.sshKey = SshKeyView.fromSdkSshKeyView(obj.sshKey); + cipherView.sshKey = obj.sshKey + ? SshKeyView.fromSdkSshKeyView(obj.sshKey) + : new SshKeyView(); break; default: break; @@ -354,19 +364,19 @@ export class CipherView implements View, InitializerMetadata { switch (this.type) { case CipherType.Card: - sdkCipherView.card = this.card.toSdkCardView(); + sdkCipherView.card = this.card?.toSdkCardView(); break; case CipherType.Identity: - sdkCipherView.identity = this.identity.toSdkIdentityView(); + sdkCipherView.identity = this.identity?.toSdkIdentityView(); break; case CipherType.Login: - sdkCipherView.login = this.login.toSdkLoginView(); + sdkCipherView.login = this.login?.toSdkLoginView(); break; case CipherType.SecureNote: - sdkCipherView.secureNote = this.secureNote.toSdkSecureNoteView(); + sdkCipherView.secureNote = this.secureNote?.toSdkSecureNoteView(); break; case CipherType.SshKey: - sdkCipherView.sshKey = this.sshKey.toSdkSshKeyView(); + sdkCipherView.sshKey = this.sshKey?.toSdkSshKeyView(); break; default: break; diff --git a/libs/common/src/vault/models/view/fido2-credential.view.ts b/libs/common/src/vault/models/view/fido2-credential.view.ts index 410757ebe30..19e7f5d7e3c 100644 --- a/libs/common/src/vault/models/view/fido2-credential.view.ts +++ b/libs/common/src/vault/models/view/fido2-credential.view.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { @@ -10,21 +8,55 @@ import { import { ItemView } from "./item.view"; export class Fido2CredentialView extends ItemView { - credentialId: string; - keyType: "public-key"; - keyAlgorithm: "ECDSA"; - keyCurve: "P-256"; - keyValue: string; - rpId: string; - userHandle: string; - userName: string; - counter: number; - rpName: string; - userDisplayName: string; - discoverable: boolean; - creationDate: Date = null; + credentialId!: string; + keyType!: "public-key"; + keyAlgorithm!: "ECDSA"; + keyCurve!: "P-256"; + keyValue!: string; + rpId!: string; + userHandle?: string; + userName?: string; + counter!: number; + rpName?: string; + userDisplayName?: string; + discoverable: boolean = false; + creationDate!: Date; - get subTitle(): string { + constructor(f?: { + credentialId: string; + keyType: "public-key"; + keyAlgorithm: "ECDSA"; + keyCurve: "P-256"; + keyValue: string; + rpId: string; + userHandle?: string; + userName?: string; + counter: number; + rpName?: string; + userDisplayName?: string; + discoverable?: boolean; + creationDate: Date; + }) { + super(); + if (f == null) { + return; + } + this.credentialId = f.credentialId; + this.keyType = f.keyType; + this.keyAlgorithm = f.keyAlgorithm; + this.keyCurve = f.keyCurve; + this.keyValue = f.keyValue; + this.rpId = f.rpId; + this.userHandle = f.userHandle; + this.userName = f.userName; + this.counter = f.counter; + this.rpName = f.rpName; + this.userDisplayName = f.userDisplayName; + this.discoverable = f.discoverable ?? false; + this.creationDate = f.creationDate; + } + + get subTitle(): string | undefined { return this.userDisplayName; } @@ -43,21 +75,21 @@ export class Fido2CredentialView extends ItemView { return undefined; } - const view = new Fido2CredentialView(); - view.credentialId = obj.credentialId; - view.keyType = obj.keyType as "public-key"; - view.keyAlgorithm = obj.keyAlgorithm as "ECDSA"; - view.keyCurve = obj.keyCurve as "P-256"; - view.rpId = obj.rpId; - view.userHandle = obj.userHandle; - view.userName = obj.userName; - view.counter = parseInt(obj.counter); - view.rpName = obj.rpName; - view.userDisplayName = obj.userDisplayName; - view.discoverable = obj.discoverable?.toLowerCase() === "true" ? true : false; - view.creationDate = obj.creationDate ? new Date(obj.creationDate) : null; - - return view; + return new Fido2CredentialView({ + credentialId: obj.credentialId, + keyType: obj.keyType as "public-key", + keyAlgorithm: obj.keyAlgorithm as "ECDSA", + keyCurve: obj.keyCurve as "P-256", + keyValue: obj.keyValue, + rpId: obj.rpId, + userHandle: obj.userHandle, + userName: obj.userName, + counter: parseInt(obj.counter), + rpName: obj.rpName, + userDisplayName: obj.userDisplayName, + discoverable: obj.discoverable?.toLowerCase() === "true", + creationDate: new Date(obj.creationDate), + }); } toSdkFido2CredentialFullView(): Fido2CredentialFullView { diff --git a/libs/common/src/vault/models/view/field.view.ts b/libs/common/src/vault/models/view/field.view.ts index 8c9a923aed2..9f34420a86c 100644 --- a/libs/common/src/vault/models/view/field.view.ts +++ b/libs/common/src/vault/models/view/field.view.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { FieldView as SdkFieldView, FieldType as SdkFieldType } from "@bitwarden/sdk-internal"; @@ -9,13 +7,13 @@ import { FieldType, LinkedIdType } from "../../enums"; import { Field } from "../domain/field"; export class FieldView implements View { - name: string = null; - value: string = null; - type: FieldType = null; + name?: string; + value?: string; + type: FieldType = FieldType.Text; newField = false; // Marks if the field is new and hasn't been saved showValue = false; showCount = false; - linkedId: LinkedIdType = null; + linkedId?: LinkedIdType; constructor(f?: Field) { if (!f) { @@ -26,8 +24,8 @@ export class FieldView implements View { this.linkedId = f.linkedId; } - get maskedValue(): string { - return this.value != null ? "••••••••" : null; + get maskedValue(): string | undefined { + return this.value != null ? "••••••••" : undefined; } static fromJSON(obj: Partial>): FieldView { diff --git a/libs/common/src/vault/models/view/identity.view.ts b/libs/common/src/vault/models/view/identity.view.ts index 2b863dc5e5f..5fb0d1acba5 100644 --- a/libs/common/src/vault/models/view/identity.view.ts +++ b/libs/common/src/vault/models/view/identity.view.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { IdentityView as SdkIdentityView } from "@bitwarden/sdk-internal"; @@ -12,65 +10,65 @@ import { ItemView } from "./item.view"; export class IdentityView extends ItemView implements SdkIdentityView { @linkedFieldOption(LinkedId.Title, { sortPosition: 0 }) - title: string = null; + title: string | undefined; @linkedFieldOption(LinkedId.MiddleName, { sortPosition: 2 }) - middleName: string = null; + middleName: string | undefined; @linkedFieldOption(LinkedId.Address1, { sortPosition: 12 }) - address1: string = null; + address1: string | undefined; @linkedFieldOption(LinkedId.Address2, { sortPosition: 13 }) - address2: string = null; + address2: string | undefined; @linkedFieldOption(LinkedId.Address3, { sortPosition: 14 }) - address3: string = null; + address3: string | undefined; @linkedFieldOption(LinkedId.City, { sortPosition: 15, i18nKey: "cityTown" }) - city: string = null; + city: string | undefined; @linkedFieldOption(LinkedId.State, { sortPosition: 16, i18nKey: "stateProvince" }) - state: string = null; + state: string | undefined; @linkedFieldOption(LinkedId.PostalCode, { sortPosition: 17, i18nKey: "zipPostalCode" }) - postalCode: string = null; + postalCode: string | undefined; @linkedFieldOption(LinkedId.Country, { sortPosition: 18 }) - country: string = null; + country: string | undefined; @linkedFieldOption(LinkedId.Company, { sortPosition: 6 }) - company: string = null; + company: string | undefined; @linkedFieldOption(LinkedId.Email, { sortPosition: 10 }) - email: string = null; + email: string | undefined; @linkedFieldOption(LinkedId.Phone, { sortPosition: 11 }) - phone: string = null; + phone: string | undefined; @linkedFieldOption(LinkedId.Ssn, { sortPosition: 7 }) - ssn: string = null; + ssn: string | undefined; @linkedFieldOption(LinkedId.Username, { sortPosition: 5 }) - username: string = null; + username: string | undefined; @linkedFieldOption(LinkedId.PassportNumber, { sortPosition: 8 }) - passportNumber: string = null; + passportNumber: string | undefined; @linkedFieldOption(LinkedId.LicenseNumber, { sortPosition: 9 }) - licenseNumber: string = null; + licenseNumber: string | undefined; - private _firstName: string = null; - private _lastName: string = null; - private _subTitle: string = null; + private _firstName: string | undefined; + private _lastName: string | undefined; + private _subTitle: string | undefined; constructor() { super(); } @linkedFieldOption(LinkedId.FirstName, { sortPosition: 1 }) - get firstName(): string { + get firstName(): string | undefined { return this._firstName; } - set firstName(value: string) { + set firstName(value: string | undefined) { this._firstName = value; - this._subTitle = null; + this._subTitle = undefined; } @linkedFieldOption(LinkedId.LastName, { sortPosition: 4 }) - get lastName(): string { + get lastName(): string | undefined { return this._lastName; } - set lastName(value: string) { + set lastName(value: string | undefined) { this._lastName = value; - this._subTitle = null; + this._subTitle = undefined; } - get subTitle(): string { + get subTitle(): string | undefined { if (this._subTitle == null && (this.firstName != null || this.lastName != null)) { this._subTitle = ""; if (this.firstName != null) { @@ -88,7 +86,7 @@ export class IdentityView extends ItemView implements SdkIdentityView { } @linkedFieldOption(LinkedId.FullName, { sortPosition: 3 }) - get fullName(): string { + get fullName(): string | undefined { if ( this.title != null || this.firstName != null || @@ -111,11 +109,11 @@ export class IdentityView extends ItemView implements SdkIdentityView { return name.trim(); } - return null; + return undefined; } - get fullAddress(): string { - let address = this.address1; + get fullAddress(): string | undefined { + let address = this.address1 ?? ""; if (!Utils.isNullOrWhitespace(this.address2)) { if (!Utils.isNullOrWhitespace(address)) { address += ", "; @@ -131,9 +129,9 @@ export class IdentityView extends ItemView implements SdkIdentityView { return address; } - get fullAddressPart2(): string { + get fullAddressPart2(): string | undefined { if (this.city == null && this.state == null && this.postalCode == null) { - return null; + return undefined; } const city = this.city || "-"; const state = this.state; @@ -146,7 +144,7 @@ export class IdentityView extends ItemView implements SdkIdentityView { return addressPart2; } - get fullAddressForCopy(): string { + get fullAddressForCopy(): string | undefined { let address = this.fullAddress; if (this.city != null || this.state != null || this.postalCode != null) { address += "\n" + this.fullAddressPart2; @@ -157,38 +155,34 @@ export class IdentityView extends ItemView implements SdkIdentityView { return address; } - static fromJSON(obj: Partial>): IdentityView { + static fromJSON(obj: Partial> | undefined): IdentityView { return Object.assign(new IdentityView(), obj); } /** * Converts the SDK IdentityView to an IdentityView. */ - static fromSdkIdentityView(obj: SdkIdentityView): IdentityView | undefined { - if (obj == null) { - return undefined; - } - + static fromSdkIdentityView(obj: SdkIdentityView): IdentityView { const identityView = new IdentityView(); - identityView.title = obj.title ?? null; - identityView.firstName = obj.firstName ?? null; - identityView.middleName = obj.middleName ?? null; - identityView.lastName = obj.lastName ?? null; - identityView.address1 = obj.address1 ?? null; - identityView.address2 = obj.address2 ?? null; - identityView.address3 = obj.address3 ?? null; - identityView.city = obj.city ?? null; - identityView.state = obj.state ?? null; - identityView.postalCode = obj.postalCode ?? null; - identityView.country = obj.country ?? null; - identityView.company = obj.company ?? null; - identityView.email = obj.email ?? null; - identityView.phone = obj.phone ?? null; - identityView.ssn = obj.ssn ?? null; - identityView.username = obj.username ?? null; - identityView.passportNumber = obj.passportNumber ?? null; - identityView.licenseNumber = obj.licenseNumber ?? null; + identityView.title = obj.title; + identityView.firstName = obj.firstName; + identityView.middleName = obj.middleName; + identityView.lastName = obj.lastName; + identityView.address1 = obj.address1; + identityView.address2 = obj.address2; + identityView.address3 = obj.address3; + identityView.city = obj.city; + identityView.state = obj.state; + identityView.postalCode = obj.postalCode; + identityView.country = obj.country; + identityView.company = obj.company; + identityView.email = obj.email; + identityView.phone = obj.phone; + identityView.ssn = obj.ssn; + identityView.username = obj.username; + identityView.passportNumber = obj.passportNumber; + identityView.licenseNumber = obj.licenseNumber; return identityView; } diff --git a/libs/common/src/vault/models/view/item.view.ts b/libs/common/src/vault/models/view/item.view.ts index 3954276ca04..d25901f8042 100644 --- a/libs/common/src/vault/models/view/item.view.ts +++ b/libs/common/src/vault/models/view/item.view.ts @@ -1,9 +1,7 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { View } from "../../../models/view/view"; import { LinkedMetadata } from "../../linked-field-option.decorator"; export abstract class ItemView implements View { - linkedFieldOptions: Map; - abstract get subTitle(): string; + linkedFieldOptions?: Map; + abstract get subTitle(): string | undefined; } diff --git a/libs/common/src/vault/models/view/login-uri.view.ts b/libs/common/src/vault/models/view/login-uri.view.ts index 49ac9c6278f..bf8dcc83b33 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { LoginUriView as SdkLoginUriView } from "@bitwarden/sdk-internal"; @@ -11,13 +9,13 @@ import { Utils } from "../../../platform/misc/utils"; import { LoginUri } from "../domain/login-uri"; export class LoginUriView implements View { - match: UriMatchStrategySetting = null; + match?: UriMatchStrategySetting; - private _uri: string = null; - private _domain: string = null; - private _hostname: string = null; - private _host: string = null; - private _canLaunch: boolean = null; + private _uri?: string; + private _domain?: string; + private _hostname?: string; + private _host?: string; + private _canLaunch?: boolean; constructor(u?: LoginUri) { if (!u) { @@ -27,59 +25,59 @@ export class LoginUriView implements View { this.match = u.match; } - get uri(): string { + get uri(): string | undefined { return this._uri; } - set uri(value: string) { + set uri(value: string | undefined) { this._uri = value; - this._domain = null; - this._canLaunch = null; + this._domain = undefined; + this._canLaunch = undefined; } - get domain(): string { + get domain(): string | undefined { if (this._domain == null && this.uri != null) { this._domain = Utils.getDomain(this.uri); if (this._domain === "") { - this._domain = null; + this._domain = undefined; } } return this._domain; } - get hostname(): string { + get hostname(): string | undefined { if (this.match === UriMatchStrategy.RegularExpression) { - return null; + return undefined; } if (this._hostname == null && this.uri != null) { this._hostname = Utils.getHostname(this.uri); if (this._hostname === "") { - this._hostname = null; + this._hostname = undefined; } } return this._hostname; } - get host(): string { + get host(): string | undefined { if (this.match === UriMatchStrategy.RegularExpression) { - return null; + return undefined; } if (this._host == null && this.uri != null) { this._host = Utils.getHost(this.uri); if (this._host === "") { - this._host = null; + this._host = undefined; } } return this._host; } - get hostnameOrUri(): string { + get hostnameOrUri(): string | undefined { return this.hostname != null ? this.hostname : this.uri; } - get hostOrUri(): string { + get hostOrUri(): string | undefined { return this.host != null ? this.host : this.uri; } @@ -104,7 +102,10 @@ export class LoginUriView implements View { return this._canLaunch; } - get launchUri(): string { + get launchUri(): string | undefined { + if (this.uri == null) { + return undefined; + } return this.uri.indexOf("://") < 0 && !Utils.isNullOrWhitespace(Utils.getDomain(this.uri)) ? "http://" + this.uri : this.uri; @@ -141,7 +142,7 @@ export class LoginUriView implements View { matchesUri( targetUri: string, equivalentDomains: Set, - defaultUriMatch: UriMatchStrategySetting = null, + defaultUriMatch?: UriMatchStrategySetting, /** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */ overrideNeverMatchStrategy?: true, ): boolean { @@ -198,7 +199,7 @@ export class LoginUriView implements View { if (Utils.DomainMatchBlacklist.has(this.domain)) { const domainUrlHost = Utils.getHost(targetUri); - return !Utils.DomainMatchBlacklist.get(this.domain).has(domainUrlHost); + return !Utils.DomainMatchBlacklist.get(this.domain)!.has(domainUrlHost); } return true; diff --git a/libs/common/src/vault/models/view/login.view.spec.ts b/libs/common/src/vault/models/view/login.view.spec.ts index 57e82faf7f1..ec011bed433 100644 --- a/libs/common/src/vault/models/view/login.view.spec.ts +++ b/libs/common/src/vault/models/view/login.view.spec.ts @@ -29,11 +29,6 @@ describe("LoginView", () => { }); describe("fromSdkLoginView", () => { - it("should return undefined when the input is null", () => { - const result = LoginView.fromSdkLoginView(null as unknown as SdkLoginView); - expect(result).toBeUndefined(); - }); - it("should return a LoginView from an SdkLoginView", () => { jest.spyOn(LoginUriView, "fromSdkLoginUriView").mockImplementation(mockFromSdk); diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index 44c6ee8f2e9..6f9167cd777 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { LoginView as SdkLoginView } from "@bitwarden/sdk-internal"; import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; @@ -15,15 +13,15 @@ import { LoginUriView } from "./login-uri.view"; export class LoginView extends ItemView { @linkedFieldOption(LinkedId.Username, { sortPosition: 0 }) - username: string = null; + username: string | undefined; @linkedFieldOption(LinkedId.Password, { sortPosition: 1 }) - password: string = null; + password: string | undefined; - passwordRevisionDate?: Date = null; - totp: string = null; + passwordRevisionDate?: Date; + totp: string | undefined; uris: LoginUriView[] = []; - autofillOnPageLoad: boolean = null; - fido2Credentials: Fido2CredentialView[] = null; + autofillOnPageLoad: boolean | undefined; + fido2Credentials: Fido2CredentialView[] = []; constructor(l?: Login) { super(); @@ -35,15 +33,15 @@ export class LoginView extends ItemView { this.autofillOnPageLoad = l.autofillOnPageLoad; } - get uri(): string { - return this.hasUris ? this.uris[0].uri : null; + get uri(): string | undefined { + return this.hasUris ? this.uris[0].uri : undefined; } - get maskedPassword(): string { - return this.password != null ? "••••••••" : null; + get maskedPassword(): string | undefined { + return this.password != null ? "••••••••" : undefined; } - get subTitle(): string { + get subTitle(): string | undefined { // if there's a passkey available, use that as a fallback if (Utils.isNullOrEmpty(this.username) && this.fido2Credentials?.length > 0) { return this.fido2Credentials[0].userName; @@ -60,14 +58,14 @@ export class LoginView extends ItemView { return !Utils.isNullOrWhitespace(this.totp); } - get launchUri(): string { + get launchUri(): string | undefined { if (this.hasUris) { const uri = this.uris.find((u) => u.canLaunch); if (uri != null) { return uri.launchUri; } } - return null; + return undefined; } get hasUris(): boolean { @@ -81,7 +79,7 @@ export class LoginView extends ItemView { matchesUri( targetUri: string, equivalentDomains: Set, - defaultUriMatch: UriMatchStrategySetting = null, + defaultUriMatch?: UriMatchStrategySetting, /** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */ overrideNeverMatchStrategy?: true, ): boolean { @@ -94,17 +92,20 @@ export class LoginView extends ItemView { ); } - static fromJSON(obj: Partial>): LoginView { - const passwordRevisionDate = - obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); - const uris = obj.uris.map((uri) => LoginUriView.fromJSON(uri)); - const fido2Credentials = obj.fido2Credentials?.map((key) => Fido2CredentialView.fromJSON(key)); + static fromJSON(obj: Partial> | undefined): LoginView { + if (obj == undefined) { + return new LoginView(); + } - return Object.assign(new LoginView(), obj, { - passwordRevisionDate, - uris, - fido2Credentials, - }); + const loginView = Object.assign(new LoginView(), obj) as LoginView; + + loginView.passwordRevisionDate = + obj.passwordRevisionDate == null ? undefined : new Date(obj.passwordRevisionDate); + loginView.uris = obj.uris?.map((uri) => LoginUriView.fromJSON(uri)) ?? []; + loginView.fido2Credentials = + obj.fido2Credentials?.map((key) => Fido2CredentialView.fromJSON(key)) ?? []; + + return loginView; } /** @@ -115,25 +116,21 @@ export class LoginView extends ItemView { * the FIDO2 credentials in encrypted form. We can decrypt them later using a separate * call to client.vault().ciphers().decrypt_fido2_credentials(). */ - static fromSdkLoginView(obj: SdkLoginView): LoginView | undefined { - if (obj == null) { - return undefined; - } - + static fromSdkLoginView(obj: SdkLoginView): LoginView { const loginView = new LoginView(); - loginView.username = obj.username ?? null; - loginView.password = obj.password ?? null; + loginView.username = obj.username; + loginView.password = obj.password; loginView.passwordRevisionDate = - obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); - loginView.totp = obj.totp ?? null; - loginView.autofillOnPageLoad = obj.autofillOnPageLoad ?? null; + obj.passwordRevisionDate == null ? undefined : new Date(obj.passwordRevisionDate); + loginView.totp = obj.totp; + loginView.autofillOnPageLoad = obj.autofillOnPageLoad; loginView.uris = obj.uris ?.filter((uri) => uri.uri != null && uri.uri !== "") - .map((uri) => LoginUriView.fromSdkLoginUriView(uri)) || []; + .map((uri) => LoginUriView.fromSdkLoginUriView(uri)!) || []; // FIDO2 credentials are not decrypted here, they remain encrypted - loginView.fido2Credentials = null; + loginView.fido2Credentials = []; return loginView; } diff --git a/libs/common/src/vault/models/view/secure-note.view.ts b/libs/common/src/vault/models/view/secure-note.view.ts index 5e401961869..85c0d3fd61c 100644 --- a/libs/common/src/vault/models/view/secure-note.view.ts +++ b/libs/common/src/vault/models/view/secure-note.view.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { SecureNoteView as SdkSecureNoteView } from "@bitwarden/sdk-internal"; @@ -10,7 +8,7 @@ import { SecureNote } from "../domain/secure-note"; import { ItemView } from "./item.view"; export class SecureNoteView extends ItemView implements SdkSecureNoteView { - type: SecureNoteType = null; + type: SecureNoteType = SecureNoteType.Generic; constructor(n?: SecureNote) { super(); @@ -21,24 +19,20 @@ export class SecureNoteView extends ItemView implements SdkSecureNoteView { this.type = n.type; } - get subTitle(): string { - return null; + get subTitle(): string | undefined { + return undefined; } - static fromJSON(obj: Partial>): SecureNoteView { + static fromJSON(obj: Partial> | undefined): SecureNoteView { return Object.assign(new SecureNoteView(), obj); } /** * Converts the SDK SecureNoteView to a SecureNoteView. */ - static fromSdkSecureNoteView(obj: SdkSecureNoteView): SecureNoteView | undefined { - if (!obj) { - return undefined; - } - + static fromSdkSecureNoteView(obj: SdkSecureNoteView): SecureNoteView { const secureNoteView = new SecureNoteView(); - secureNoteView.type = obj.type ?? null; + secureNoteView.type = obj.type; return secureNoteView; } diff --git a/libs/common/src/vault/models/view/ssh-key.view.ts b/libs/common/src/vault/models/view/ssh-key.view.ts index 0547eeb7f8e..525608ce274 100644 --- a/libs/common/src/vault/models/view/ssh-key.view.ts +++ b/libs/common/src/vault/models/view/ssh-key.view.ts @@ -1,24 +1,13 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify } from "type-fest"; import { SshKeyView as SdkSshKeyView } from "@bitwarden/sdk-internal"; -import { SshKey } from "../domain/ssh-key"; - import { ItemView } from "./item.view"; export class SshKeyView extends ItemView { - privateKey: string = null; - publicKey: string = null; - keyFingerprint: string = null; - - constructor(n?: SshKey) { - super(); - if (!n) { - return; - } - } + privateKey!: string; + publicKey!: string; + keyFingerprint!: string; get maskedPrivateKey(): string { if (!this.privateKey || this.privateKey.length === 0) { @@ -43,23 +32,19 @@ export class SshKeyView extends ItemView { return this.keyFingerprint; } - static fromJSON(obj: Partial>): SshKeyView { + static fromJSON(obj: Partial> | undefined): SshKeyView { return Object.assign(new SshKeyView(), obj); } /** * Converts the SDK SshKeyView to a SshKeyView. */ - static fromSdkSshKeyView(obj: SdkSshKeyView): SshKeyView | undefined { - if (!obj) { - return undefined; - } - + static fromSdkSshKeyView(obj: SdkSshKeyView): SshKeyView { const sshKeyView = new SshKeyView(); - sshKeyView.privateKey = obj.privateKey ?? null; - sshKeyView.publicKey = obj.publicKey ?? null; - sshKeyView.keyFingerprint = obj.fingerprint ?? null; + sshKeyView.privateKey = obj.privateKey; + sshKeyView.publicKey = obj.publicKey; + sshKeyView.keyFingerprint = obj.fingerprint; return sshKeyView; } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index d8e98c75d40..b3cb43dcc93 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -45,6 +45,7 @@ import { CipherView } from "../models/view/cipher.view"; import { LoginUriView } from "../models/view/login-uri.view"; import { CipherService } from "./cipher.service"; +import { ENCRYPTED_CIPHERS } from "./key-state/ciphers.state"; const ENCRYPTED_TEXT = "This data has been encrypted"; function encryptText(clearText: string | Uint8Array) { @@ -817,4 +818,87 @@ describe("Cipher Service", () => { expect(failures).toHaveLength(0); }); }); + + describe("replace (no upsert)", () => { + // In order to set up initial state we need to manually update the encrypted state + // which will result in an emission. All tests will have this baseline emission. + const TEST_BASELINE_EMISSIONS = 1; + + const makeCipher = (id: string): CipherData => + ({ + ...cipherData, + id, + name: `Enc ${id}`, + }) as CipherData; + + const tick = async () => new Promise((r) => setTimeout(r, 0)); + + const setEncryptedState = async (data: Record, uid = userId) => { + // Directly set the encrypted state, this will result in a single emission + await stateProvider.getUser(uid, ENCRYPTED_CIPHERS).update(() => data); + // match service’s “next tick” behavior so subscribers see it + await tick(); + }; + + it("emits and calls updateEncryptedCipherState when current state is empty and replace({}) is called", async () => { + // Ensure empty state + await setEncryptedState({}); + + const emissions: Array> = []; + const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v)); + await tick(); + + const spy = jest.spyOn(cipherService, "updateEncryptedCipherState"); + + // Calling replace with empty object MUST still update to trigger init emissions + await cipherService.replace({}, userId); + await tick(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(emissions.length).toBeGreaterThanOrEqual(TEST_BASELINE_EMISSIONS + 1); + + sub.unsubscribe(); + }); + + it("does NOT emit or call updateEncryptedCipherState when state is non-empty and identical", async () => { + const A = makeCipher("A"); + await setEncryptedState({ [A.id as CipherId]: A }); + + const emissions: Array> = []; + const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v)); + await tick(); + + const spy = jest.spyOn(cipherService, "updateEncryptedCipherState"); + + // identical snapshot → short-circuit path + await cipherService.replace({ [A.id as CipherId]: A }, userId); + await tick(); + + expect(spy).not.toHaveBeenCalled(); + expect(emissions.length).toBe(TEST_BASELINE_EMISSIONS); + + sub.unsubscribe(); + }); + + it("emits and calls updateEncryptedCipherState when the provided state differs from current", async () => { + const A = makeCipher("A"); + await setEncryptedState({ [A.id as CipherId]: A }); + + const emissions: Array> = []; + const sub = cipherService.ciphers$(userId).subscribe((v) => emissions.push(v)); + await tick(); + + const spy = jest.spyOn(cipherService, "updateEncryptedCipherState"); + + const B = makeCipher("B"); + await cipherService.replace({ [B.id as CipherId]: B }, userId); + await tick(); + + expect(spy).toHaveBeenCalledTimes(1); + + expect(emissions.length).toBeGreaterThanOrEqual(TEST_BASELINE_EMISSIONS + 1); + + sub.unsubscribe(); + }); + }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 86da7ca1c4f..809f7627e19 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1172,6 +1172,18 @@ export class CipherService implements CipherServiceAbstraction { } async replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise { + const current = (await firstValueFrom(this.encryptedCiphersState(userId).state$)) ?? {}; + + // The extension relies on chrome.storage.StorageArea.onChanged to detect updates. + // If stored and provided data are identical, this event doesn’t fire and the ciphers$ + // observable won’t emit a new value. In this case we can skip the update to avoid calling + // clearCache and causing an empty state. + // If the current state is empty (eg. for new users), we still want to perform the update to ensure + // we trigger an emission as many subscribers rely on it during initialization. + if (Object.keys(current).length > 0 && JSON.stringify(current) === JSON.stringify(ciphers)) { + return; + } + await this.updateEncryptedCipherState(() => ciphers, userId); } @@ -1185,13 +1197,16 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId = null, ): Promise> { userId ||= await firstValueFrom(this.stateProvider.activeUserId$); + await this.clearCache(userId); + const updatedCiphers = await this.stateProvider .getUser(userId, ENCRYPTED_CIPHERS) .update((current) => { const result = update(current ?? {}); return result; }); + // Some state storage providers (e.g. Electron) don't update the state immediately, wait for next tick // Otherwise, subscribers to cipherViews$ can get stale data await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index 2d440adeb29..e95f39aad85 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -272,11 +272,16 @@ export class FolderService implements InternalFolderServiceAbstraction { return []; } - const decryptFolderPromises = folders.map((f) => - f.decryptWithKey(userKey, this.encryptService), - ); - const decryptedFolders = await Promise.all(decryptFolderPromises); - decryptedFolders.sort(Utils.getSortFunction(this.i18nService, "name")); + const decryptFolderPromises = folders.map(async (f) => { + try { + return await f.decryptWithKey(userKey, this.encryptService); + } catch { + return null; + } + }); + const decryptedFolders = (await Promise.all(decryptFolderPromises)) + .filter((p) => p !== null) + .sort(Utils.getSortFunction(this.i18nService, "name")); const noneFolder = new FolderView(); noneFolder.name = this.i18nService.t("noneFolder"); diff --git a/libs/common/src/vault/services/vault-settings/vault-settings.service.ts b/libs/common/src/vault/services/vault-settings/vault-settings.service.ts index 28671a94cc9..dbdb3a58dcc 100644 --- a/libs/common/src/vault/services/vault-settings/vault-settings.service.ts +++ b/libs/common/src/vault/services/vault-settings/vault-settings.service.ts @@ -1,4 +1,4 @@ -import { Observable, combineLatest, map, shareReplay, startWith } from "rxjs"; +import { Observable, combineLatest, map, shareReplay } from "rxjs"; import { ActiveUserState, GlobalState, StateProvider } from "../../../platform/state"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "../../abstractions/vault-settings/vault-settings.service"; @@ -31,7 +31,7 @@ export class VaultSettingsService implements VaultSettingsServiceAbstraction { */ readonly showCardsCurrentTab$: Observable = combineLatest([ this.showCardsCurrentTabState.state$.pipe(map((x) => x ?? true)), - this.restrictedItemTypesService.restricted$.pipe(startWith([])), + this.restrictedItemTypesService.restricted$, ]).pipe( map( ([enabled, restrictions]) => diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts index 70122ebd27b..56b94fcf3ce 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.spec.ts @@ -298,6 +298,10 @@ describe("CipherViewLikeUtils", () => { (cipherView.attachments as any) = null; expect(CipherViewLikeUtils.hasAttachments(cipherView)).toBe(false); + + cipherView.attachments = []; + + expect(CipherViewLikeUtils.hasAttachments(cipherView)).toBe(false); }); }); diff --git a/libs/common/src/vault/utils/observable-utilities.ts b/libs/common/src/vault/utils/observable-utilities.ts index cdec51fc953..025da8a36f0 100644 --- a/libs/common/src/vault/utils/observable-utilities.ts +++ b/libs/common/src/vault/utils/observable-utilities.ts @@ -30,7 +30,7 @@ export function perUserCache$( create(userId), clearBuffer$.pipe( filter((clearId) => clearId === userId || clearId === null), - map(() => null), + map((): any => null), ), ).pipe(shareReplay({ bufferSize: 1, refCount: false })); cache.set(userId, observable); diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index 35c6f04911c..8ae26e7771b 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -23,7 +23,7 @@ [id]="mainContentId" tabindex="-1" bitScrollLayoutHost - class="tw-overflow-auto tw-max-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6" + class="tw-overflow-auto tw-max-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container" > diff --git a/libs/components/src/stories/icons/icon-data.ts b/libs/components/src/stories/icons/icon-data.ts index fc211ed4f82..802e23fa3d6 100644 --- a/libs/components/src/stories/icons/icon-data.ts +++ b/libs/components/src/stories/icons/icon-data.ts @@ -65,6 +65,10 @@ const bitwardenObjects = [ id: "bwi-id-card", usage: "identity item type", }, + { + id: "bwi-premium", + usage: "upgrade to premium", + }, { id: "bwi-send", usage: "send action or feature", @@ -204,6 +208,10 @@ const actions = [ id: "bwi-trash", usage: "delete action or trash area", }, + { + id: "bwi-unarchive", + usage: "remove item from archive", + }, { id: "bwi-undo", usage: "restore action", diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index 40362bfa8da..b3f3077b1a3 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -157,6 +157,9 @@ module.exports = { xs: [".8125rem", "1rem"], "3xl": ["1.75rem", "2rem"], }, + container: { + "@5xl": "1100px", + }, }, }, plugins: [ @@ -196,5 +199,6 @@ module.exports = { plugin(function ({ addVariant }) { addVariant("bit-compact", ".bit-compact &"); }), + require("@tailwindcss/container-queries"), ], }; diff --git a/libs/importer/src/components/import.component.html b/libs/importer/src/components/import.component.html index bca7d15f087..3bd4b741dbb 100644 --- a/libs/importer/src/components/import.component.html +++ b/libs/importer/src/components/import.component.html @@ -65,7 +65,7 @@

{{ "data" | i18n }}

- + {{ "fileFormat" | i18n }} diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index 19a8a4828e1..f8acb5e0643 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -193,7 +193,6 @@ export abstract class BaseImporter { if (this.isNullOrWhitespace(loginUri.uri)) { return null; } - loginUri.match = null; return [loginUri]; } @@ -205,7 +204,6 @@ export abstract class BaseImporter { if (this.isNullOrWhitespace(loginUri.uri)) { return; } - loginUri.match = null; returnArr.push(loginUri); }); return returnArr.length === 0 ? null : returnArr; @@ -236,7 +234,7 @@ export abstract class BaseImporter { return hostname.startsWith("www.") ? hostname.replace("www.", "") : hostname; } - protected isNullOrWhitespace(str: string): boolean { + protected isNullOrWhitespace(str: string | undefined | null): boolean { return Utils.isNullOrWhitespace(str); } diff --git a/libs/importer/src/importers/chrome-csv-importer.spec.ts b/libs/importer/src/importers/chrome-csv-importer.spec.ts index a7a29094707..df60a6f2647 100644 --- a/libs/importer/src/importers/chrome-csv-importer.spec.ts +++ b/libs/importer/src/importers/chrome-csv-importer.spec.ts @@ -11,9 +11,6 @@ const CipherData = [ title: "should parse app name", csv: androidData, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "com.xyz.example.app.android", login: Object.assign(new LoginView(), { username: "username@example.com", @@ -24,7 +21,6 @@ const CipherData = [ }), ], }), - notes: null, type: 1, }), }, @@ -32,9 +28,6 @@ const CipherData = [ title: "should parse password", csv: simplePasswordData, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "www.example.com", login: Object.assign(new LoginView(), { username: "username@example.com", @@ -45,7 +38,6 @@ const CipherData = [ }), ], }), - notes: null, type: 1, }), }, @@ -54,6 +46,7 @@ const CipherData = [ describe("Chrome CSV Importer", () => { CipherData.forEach((data) => { it(data.title, async () => { + jest.useFakeTimers().setSystemTime(data.expected.creationDate); const importer = new ChromeCsvImporter(); const result = await importer.parse(data.csv); expect(result != null).toBe(true); diff --git a/libs/importer/src/importers/dashlane/dashlane-csv-importer.spec.ts b/libs/importer/src/importers/dashlane/dashlane-csv-importer.spec.ts index b8d84a9378a..2dedcec6b2a 100644 --- a/libs/importer/src/importers/dashlane/dashlane-csv-importer.spec.ts +++ b/libs/importer/src/importers/dashlane/dashlane-csv-importer.spec.ts @@ -59,12 +59,12 @@ describe("Dashlane CSV Importer", () => { const cipher = result.ciphers.shift(); expect(cipher.type).toBe(CipherType.Card); expect(cipher.name).toBe("John's savings account"); - expect(cipher.card.brand).toBeNull(); + expect(cipher.card.brand).toBeUndefined(); expect(cipher.card.cardholderName).toBe("John Doe"); expect(cipher.card.number).toBe("accountNumber"); - expect(cipher.card.code).toBeNull(); - expect(cipher.card.expMonth).toBeNull(); - expect(cipher.card.expYear).toBeNull(); + expect(cipher.card.code).toBeUndefined(); + expect(cipher.card.expMonth).toBeUndefined(); + expect(cipher.card.expYear).toBeUndefined(); expect(cipher.fields.length).toBe(4); @@ -112,7 +112,7 @@ describe("Dashlane CSV Importer", () => { expect(cipher.name).toBe("John Doe card"); expect(cipher.identity.fullName).toBe("John Doe"); expect(cipher.identity.firstName).toBe("John"); - expect(cipher.identity.middleName).toBeNull(); + expect(cipher.identity.middleName).toBeUndefined(); expect(cipher.identity.lastName).toBe("Doe"); expect(cipher.identity.licenseNumber).toBe("123123123"); @@ -133,7 +133,7 @@ describe("Dashlane CSV Importer", () => { expect(cipher2.name).toBe("John Doe passport"); expect(cipher2.identity.fullName).toBe("John Doe"); expect(cipher2.identity.firstName).toBe("John"); - expect(cipher2.identity.middleName).toBeNull(); + expect(cipher2.identity.middleName).toBeUndefined(); expect(cipher2.identity.lastName).toBe("Doe"); expect(cipher2.identity.passportNumber).toBe("123123123"); @@ -154,7 +154,7 @@ describe("Dashlane CSV Importer", () => { expect(cipher3.name).toBe("John Doe license"); expect(cipher3.identity.fullName).toBe("John Doe"); expect(cipher3.identity.firstName).toBe("John"); - expect(cipher3.identity.middleName).toBeNull(); + expect(cipher3.identity.middleName).toBeUndefined(); expect(cipher3.identity.lastName).toBe("Doe"); expect(cipher3.identity.licenseNumber).toBe("1234556"); expect(cipher3.identity.state).toBe("DC"); @@ -173,7 +173,7 @@ describe("Dashlane CSV Importer", () => { expect(cipher4.name).toBe("John Doe social_security"); expect(cipher4.identity.fullName).toBe("John Doe"); expect(cipher4.identity.firstName).toBe("John"); - expect(cipher4.identity.middleName).toBeNull(); + expect(cipher4.identity.middleName).toBeUndefined(); expect(cipher4.identity.lastName).toBe("Doe"); expect(cipher4.identity.ssn).toBe("123123123"); diff --git a/libs/importer/src/importers/firefox-csv-importer.spec.ts b/libs/importer/src/importers/firefox-csv-importer.spec.ts index 78bca0599b5..59d2aa9e7a4 100644 --- a/libs/importer/src/importers/firefox-csv-importer.spec.ts +++ b/libs/importer/src/importers/firefox-csv-importer.spec.ts @@ -11,9 +11,6 @@ const CipherData = [ title: "should parse password", csv: simplePasswordData, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "example.com", login: Object.assign(new LoginView(), { username: "foo", @@ -24,7 +21,6 @@ const CipherData = [ }), ], }), - notes: null, type: 1, }), }, @@ -32,9 +28,6 @@ const CipherData = [ title: 'should skip "chrome://FirefoxAccounts"', csv: firefoxAccountsData, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "example.com", login: Object.assign(new LoginView(), { username: "foo", @@ -45,7 +38,6 @@ const CipherData = [ }), ], }), - notes: null, type: 1, }), }, @@ -54,6 +46,7 @@ const CipherData = [ describe("Firefox CSV Importer", () => { CipherData.forEach((data) => { it(data.title, async () => { + jest.useFakeTimers().setSystemTime(data.expected.creationDate); const importer = new FirefoxCsvImporter(); const result = await importer.parse(data.csv); expect(result != null).toBe(true); diff --git a/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts b/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts index b326bc5d351..dcaacffc05e 100644 --- a/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts +++ b/libs/importer/src/importers/keeper/keeper-csv-importer.spec.ts @@ -51,10 +51,10 @@ describe("Keeper CSV Importer", () => { expect(result != null).toBe(true); const cipher = result.ciphers.shift(); - expect(cipher.login.totp).toBeNull(); + expect(cipher.login.totp).toBeUndefined(); const cipher2 = result.ciphers.shift(); - expect(cipher2.login.totp).toBeNull(); + expect(cipher2.login.totp).toBeUndefined(); const cipher3 = result.ciphers.shift(); expect(cipher3.login.totp).toEqual( diff --git a/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts b/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts index 1141897a044..a9d42369b1e 100644 --- a/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts +++ b/libs/importer/src/importers/keeper/keeper-json-importer.spec.ts @@ -51,7 +51,7 @@ describe("Keeper Json Importer", () => { expect(result != null).toBe(true); const cipher = result.ciphers.shift(); - expect(cipher.login.totp).toBeNull(); + expect(cipher.login.totp).toBeUndefined(); // 2nd Cipher const cipher2 = result.ciphers.shift(); diff --git a/libs/importer/src/importers/lastpass/lastpass-csv-importer.spec.ts b/libs/importer/src/importers/lastpass/lastpass-csv-importer.spec.ts index cabd246fa7e..6515e3959b0 100644 --- a/libs/importer/src/importers/lastpass/lastpass-csv-importer.spec.ts +++ b/libs/importer/src/importers/lastpass/lastpass-csv-importer.spec.ts @@ -37,9 +37,6 @@ Expiration Date:June,2020 Notes:some text ",Credit-card,,0`, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "Credit-card", notes: "some text\n", type: 3, @@ -71,11 +68,7 @@ Start Date:, Expiration Date:, Notes:",empty,,0`, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "empty", - notes: null, type: 3, card: { expMonth: undefined, @@ -101,11 +94,7 @@ Start Date:, Expiration Date:January, Notes:",noyear,,0`, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "noyear", - notes: null, type: 3, card: { cardholderName: "John Doe", @@ -139,11 +128,7 @@ Start Date:, Expiration Date:,2020 Notes:",nomonth,,0`, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "nomonth", - notes: null, type: 3, card: { cardholderName: "John Doe", @@ -171,6 +156,7 @@ Notes:",nomonth,,0`, describe("Lastpass CSV Importer", () => { CipherData.forEach((data) => { it(data.title, async () => { + jest.useFakeTimers().setSystemTime(data.expected.creationDate); const importer = new LastPassCsvImporter(); const result = await importer.parse(data.csv); expect(result != null).toBe(true); diff --git a/libs/importer/src/importers/myki-csv-importer.spec.ts b/libs/importer/src/importers/myki-csv-importer.spec.ts index 6f804523ef0..a77e85d134a 100644 --- a/libs/importer/src/importers/myki-csv-importer.spec.ts +++ b/libs/importer/src/importers/myki-csv-importer.spec.ts @@ -468,8 +468,8 @@ describe("Myki CSV Importer", () => { const cipher = result.ciphers.shift(); expect(cipher.name).toEqual("2FA nickname"); - expect(cipher.login.username).toBeNull(); - expect(cipher.login.password).toBeNull(); + expect(cipher.login.username).toBeUndefined(); + expect(cipher.login.password).toBeUndefined(); expect(cipher.login.totp).toBe("someTOTPSeed"); expect(cipher.notes).toEqual("Additional information field content."); diff --git a/libs/importer/src/importers/nordpass-csv-importer.spec.ts b/libs/importer/src/importers/nordpass-csv-importer.spec.ts index e633310e6ee..f04272de012 100644 --- a/libs/importer/src/importers/nordpass-csv-importer.spec.ts +++ b/libs/importer/src/importers/nordpass-csv-importer.spec.ts @@ -17,8 +17,8 @@ const namesTestData = [ fullName: "MyFirstName", expected: Object.assign(new IdentityView(), { firstName: "MyFirstName", - middleName: null, - lastName: null, + middleName: undefined, + lastName: undefined, }), }, { @@ -26,7 +26,7 @@ const namesTestData = [ fullName: "MyFirstName MyLastName", expected: Object.assign(new IdentityView(), { firstName: "MyFirstName", - middleName: null, + middleName: undefined, lastName: "MyLastName", }), }, diff --git a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts index 1ca12a9ce69..4ec20ba2a87 100644 --- a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts +++ b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.spec.ts @@ -393,7 +393,7 @@ describe("1Password 1Pux Importer", () => { const identity = cipher.identity; expect(identity.firstName).toEqual("Michael"); - expect(identity.middleName).toBeNull(); + expect(identity.middleName).toBeUndefined(); expect(identity.lastName).toEqual("Scarn"); expect(identity.address1).toEqual("2120 Mifflin Rd."); expect(identity.state).toEqual("Pennsylvania"); @@ -423,7 +423,7 @@ describe("1Password 1Pux Importer", () => { const identity = cipher.identity; expect(identity.firstName).toEqual("Cash"); - expect(identity.middleName).toBeNull(); + expect(identity.middleName).toBeUndefined(); expect(identity.lastName).toEqual("Bandit"); expect(identity.state).toEqual("Washington"); expect(identity.country).toEqual("United States of America"); @@ -447,7 +447,7 @@ describe("1Password 1Pux Importer", () => { const identity = cipher.identity; expect(identity.firstName).toEqual("George"); - expect(identity.middleName).toBeNull(); + expect(identity.middleName).toBeUndefined(); expect(identity.lastName).toEqual("Engels"); expect(identity.company).toEqual("National Public Library"); expect(identity.phone).toEqual("9995555555"); @@ -472,7 +472,7 @@ describe("1Password 1Pux Importer", () => { const identity = cipher.identity; expect(identity.firstName).toEqual("David"); - expect(identity.middleName).toBeNull(); + expect(identity.middleName).toBeUndefined(); expect(identity.lastName).toEqual("Global"); expect(identity.passportNumber).toEqual("76436847"); @@ -499,7 +499,7 @@ describe("1Password 1Pux Importer", () => { const identity = cipher.identity; expect(identity.firstName).toEqual("Chef"); - expect(identity.middleName).toBeNull(); + expect(identity.middleName).toBeUndefined(); expect(identity.lastName).toEqual("Coldroom"); expect(identity.company).toEqual("Super Cool Store Co."); @@ -523,7 +523,7 @@ describe("1Password 1Pux Importer", () => { const identity = cipher.identity; expect(identity.firstName).toEqual("Jack"); - expect(identity.middleName).toBeNull(); + expect(identity.middleName).toBeUndefined(); expect(identity.lastName).toEqual("Judd"); expect(identity.ssn).toEqual("131-216-1900"); }); @@ -682,12 +682,12 @@ describe("1Password 1Pux Importer", () => { expect(folders[3].name).toBe("Education"); expect(folders[4].name).toBe("Starter Kit"); - // Check that ciphers have a folder assigned to them - expect(result.ciphers.filter((c) => c.folderId === folders[0].id).length).toBeGreaterThan(0); - expect(result.ciphers.filter((c) => c.folderId === folders[1].id).length).toBeGreaterThan(0); - expect(result.ciphers.filter((c) => c.folderId === folders[2].id).length).toBeGreaterThan(0); - expect(result.ciphers.filter((c) => c.folderId === folders[3].id).length).toBeGreaterThan(0); - expect(result.ciphers.filter((c) => c.folderId === folders[4].id).length).toBeGreaterThan(0); + // Check that folder/cipher relationships + expect(result.folderRelationships.filter(([_, f]) => f == 0).length).toBeGreaterThan(0); + expect(result.folderRelationships.filter(([_, f]) => f == 1).length).toBeGreaterThan(0); + expect(result.folderRelationships.filter(([_, f]) => f == 2).length).toBeGreaterThan(0); + expect(result.folderRelationships.filter(([_, f]) => f == 3).length).toBeGreaterThan(0); + expect(result.folderRelationships.filter(([_, f]) => f == 4).length).toBeGreaterThan(0); }); it("should create collections if part of an organization", async () => { diff --git a/libs/importer/src/importers/safari-csv-importer.spec.ts b/libs/importer/src/importers/safari-csv-importer.spec.ts index 4ca8df23f34..c55117226f9 100644 --- a/libs/importer/src/importers/safari-csv-importer.spec.ts +++ b/libs/importer/src/importers/safari-csv-importer.spec.ts @@ -11,9 +11,6 @@ const CipherData = [ title: "should parse URLs in new CSV format", csv: simplePasswordData, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "example.com (example_user)", login: Object.assign(new LoginView(), { username: "example_user", @@ -33,9 +30,6 @@ const CipherData = [ title: "should parse URLs in old CSV format", csv: oldSimplePasswordData, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "example.com (example_user)", login: Object.assign(new LoginView(), { username: "example_user", @@ -45,6 +39,7 @@ const CipherData = [ uri: "https://example.com", }), ], + totp: null, }), type: 1, }), @@ -54,6 +49,7 @@ const CipherData = [ describe("Safari CSV Importer", () => { CipherData.forEach((data) => { it(data.title, async () => { + jest.useFakeTimers().setSystemTime(data.expected.creationDate); const importer = new SafariCsvImporter(); const result = await importer.parse(data.csv); expect(result != null).toBe(true); diff --git a/libs/importer/src/importers/zohovault-csv-importer.spec.ts b/libs/importer/src/importers/zohovault-csv-importer.spec.ts index d3904fb521a..c82e3e5dcf1 100644 --- a/libs/importer/src/importers/zohovault-csv-importer.spec.ts +++ b/libs/importer/src/importers/zohovault-csv-importer.spec.ts @@ -11,9 +11,6 @@ const CipherData = [ title: "should parse Zoho Vault CSV format", csv: samplezohovaultcsvdata, expected: Object.assign(new CipherView(), { - id: null, - organizationId: null, - folderId: null, name: "XYZ Test", login: Object.assign(new LoginView(), { username: "email@domain.de", @@ -41,6 +38,7 @@ describe("Zoho Vault CSV Importer", () => { CipherData.forEach((data) => { it(data.title, async () => { + jest.useFakeTimers().setSystemTime(data.expected.creationDate); const importer = new ZohoVaultCsvImporter(); const result = await importer.parse(data.csv); expect(result != null).toBe(true); diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index da080bacaad..351d89be3fa 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -146,7 +146,8 @@ export class ImportService implements ImportServiceAbstraction { map(([type, enabled]) => { let loaders = availableLoaders(type, client); - let isUnsupported = false; + // Mac App Store is currently disabled due to sandboxing. + let isUnsupported = this.system.environment.isMacAppStore(); if (enabled && type === "bravecsv") { try { diff --git a/libs/pricing/project.json b/libs/pricing/project.json index 7e6e154bceb..c47114f32ea 100644 --- a/libs/pricing/project.json +++ b/libs/pricing/project.json @@ -1,18 +1,15 @@ { - "name": "pricing", + "name": "@bitwarden/pricing", "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/pricing/src", "projectType": "library", - "tags": [], + "tags": ["scope:pricing", "type:lib"], "targets": { "build": { - "executor": "@nx/js:tsc", - "outputs": ["{options.outputPath}"], + "executor": "nx:run-script", + "dependsOn": [], "options": { - "outputPath": "dist/libs/pricing", - "main": "libs/pricing/src/index.ts", - "tsConfig": "libs/pricing/tsconfig.lib.json", - "assets": ["libs/pricing/*.md"] + "script": "build" } }, "lint": { diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index dfa3a797eb3..85695ea1395 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -4,7 +4,7 @@ @let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts;
-
+

-
+

{{ "passwordManager" | i18n }}

-
+
{{ passwordManager.quantity }} {{ passwordManager.name | i18n }} x @@ -56,7 +56,7 @@ @if (additionalStorage) { -
+
{{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x @@ -73,13 +73,13 @@ @if (secretsManager) { -
+

{{ "secretsManager" | i18n }}

-
+
{{ secretsManager.seats.quantity }} {{ secretsManager.seats.name | i18n }} x {{ secretsManager.seats.cost | currency: "USD" : "symbol" }} @@ -96,7 +96,7 @@ @if (additionalServiceAccounts) { -
+
{{ additionalServiceAccounts.quantity }} {{ additionalServiceAccounts.name | i18n }} x @@ -117,7 +117,10 @@ } -
+

{{ "estimatedTax" | i18n }}

{{ estimatedTax() | currency: "USD" : "symbol" }} @@ -125,7 +128,7 @@
-
+

{{ "total" | i18n }}

{{ total() | currency: "USD" : "symbol" }} / {{ passwordManager.cadence | i18n }} diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts index 9e48e7f5c20..4ec6ca1b113 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts @@ -1,6 +1,8 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { CartSummaryComponent, LineItem } from "./cart-summary.component"; describe("CartSummaryComponent", () => { @@ -9,14 +11,14 @@ describe("CartSummaryComponent", () => { const mockPasswordManager: LineItem = { quantity: 5, - name: "Password Manager", + name: "members", cost: 50, cadence: "month", }; const mockAdditionalStorage: LineItem = { quantity: 2, - name: "Additional Storage", + name: "additionalStorageGB", cost: 10, cadence: "month", }; @@ -24,46 +26,26 @@ describe("CartSummaryComponent", () => { const mockSecretsManager = { seats: { quantity: 3, - name: "Secrets Manager Seats", + name: "secretsManagerSeats", cost: 30, - cadence: "month" as "month" | "year", + cadence: "month", }, additionalServiceAccounts: { quantity: 2, - name: "Additional Service Accounts", + name: "additionalServiceAccountsV2", cost: 6, - cadence: "month" as "month" | "year", + cadence: "month", }, }; const mockEstimatedTax = 9.6; - function setupComponent( - options: { - passwordManager?: LineItem; - additionalStorage?: LineItem | null; - secretsManager?: { seats: LineItem; additionalServiceAccounts?: LineItem } | null; - estimatedTax?: number; - } = {}, - ) { - const pm = options.passwordManager ?? mockPasswordManager; - const storage = - options.additionalStorage !== null - ? (options.additionalStorage ?? mockAdditionalStorage) - : undefined; - const sm = - options.secretsManager !== null ? (options.secretsManager ?? mockSecretsManager) : undefined; - const tax = options.estimatedTax ?? mockEstimatedTax; - + function setupComponent() { // Set input values - fixture.componentRef.setInput("passwordManager", pm); - if (storage !== undefined) { - fixture.componentRef.setInput("additionalStorage", storage); - } - if (sm !== undefined) { - fixture.componentRef.setInput("secretsManager", sm); - } - fixture.componentRef.setInput("estimatedTax", tax); + fixture.componentRef.setInput("passwordManager", mockPasswordManager); + fixture.componentRef.setInput("additionalStorage", mockAdditionalStorage); + fixture.componentRef.setInput("secretsManager", mockSecretsManager); + fixture.componentRef.setInput("estimatedTax", mockEstimatedTax); fixture.detectChanges(); } @@ -71,6 +53,49 @@ describe("CartSummaryComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [CartSummaryComponent], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string) => { + switch (key) { + case "month": + return "month"; + case "year": + return "year"; + case "members": + return "Members"; + case "additionalStorageGB": + return "Additional storage GB"; + case "additionalServiceAccountsV2": + return "Additional machine accounts"; + case "secretsManagerSeats": + return "Secrets Manager seats"; + case "passwordManager": + return "Password Manager"; + case "secretsManager": + return "Secrets Manager"; + case "additionalStorage": + return "Additional Storage"; + case "estimatedTax": + return "Estimated tax"; + case "total": + return "Total"; + case "expandPurchaseDetails": + return "Expand purchase details"; + case "collapsePurchaseDetails": + return "Collapse purchase details"; + case "familiesMembership": + return "Families membership"; + case "premiumMembership": + return "Premium membership"; + default: + return key; + } + }, + }, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(CartSummaryComponent); @@ -116,7 +141,7 @@ describe("CartSummaryComponent", () => { fixture.detectChanges(); // Act / Assert - const detailsSection = fixture.debugElement.query(By.css(".tw-mb-4.tw-pb-4.tw-text-muted")); + const detailsSection = fixture.debugElement.query(By.css('[id="purchase-summary-details"]')); expect(detailsSection).toBeFalsy(); }); @@ -126,7 +151,7 @@ describe("CartSummaryComponent", () => { fixture.detectChanges(); // Act / Assert - const detailsSection = fixture.debugElement.query(By.css(".tw-mb-4.tw-pb-4.tw-text-muted")); + const detailsSection = fixture.debugElement.query(By.css('[id="purchase-summary-details"]')); expect(detailsSection).toBeTruthy(); }); }); @@ -134,10 +159,10 @@ describe("CartSummaryComponent", () => { describe("Content Rendering", () => { it("should display correct password manager information", () => { // Arrange - const pmSection = fixture.debugElement.query(By.css(".tw-mb-3.tw-border-b")); - const pmHeading = pmSection.query(By.css(".tw-font-semibold")); - const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-sm")); - const pmTotal = pmSection.query(By.css(".tw-text-sm:not(.tw-flex-1 *)")); + const pmSection = fixture.debugElement.query(By.css('[id="password-manager"]')); + const pmHeading = pmSection.query(By.css("h3")); + const pmLineItem = pmSection.query(By.css(".tw-flex-1 .tw-text-muted")); + const pmTotal = pmSection.query(By.css("[data-testid='password-manager-total']")); // Act/ Assert expect(pmSection).toBeTruthy(); @@ -150,55 +175,49 @@ describe("CartSummaryComponent", () => { it("should display correct additional storage information", () => { // Arrange - const storageItem = fixture.debugElement.query( - By.css(".tw-mb-3.tw-border-b .tw-flex-justify-between:nth-of-type(3)"), - ); - const storageText = fixture.debugElement.query(By.css(".tw-mb-3.tw-border-b")).nativeElement - .textContent; + const storageItem = fixture.debugElement.query(By.css("[id='additional-storage']")); + const storageText = storageItem.nativeElement.textContent; // Act/Assert expect(storageItem).toBeTruthy(); - expect(storageText).toContain("2 Additional GB"); + expect(storageText).toContain("2 Additional storage GB"); expect(storageText).toContain("$10.00"); expect(storageText).toContain("$20.00"); }); it("should display correct secrets manager information", () => { // Arrange - const smSection = fixture.debugElement.queryAll(By.css(".tw-mb-3.tw-border-b"))[1]; - const smHeading = smSection.query(By.css(".tw-font-semibold")); - const sectionText = smSection.nativeElement.textContent; + const smSection = fixture.debugElement.query(By.css('[id="secrets-manager"]')); + const smHeading = smSection.query(By.css("h3")); + const sectionText = fixture.debugElement.query(By.css('[id="secrets-manager-members"]')) + .nativeElement.textContent; + const additionalSA = fixture.debugElement.query(By.css('[id="additional-service-accounts"]')) + .nativeElement.textContent; // Act/ Assert expect(smSection).toBeTruthy(); expect(smHeading.nativeElement.textContent.trim()).toBe("Secrets Manager"); // Check seats line item - expect(sectionText).toContain("3 Members"); + expect(sectionText).toContain("3 Secrets Manager seats"); expect(sectionText).toContain("$30.00"); expect(sectionText).toContain("$90.00"); // 3 * $30 // Check additional service accounts - expect(sectionText).toContain("2 Additional machine accounts"); - expect(sectionText).toContain("$6.00"); - expect(sectionText).toContain("$12.00"); // 2 * $6 + expect(additionalSA).toContain("2 Additional machine accounts"); + expect(additionalSA).toContain("$6.00"); + expect(additionalSA).toContain("$12.00"); // 2 * $6 }); it("should display correct tax and total", () => { // Arrange - const taxSection = fixture.debugElement.query( - By.css(".tw-flex.tw-justify-between.tw-mb-3.tw-border-b:last-of-type"), - ); + const taxSection = fixture.debugElement.query(By.css('[id="estimated-tax-section"]')); const expectedTotal = "$381.60"; // 250 + 20 + 90 + 12 + 9.6 const topTotal = fixture.debugElement.query(By.css("h2")); - const bottomTotal = fixture.debugElement.query( - By.css( - ".tw-flex.tw-justify-between.tw-items-center:last-child .tw-font-semibold:last-child", - ), - ); + const bottomTotal = fixture.debugElement.query(By.css("[data-testid='final-total']")); // Act / Assert - expect(taxSection.nativeElement.textContent).toContain("Estimated Tax"); + expect(taxSection.nativeElement.textContent).toContain("Estimated tax"); expect(taxSection.nativeElement.textContent).toContain("$9.60"); expect(topTotal.nativeElement.textContent).toContain(expectedTotal); diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.html b/libs/pricing/src/components/pricing-card/pricing-card.component.html index d0ac4fc519f..d0c1ad4a2bb 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.html +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.html @@ -24,9 +24,9 @@ @if (price(); as priceValue) {
- ${{ priceValue.amount }} + {{ + priceValue.amount | currency: "USD" : "symbol" + }} / {{ priceValue.cadence }} @if (priceValue.showPerUser) { diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.ts index b727fb10673..022653aa9e4 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.ts @@ -1,3 +1,4 @@ +import { CurrencyPipe } from "@angular/common"; import { Component, EventEmitter, input, Output } from "@angular/core"; import { @@ -17,7 +18,7 @@ import { @Component({ selector: "billing-pricing-card", templateUrl: "./pricing-card.component.html", - imports: [BadgeModule, ButtonModule, IconModule, TypographyModule], + imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe], }) export class PricingCardComponent { tagline = input.required(); diff --git a/libs/pricing/tsconfig.json b/libs/pricing/tsconfig.json index 62ebbd94647..919c7bf77bf 100644 --- a/libs/pricing/tsconfig.json +++ b/libs/pricing/tsconfig.json @@ -1,13 +1,5 @@ { - "extends": "../../tsconfig.base.json", - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.lib.json" - }, - { - "path": "./tsconfig.spec.json" - } - ] + "extends": "../../tsconfig.base", + "include": ["src"], + "exclude": ["node_modules", "dist"] } diff --git a/libs/tools/generator/components/src/password-settings.component.html b/libs/tools/generator/components/src/password-settings.component.html index 2145782df57..4f2aa8257ba 100644 --- a/libs/tools/generator/components/src/password-settings.component.html +++ b/libs/tools/generator/components/src/password-settings.component.html @@ -15,9 +15,8 @@
{{ "include" | i18n }}
-
+
@@ -30,7 +29,6 @@ {{ "uppercaseLabel" | i18n }} @@ -43,7 +41,6 @@ {{ "lowercaseLabel" | i18n }} @@ -51,7 +48,6 @@ {{ "numbersLabel" | i18n }} diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.html index 724ebda0df8..60c78c7dece 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.html @@ -1,10 +1,10 @@ -
+
{{ "file" | i18n }}
-
{{ originalSendView.file.fileName }}
-
{{ originalSendView.file.sizeName }}
+
{{ originalSendView().file.fileName }}
+
{{ originalSendView().file.sizeName }}
- + {{ "fileToShare" | i18n }}